Upstream removed every setup-side writer of data/env/env (c82f062d) because
nothing has read it since the container mount was dropped — before this
branch even forked. Our engine codified the dead pattern as a directive:
every apply copied the full .env (live tokens included) into a file nothing
consumes.
- engine: env-sync handler removed from selfStatus + applyOne
- grammar: dropped from KNOWN; a new RETIRED table gives a targeted lint
error ("delete the fence, the adapter reads .env directly") instead of a
generic unknown-directive message
- skills: fence stripped from the 14 converted skills; orphaned "sync to the
container" prose cleaned (incl. add-deltachat/add-wechat old-format prose
upstream's sweep missed); teams troubleshooting entry rewritten
- policy/spec doc lists updated
Suite 827 passed | 1 skipped; all nc: skills lint clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
45 KiB
The skill-engine seam: declare/emit vs. acquire/present
Status: SPEC — approved boundary decision, pre-implementation, review findings folded. Branch: feat/structured-skill-format (pre-merge; clean break, no compat shims).
1. The boundary rule
The engine may DECLARE needs and EMIT events; it may never ACQUIRE input or PRESENT anything.
scripts/skill-apply.ts accumulated interactive-setup-wizard concerns in its core
contract: a Prompter with tell/confirm/open, authored presentation attrs
(gate, open:, min:, error:, label:, on-fail:), and a StepReporter
shaped around a clack spinner. That couples the deterministic applier to one
consumer (the setup wizard) when there are three:
- wizard — interactive setup (
setup/lib/skill-driver.ts+setup/channels/run-channel-skill.ts, clack UI) - agent-relay — a coding agent driving a skill conversationally over chat
- pipeline — CI/CD & customer deployments: inputs from env, no human,
operatorMessagesconsumed as a "manual steps" report
Declaration & semantics (what a value must look like, what a step is, what the human must be told) = core. Acquisition & presentation (how the value is collected, how the message is rendered, when to pause) = consumer. The rule binds only the core: a driver may define whatever interaction types it wants on its side of the seam.
Invariants (non-negotiable)
- Prose-primary / oblivious-to-auto-apply: with
nc:fences stripped, every SKILL.md reads as a normal skill. Never narrate the engine. - Degrade-to-agent: anything the engine can't do bounces to an
agentTask— never a crash, never a silent drop. - Option A split untouched: no change to any resolve/wire logic; every
platform_id/user_idbyte produced by the production code paths is identical. The Option-A test (setup/channels/run-channel-skill.test.ts:24-70) keeps its assertion structure, but its fixture credentials MUST be updated to valid-shaped values in the same step that lands validate-at-bind (§4): today'ssigning_secret: 's'fails add-slack's^[a-fA-F0-9]{16,}$andowner_handle: 'U1'fails^U[A-Z0-9]{8,}$(both bypass validation only because inputs bypass it today). TheuserIdassertion updates consistently (e.g.owner_handle: 'U12345678'⇒'slack:U12345678'). Byte-parity is a claim about production code, not about test-fixture literals shaped to exploit the removed bypass. - Coverage parity: suite is 680 passed | 1 skipped today. Seam-touching tests are reworked at the seam; gate/open attr tests become driver-policy tests proving the parity claims in §5.
- Never stage/commit the pre-existing unrelated local changes:
package.json,pnpm-lock.yaml,src/channels/index.ts,src/providers/claude.ts,src/providers/index.ts— nor untracked files this work did not create.
2. The new core interface
The entire interaction surface of the engine, after the refactor
(scripts/skill-apply.ts):
// What an nc:prompt declares about the value it needs. Passed to resolveInput
// so a consumer can run its OWN re-ask loop (clack validate, a chat exchange).
export interface InputMeta {
question: string; // the prompt body (verbatim)
secret: boolean; // consumer must mask
validate?: string; // regex source (nc:prompt validate:<re>)
flags?: string; // regex flags (nc:prompt flags:<f>)
normalize?: 'trim' | 'rstrip-slash' | 'lower'; // applied by the ENGINE at bind
}
// Everything the engine emits. `onEvent` is AWAITED before the engine
// proceeds — that ordering guarantee is what lets a consumer implement
// gating (hold the operator event until the human confirms readiness).
export type ApplyEvent =
| { type: 'step-start'; kind: string; line: number; label: string | null }
| { type: 'step-end'; kind: string; line: number; label: string | null;
ok: boolean; durationMs: number; error?: string }
| { type: 'operator'; line: number; text: string };
// text = the rendered, {{var}}-substituted block body;
// line = the directive's opening-fence line (keys driver policy maps)
export interface ApplyOptions {
// Pre-supplied answers (var → value). Checked FIRST. Unchanged.
inputs?: Record<string, string>;
// Replaces Prompter.ask. undefined ⇒ defer (unchanged semantics).
resolveInput?: (name: string, meta: InputMeta) => Promise<string | undefined>;
// Unchanged.
exec?: (cmd: string) => string | void | Promise<string | void>;
// Unchanged.
execStream?: (cmd: string) => Promise<StepOutcome>;
// Unchanged.
skipEffects?: string[];
// Unchanged.
resolveRemote?: (branch: string) => string;
// Replaces BOTH StepReporter and Prompter.tell. Awaited before proceeding.
onEvent?: (e: ApplyEvent) => void | Promise<void>;
}
Gone from the core entirely: Prompter (ask/tell/confirm/open), PromptOpts,
StepReporter, ApplyOptions.prompter, ApplyOptions.reporter.
ApplyResult — unchanged fields: applied, skipped, agentTasks,
operatorMessages (still collected in the result — the pipeline reads them
there; the operator event is for live rendering), vars, journal,
referenceProse. fullyApplied() and firstFailureHint() unchanged.
Two adjustments:
deferred: string[]— same field, one new entry form: an input rejected by validate-at-bind is recorded as`<var>: invalid value (does not match validate:<re>)`(see §4). Missing-input entries stay the bare var name; unresolved-{{var}}entries stay the thrown message (skill-apply.ts:841today).AgentTask.hintis dropped (it existed only foron-fail:; with that attr gone, hint ≡ prose).firstFailureHintreadsprosedirectly.
Derived metadata stays core (exposure, not authored presentation):
stepLabel (heading-derived only — the label: attr override at
skill-apply.ts:458 is removed), AgentTask prose/reason (proseFor-derived),
firstFailureHint, referenceProse, operatorMessages.
stepLabel null semantics, re-documented. Today's doc comment
(skill-apply.ts:73-79, :442-446) frames label: null as "the driver should
NOT spin on this" — spinner advice, i.e. presentation smuggled into the core.
The contract wording changes (no payload change): label: null means instant/
cheap, or the step renders its own live operator-facing output (effect:step's
QR card / pairing code). That is step-cost/interactivity declaration; the
event carries kind + line, so a consumer wanting a different render policy
can derive its own.
Event ordering contract (normative):
- Per directive,
step-startfires immediately before the mutation,step-endimmediately after (or on the failure path) — always balanced. The payload is today'sStepReporterpayload (skill-apply.ts:80-83— it already includesline) unchanged, plus only the discriminatingtypefield. - For an
nc:operator, the engine substitutes{{vars}}, pushes tores.operatorMessages, thenawait onEvent({type:'operator', …})before evaluating the next directive. An unresolved{{var}}in the body defers the whole block before any event fires (today's behavior,skill-apply.ts:787). Once the run isblocked(an earlier bounce), operator directives are skipped instead — no event, nooperatorMessagesentry, recorded inskipped. Walking the human through steps whose side effects the run has already gated ("a pairing code is about to appear" → nothing appears) is actively misleading, and a failed run's manual-steps report must not include steps predicated on the failure. - Every
onEventcall is awaited; a rejection fromonEventis treated like any other throw at that directive (bounce, not crash). This applies to operator events too: a consumer that throws fromonEventaccepts the bounce consequence, including theblockedlatch cascading over later side effects. The engine itself never defers/bounces an operator block (open question 7); consequently a well-behaved driver's handler must never throw for a declined confirm — decline semantics are defined in §5.1.
3. Migration table
Every existing hook / attr / caller → destination. "core seam" = survives in
ApplyOptions/ApplyResult; "driver policy" = reimplemented from document
structure (shared policy module + setup/lib/skill-driver.ts, §5); "deleted" =
removed with no replacement syntax.
Engine hooks
| Today | Where (file:line) | Destination |
|---|---|---|
ApplyOptions.inputs |
scripts/skill-apply.ts:263 |
core seam (unchanged, but validated — §4) |
Prompter.ask(name, question, secret, validate, opts) |
scripts/skill-apply.ts:50, called at :774 |
core seam → resolveInput(name, meta) |
Prompter.tell(text) |
scripts/skill-apply.ts:54, called at :793 |
core seam → onEvent operator event |
Prompter.confirm(msg) |
scripts/skill-apply.ts:58, called at :802 (gate; result discarded — decline proceeds today) |
driver policy (natural-barrier gating, §5.1) + driver-owned reuse offer — both via the new RunSkillOptions.confirm seam (§5.0) |
Prompter.open(url) |
scripts/skill-apply.ts:63, called at :796 |
driver policy (URL offer, §5.2) via the new RunSkillOptions.openUrl seam (§5.0) |
StepReporter.stepStart/stepEnd |
scripts/skill-apply.ts:80-83, fired at :827,:830,:839 |
core seam → onEvent step-start/step-end events (payload + balance guarantee identical; only type added) |
ApplyOptions.prompter |
scripts/skill-apply.ts:266 |
deleted (split into resolveInput + onEvent) |
ApplyOptions.reporter |
scripts/skill-apply.ts:287 |
deleted (folded into onEvent) |
ApplyOptions.exec / execStream |
scripts/skill-apply.ts:269,:275 |
core seam (unchanged) |
ApplyOptions.skipEffects |
scripts/skill-apply.ts:279 |
core seam (unchanged) |
ApplyOptions.resolveRemote |
scripts/skill-apply.ts:283 |
core seam (unchanged) |
PromptOpts (flags/min/error/normalize) |
scripts/skill-apply.ts:37-42, built at :499 (promptOptsOf) |
type deleted; flags/normalize move into InputMeta; min/error deleted (§ grammar) |
normalizeValue at bind |
scripts/skill-apply.ts:483, applied :779 |
core seam (unchanged; now paired with validate-at-bind, §4) |
stepLabel |
scripts/skill-apply.ts:457-474 |
core seam, minus the label: attr branch (:458); null semantics re-documented (§2) |
failHint / AgentTask.hint |
scripts/skill-apply.ts:419-430, :236, :749 |
deleted (on-fail: gone ⇒ hint ≡ prose; firstFailureHint at :308 reads prose) |
run-health gate (blocked latch) |
scripts/skill-apply.ts:744-750,:816 |
core seam (unchanged) |
when: guard |
scripts/skill-apply.ts:521-525,:762 |
core seam (unchanged) |
effect:check / effect:step + terminal-block capture |
scripts/skill-apply.ts:646-667 |
core seam (unchanged) |
| multi-field JSON capture + validate-on-capture | scripts/skill-apply.ts:551-572 |
core seam (unchanged) |
journal / removeSkill |
scripts/skill-apply.ts:851-870 |
core seam (unchanged) |
referenceProse / operatorMessages / vars in result |
scripts/skill-apply.ts:239-257 |
core seam (unchanged) |
Authored grammar (scripts/skill-directives.ts)
| Attr / syntax | Where | Destination |
|---|---|---|
nc:operator open:<url> |
grammar doc :61-73; lint :277, :287-291; engine :791,:796 |
deleted. Driver URL-offer policy scans the rendered text (§5.2). URLs must live in the prose. |
nc:operator gate |
grammar doc :68-71; engine :802 |
deleted. Driver natural-barrier policy (§5.1). |
nc:prompt min:<n> |
grammar doc :53-54; lint :264-266; driver enforcement setup/lib/skill-driver.ts:46 |
deleted. Authors re-encode as regex, e.g. min:20 → validate:^.{20,}$. |
nc:prompt error:<msg> |
grammar doc :55; driver setup/lib/skill-driver.ts:46-47 |
deleted. Error text derived from the question prose (§5.3). |
nc:run label:<word> |
scripts/skill-apply.ts:458; doc-comment mention :451 |
deleted. Labels are heading-derived only. |
on-fail:<token> |
scripts/skill-apply.ts:419-430 (no lint rule exists) |
deleted. Hint is always the surrounding prose. |
validate: + flags: (prompt & run-capture) |
lint :249-263,:311-317 |
kept (data semantics). Prompt validate now enforced at bind (§4). |
normalize:trim|rstrip-slash|lower |
lint :267-269; bind skill-apply.ts:483 |
kept (canonical-value semantics). |
reuse:<ENV_KEY> |
lint :270-272; driver setup/lib/skill-driver.ts:169-172 |
kept (binding metadata; consumed only by the driver's reuse offer). |
when:, effect:check, effect:step, capture forms, journal semantics |
various | kept, unchanged. |
Lint addition: validate() gains errors for the six removed attrs
(operator open:/gate, prompt min:/error:, any-directive label:/on-fail:)
so stale authorship fails loudly instead of silently no-oping. Lint also gains a
warning for an unguarded nc:operator immediately followed by when:-guarded
directives spanning more than one branch value (the static gate policy cannot
know which branch runs — see §5.1 and open question 1).
Authored skills carrying removed attrs (strip + prose check)
| Skill | Line | Change |
|---|---|---|
.claude/skills/add-teams/SKILL.md |
:101 |
drop open:https://portal.azure.com (URL already in the body — step 1, body line 2; body line 1 is the heading sentence) |
.claude/skills/add-teams/SKILL.md |
:132 |
min:20 → validate:^.{20,}$ |
.claude/skills/add-teams/SKILL.md |
:173, :203 |
drop gate (policy reproduces both — §5.1 parity) |
.claude/skills/add-telegram/SKILL.md |
:134 |
drop open:https://t.me/{{bot_username}} and fold the URL into the body (verified: body says "Open @{{bot_username}}" — the URL exists only in the attr today) |
.claude/skills/add-discord/SKILL.md |
:125 |
drop open:https://discord.com/... and fold the invite URL into the body (verified: body says "Open the invite link" — URL only in the attr today) |
No skill uses error:, label:, or on-fail: (grep verified). Re-lint every
touched skill (pnpm exec tsx scripts/skill-directives.ts <dir>).
Prompter / reporter implementers & callers (all migrate — clean break)
| Caller / implementer | Where | Migration |
|---|---|---|
clackPrompter (the wizard Prompter) |
setup/lib/skill-driver.ts:66-120 |
becomes the driver's resolveInput impl (ask + ? help-escape + clearOnError + secret masking) — tell/confirm/open dissolve into the onEvent handler + the confirm/openUrl seams (§5) |
promptValidator |
setup/lib/skill-driver.ts:37-50 |
driver-side; loses min/error, gains prose-derived message (§5.3) |
spinnerReporter |
setup/lib/skill-driver.ts:259-274 |
folded into the driver's onEvent handler (step-start/step-end branch), still built on startSpinner (setup/lib/runner.ts:314) |
runSkill + RunSkillOptions |
setup/lib/skill-driver.ts:286-340 |
prompter?/reporter? options become resolveInput?/onEvent?; new confirm?/openUrl? options (§5.0); reuse, channel/step (help-escape ctx, :308-314), reuseFromEnv (:143-188, now validate-pre-filtered — §5.4) stay driver-side |
| skill-driver CLI | setup/lib/skill-driver.ts:343-360 |
uses the new defaults; no interface change visible to the operator |
runChannelSkill overrides |
setup/channels/run-channel-skill.ts:122 (prompter), :126 (reporter) |
override fields renamed to resolveInput/onEvent; confirm/openUrl passthroughs added; fail-path (:133-151) unchanged |
applyProviderSkill defer-all Prompter |
setup/providers/install.ts:62-66 |
delete the stub — omit resolveInput entirely (absent ⇒ defer, same semantics) |
setup/auto.ts call sites |
:350 (provider), :560-572 (channels) |
no signature change needed (they pass no prompter/reporter) |
setup/provider-auth.ts |
:53 |
unchanged (blockers contract survives) |
| engine test fakes | scripts/skill-apply.test.ts:39 (headless), :447, :505, :518, :534, :545, :613, :637, :1219 |
headless(vals) becomes { resolveInput: async (n) => vals[n] }; tell/open/confirm fakes become recorded onEvent handlers or move to driver-policy tests (§9); any fixture whose inputs violate a declared validate: updates to valid-shaped values (§4) |
| driver test fakes | setup/lib/skill-driver.test.ts:73, :100 (reporter) |
mechanical rewrite to resolveInput/onEvent |
| driver reuse-offer tests | setup/lib/skill-driver.test.ts:149, :167, :202 |
NOT a mechanical rewrite — today they queue answers through fake prompter.confirms; they migrate to the new RunSkillOptions.confirm seam (§5.0) |
| run-channel-skill test stub prompter | setup/channels/run-channel-skill.test.ts:95-100 (teams gate/open assertions :115-124) |
becomes a driver-policy assertion running the default onEvent policy handler with confirm/openUrl injected (§5.0 injection semantics) — proving the §5.1/§5.2 parity claims. Fixture app_password: 'sekret' → a 20+-char value (§4); Option-A slack fixture (:41-70) updates signing_secret/owner_handle + the userId assertion (§1 invariant) |
back-nav.ts backGate, claude-handoff.ts help-escape |
setup/lib/back-nav.ts:31, setup/lib/claude-handoff.ts:82,:164,:182 |
untouched (already driver-side) |
4. Behavior change: validate + normalize apply to EVERY bound value
Today validate: is enforced only by the interactive prompter
(setup/lib/skill-driver.ts:37-50); inputs bypass it
(documented at scripts/skill-directives.ts:51). That is data validation
misfiled as prompt UX. New rule, at the single bind point
(skill-apply.ts:766-780 region):
- Resolve the raw value:
inputs[var]first, elseawait resolveInput(var, meta). Bothundefined⇒ defer (push bare var name — unchanged). - Apply
normalize:(unchanged, already both-paths —normalizeValue,:483). - New: if the prompt carries
validate:(+flags:), test the normalized value. On mismatch: the var stays unbound, and`<var>: invalid value (does not match validate:<re>)`is pushed todeferred. Not an agentTask, not a throw — downstream consumers of the var defer exactly as if the value were never supplied, andfullyAppliedisfalse. A pipeline passing a malformed env value fails loudly.
Notes:
- Normalize-then-validate order is normative (a trailing-slash URL is stripped
before the
^https://check — matches the teamspublic_urlauthoring). - An invalid
inputsvalue does not fall through toresolveInput— inputs win outright, and a caller that pre-supplied a value gets a loud rejection, never a surprise second acquisition path. The interactive dead-end this could create for reused.envcredentials is closed on the driver side instead:reuseFromEnvpre-filters every offer through the prompt'snormalize/validate/flagsmeta, so a stale credential that no longer matches the declared shape is never offered and the operator is prompted fresh (§5.4). A caller passing rawinputs(pipeline, tests) still fails loudly — that is the point. - The interactive re-ask loop moves into the wizard's
resolveInput(clackvalidate), so engine-level rejection rarely fires interactively; it is the backstop for programmatic paths. - Secret values never appear in the deferred entry (only the var name and the regex source).
- run-capture
validate:is unchanged (it already throws → bounces,skill-apply.ts:551-572— a command's output has no human to re-ask). - Test-fixture consequence (part of the step that lands this change): every
in-tree fixture that supplies an
inputsvalue violating its prompt's declaredvalidate:must update to a valid-shaped value. Known: the Option-A slack fixture (run-channel-skill.test.ts:49—signing_secret,owner_handle, with theuserIdassertion at:62updated consistently) and the teams deferWire fixture (:102-107—app_passwordvs. the new^.{20,}$). Sweepscripts/skill-apply.test.tsfixtures the same way.
5. Wizard driver policy (presentation derived from document structure)
5.0 Where the policy lives, and the driver's own seams
The policy logic is UI-free and shared: a new module
scripts/skill-policy.ts beside the parser exports gatePolicy(md) (→ map of
operator line → needs-confirm + confirm flavor, §5.1) and extractOfferUrl(text)
(§5.2), both built on the shared parseDirectives. The wizard driver consumes
it; an agent-relay consumer (§7) imports the same module instead of duplicating
the judgment or dragging in clack. The §9 policy unit tests live at this shared
home.
The wizard driver (setup/lib/skill-driver.ts) keeps the clack rendering and
gains two injectable interaction seams on RunSkillOptions — these are
driver options, allowed by the boundary rule (it binds only the core):
confirm?: (message: string) => Promise<boolean>— used by the reuse offer (§5.4), the natural-barrier gate (§5.1), and the URL offer (§5.2). Default: clackp.confirm, TTY-gated exactly likespinnerReporter(skill-driver.ts:260) — non-TTY resolvestrue(proceed), preserving today's headless-prompter-without-confirm semantics (skill-apply.ts:802's optional chain). A non-TTY run with full inputs never stalls.openUrl?: (url: string) => Promise<void>— used by the URL offer. Default:setup/lib/browser.tsopenUrl, attempted only after aconfirmyes.
Injection semantics (normative): an injected onEvent replaces the
driver's default policy handler entirely — same rule as today's injected
prompter ("the injector owns its I/O", skill-driver.ts:311). Therefore
driver-policy parity tests must run the default handler and inject
confirm/openUrl (the run-channel teams test does exactly this — §3, §9);
injecting onEvent to observe policy behavior would only observe itself.
Because the engine awaits onEvent (§2), a confirm inside the default handler
blocks the engine — that is the entire gating mechanism.
5.1 Natural-barrier gate policy
For each nc:operator directive at line L, gatePolicy computes
needsConfirm(L):
- Scan forward through subsequent directives, skipping only directives
whose
when:<var>=<value>guard is incompatible with this operator's own guard — same var, different value. No guard, or an identical guard, is compatible. (This makes mutually-exclusive branches gate on their own next action: imessage'swhen:mode=localoperator at:111skips the two remote-only prompts and gates on the local configure run at:151; whatsapp'swhen:auth_method=qroperator at:96skips the pairing-code operator at:104— guard-incompatible — and gates on the qr step at:114.) - Next compatible directive is another
operator→ no confirm — the chain's last operator carries the barrier. (Operators are NOT skipped-and-scanned-past: that would make the earlier block of a chain inherit the later block's barrier and double-confirm — the exact bug the teams parity table below forbids.) - Next compatible directive is a
prompt→ no confirm (the prompt is the barrier). - No such directive (end of document) → no confirm (a final handoff block, e.g. teams
:228). - Anything else (
run,copy,dep,append,env-set,json-merge) → confirm after rendering. Confirm wording is derived from the barrier's flavor — the next compatible directive's effect:effect:step→ readiness phrasing ("Ready? The next step starts immediately."— the block describes future action: "a pairing code is about to appear"); anything else → completed-work phrasing ("Done with the steps above? Continue when you're ready.").gatePolicyreturns the flavor with the boolean.
Decline semantics (normative): the barrier confirm is a pause, not a
branch. A "No"/cancel answer proceeds anyway — matching today's engine, which
discards the gate confirm's result (skill-apply.ts:802). The driver's handler
must never throw for a decline (an operator-event throw would bounce + latch
blocked, §2.3). A driver MAY upgrade decline to a re-ask loop as pure polish;
it must not abort.
Known limitation (lint-warned, open question 1): an unguarded operator followed by guarded directives of more than one branch value keys its barrier decision off a directive that may be runtime-skipped. No in-tree skill authors this; the §3 lint warning flags it.
At runtime, on each operator event the default handler: renders the clack
note (p.note(text, 'Do this')), runs the URL offer (§5.2), then the confirm
if needsConfirm(line).
Verified parity against today's tree (re-derived under rules 1–5 above):
- teams
:80→ prompt:93: no confirm.:101→ prompt:110: no confirm.:124→ prompt:132: no confirm.:158→ next compatible is operator:173(rule 2): no confirm — the chain's last block carries the barrier.:173→run effect:check:186: confirm.:203→run effect:restart:217: confirm.:228→ end: no confirm. Exactly the two authoredgates reproduced — behavior identical. - telegram
:134(→run effect:steppairing:142) gains a confirm with readiness phrasing — this restores the old bespoke flow's readiness pause that the directive port lost. - signal
:94(→effect:step:105) and both whatsapp operators (:96→ guard-skip:104→effect:step:114;:104→ guard-skip:114→effect:step:117) gain the same readiness pause before a QR/pairing appears. whatsapp:146→ prompt:155: no confirm. - imessage
:111(when:mode=local) → guard-skips:126,:134,:137→run effect:external:151: confirm.:126(when:mode=remote) → prompt:134: no confirm. - discord
:125(→run effect:fetch:139, the DM resolve) gains a confirm — desirable: the DM open fails until the bot is invited (open question 2). Slack's operators (:69→ prompt:80;:97→ prompt:112) are prompt-followed → unchanged.
5.2 URL offer (replaces open:)
On an operator event, extractOfferUrl(text) scans the rendered text for
the first offerable URL: matches /https?:\/\/[^\s)>\]]+/, then excludes
candidates containing < or {{ (template placeholders / unsubstituted vars)
and requires the candidate to parse via new URL() with a well-formed host.
Without the exclusion, slack's :97 block — https://<your-public-host>/webhook/slack
— would produce a nonsense "Open https://<your-public-host?" offer (the char
class stops at > but not <). Slack :97 is the normative negative fixture.
If an offerable URL is found and the run is interactive:
confirm("Open <url> in your browser?") → on yes, openUrl (both via the §5.0
seams — TTY-gated, non-TTY skips). Confirm-then-open matches the old bespoke
flows; prose-primary already forces URLs into the text (after the §3 skill
edits), so open: was redundant authorship. Order within the handler:
note → URL offer → natural-barrier confirm.
Full operator-body URL inventory (the offer scans every operator body,
not just ex-open: blocks — audited like §5.1):
| Site | URL | Outcome |
|---|---|---|
teams :101 (body line 2) |
https://portal.azure.com |
offer — preserves today's open: behavior |
telegram :134, discord :125 (after the §3 fold-into-prose edits) |
t.me / invite URL | offer — preserves today's open: behavior |
teams :158 (body) |
https://portal.azure.com |
new offer (no open: today) — accepted, same judgment as the discord confirm; open question 3 |
discord :70 (body :72) |
https://discord.com/developers/applications |
new offer — accepted; open question 3 |
imessage :126 (body :128) |
https://photon.codes |
new offer — accepted; open question 3 |
slack :97 (body :99) |
https://<your-public-host>/webhook/slack |
excluded (placeholder) — slack stays offer-free, as today |
slack :69 (api.slack.com/apps, no scheme), signal :94 (sgnl://…), all other operators |
— | no match |
5.3 Validation error text (replaces error:)
promptValidator(validate, flags, question) — on a regex miss the message is
`That doesn't match the expected format. ${question}` (the full prompt
body, which by authoring convention describes the expected shape, e.g.
"Paste the bot token from BotFather (looks like 123456:ABC-DEF...)."). No
min branch (regex-encoded now), no error: override.
5.4 Stays driver-side, unchanged in spirit
clearOnError secret re-paste, secret masking (p.password), the masked reuse
offer (reuseFromEnv + its reuse: linkage — confirm now via the §5.0 seam,
skill-driver.ts:327-328 today), the ? help-escape (channel/step ctx already
threaded via RunSkillOptions, run-channel-skill.ts:130-131), backGate,
spinners (now driven by step events), hostExec/hostExecStream.
One behavior addition: reuseFromEnv pre-filters offers through the target
prompt's normalize/validate/flags (parsed from the same directives it
already walks) — an .env value that would fail validate-at-bind is silently
not offered, so the operator is prompted fresh instead of hitting a §4
dead-end (fullyApplied false → failWith). This is the driver-side closure
of the stale-credential path; raw inputs remain loud-fail (§4).
6. Pipeline consumer contract
- Inputs from env — convention: for each prompt var
foo_bar, readNC_INPUT_FOO_BAR(prefixNC_INPUT_, var uppercased). A small helperinputsFromEnv(md: string, env = process.env)(driver-agnostic, may live beside the engine) parses the skill's prompt vars viaparseDirectivesand returns theinputsrecord. Var names are case-sensitive in the grammar (skill-directives.ts:111), so uppercasing can collide (bot_tokenvsBot_Token):inputsFromEnverrors on a collision. All in-tree vars are lowercase today; a lint rule requiring lowercase prompt/capture var names is cheap and makes the mapping bijective (open question 6). NoresolveInput, noonEventrequired (optionally anonEventthat logs step events as CI lines). - Run:
applySkill(skillDir, root, { inputs, exec, execStream?, skipEffects })withskipEffectsper deployment (e.g.['restart']when the deploy restarts once). - What a pipeline can fully apply (normative boundary):
inputsbinds prompt vars only — the engine never reads it forrun/stepcaptures (skill-apply.ts:773— prompt branch only), so a pipeline cannot pre-supply a capture-bound var likeplatform_id. Andeffect:stepwithout a realexecStreamthrows → bounces (skill-apply.ts:655-656);skipEffects:['step']avoids the bounce but leaves the step's capture vars unbound, so downstream consumers defer either way. Consequence: skills with aneffect:stepare wizard/relay-only for full application — today that is telegram (:142), whatsapp (:114,:117), and signal (:105). slack, discord, teams, and imessage carry noeffect:stepand can go fully green from env inputs (+exec), with their operator blocks landing in the manual-steps report. A pipeline-gradeexecStream, or lettinginputspre-bind capture vars (bound var ⇒ capture skipped), would move that boundary — deliberately out of scope here (open question 10). - Consume the result:
res.operatorMessages→ emitted verbatim, numbered, as the "manual steps" report artifact (the human steps the pipeline cannot do).fullyApplied(res)gates the job:false⇒ non-zero exit, printingres.deferred(which now includes §4 invalid-input reasons — a malformed env value is a loud failure) andfirstFailureHint(res)+ eachagentTask.reasonfor real bounces.res.vars→ exported for downstream jobs (e.g.platform_idinto a wire step).
7. Agent-relay consumer sketch
A coding agent driving a skill over chat implements the two seams:
resolveInput(name, meta): sendmeta.questionto the chat; formeta.secret, instruct the user to supply the value out-of-band (or via the platform's redaction affordance) and never echo it back. Run its own re-ask loop againstmeta.validate/meta.flagsconversationally ("that doesn't look like a bot token — it should start withxoxb-"); return the final answer, orundefinedif the user says skip (⇒ defer, degrade-to-agent semantics apply downstream).onEvent(e):operator→ relay the text as a chat message and (because the engine awaits) hold the return until the user replies "done" when the next action is side-effecting — importinggatePolicyfrom the sharedscripts/skill-policy.ts(§5.0) for the same natural-barrier judgment as the wizard, with no clack/TTY baggage; or simply always ask.step-start/step-end→ optional progress messages ("Building… ok, 12s").- Everything else (
exec, journal, bounce handling) is identical to the wizard; the agent readsagentTasks[].proseand applies bounced steps itself — which is exactly the degrade-to-agent path the prose was written for.
8. Implementation step plan
Each step must be independently green — pnpm exec tsc --noEmit (root) +
pnpm test (full vitest suite) + skill lint on every touched skill — and
committable on its own. Core lands before consumers migrate; the old seam is
deleted only after no consumer uses it (transitional coexistence inside the
branch is fine; the merged result has no compat layer).
- Core: add the new seam (additive) + validate-at-bind.
scripts/skill-apply.ts: addresolveInput+onEvent+InputMeta+ApplyEvent; engine prefers them when present (falls back toprompter/reporterif not — temporary); implement validate-at-bind (§4) and the awaited-event ordering; re-documentstepLabelnull semantics (§2). Includes the §4 fixture sweep: Option-A slack inputs +userIdassertion, teams deferWireapp_password, and anyskill-apply.test.tsfixture with shape-violating inputs — updated in this same commit so the suite is green. New tests: event union payloads + balance, await-before-proceed ordering (an asynconEventthat records completion),resolveInputmeta contents, validate-at-bind for inputs AND resolveInput answers (normalize-then-validate, no-fallthrough from invalid inputs toresolveInput, deferred entry format,fullyAppliedfalse, secret never in the entry), onEvent-throw ⇒ bounce (incl. on an operator event). - Shared policy module + wizard driver migration. New
scripts/skill-policy.ts:gatePolicy(md)(§5.1 rules incl. operator-chain termination, guard-compatibility, confirm flavor) +extractOfferUrl(text)(§5.2 incl. placeholder exclusion) — with the parity-table unit tests.setup/lib/skill-driver.ts:resolveInputimpl (ex-clackPrompter.ask, help-escape intact), defaultonEventhandler (spinner branch fromspinnerReporter+ operator branch consuminggatePolicy/extractOfferUrl), newconfirm/openUrloptions with TTY-gated defaults (§5.0), decline = proceed,reuseFromEnvvalidate-pre-filter (§5.4), §5.3 error text;RunSkillOptions.prompter/reporter→resolveInput/onEvent(+ documented replacement semantics for injectedonEvent);setup/channels/run-channel-skill.tsoverride renames +confirm/openUrlpassthrough;setup/providers/install.tsdrops its stub Prompter. Reuse tests migrate to theconfirmseam; the teams run-channel test runs the default handler with injectedconfirm/openUrl(§9). After this step no in-tree caller passesprompter/reporter. - Core: delete the old seam. Remove
Prompter,PromptOpts,StepReporter,ApplyOptions.prompter/reporter; remove engine handling ofopen:/gate(:791,:796,:802),label:(:458),on-fail:+AgentTask.hint(:419-430,:236),min:/error:plumbing (:499-506). Rework/delete the engine tests that asserted removed behavior (§9). - Skills cleanup. Strip attrs per §3 table; fold the telegram + discord
URLs into their operator prose; teams
min:20→validate:^.{20,}$. Re-lint all touched skills; the run-channel teams parity test (now driver-policy-based from step 2) must still prove both barriers fire and the portal offer survives theopen:removal (body URL, §5.2 inventory). - Grammar diet.
scripts/skill-directives.ts: dropmin:/error:/open:/gatevalidation + grammar doc; add lint errors rejecting the six removed attrs and the §3 unguarded-operator/multi-branch warning; rework directive tests. (Ordered after step 4 so lint never fails on skills still carrying the attrs.) - Sweep + parity audit. Grep proves zero remaining references to
Prompter|PromptOpts|StepReporter|on-fail:|label:|min:|error:(in the seam sense) andoperator.*(open:|gate); container typecheck (pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit) untouched; full suite count ≥ 680 passed | 1 skipped; Option-A test assertion structure unmodified (fixture values per §1/§4); production resolve/wire paths untouched.
9. Test-migration notes (per step)
- Step 1: all existing tests stay green — the ONLY permitted edits are the
§4 shape-violating input fixtures (Option-A slack
:49/:62, teams deferWire:106, plus anyskill-apply.test.tssiblings the sweep finds). New describe blocks inscripts/skill-apply.test.ts:onEventevents (mirror of the reporter suite at:1003-1054, plus operator events + await-ordering) and validate-at-bind (extends the PromptOpts suite pattern at:1189-1242). - Step 2:
setup/lib/skill-driver.test.tsfakes (:73,:100) rewritten mechanically toresolveInput/onEvent; the reuse-offer tests (:149,:167,:202) migrate to the injectedconfirmoption (accept / decline / helper-reuse paths preserved); theclackPrompter.openexistence test (:218-223) becomes a URL-offer policy test; help-escape tests (:225-259) keep their shape (the escape lives in the driver'sresolveInput);promptValidatortest (:261-271) loses min/error, gains prose-derived-message assertions. Policy tests atscripts/skill-policy.ts's home encode the §5.1 parity table: teams (confirm ×2, no confirm on the:158chain head — the operator-chain rule's regression fixture), telegram/signal/whatsapp (readiness-flavor pause), imessage (guard-compatibility), end-of-document; plusextractOfferUrlfixtures: the §5.2 inventory incl. slack's placeholder as the negative case.run-channel-skill.test.ts:75-125(teams) re-asserts gate-before-manifest + portal-URL-offer by running the defaultonEventhandler with injectedconfirm/openUrl(never an injectedonEvent, which replaces the policy — §5.0). - Step 3: delete engine tests for removed syntax:
nc:operator open + gate(scripts/skill-apply.test.ts:492-551),label:override +on-fail:cases (:1064,:1072,:1145-1162); keep their semantic siblings (heading labels, prose hint default, unresolved-var deferral of an operator body). The prompter threading tests (:634-645,:1212-1231) becomeInputMetaassertions. - Step 4: no test-file changes beyond fixtures; skill lint is the gate.
- Step 5:
scripts/skill-directives.test.ts: drop:268-271(min) and:325-355(open/gate) as validations-of-supported-syntax; re-add them inverted (the new lint errors reject the attrs). PromptOpts-parse test (:247-261) dropsmin:. - Throughout: the coverage-parity bar is behavioral, not file-shaped: every guarantee an old test proved (barrier ordering, open-after-render, balanced spinner events, hint provenance) must have a successor at its new home.
10. Open questions (decisions this spec made that the design left open)
- Guard-compatibility in the gate scan (§5.1.1). The design said "followed
by"; this spec defines followed by as skipping
when:-incompatible directives so mutually-exclusive branches gate correctly (imessage local, whatsapp qr/pairing). Alternative (pure document order) would miss imessage's "stop and wait" and double-gate whatsapp. Two scrutiny points: (a) the compatibility rule compares only same-var/different-value; different-var guards are treated as compatible (conservative). (b) An unguarded operator followed by guarded directives of more than one branch value keys its decision off a directive that may be runtime-skipped (e.g. unguarded operator →prompt when:mode=remote→run when:mode=local: policy says no-confirm, but at runtime mode=local skips the prompt). No in-tree skill authors this; §3's lint warning covers it. - Discord gains a confirm (
add-discord:125→effect:fetch). The design's parity list mentioned teams + telegram only; the policy also pauses before discord's DM resolve. Judged desirable (the fetch fails until the bot is invited) — but it is a new prompt in an existing flow. - Three new URL offers (§5.2 inventory): teams
:158(portal.azure.com), discord:70(developers portal), imessage:126(photon.codes) had noopen:today and gain an offer because the scan covers every operator body. Accepted — same judgment as the discord confirm — but they are behavior changes in existing flows. - Confirm triggers on ALL non-prompt/non-operator directives (§5.1.5), not
just the engine's dangerous-side-effect set (
restart|step|wire). Needed for teams parity (its first gate precedeseffect:check+external). Consequence: an operator block directly followed by e.g. anenv-setwould also confirm — no such authoring exists today. - Invalid-input failure shape (§4): deferred, not agentTask. Chosen because
a bad value is a missing-valid-value (re-supply and re-run), not a step an
agent can apply from prose; it also keeps the run-health gate un-tripped so a
re-run with a fixed env var completes. The alternative (bounce) would
cascade-block later side effects. Relatedly: invalid
inputsdo NOT fall through toresolveInput(§4) — the driver's reuse pre-filter (§5.4) is the interactive recovery path. - Env-input convention
NC_INPUT_<VAR>(§6) and thatinputsFromEnvis a helper, not an engine feature (inputs stay the only env-agnostic seam). Prefix bikeshed welcome; the engine never readsprocess.envfor inputs.inputsFromEnverrors on an uppercase collision; a lowercase-var lint rule would make the mapping bijective. - Operator event fires even with no consumer — with
onEventabsent the block is still collected inoperatorMessages(today's headless behavior,skill-apply.test.ts:452-456). The engine never defers/bounces an operator block on its own; a consumer that throws fromonEventopts into the standard bounce path (§2.3) — that is the consumer's choice, not an engine judgment, and the wizard's default handler never throws (decline = proceed, §5.1). AgentTask.hintfield removal (vs. keeping it always-equal-to-prose for result-shape stability). Removal chosen for the clean break; any external consumer ofhint(none in-tree) would break.- Teams
min:20regex chosen as^.{20,}$(any 20+ chars). If the intent was tighter (Azure secret alphabet), tighten in the skill edit — the seam doesn't care. - Pipeline boundary for
effect:stepskills (§6). telegram/whatsapp/signal cannot go fully green in a pipeline (no human at the QR/pairing step, andinputscannot pre-bind capture vars). Two possible future escapes — a pipeline-gradeexecStreamcontract, orinputspre-binding capture vars (bound var ⇒ capture skipped) — both deliberately out of scope; the spec'd behavior is the loudfullyApplied:false. step-endon validate-at-bind rejection: none — prompts never emitted step events (they are not mutations) and still don't. Only the deferred entry records the rejection. If wizards want live feedback, their ownresolveInputloop already provided it.
11. Rejected review findings
- "
run-channel-skill.ts:122isexec; theprompterpassthrough is at:123" (review 1, citation sweep) — rejected:grep -nshows:121 exec: overrides.exec,and:122 prompter: overrides.prompter,. The spec's:122citation was correct as written. (The same review's other three citation fixes —resolveRemote :283, teams:101body-line wording,label:doc mention:451— were verified correct and are folded.)