@jenga-ai/agent 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/agents/developer.md +15 -15
  3. package/agents/scrum-master.md +17 -17
  4. package/agents/tester.md +25 -15
  5. package/lib/skill-allow-list.json +3 -2
  6. package/package.json +1 -1
  7. package/scripts/audit-twin-divergence.sh +625 -0
  8. package/scripts/build-pages-site.sh +268 -0
  9. package/scripts/check-public-playbook-steps.sh +136 -0
  10. package/skills/j-close-story/SKILL.md +1 -1
  11. package/skills/j-do/SKILL.md +19 -19
  12. package/skills/j-doc-sync/SKILL.md +12 -1
  13. package/skills/j-idea/SKILL.md +1 -1
  14. package/skills/j-init/SKILL.md +5 -4
  15. package/skills/j-init/assets/directory_structure.txt +1 -0
  16. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  17. package/skills/j-init/scripts/init.sh +13 -2
  18. package/skills/j-playbook/SKILL.md +81 -0
  19. package/skills/j-proceed/SKILL.md +1 -1
  20. package/skills/j-publish/SKILL.md +1 -1
  21. package/skills/j-publish/adapters/npm-ci.md +29 -0
  22. package/skills/j-publish/scripts/npm_ci_pipeline.sh +3 -0
  23. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  24. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  25. package/skills/j-reconcile/SKILL.md +1 -0
  26. package/skills/j-redo/SKILL.md +1 -1
  27. package/skills/j-status/SKILL.md +12 -0
  28. package/skills/j-todo/SKILL.md +2 -2
  29. package/skills/j-uncharted/SKILL.md +8 -7
  30. package/skills/j-uncharted/scripts/elicitation-state.sh +15 -1
  31. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  32. package/skills/jenga/SKILL.md +80 -9
  33. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  34. package/skills/jenga/playbooks/schema.json +42 -0
  35. package/skills/jenga/scripts/detect-nl-intent.sh +179 -0
  36. package/skills/jenga/scripts/load-nl-catalog.js +206 -0
  37. package/skills/jenga/scripts/load-nl-catalog.sh +65 -0
  38. package/skills/jenga/scripts/load-playbooks.sh +1022 -0
  39. package/skills/jenga/scripts/match-playbook.sh +262 -0
  40. package/skills/jenga/scripts/render-playbook-confirmation.sh +517 -0
  41. package/skills/jenga/scripts/run-playbook-step.sh +766 -0
  42. package/skills/jenga-permission-level/SKILL.md +4 -4
  43. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  44. package/templates/playbook-types.json +8 -0
@@ -1,6 +1,9 @@
1
1
  ---
2
2
  name: j.jenga
3
3
  description: Interactive-by-default board orchestrator with a fully automated escape hatch. Bare `/jenga` renders a picker and confirmation tree before scoping the run; `/jenga <ids>` resolves an explicit fuzzy-ID scope and confirms it; `/jenga *` reproduces the original zero-prompt behavior — decomposing any unbroken Epics into Stories, any unbroken Stories into Tasks, queuing all unqueued Tasks into todo.md, then executing every eligible item with no user prompts — until the board is fully started.
4
+ output_types:
5
+ - when: detect-nl-intent
6
+ type: id_list
4
7
  keywords:
5
8
  - jenga
6
9
  - orchestrate
@@ -96,7 +99,7 @@ Do not proceed with this task.
96
99
 
97
100
  #### Rule 4 — crucial_level: locked forces execution_scope: inline
98
101
 
