docs/skill-engine-seam.md
Status: IMPLEMENTED on feat/structured-skill-format (clean break, no compat shims) — kept as the boundary-rule rationale and the consumer-contract reference. Author-facing directive grammar: skill-directives.md.
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:
setup/lib/skill-driver.ts + setup/channels/run-channel-skill.ts, clack UI)operatorMessages consumed as a "manual steps" reportDeclaration & 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.
nc: fences stripped, every SKILL.md reads as a normal skill. Never narrate the engine.agentTask — never a crash, never a silent drop.platform_id / user_id byte 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's signing_secret: 's' fails add-slack's ^[a-fA-F0-9]{16,}$ and owner_handle: 'U1' fails ^U[A-Z0-9]{8,}$ (both bypass validation only because inputs bypass it today). The userId assertion 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.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.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:841 today).AgentTask.hint is dropped (it existed only for on-fail:; with that attr
gone, hint ≡ prose). firstFailureHint reads prose directly.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):
step-start fires immediately before the mutation, step-end
immediately after (or on the failure path) — always balanced. The payload is
today's StepReporter payload (skill-apply.ts:80-83 — it already includes
line) unchanged, plus only the discriminating type field.nc:operator, the engine substitutes {{vars}}, pushes to
res.operatorMessages, then await 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 is blocked (an earlier bounce), operator directives are
skipped instead — no event, no operatorMessages entry, recorded in
skipped. 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.onEvent call is awaited; a rejection from onEvent is treated like
any other throw at that directive (bounce, not crash). This applies to
operator events too: a consumer that throws from onEvent accepts the
bounce consequence, including the blocked latch 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.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.
| 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) |
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).
| 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>).
| 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) |
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):
inputs[var] first, else await resolveInput(var, meta).
Both undefined ⇒ defer (push bare var name — unchanged).normalize: (unchanged, already both-paths — normalizeValue, :483).validate: (+ flags:), test the normalized
value. On mismatch: the var stays unbound, and
`<var>: invalid value (does not match validate:<re>)` is pushed to
deferred. Not an agentTask, not a throw — downstream consumers of the var
defer exactly as if the value were never supplied, and fullyApplied is
false. A pipeline passing a malformed env value fails loudly.Notes:
^https:// check — matches the teams public_url authoring).inputs value does not fall through to resolveInput — 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 .env credentials is closed on the driver side instead:
reuseFromEnv pre-filters every offer through the prompt's
normalize/validate/flags meta, 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 raw inputs (pipeline, tests) still fails
loudly — that is the point.resolveInput (clack
validate), so engine-level rejection rarely fires interactively; it is the
backstop for programmatic paths.validate: is unchanged (it already throws → bounces,
skill-apply.ts:551-572 — a command's output has no human to re-ask).inputs value violating its prompt's
declared validate: must update to a valid-shaped value. Known: the Option-A
slack fixture (run-channel-skill.test.ts:49 — signing_secret,
owner_handle, with the userId assertion at :62 updated consistently)
and the teams deferWire fixture (:102-107 — app_password vs. the new
^.{20,}$). Sweep scripts/skill-apply.test.ts fixtures the same way.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:
clack p.confirm, TTY-gated exactly like spinnerReporter
(skill-driver.ts:260) — non-TTY resolves true (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.ts openUrl, attempted only after a confirm yes.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.
For each nc:operator directive at line L, gatePolicy computes
needsConfirm(L):
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's when:mode=local operator at :111 skips the two
remote-only prompts and gates on the local configure run at :151;
whatsapp's when:auth_method=qr operator at :96 skips the pairing-code
operator at :104 — guard-incompatible — and gates on the qr step at :114.)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.)prompt → no confirm (the prompt is the barrier).:228).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."). gatePolicy returns 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):
: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 authored
gates reproduced — behavior identical.:134 (→ run effect:step pairing :142) gains a confirm with
readiness phrasing — this restores the old bespoke flow's readiness pause
that the directive port lost.: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.:111 (when:mode=local) → guard-skips :126,:134,:137 →
run effect:external :151: confirm. :126 (when:mode=remote) →
prompt :134: no confirm.: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.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 |
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.
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).
foo_bar, read
NC_INPUT_FOO_BAR (prefix NC_INPUT_, var uppercased). A small helper
inputsFromEnv(md: string, env = process.env) (driver-agnostic, implemented
at scripts/skill-inputs.ts) parses the skill's prompt vars via parseDirectives and
returns the inputs record. Var names are case-sensitive in the grammar
(skill-directives.ts:111), so uppercasing can collide (bot_token vs
Bot_Token): inputsFromEnv errors 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). No resolveInput,
no onEvent required (optionally an onEvent that logs step events as CI
lines).applySkill(skillDir, root, { inputs, exec, execStream?, skipEffects })
with skipEffects per deployment (e.g. ['restart'] when the deploy restarts once).inputs binds
prompt vars only — the engine never reads it for run/step captures
(skill-apply.ts:773 — prompt branch only), so a pipeline cannot pre-supply
a capture-bound var like platform_id. And effect:step without a real
execStream throws → 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
an effect:step are wizard/relay-only for full application — today that is
telegram (:142), whatsapp (:114,:117), and signal (:105). slack,
discord, teams, and imessage carry no effect:step and can go fully green
from env inputs (+ exec), with their operator blocks landing in the
manual-steps report. A pipeline-grade execStream, or letting inputs
pre-bind capture vars (bound var ⇒ capture skipped), would move that
boundary — deliberately out of scope here (open question 10).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, printing
res.deferred (which now includes §4 invalid-input reasons — a malformed
env value is a loud failure) and firstFailureHint(res) + each
agentTask.reason for real bounces.res.vars → exported for downstream jobs (e.g. platform_id into a wire step).A coding agent driving a skill over chat implements the two seams:
resolveInput(name, meta): send meta.question to the chat; for
meta.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 against meta.validate/meta.flags conversationally ("that doesn't look
like a bot token — it should start with xoxb-"); return the final answer, or
undefined if 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 — importing gatePolicy from the shared
scripts/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").exec, journal, bounce handling) is identical to the wizard;
the agent reads agentTasks[].prose and applies bounced steps itself — which
is exactly the degrade-to-agent path the prose was written for.Historical — this plan was executed on feat/structured-skill-format; kept as the record of how the refactor landed.
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).
scripts/skill-apply.ts: add resolveInput + onEvent + InputMeta +
ApplyEvent; engine prefers them when present (falls back to
prompter/reporter if not — temporary); implement validate-at-bind (§4)
and the awaited-event ordering; re-document stepLabel null semantics (§2).
Includes the §4 fixture sweep: Option-A slack inputs + userId
assertion, teams deferWire app_password, and any skill-apply.test.ts
fixture 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 async onEvent that records completion),
resolveInput meta contents, validate-at-bind for inputs AND resolveInput
answers (normalize-then-validate, no-fallthrough from invalid inputs to
resolveInput, deferred entry format, fullyApplied false, secret never in
the entry), onEvent-throw ⇒ bounce (incl. on an operator event).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: resolveInput impl (ex-clackPrompter.ask,
help-escape intact), default onEvent handler (spinner branch from
spinnerReporter + operator branch consuming gatePolicy/extractOfferUrl),
new confirm/openUrl options with TTY-gated defaults (§5.0), decline =
proceed, reuseFromEnv validate-pre-filter (§5.4), §5.3 error text;
RunSkillOptions.prompter/reporter → resolveInput/onEvent (+ documented
replacement semantics for injected onEvent);
setup/channels/run-channel-skill.ts override renames + confirm/openUrl
passthrough; setup/providers/install.ts drops its stub Prompter. Reuse
tests migrate to the confirm seam; the teams run-channel test runs the
default handler with injected confirm/openUrl (§9). After this step no
in-tree caller passes prompter/reporter.Prompter, PromptOpts,
StepReporter, ApplyOptions.prompter/reporter; remove engine handling of
open:/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).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 the open: removal (body URL, §5.2 inventory).scripts/skill-directives.ts: drop min:/error:/open:/gate
validation + 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.)Prompter|PromptOpts|StepReporter|on-fail:|label:|min:|error: (in the seam
sense) and operator.*(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.Historical — executed alongside §8; line numbers reference the pre-refactor tree.
:49/:62, teams deferWire
:106, plus any skill-apply.test.ts siblings the sweep finds). New
describe blocks in scripts/skill-apply.test.ts: onEvent events (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).setup/lib/skill-driver.test.ts fakes (:73,:100) rewritten
mechanically to resolveInput/onEvent; the reuse-offer tests
(:149,:167,:202) migrate to the injected confirm option (accept /
decline / helper-reuse paths preserved); the clackPrompter.open
existence test (:218-223) becomes a URL-offer policy test; help-escape tests
(:225-259) keep their shape (the escape lives in the driver's resolveInput);
promptValidator test (:261-271) loses min/error, gains prose-derived-message
assertions. Policy tests at scripts/skill-policy.ts's home encode the
§5.1 parity table: teams (confirm ×2, no confirm on the :158 chain head —
the operator-chain rule's regression fixture), telegram/signal/whatsapp
(readiness-flavor pause), imessage (guard-compatibility), end-of-document;
plus extractOfferUrl fixtures: 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 default onEvent
handler with injected confirm/openUrl (never an injected onEvent, which
replaces the policy — §5.0).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) become InputMeta assertions.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) drops min:.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.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.:158 (portal.azure.com),
discord :70 (developers portal), imessage :126 (photon.codes) had no
open: 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.restart|step|wire). Needed for
teams parity (its first gate precedes effect:check+external). Consequence:
an operator block directly followed by e.g. an env-set would also confirm —
no such authoring exists today.inputs do NOT fall
through to resolveInput (§4) — the driver's reuse pre-filter (§5.4) is the
interactive recovery path.NC_INPUT_<VAR> (§6) and that inputsFromEnv is a
helper, not an engine feature (inputs stay the only env-agnostic seam). Prefix
bikeshed welcome; the engine never reads process.env for inputs.
inputsFromEnv errors on an uppercase collision; a lowercase-var lint rule
would make the mapping bijective.onEvent absent the
block is still collected in operatorMessages (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 from onEvent opts 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.hint field removal (vs. keeping it always-equal-to-prose for
result-shape stability). Removal chosen for the clean break; any external
consumer of hint (none in-tree) would break.min:20 regex chosen as ^.{20,}$ (any 20+ chars). If the intent
was tighter (Azure secret alphabet), tighten in the skill edit — the seam
doesn't care.effect:step skills (§6). telegram/whatsapp/signal
cannot go fully green in a pipeline (no human at the QR/pairing step, and
inputs cannot pre-bind capture vars). Two possible future escapes — a
pipeline-grade execStream contract, or inputs pre-binding capture vars
(bound var ⇒ capture skipped) — both deliberately out of scope; the spec'd
behavior is the loud fullyApplied:false.step-end on 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 own resolveInput
loop already provided it.run-channel-skill.ts:122 is exec; the prompter passthrough is at :123"
(review 1, citation sweep) — rejected: grep -n shows :121 exec: overrides.exec,
and :122 prompter: overrides.prompter,. The spec's :122 citation was
correct as written. (The same review's other three citation fixes —
resolveRemote :283, teams :101 body-line wording, label: doc mention
:451 — were verified correct and are folded.)