99
- If the task frontmatter contains `crucial_level: locked` (per `templates/SCRUM_BOARD_SCHEMA.md`'s Crucial Flag Fields), `execution_scope` for that task MUST be `inline` — only the current foreground/inline session can pause mid-run for a live confirmation; a backgrounded subagent has no live channel back to the user.
102
+ If the task frontmatter contains `crucial_level: locked` (per `$([ -f templates/SCRUM_BOARD_SCHEMA.md ] && echo templates/SCRUM_BOARD_SCHEMA.md || echo node_modules/@jenga-ai/agent/templates/SCRUM_BOARD_SCHEMA.md)`'s Crucial Flag Fields), `execution_scope` for that task MUST be `inline` — only the current foreground/inline session can pause mid-run for a live confirmation; a backgrounded subagent has no live channel back to the user.
100
103
 
101
104
  This rule **auto-corrects and continues**; unlike Rules 1-3, it never halts.
102
105
 
@@ -132,13 +135,15 @@ If all applicable rules pass (or the task is a legacy task), proceed to the next
132
135
 
133
136
  ### Phase 0.75 — Entry Mode Resolution
134
137
 
135
- This phase determines **how `/jenga` was invoked** and, for two of the three entry modes, produces a **scoped set** — a confirmed list of board IDs (epics/stories/tasks) that Phases 1-4 must restrict themselves to. All board scanning, ID parsing, cascade expansion, and rendering used by this phase already live in `skills/jenga/scripts/` per this repo's "Scripts Over Inline Logic" principle — this phase never re-implements any of that logic inline. The executing agent's job here is limited to: invoking the right script with the right arguments, relaying its STDOUT verbatim to the user when the contract calls for that, capturing the `STATE_FILE:` line from STDERR for the next turn, and forwarding the user's raw reply back into the next invocation unmodified.
138
+ This phase determines **how `/jenga` was invoked** and, for two of the four entry modes, produces a **scoped set** — a confirmed list of board IDs (epics/stories/tasks) that Phases 1-4 must restrict themselves to. All board scanning, ID parsing, cascade expansion, and rendering used by this phase already live in `skills/jenga/scripts/` per this repo's "Scripts Over Inline Logic" principle — this phase never re-implements any of that logic inline. The executing agent's job here is limited to: invoking the right script with the right arguments, relaying its STDOUT verbatim to the user when the contract calls for that, capturing the `STATE_FILE:` line from STDERR for the next turn, and forwarding the user's raw reply back into the next invocation unmodified.
136
139
 
137
140
  **Determine the invocation form** from the raw argument (if any) passed to `/jenga`:
138
141
 
139
142
  - No argument at all → **bare branch**.
140
143
  - The argument is the literal string `*` → **wildcard branch**.
141
- - Any other non-empty argument → **scoped branch** (treat the whole argument as the comma-separated raw ID list).
144
+ - Any other non-empty argument → invoke `skills/jenga/scripts/detect-nl-intent.sh "<raw argument>"` (E53_S01_T01) and branch on its `classification` field:
145
+ - `all_resolved` or `mixed` → **scoped branch** (below) — this is the same branch as before; only its internal mechanics changed (see below).
146
+ - `nl_intent` → **natural-language branch** (below) — new for E53_S01, no new sigil or entry point, purely a new outcome of this same argument-shape detection.
142
147
 
143
148
  #### Wildcard branch (`/jenga *`)
144
149
 
@@ -154,10 +159,61 @@ Skip both the picker and the confirmation step entirely. There is no scoped set
154
159
 
155
160
  #### Scoped branch (`/jenga <ids>`)
156
161
 
157
- 1. Invoke `skills/jenga/scripts/resolve-id.sh "<raw argument>"` directly — the picker is skipped entirely in this branch.
158
- 2. Parse the JSON array response, one object per comma-delimited input segment.
159
- - If **every** segment has `status: "resolved"`, collect their `resolved_id` values into a comma-separated list and continue to the shared confirmation step below.
160
- - If **any** segment has `status: "rejected"`, halt this phase (do not proceed to confirmation or Phase 1) and report each rejected segment's `input` and `reason` to the user verbatim, per `resolve-id.sh`'s own contract — a partial or ambiguous ID is never guessed. The user must re-invoke `/jenga <ids>` with corrected input.
162
+ This branch is entered when `detect-nl-intent.sh` (invoked above) classifies the argument as `all_resolved` or `mixed` — the picker is skipped entirely in this branch. `detect-nl-intent.sh` has already invoked `resolve-id.sh` internally and reduced its per-segment output to one of these two shapes; `skills/jenga/SKILL.md` never parses `resolve-id.sh`'s raw array itself (see `detect-nl-intent.sh`'s own header comment for the full classification contract, E53_S01_T01).
163
+
164
+ 1. On `all_resolved`, take the `resolved_ids` (or `resolved_ids_csv`) field directly from `detect-nl-intent.sh`'s output and continue to the shared confirmation step below.
165
+ 2. On `mixed`, halt this phase (do not proceed to confirmation or Phase 1) and report each entry in `detect-nl-intent.sh`'s `rejected` array — its `input` and `reason` — to the user verbatim; a partial or ambiguous ID is never guessed. The user must re-invoke `/jenga <ids>` with corrected input.
166
+
167
+ #### Natural-language branch (`/jenga <free-form text>`)
168
+
169
+ This branch is entered when `detect-nl-intent.sh` classifies the argument as `nl_intent` — every comma-delimited segment failed the ID grammar, so the raw argument is treated as natural-language intent rather than a malformed ID list. This is purely a new *outcome* of the same argument-shape detection above — no new sigil, trigger prefix, or separate entry point is introduced.
170
+
171
+ 1. **Load the catalog** — invoke `skills/jenga/scripts/load-nl-catalog.sh` with no arguments (E53_S01_T02). Its stdout is the full skill catalog (`name`/`description`/`keywords`/`examples`/`prefered_agent` per skill), sourced exclusively from `lib/generate-skill-allow-list.js`'s generated inventory — see the script's own header for the full contract. Never re-derive this catalog by re-scanning `skills/` inline.
172
+ 2. **Match** — run `skills/route/SKILL.md`'s **Step 2 — Match the Prompt to a Skill** (the three-pass keyword → example-similarity → description match, including its tie-break and no-match handling) against this catalog, treating `detect-nl-intent.sh`'s `raw_argument` field as the prompt. Reuse that section's matching logic by reference — do not re-author its prose here.
173
+ 3. **Confident single match** — report the routing decision using `skills/route/SKILL.md`'s **Step 7 — Report Routing Decision** format (substitute `/jenga` for `/route` as the invoking command named in the report), then invoke the matched skill exactly as `skills/route/SKILL.md`'s **Step 6 — Invoke the Matched Skill** already does: load `agents/<prefered_agent>.md` when the matched skill specifies `metadata.prefered_agent`, otherwise execute the skill instructions directly. The matched skill's own execution takes over from here — do not continue into this `/jenga` invocation's Phase 1.
174
+ 4. **No match, or an ambiguous multi-way tie (single-skill match)** — before surfacing `/route`'s generic disambiguation options, attempt a **playbook fallback** (E53_S02): invoke `skills/jenga/scripts/match-playbook.sh "<raw_argument>"`. This step only ever runs when step 3 above did NOT already commit to a confident single-skill match — a confident single-skill match always wins outright and this playbook fallback is never even invoked in that case. Branch on `match-playbook.sh`'s `classification` field:
175
+ - `playbook_match` → continue to **step 5 (Playbook proposal and execution)** below.
176
+ - `ambiguous` or `no_match` → continue to **step 6 (Fall through to `/route`'s disambiguation)** below — the exact behavior this branch already had before E53_S02, unchanged.
177
+ 5. **Playbook proposal and execution** — entered only on a `playbook_match` result from step 4. A proposed playbook is an ordered chain of skills (e.g. the canonical `brainstorm -> j.todo -> j.do -> j.dev-done -> j.mirror-public` chain defined in `skills/jenga/playbooks/brainstorm-to-mirror.json`) that must be confirmed, editable, and confirmable per `CLAUDE.md`'s Interaction Pattern before any step executes — the same confirm-before-execute posture `/jenga` already applies to the bare/scoped branches via `render-confirmation.sh`.
178
+ a. **Resolve conditional metadata** (`E53_S04_T02`/`T04`) — before rendering, inspect the
179
+ matched playbook's own `steps` array (as returned by `load-playbooks.sh`'s catalog, not the
180
+ flattened string list) for any StepObject carrying a `conditional: {"depends_on": "<name>",
181
+ "predicate": "..."}` field. Build a JSON object mapping each such step's name to its
182
+ `depends_on` step's name only (e.g. `{"stepC": "stepA"}` — the confirmation display needs no
183
+ predicate detail, only which step to point at). If no step carries a `conditional`, this
184
+ object is empty/omitted.
185
+ **Also resolve composition origin metadata** (`E53_S05_T01`/`T03`) — inspect the SAME
186
+ `steps` array (`match-playbook.sh`'s `steps` field is already load-playbooks.sh's fully
187
+ FLATTENED, composition-resolved catalog output — composition is invisible to
188
+ `match-playbook.sh` itself; there is nothing further to "flatten" at this point, only to
189
+ read) for any StepObject carrying `_origin_playbook`/`_origin_depth` (present only on steps
190
+ whose `_origin_depth` is greater than 1 — see `load-playbooks.sh`'s header "COMPOSITION
191
+ RESOLUTION"). Build a JSON object mapping each such step's name to `{"playbook_id":
192
+ "<_origin_playbook>", "depth": <_origin_depth>}`. If no step in this playbook came from a
193
+ composed/nested playbook, this object is empty/omitted.
194
+ b. **Render and confirm the chain** — invoke `skills/jenga/scripts/render-playbook-confirmation.sh "<playbook_id>" "<name>" "<comma-separated steps>" ["<json-conditionals from 5a>" ["<json-origins from 5a>"]]` (start mode, using `match-playbook.sh`'s `playbook_id`/`name`/`steps` fields verbatim; the 4th argument is omitted entirely when 5a produced no conditionals, and the 5th argument is omitted entirely when 5a produced no composition-origin metadata — omitting both reproduces the exact pre-`E53_S04` 3-arg call, and omitting only the 5th reproduces the exact pre-`E53_S05` 4-arg call). Relay STDOUT (the numbered chain + instructions, now marking any conditional step with "(may be skipped depending on step N's result)" and any composed/nested step with indentation plus "(from playbook: <id>, depth N)") to the user verbatim. Capture the `STATE_FILE:` path from STDERR.
195
+ c. Wait for the user's chat reply, then invoke `skills/jenga/scripts/render-playbook-confirmation.sh <state_file> "<raw_reply>"` (continue mode).
196
+ - **Toggle or error turn** (plain text on STDOUT, state file retained) — relay verbatim and return to step 5c for another reply. This loops exactly as the existing bare/scoped confirmation flow's own toggle/error turns already do.
197
+ - **Cancellation** — relay the cancellation acknowledgement and halt the entire `/jenga` invocation immediately, with no step executed — identical posture to the existing picker/confirmation cancellation edge cases already documented for the bare/scoped branches (see "## Edge Cases" below).
198
+ - **Confirmed** (JSON object on STDOUT, state file removed) — take the confirmed `steps` array (checked-only, in original playbook order) and continue to step 5d.
199
+ d. **Initialize the sequential runner** — re-derive the `<json-conditionals>` object for `run-playbook-step.sh init` from the same StepObject data as 5a, but in `init`'s own richer shape (`{"<step>": {"depends_on": "...", "predicate": "..."}}`, `E53_S04_T02`), and apply one filtering rule first: **drop any conditional whose `depends_on` step is not present in the CONFIRMED step list from 5c.** The user may have unchecked the depended-on step at confirmation; `init` would otherwise reject such a conditional outright (its existence check requires `depends_on` to be an earlier step in the run), and the more sensible fallback than a hard failure over the user's own edit is to treat that now-conditionless step as always-running for this particular execution. **Also re-derive the composition origin metadata** (`E53_S05_T04`) for `init`'s optional 5th argument, from the same `_origin_playbook`/`_origin_depth` data as 5a, filtered the same way (drop any entry for a step the user unchecked at confirmation — `init` requires every key to be present in the confirmed step list). Invoke `skills/jenga/scripts/run-playbook-step.sh init "<playbook_id>" "<name>" "<comma-separated confirmed steps>" ["<json-conditionals, filtered>" ["<json-origins, filtered>"]]` (the 4th argument is omitted when empty, and the 5th is omitted when empty — omitting both reproduces the exact pre-`E53_S04` 3-arg call, and omitting only the 5th reproduces the exact pre-`E53_S05` 4-arg call). Its `step_ready` result names the first step to invoke.
200
+ e. **Execute steps in a loop** — for the step named by the runner's most recent `step_ready` result:
201
+ i. **Evaluate whether this step should run** (`E53_S04_T02`) — invoke `skills/jenga/scripts/run-playbook-step.sh should-skip <state_file>`.
202
+ - `{"skip": true, ...}` — do **not** invoke the step. Call `skills/jenga/scripts/run-playbook-step.sh advance <state_file> skipped` directly (never `passed`/`failed` for a step that never ran), then handle its result exactly as iv-vi below (`step_ready`/`complete`/`halted`) — skip step 5e-ii and 5e-iii entirely for this step, since it never runs. This is non-blocking and non-failing — it never halts the chain, and is not narrated to the user turn-by-turn (it is folded into the final `completed`/`skipped` summary reported at `complete`/`halted`, the same posture already given to individual `passed` steps, which also aren't separately narrated mid-chain).
203
+ - `{"skip": false, ...}` — proceed to step 5e-ii and invoke the step normally.
204
+ ii. **Resolve `forward_from` and `resolve`, if the step declares either** (`E53_S03`, runtime-wired by `E53_S04_T02`; `resolve` runtime behavior added by `E53_S06_T01`) — invoke `skills/jenga/scripts/run-playbook-step.sh get-output <state_file> <named source step>` before invoking this step. On `{"status": "found", "value": "..."}`, use that value as this step's actual invocation input (per the design's forwarding semantics). On `{"status": "unavailable", "reason": "step_skipped"}` or `"reason": "not_captured"` — the named source produced no usable value (it was itself skipped, or never captured one) — do not guess a fallback value; treat this exactly like a step failure: call `advance <state_file> failed "forward_from source '<name>' unavailable (<reason>)"` and follow the `halted` handling in 5e-vi below, without ever invoking this step. This is the documented, non-silent failure mode for a `forward_from` naming a skipped step (`E53_S04_T06`'s fixture coverage).
205
+
206
+ **Then, if the step also declares a non-empty `resolve` field** (`E53_S06_T01`) — this is a documented, named, scoped exception to `CLAUDE.md`'s "Skill Implementation Principle — Scripts Over Inline Logic": open-ended reshaping/filtering/type-bridging genuinely needs the agent's own LLM judgment, which a deterministic script cannot provide, and `resolve` is never used for anything else in this codebase's playbook mechanism — in particular, never to pre-authorize a downstream confirmation, a combination `load-playbooks.sh` already rejects outright at load time (see `docs/skill-authoring.md`'s "The `resolve` / confirmation-gate rule"):
207
+ - **No-op without `forward_from`** — a step carrying `resolve` but no `forward_from` (or one that resolved to no forwardable value) has nothing to reshape. This is a defined, non-crashing runtime behavior, not an error: the `resolve` field is simply ignored for that step, and invocation proceeds exactly as it would with no `resolve` field at all.
208
+ - **Apply the transform** — when both `forward_from` (successfully resolved immediately above) and `resolve` are present, use your own LLM judgment to reshape/filter/type-bridge the forwarded value per `resolve`'s natural-language instructions (e.g. "pick the first three items", "convert this file_list to a text summary"). The transformed value — never the raw forwarded value — becomes this step's actual invocation input.
209
+ - **Hard-fail, never silent pass-through** — if the transform cannot cleanly produce a usable, type-compatible result (the instructions don't plausibly apply to the actual value, the value is empty/malformed for what's being asked, or the result would not plausibly satisfy the target step's expected input shape), do **not** invoke this step and do **not** guess or pass through a differently-shaped value. Instead call `skills/jenga/scripts/run-playbook-step.sh advance <state_file> failed "<note>"`, where `<note>` follows the format `resolve failed on step '<step name>': could not apply "<resolve text>" to raw value <raw pre-transform value> — <short reason>` (the raw pre-transform value is always included, for debugging). Then follow the `halted` handling in 5e-vi below exactly as any other step failure — immediately stop executing further steps, report `failed_step`/`failed_note`/`completed`/`skipped`/`never_run` verbatim.
210
+
211
+ Then invoke the step exactly as `skills/route/SKILL.md`'s **Step 6 — Invoke the Matched Skill** already does for a single matched skill: load `agents/<prefered_agent>.md` when that step's own `SKILL.md` specifies `metadata.prefered_agent`, otherwise execute its instructions directly.
212
+ iii. After a normally-invoked step's execution concludes, call `skills/jenga/scripts/run-playbook-step.sh advance <state_file> passed ["<typed-output-value>"]` (the step completed successfully — supply the step's declared typed output, per its `output_types`, if it produced one) or `... advance <state_file> failed "<short failure note>"` (the step failed).
213
+ iv. On a `step_ready` result, repeat step 5e for the newly-named step.
214
+ v. On a `complete` result, report the full lists of `completed` AND `skipped` steps to the user and stop — the playbook run is finished; do not continue into this `/jenga` invocation's Phase 1.
215
+ vi. On a `halted` result, **immediately stop executing any further steps** — no silent skip-ahead. Report `failed_step`, `failed_note`, `completed`, `skipped` (steps that already finished or were skipped), and `never_run` (steps that never got a chance to run) to the user verbatim from the halt report. Do not continue into this `/jenga` invocation's Phase 1.
216
+ 6. **Fall through to `/route`'s disambiguation** — entered when step 4 found no playbook match (`ambiguous` or `no_match`). Surface the same disambiguation options `skills/route/SKILL.md`'s **Step 2** already defines for these cases (browse `/help`, create a new skill via `/btw`, or proceed with the raw prompt) by reference to that section — do not re-copy its prose. Halt this `/jenga` invocation once the user picks an option; none of Phase 0.75's remaining steps or Phases 1-4 run for this branch.
161
217
 
162
218
  #### Shared confirmation step (bare and scoped branches only)
163
219
 
@@ -208,7 +264,7 @@ For each in-scope story that has one or more tasks listed in `todo.md`:
208
264
  2. **Guard: empty task list** — if the `tasks:` list is empty (zero entries), this story is **not** eligible for the bundle path. Skip to per-task dispatch in Phase 4.
209
265
  3. **Read each task file** — for every task ID in the `tasks:` list, read the corresponding task file from `project/board/tasks/`.
210
266
  4. **Collect `execution_scope`** — extract the `execution_scope` field from each task's YAML frontmatter. If the field is absent or has any value other than `story`, treat that task as **not** story-scoped.
211
- 5. **Guard: locked-task disqualifier (defense-in-depth)** — for each task file already read in step 3, also read `crucial_level` (per `templates/SCRUM_BOARD_SCHEMA.md`'s Crucial Flag Fields). If **any** task in the story's `tasks:` list has `crucial_level: locked`, this story is **not** eligible for the bundle path — skip to per-task dispatch in Phase 4 for this story, **regardless of that task's `execution_scope` value**, even if it already reads `inline`. This check is defense-in-depth alongside Phase 0.5's Rule 4 (which forces a locked task's own `execution_scope` to `inline` when Rule 4 processes it): it exists for the race window where Rule 4 hasn't (yet) corrected the task — e.g. the task was added to the story's `tasks:` list after Rule 4 last ran, or the file was edited by hand after validation. It is not a replacement for Rule 4.
267
+ 5. **Guard: locked-task disqualifier (defense-in-depth)** — for each task file already read in step 3, also read `crucial_level` (per `$([ -f templates/SCRUM_BOARD_SCHEMA.md ] && echo templates/SCRUM_BOARD_SCHEMA.md || echo node_modules/@jenga-ai/agent/templates/SCRUM_BOARD_SCHEMA.md)`'s Crucial Flag Fields). If **any** task in the story's `tasks:` list has `crucial_level: locked`, this story is **not** eligible for the bundle path — skip to per-task dispatch in Phase 4 for this story, **regardless of that task's `execution_scope` value**, even if it already reads `inline`. This check is defense-in-depth alongside Phase 0.5's Rule 4 (which forces a locked task's own `execution_scope` to `inline` when Rule 4 processes it): it exists for the race window where Rule 4 hasn't (yet) corrected the task — e.g. the task was added to the story's `tasks:` list after Rule 4 last ran, or the file was edited by hand after validation. It is not a replacement for Rule 4.
212
268
  6. **Apply the all-or-nothing rule** — a story qualifies for the bundle path **only if every task** in its `tasks:` list has `execution_scope: story`. A single task with a different scope (or a missing field) disqualifies the entire story.
213
269
  7. **Route bundle candidates** — if all tasks in the story are `execution_scope: story` and the list is non-empty:
214
270
  a. Emit:
@@ -255,6 +311,21 @@ When no eligible candidates remain in Phase 4, exit and output:
255
311
  - **Bundle `/do` call failure** — treated as a skip for the entire bundle; mark all bundled tasks' status back to `Pending` and continue Phase 4 with remaining non-bundled candidates.
256
312
  - **Picker cancelled (bare branch)** — the entire `/jenga` run halts immediately after relaying the cancellation acknowledgement; no phase past 0.75 runs, and nothing on the board is modified.
257
313
  - **Confirmation cancelled (bare or scoped branch)** — same as picker cancellation: the entire `/jenga` run halts immediately; no scoped set is produced and no later phase runs.
258
- - **`resolve-id.sh` rejects one or more segments (scoped branch)** — the whole invocation halts at Phase 0.75 with the rejected segments' reasons reported verbatim; no partial scope is assembled from the segments that did resolve, and no fallback guess is made for the rejected ones. The user must re-invoke `/jenga <ids>` with corrected input.
314
+ - **`detect-nl-intent.sh` classifies the argument as `mixed` (scoped branch)** — the whole invocation halts at Phase 0.75 with each rejected segment's `input`/`reason` reported verbatim, per `detect-nl-intent.sh`'s own classification contract (E53_S01_T01); no partial scope is assembled from the segments that did resolve, and no fallback guess is made for the rejected ones. The user must re-invoke `/jenga <ids>` with corrected input.
315
+ - **`detect-nl-intent.sh` classifies the argument as `nl_intent`, no confident single-skill match, and `match-playbook.sh` (E53_S02) also finds no playbook match** — the natural-language branch's step 4 attempts the playbook fallback first (see the Natural-language branch's step 4/6), and only THEN surfaces `skills/route/SKILL.md`'s Step 2 no-match disambiguation options (browse `/help`, create a new skill via `/btw`, proceed with the raw prompt) instead of guessing; no phase past 0.75 runs until the user picks one.
316
+ - **`detect-nl-intent.sh` classifies the argument as `nl_intent`, no confident single-skill match, and `match-playbook.sh` returns an ambiguous multi-way tie between playbooks** — treated the same as the no-playbook-match case above: falls through to `skills/route/SKILL.md`'s Step 2 tie-break prompt (top candidates + a "neither, describe what you need" option) instead of guessing; no phase past 0.75 runs until the user picks one. (`match-playbook.sh`'s own `ambiguous` result — a tie between playbooks — is intentionally not given its own separate disambiguation UI; it is treated identically to `no_match` and routed to the same `/route` Step 2 fallback prose, which already has its own tie-break handling.)
317
+ - **`match-playbook.sh` returns `playbook_match` and the user confirms the full chain, and every step succeeds** — the Natural-language branch's step 5e reports the full `completed` AND `skipped` steps lists to the user and stops; `/jenga`'s own Phase 1 never runs for this invocation (execution was already fully handled by the playbook's own steps, e.g. `j.do`/`j.dev-done`).
318
+ - **`match-playbook.sh` returns `playbook_match` but the user cancels at the chain confirmation step (step 5c)** — identical posture to the existing picker/confirmation cancellation cases above: the entire `/jenga` run halts immediately after relaying the cancellation acknowledgement, with NO step of the chain executed; nothing on the board is modified by this invocation.
319
+ - **`match-playbook.sh` returns `playbook_match`, the user confirms, and a step mid-chain fails** — the Natural-language branch's step 5e(vi) halts immediately on `run-playbook-step.sh`'s `halted` result: no step after the failed one runs (no silent skip-ahead), and the user is shown exactly which steps already completed or were skipped, which step failed (with its note), and which steps never ran.
320
+ - **A step's conditional predicate evaluates false (`E53_S04_T02`)** — the Natural-language branch's step 5e(i) never invokes that step at all; it calls `advance <state_file> skipped` directly, the step is recorded under the run's `skipped` list (never `completed`, never `failed`), and the chain continues to the next step exactly as it would after a `passed` step — a skipped step never halts the chain and is not narrated to the user as a separate turn, only reflected in the final `completed`/`skipped` summary (or the `halted` report's `skipped` field, if a later step fails).
321
+ - **A `skipped` step is later named by a `forward_from`** — the Natural-language branch's step 5e(ii) calls `get-output` for the named source before invoking the dependent step; a skipped source returns `{"status": "unavailable", "reason": "step_skipped"}`. This is the documented, non-silent failure mode (`E53_S04_T06`'s fixture coverage): the calling agent does NOT guess a fallback value or silently forward an empty string — it calls `advance <state_file> failed "forward_from source '<name>' unavailable (<reason>)"` and the chain halts with a `halted` report naming the dependent step as `failed_step`, exactly as any other step failure would.
322
+ - **A `resolve` step carries no `forward_from` (`E53_S06_T01`)** — the Natural-language branch's step 5e(ii) treats this as a defined no-op: there is no forwarded value to reshape, so the `resolve` field is simply ignored and the step is invoked normally with whatever input it would otherwise have received. This is never surfaced to the user as an error or a warning.
323
+ - **A `resolve` step's transform cannot cleanly produce a usable result (`E53_S06_T01`)** — the Natural-language branch's step 5e(ii) never invokes the target step and never guesses or passes through a differently-shaped value; it calls `advance <state_file> failed "<note>"` with a note that includes the raw pre-transform value, and the chain halts exactly as any other step failure would (5e-vi) — no silent skip-ahead, no fallback value.
324
+ - **A playbook step carries a `conditional`, shown at confirmation (`E53_S04_T04`)** — the Natural-language branch's step 5b relays `render-playbook-confirmation.sh`'s rendered chain, which visibly marks that step's line with "(may be skipped depending on step N's result)" — confirming a chain with a conditional step never hides its real conditional structure from the user, even though checking/unchecking that step still works exactly like any other step (the marker is display-only; the actual runtime skip decision belongs entirely to `should-skip`, independent of what the user checks or unchecks here).
325
+ - **The user unchecks, at confirmation, the specific step a later step's conditional depends on** — the Natural-language branch's step 5d drops that conditional before calling `init` (its `depends_on` step is no longer in the confirmed list, and `init` would otherwise reject the conditional outright as naming a nonexistent earlier step). The dependent step becomes unconditional for this run and always executes — a deliberate, documented fallback rather than a hard failure over the user's own edit.
326
+ - **A playbook contains a cyclic `{"playbook": "<id>"}` reference (`E53_S05_T01`)** — rejected entirely at `load-playbooks.sh` load time, before `/jenga` ever runs: the whole cyclic playbook is dropped from the catalog with a stderr warning naming the cycle. It is never surfaced as a `match-playbook.sh` candidate at all (a dropped playbook simply doesn't exist in the catalog `match-playbook.sh` matches against) — there is no runtime-visible error for this case, only a load-time one a human reviewing stderr output would see.
327
+ - **A playbook's composition nests deeper than the configured `max_composition_depth` (`E53_S05_T01`, default 3)** — same posture as the cyclic-reference case above: dropped at `load-playbooks.sh` load time with a stderr warning, never surfaced as a `match-playbook.sh` candidate, no runtime-visible error.
328
+ - **A `forward_from` crosses a composition boundary (`E53_S05_T02`)** — transparent in both directions with no special handling required anywhere in this SKILL.md: `load-playbooks.sh`'s composition resolution runs before its `forward_from`/`conditional` validation, so by the time a playbook reaches `match-playbook.sh`'s catalog, its `steps` array is already fully flattened — a step from a composed/nested playbook forwarding from (or being forwarded into by) a step outside it behaves exactly like any other `forward_from` relationship in step 5e(ii); the Natural-language branch never needs to know or care which originating playbook a step came from.
329
+ - **A playbook step carries composition origin metadata (depth > 1), shown at confirmation (`E53_S05_T03`)** — the Natural-language branch's step 5b relays `render-playbook-confirmation.sh`'s rendered chain, which now visibly indents and labels that step's line with "(from playbook: <id>, depth N)" — composing another playbook's steps into a chain never hides where one playbook ends and another begins from the user, even though the whole chain is still ONE numbered, editable, confirmable list (never a separate confirmation per nested playbook) and checking/unchecking a composed step still works exactly like any other step.
259
330
  - **`/jenga *` (wildcard branch)** — never produces a scoped set; Phases 1-4 run fully unrestricted over the entire board, identical to `/jenga`'s behavior before Phase 0.75 existed.
260
331
  - **Stale out-of-scope story queued in `todo.md` from an earlier run (scoped run only)** — Phase 3.5's scoped-set guard skips it entirely (not considered for bundling), so it cannot be dispatched via a bundle `/do <E##_S##>` call that would otherwise bypass Phase 4's own scoped-set exclusion; it remains untouched in `todo.md` until a future run's scope includes it.
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": "idea-to-committed",
3
+ "name": "Idea to Committed",
4
+ "description": "Takes a rough idea through planning, board capture, implementation, and a commit -- the end-to-end Jenga workflow chain, stopping at the commit rather than at a release.",
5
+ "keywords": [
6
+ "idea to commit",
7
+ "plan build commit",
8
+ "brainstorm to commit",
9
+ "capture and implement",
10
+ "start to commit"
11
+ ],
12
+ "examples": [
13
+ "I have an idea, help me plan it, build it, and commit it",
14
+ "take this feature from a rough idea through to a commit",
15
+ "plan this out, put it on the board, implement it, and commit the work",
16
+ "walk this through planning, implementation, and committing",
17
+ "help me think this through, break it down, build it, and commit"
18
+ ],
19
+ "steps": ["j-brainstorm", "j-todo", "j-do", "j-commit"]
20
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://jenga.local/schemas/jenga-playbook.schema.json",
4
+ "title": "Jenga Multi-Skill Playbook",
5
+ "description": "Schema for a single multi-skill playbook definition consumed by skills/jenga/scripts/load-playbooks.sh (E53_S02_T01). A playbook is a dedicated, versionable data file describing an ORDERED chain of skills that /jenga's natural-language branch may propose (as an editable, confirmable numbered list -- see skills/jenga/scripts/render-playbook-confirmation.sh, E53_S02_T03) when free-text intent spans more than one skill and does not cleanly resolve to a single one via skills/route/SKILL.md's Step 2 matching. This file itself (schema.json) is never treated as a playbook -- load-playbooks.sh explicitly excludes it by filename when scanning skills/jenga/playbooks/*.json.",
6
+ "type": "object",
7
+ "required": ["id", "name", "description", "keywords", "examples", "steps"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "id": {
11
+ "type": "string",
12
+ "description": "Stable, unique, kebab-case identifier for this playbook (e.g. \"brainstorm-to-mirror\"). Must equal the filename's basename without the .json extension -- load-playbooks.sh validates this so a playbook's id can never silently drift from its file location.",
13
+ "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
14
+ },
15
+ "name": {
16
+ "type": "string",
17
+ "description": "Short human-readable display name shown to the user in the confirmation prompt and in routing/report output, e.g. \"Idea to Public Release\"."
18
+ },
19
+ "description": {
20
+ "type": "string",
21
+ "description": "One-sentence explanation of what this playbook accomplishes end-to-end, used as the lowest-priority match signal (description match, same as skills/route/SKILL.md's Step 2 Pass 3) when keywords/examples don't produce a confident match."
22
+ },
23
+ "keywords": {
24
+ "type": "array",
25
+ "description": "Short phrases (1-3 words) for verbatim, case-insensitive keyword matching against the raw natural-language prompt -- the highest-priority match signal (Pass 1), mirroring skills/route/SKILL.md's Step 2 Pass 1 semantics exactly, but scoped to this playbook's catalog rather than the single-skill catalog.",
26
+ "items": { "type": "string" },
27
+ "minItems": 1
28
+ },
29
+ "examples": {
30
+ "type": "array",
31
+ "description": "Natural-language example prompts a user might type that should resolve to this playbook. Used for the semantic similarity match (Pass 2), mirroring skills/route/SKILL.md's Step 2 Pass 2 semantics. At least one example must plausibly span the full breadth of this playbook's steps (not just its first step) so it is distinguishable from a plain single-skill match.",
32
+ "items": { "type": "string" },
33
+ "minItems": 1
34
+ },
35
+ "steps": {
36
+ "type": "array",
37
+ "description": "Ordered list of canonical skill DIRECTORY names (the directory under skills/<dir>/SKILL.md, e.g. \"j-brainstorm\", not the \"j.brainstorm\" frontmatter/invocation form and not the retired bare \"brainstorm\") that make up this playbook's chain, in the exact execution order. Per E50_S10's canonical naming contract the canonical directory is skills/j-<name>/, so a step value carries the j- prefix; the three permanent exceptions (jenga, jenga-permission-level, index) keep their bare directory names and are written bare. Each entry MUST resolve to an existing skills/<dir>/SKILL.md at load time -- load-playbooks.sh skips (with a stderr warning) any playbook referencing a nonexistent skill rather than silently including a broken chain in the catalog.",
38
+ "items": { "type": "string" },
39
+ "minItems": 2
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/detect-nl-intent.sh
4
+ #
5
+ # Deterministic classification wrapper around `resolve-id.sh` for `/jenga`'s
6
+ # Phase 0.75 entry-mode resolution (E53_S01). Phase 0.75's scoped branch used
7
+ # to call `resolve-id.sh` directly and halt the whole invocation the moment
8
+ # ANY comma-delimited segment was rejected by the ID grammar. E53_S01 adds a
9
+ # new outcome: when EVERY segment is rejected, the raw argument is not a
10
+ # malformed ID list at all — it's natural-language intent, which should be
11
+ # routed to /jenga's new NL branch (wired up in E53_S01_T03) instead of
12
+ # halting.
13
+ #
14
+ # This script performs exactly that classification and nothing else. Per
15
+ # CLAUDE.md's "Skill Implementation Principle — Scripts Over Inline Logic",
16
+ # `skills/jenga/SKILL.md` never parses `resolve-id.sh`'s raw JSON array
17
+ # itself for this decision — it only ever reads this script's three output
18
+ # shapes below.
19
+ #
20
+ # ---------------------------------------------------------------------------
21
+ # USAGE
22
+ # ---------------------------------------------------------------------------
23
+ # skills/jenga/scripts/detect-nl-intent.sh "<raw Phase 0.75 argument>"
24
+ #
25
+ # The argument is passed through verbatim to `resolve-id.sh` — this script
26
+ # does not itself split, clean, or reinterpret it. See `resolve-id.sh`'s own
27
+ # header for the exact ID grammar and its per-segment output schema.
28
+ #
29
+ # ---------------------------------------------------------------------------
30
+ # CLASSIFICATION CONTRACT (stable — E53_S01_T03 wires against this exactly)
31
+ # ---------------------------------------------------------------------------
32
+ # `resolve-id.sh`'s JSON array (one object per comma-delimited segment) is
33
+ # reduced to exactly ONE of three classifications, based on the distinct set
34
+ # of `status` values across all segments:
35
+ #
36
+ # 1. every segment "resolved" -> "all_resolved"
37
+ # 2. every segment "rejected" -> "nl_intent"
38
+ # 3. a mix of both -> "mixed"
39
+ #
40
+ # stdout is always a single JSON object. Nothing else is ever written to
41
+ # stdout — errors and warnings go to stderr only.
42
+ #
43
+ # "all_resolved":
44
+ # {
45
+ # "classification": "all_resolved",
46
+ # "resolved_ids": ["E01_S02", ...],
47
+ # "resolved_ids_csv": "E01_S02,..."
48
+ # }
49
+ # exit 0 — the existing scoped-branch confirmation flow in
50
+ # `skills/jenga/SKILL.md` is unaffected by this script's introduction.
51
+ #
52
+ # "nl_intent":
53
+ # {
54
+ # "classification": "nl_intent",
55
+ # "raw_argument": "<the original $1, verbatim>"
56
+ # }
57
+ # exit 0 — this is the signal E53_S01_T03's new branch uses to enter
58
+ # natural-language matching instead of halting.
59
+ #
60
+ # "mixed":
61
+ # {
62
+ # "classification": "mixed",
63
+ # "rejected": [
64
+ # {"input": "<raw segment>", "reason": "<human-readable reason>"},
65
+ # ...
66
+ # ]
67
+ # }
68
+ # exit 1 — preserves the exact current halt-and-report behavior already
69
+ # documented in `skills/jenga/SKILL.md`'s "`resolve-id.sh` rejects one or
70
+ # more segments" edge case. No partial scope is ever assembled from the
71
+ # segments that did resolve.
72
+ #
73
+ # ---------------------------------------------------------------------------
74
+ # EXIT CODES
75
+ # ---------------------------------------------------------------------------
76
+ # 0 "all_resolved" or "nl_intent" classification (see above)
77
+ # 1 "mixed" classification (see above)
78
+ # 2 usage error (no argument given) or a `resolve-id.sh` / `board-scan.sh`
79
+ # setup failure — a real setup problem, not a classification outcome.
80
+ # `resolve-id.sh`'s own stderr message (already emitted by it) is what
81
+ # explains the failure; this script does not re-emit a second message
82
+ # on top of it.
83
+ #
84
+ # ---------------------------------------------------------------------------
85
+
86
+ set -euo pipefail
87
+
88
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
89
+ RESOLVE_ID="$SCRIPT_DIR/resolve-id.sh"
90
+
91
+ if [ $# -lt 1 ] || [ -z "${1:-}" ]; then
92
+ echo 'Usage: detect-nl-intent.sh "<raw Phase 0.75 argument>"' >&2
93
+ exit 2
94
+ fi
95
+
96
+ RAW_INPUT="$1"
97
+
98
+ if [ ! -x "$RESOLVE_ID" ]; then
99
+ echo "Error: resolve-id.sh not found or not executable at $RESOLVE_ID" >&2
100
+ exit 2
101
+ fi
102
+
103
+ if ! command -v python3 >/dev/null 2>&1; then
104
+ echo "Error: python3 is required by detect-nl-intent.sh" >&2
105
+ exit 2
106
+ fi
107
+
108
+ # resolve-id.sh exits 0 (all resolved) or 1 (at least one rejected) as
109
+ # legitimate per-segment outcomes — only exit 2 (usage/setup failure) is a
110
+ # real problem here. Guard the capture explicitly so `set -e` doesn't abort
111
+ # this script on resolve-id.sh's own exit 1.
112
+ set +e
113
+ RESOLVE_JSON="$("$RESOLVE_ID" "$RAW_INPUT")"
114
+ RESOLVE_EXIT=$?
115
+ set -e
116
+
117
+ if [ "$RESOLVE_EXIT" -eq 2 ]; then
118
+ # resolve-id.sh already wrote a plain-text error to stderr — just
119
+ # propagate its exit code rather than re-deriving a second message.
120
+ exit 2
121
+ fi
122
+
123
+ PY_SCRIPT="$(mktemp -t detect-nl-intent-XXXXXX.py)"
124
+ trap 'rm -f "$PY_SCRIPT"' EXIT
125
+
126
+ cat > "$PY_SCRIPT" <<'PY'
127
+ import json
128
+ import sys
129
+
130
+ raw_argument = sys.argv[1]
131
+ resolve_json = sys.stdin.read()
132
+
133
+ try:
134
+ segments = json.loads(resolve_json)
135
+ except Exception as e:
136
+ print(f"Error: could not parse resolve-id.sh output as JSON: {e}", file=sys.stderr)
137
+ sys.exit(2)
138
+
139
+ if not isinstance(segments, list) or len(segments) == 0:
140
+ # resolve-id.sh only emits [] for an empty/whitespace-only argument,
141
+ # which should never reach this script — Phase 0.75 routes an empty
142
+ # argument to the bare branch before detect-nl-intent.sh is ever
143
+ # invoked. Treat this as a setup problem rather than silently guessing
144
+ # a classification for input this script was never meant to see.
145
+ print("Error: resolve-id.sh produced an empty or non-array result", file=sys.stderr)
146
+ sys.exit(2)
147
+
148
+ statuses = {seg.get("status") for seg in segments}
149
+
150
+ if statuses == {"resolved"}:
151
+ resolved_ids = [seg["resolved_id"] for seg in segments]
152
+ print(json.dumps({
153
+ "classification": "all_resolved",
154
+ "resolved_ids": resolved_ids,
155
+ "resolved_ids_csv": ",".join(resolved_ids),
156
+ }))
157
+ sys.exit(0)
158
+
159
+ if statuses == {"rejected"}:
160
+ print(json.dumps({
161
+ "classification": "nl_intent",
162
+ "raw_argument": raw_argument,
163
+ }))
164
+ sys.exit(0)
165
+
166
+ rejected = [
167
+ {"input": seg.get("input"), "reason": seg.get("reason")}
168
+ for seg in segments
169
+ if seg.get("status") == "rejected"
170
+ ]
171
+ print(json.dumps({
172
+ "classification": "mixed",
173
+ "rejected": rejected,
174
+ }))
175
+ sys.exit(1)
176
+ PY
177
+
178
+ python3 "$PY_SCRIPT" "$RAW_INPUT" <<< "$RESOLVE_JSON"
179
+ exit $?
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * skills/jenga/scripts/load-nl-catalog.js
4
+ *
5
+ * Node (ESM) helper behind `load-nl-catalog.sh` — the SINGLE REQUIRED SOURCE of skill-catalog
6
+ * data for `/jenga`'s natural-language branch (E53_S01_T03). `skills/jenga/SKILL.md` must never
7
+ * re-implement its own skill directory scan or hand-maintain a skill list; it only ever reads
8
+ * this script's stdout.
9
+ *
10
+ * The catalog's NAME LIST comes exclusively from `lib/generate-skill-allow-list.js`'s generated
11
+ * inventory (`readSkillAllowList()`, which reads the committed `lib/skill-allow-list.json`
12
+ * artifact) — this script does not independently re-scan `skills/` for a name list of its own,
13
+ * per E53_S01_T02's acceptance criteria and the drift lesson E41_S04 already documented for that
14
+ * generator. For each name in that inventory, this script reads exactly one file —
15
+ * `skills/<name>/SKILL.md` — to populate the remaining catalog fields: `description`, `keywords`,
16
+ * `examples`, and `metadata.prefered_agent`. These are the same fields `/route`'s Step 1
17
+ * ("Discover Available Skills") collects.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * USAGE
21
+ * ---------------------------------------------------------------------------
22
+ * node load-nl-catalog.js <projectRoot> <pkgRoot>
23
+ *
24
+ * <projectRoot> the consuming project's root (passed through to
25
+ * readSkillAllowList's projectRoot fallback candidate)
26
+ * <pkgRoot> the jenga-agent PACKAGE root — where lib/generate-skill-allow-list.js and the
27
+ * canonical skills/ tree actually live (monorepo checkout root, or
28
+ * node_modules/@jenga-ai/agent for an installed consumer)
29
+ *
30
+ * ---------------------------------------------------------------------------
31
+ * OUTPUT SCHEMA
32
+ * ---------------------------------------------------------------------------
33
+ * stdout is a single JSON array, one object per catalog entry, e.g.:
34
+ *
35
+ * [
36
+ * {
37
+ * "name": "btw",
38
+ * "description": "...",
39
+ * "keywords": ["..."],
40
+ * "examples": ["..."],
41
+ * "prefered_agent": "scrum-master" // or null when absent
42
+ * },
43
+ * ...
44
+ * ]
45
+ *
46
+ * Nothing but this JSON array is ever written to stdout. Warnings (a skill skipped because its
47
+ * SKILL.md is missing/unreadable, or its frontmatter lacks `description`) go to stderr only, and
48
+ * are non-fatal.
49
+ *
50
+ * ---------------------------------------------------------------------------
51
+ * EXIT CODES
52
+ * ---------------------------------------------------------------------------
53
+ * 0 catalog written to stdout (possibly with skip warnings already emitted to stderr)
54
+ * 2 usage error, or a real setup failure (allow-list inventory unreadable/empty, or
55
+ * lib/generate-skill-allow-list.js failed to load)
56
+ *
57
+ * ---------------------------------------------------------------------------
58
+ */
59
+
60
+ import { readFileSync, existsSync } from "fs";
61
+ import { join } from "path";
62
+ import { pathToFileURL } from "url";
63
+
64
+ /**
65
+ * Parses YAML frontmatter from a SKILL.md's content into a plain object. This is a hand-rolled,
66
+ * intentionally minimal parser scoped to the small set of shapes SKILL.md frontmatter actually
67
+ * uses — it follows the same overall approach as mcp/router/skill-index.js's `parseFrontmatter`
68
+ * (scalar keys, and array keys introduced by an empty `key:` line followed by ` - item` lines),
69
+ * extended here to also recognize ONE level of nested mapping (the `metadata:` block, e.g.
70
+ * `metadata:\n prefered_agent: developer`) — a shape skill-index.js's parser doesn't need to
71
+ * handle, since it never reads `metadata`.
72
+ *
73
+ * Returns {} if no frontmatter block is found.
74
+ */
75
+ function parseFrontmatter(content) {
76
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
77
+ if (!match) return {};
78
+
79
+ const lines = match[1].split("\n");
80
+ const obj = {};
81
+ let i = 0;
82
+
83
+ const stripQuotes = (s) => s.trim().replace(/^["']|["']$/g, "");
84
+
85
+ while (i < lines.length) {
86
+ const topMatch = lines[i].match(/^(\w+):\s*(.*)$/);
87
+ if (!topMatch) {
88
+ i++;
89
+ continue;
90
+ }
91
+ const [, key, valRaw] = topMatch;
92
+ const val = valRaw.trim();
93
+
94
+ if (val === "[]") {
95
+ obj[key] = [];
96
+ i++;
97
+ continue;
98
+ }
99
+
100
+ if (val !== "") {
101
+ obj[key] = stripQuotes(val);
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ // Empty value — look ahead at indented child lines to decide whether this key is an array
107
+ // (child lines shaped ` - item`) or a nested map (child lines shaped ` childKey: value`).
108
+ // A block is only ever consistently one or the other in this repo's frontmatter, so the
109
+ // first child line's shape decides it.
110
+ let j = i + 1;
111
+ const arrayItems = [];
112
+ const mapObj = {};
113
+ let sawArray = false;
114
+ let sawMap = false;
115
+
116
+ while (j < lines.length && /^\s+\S/.test(lines[j])) {
117
+ const arrItem = lines[j].match(/^\s+-\s+(.*)$/);
118
+ const mapItem = lines[j].match(/^\s+(\w+):\s*(.*)$/);
119
+ if (arrItem && !sawMap) {
120
+ sawArray = true;
121
+ arrayItems.push(stripQuotes(arrItem[1]));
122
+ } else if (mapItem && !sawArray) {
123
+ sawMap = true;
124
+ mapObj[mapItem[1]] = stripQuotes(mapItem[2]);
125
+ }
126
+ j++;
127
+ }
128
+
129
+ obj[key] = sawArray ? arrayItems : sawMap ? mapObj : [];
130
+ i = j;
131
+ }
132
+
133
+ return obj;
134
+ }
135
+
136
+ async function main() {
137
+ const [projectRoot, pkgRoot] = process.argv.slice(2);
138
+ if (!projectRoot || !pkgRoot) {
139
+ process.stderr.write("Usage: load-nl-catalog.js <projectRoot> <pkgRoot>\n");
140
+ process.exit(2);
141
+ }
142
+
143
+ const generatorPath = join(pkgRoot, "lib", "generate-skill-allow-list.js");
144
+ let readSkillAllowList;
145
+ try {
146
+ ({ readSkillAllowList } = await import(pathToFileURL(generatorPath).href));
147
+ } catch (e) {
148
+ process.stderr.write(`Error: failed to load ${generatorPath}: ${e.message}\n`);
149
+ process.exit(2);
150
+ return;
151
+ }
152
+
153
+ const names = readSkillAllowList(projectRoot, pkgRoot);
154
+ if (!Array.isArray(names) || names.length === 0) {
155
+ process.stderr.write(
156
+ "Error: skill allow-list inventory is empty or unreadable (lib/skill-allow-list.json) — cannot build NL catalog\n"
157
+ );
158
+ process.exit(2);
159
+ return;
160
+ }
161
+
162
+ const catalog = [];
163
+ for (const name of names) {
164
+ const skillMdPath = join(pkgRoot, "skills", name, "SKILL.md");
165
+ if (!existsSync(skillMdPath)) {
166
+ process.stderr.write(
167
+ `Warning: ${skillMdPath} not found for allow-listed skill '${name}' — skipped\n`
168
+ );
169
+ continue;
170
+ }
171
+
172
+ let content;
173
+ try {
174
+ content = readFileSync(skillMdPath, "utf8");
175
+ } catch (e) {
176
+ process.stderr.write(`Warning: failed to read ${skillMdPath}: ${e.message} — skipped\n`);
177
+ continue;
178
+ }
179
+
180
+ const fm = parseFrontmatter(content);
181
+ if (!fm.description) {
182
+ process.stderr.write(
183
+ `Warning: ${skillMdPath} missing 'description' in frontmatter — skipped\n`
184
+ );
185
+ continue;
186
+ }
187
+
188
+ catalog.push({
189
+ name,
190
+ description: fm.description,
191
+ keywords: Array.isArray(fm.keywords) ? fm.keywords : [],
192
+ examples: Array.isArray(fm.examples) ? fm.examples : [],
193
+ prefered_agent:
194
+ fm.metadata && typeof fm.metadata === "object" && fm.metadata.prefered_agent
195
+ ? fm.metadata.prefered_agent
196
+ : null,
197
+ });
198
+ }
199
+
200
+ process.stdout.write(JSON.stringify(catalog, null, 2) + "\n");
201
+ }
202
+
203
+ main().catch((e) => {
204
+ process.stderr.write(`Error: ${e.message}\n`);
205
+ process.exit(2);
206
+ });