@jenga-ai/agent 3.1.1 → 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 (39) hide show
  1. package/agents/developer.md +15 -15
  2. package/agents/scrum-master.md +17 -17
  3. package/agents/tester.md +25 -15
  4. package/lib/skill-allow-list.json +3 -2
  5. package/package.json +1 -1
  6. package/scripts/audit-twin-divergence.sh +625 -0
  7. package/scripts/check-public-playbook-steps.sh +136 -0
  8. package/skills/j-close-story/SKILL.md +1 -1
  9. package/skills/j-do/SKILL.md +19 -19
  10. package/skills/j-doc-sync/SKILL.md +12 -1
  11. package/skills/j-idea/SKILL.md +1 -1
  12. package/skills/j-init/SKILL.md +5 -4
  13. package/skills/j-init/assets/directory_structure.txt +1 -0
  14. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  15. package/skills/j-init/scripts/init.sh +13 -2
  16. package/skills/j-playbook/SKILL.md +81 -0
  17. package/skills/j-proceed/SKILL.md +1 -1
  18. package/skills/j-publish/SKILL.md +1 -1
  19. package/skills/j-publish/adapters/npm-ci.md +29 -0
  20. package/skills/j-publish/scripts/npm_ci_pipeline.sh +3 -0
  21. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  22. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  23. package/skills/j-reconcile/SKILL.md +1 -0
  24. package/skills/j-redo/SKILL.md +1 -1
  25. package/skills/j-status/SKILL.md +12 -0
  26. package/skills/j-todo/SKILL.md +2 -2
  27. package/skills/j-uncharted/SKILL.md +8 -7
  28. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  29. package/skills/jenga/SKILL.md +55 -16
  30. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  31. package/skills/jenga/playbooks/schema.json +1 -1
  32. package/skills/jenga/scripts/load-playbooks.sh +855 -27
  33. package/skills/jenga/scripts/match-playbook.sh +1 -1
  34. package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
  35. package/skills/jenga/scripts/run-playbook-step.sh +535 -42
  36. package/skills/jenga-permission-level/SKILL.md +4 -4
  37. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  38. package/templates/playbook-types.json +8 -0
  39. package/skills/jenga/playbooks/brainstorm-to-mirror.json +0 -22
@@ -9,12 +9,251 @@
9
9
  # stdout, mirroring the single-source contract `load-nl-catalog.sh` already established for the
10
10
  # single-skill catalog (E53_S01_T02).
11
11
  #
12
- # A "playbook" is an ORDERED chain of skills (e.g. brainstorm -> todo -> do -> dev-done ->
12
+ # A "playbook" is an ORDERED chain of steps (e.g. brainstorm -> todo -> do -> dev-done ->
13
13
  # mirror-public) that `/jenga`'s natural-language branch may propose, as an editable, confirmable
14
14
  # numbered list (see `render-playbook-confirmation.sh`, E53_S02_T03), when free-text intent spans
15
15
  # more than one skill and does not cleanly resolve to a single one.
16
16
  #
17
17
  # ---------------------------------------------------------------------------
18
+ # STEP SHAPES (E53_S03_T01 — Playbooks v2)
19
+ # ---------------------------------------------------------------------------
20
+ # Each entry in a playbook's `steps` array may be either:
21
+ #
22
+ # - a BARE STRING — unchanged, original behavior. Shorthand for `{"skill": "<string>"}`. No
23
+ # migration needed: an all-bare-string playbook (e.g. the committed `brainstorm-to-mirror.json`)
24
+ # loads byte-for-byte the same as before this task, including in this script's own JSON
25
+ # output — a bare-string step is never rewritten into an object in the catalog. This is a
26
+ # deliberate backward-compatibility choice: `match-playbook.sh`, `render-playbook-confirmation.sh`,
27
+ # and `run-playbook-step.sh` (all from E53_S02) consume `steps` as a comma-joinable list of
28
+ # plain skill-name strings.
29
+ #
30
+ # - a StepObject (a JSON object) — `{"skill": "<string>"}` OR `{"playbook": "<string>"}`,
31
+ # mutually exclusive (a step naming both, or naming neither, is a validation error — see
32
+ # below), plus all of the following OPTIONAL fields, each validated only for shape at this
33
+ # point in the pipeline:
34
+ # - `instruction` (string) — static natural-language text appended to this step's own
35
+ # invocation message.
36
+ # - `forward_from` (string) — names a prior step this step's invocation input is drawn from.
37
+ # RUNTIME-EXECUTABLE as of `E53_S04_T02`: the calling agent
38
+ # resolves the actual value via
39
+ # `run-playbook-step.sh get-output <state_file> <step_name>`.
40
+ # Transparent across composition boundaries as of `E53_S05_T02`
41
+ # — see "COMPOSITION RESOLUTION" below.
42
+ # - `resolve` (string) — natural-language instructions for reshaping/filtering/
43
+ # type-bridging a forwarded value. Mutually exclusive with
44
+ # `playbook` on the SAME step (see "CONFIRMATION-GATE
45
+ # CONVENTION" below) — this is now enforced directly in
46
+ # `normalize_step()` (E53_S05_T01), since composition
47
+ # resolution later splices a `playbook`-type step away
48
+ # entirely, which would make the conflict unreachable if this
49
+ # check were deferred until after flattening.
50
+ # - `conditional` (object) — `{"depends_on": "<earlier step name>", "predicate":
51
+ # "<predicate>"}` (E53_S04_T02). Determines whether this step
52
+ # executes at all, evaluated at RUNTIME by
53
+ # `run-playbook-step.sh should-skip` against the named prior
54
+ # step's captured typed-output artifact. Transparent across
55
+ # composition boundaries as of `E53_S05_T02`, exactly like
56
+ # `forward_from`.
57
+ # - `playbook` (string) — composes in ANOTHER playbook by id, resolved by THIS script
58
+ # (E53_S05_T01) — see "COMPOSITION RESOLUTION" below.
59
+ # - `version` / `schema_version` (any type, either key name) — RESERVED. Accepted verbatim,
60
+ # passed through into the catalog unchanged, and never acted
61
+ # upon by this script. Exists purely so a future schema revision
62
+ # has a place to declare itself without every existing playbook
63
+ # file needing a retroactive migration.
64
+ #
65
+ # ---------------------------------------------------------------------------
66
+ # FORWARD_FROM RESOLUTION (E53_S03_T03, extended by E53_S03_T04)
67
+ # ---------------------------------------------------------------------------
68
+ # A step whose StepObject carries `forward_from: "<name>"` is validated as follows, entirely at
69
+ # load time, entirely from this repository's own files on disk. As of `E53_S05_T01`/`T02`, this
70
+ # validation loop runs over the FULLY FLATTENED step list (see "COMPOSITION RESOLUTION" below) —
71
+ # "earlier step" below means earlier in that final, flattened, position-ordered list, regardless
72
+ # of whether the step originated in the top-level playbook or a nested (composed) one:
73
+ #
74
+ # 1. EXISTENCE — `<name>` must equal the skill name of some EARLIER step in the FLATTENED
75
+ # `steps` list (a bare string equal to `<name>`, or a StepObject whose `skill` field equals
76
+ # `<name>`; a `playbook`-type step never satisfies this directly, since by the time this loop
77
+ # runs, every `playbook`-type step has already been replaced by its resolved contents — see
78
+ # below). If no earlier match exists, the playbook is rejected.
79
+ #
80
+ # 2. DECLARED OUTPUT — the source skill's own `skills/<name>/SKILL.md` frontmatter must declare
81
+ # a non-empty `output_types` field (see `docs/skill-authoring.md`'s `output_types` section,
82
+ # E53_S03_T02). A skill with no declared `output_types` cannot be a forward source — this is
83
+ # the design's partial-adoption rule (`templates/playbook-types.json`, E53_S03_T02) made
84
+ # load-time-enforced: only `j.status`, `j.uncharted`, `j.jenga`, and `j.reconcile` declare it
85
+ # as of this story. If the source has no declared `output_types`, the playbook is rejected.
86
+ # This check is keyed purely off the source step's own resolved skill name and its own
87
+ # `SKILL.md` — origin-independent by construction, so it applies identically whether the
88
+ # source step originated in the top-level playbook or a nested one (`E53_S05_T02`).
89
+ #
90
+ # 3. BLOCKER 1'S STRUCTURAL {when, type} CHECK (E53_S03_T04) — applies only when the source's
91
+ # `output_types` is the list-of-`{when, type}` form (as opposed to a single static type
92
+ # string). For each `{when, type}` entry:
93
+ # - both `when` and `type` must be present, or the playbook is rejected.
94
+ # - if `when` is one of the two BUILT-IN predicates (`argument_empty` / `argument_nonempty`),
95
+ # no further check is made here — those predicates describe the STEP's own invocation
96
+ # shape, not a separate classifier, and are accepted as-is.
97
+ # - otherwise `when` is a CLASSIFIER-SCRIPT REFERENCE (e.g. `j.jenga`'s own `when:
98
+ # detect-nl-intent`, referencing `skills/jenga/scripts/detect-nl-intent.sh`). What IS
99
+ # load-time-knowable, and the only claim this check ever makes, is purely structural:
100
+ # does a script matching that reference actually exist on disk
101
+ # (`skills/<name>/scripts/<when>.sh`)? If not, the reference is bogus and the playbook is
102
+ # rejected. This loader never invokes either the classifier script or the source skill to
103
+ # make this claim.
104
+ #
105
+ # WORKED TWO-LEVEL CROSS-BOUNDARY EXAMPLE (E53_S05_T02) — demonstrates BOTH directions at once:
106
+ #
107
+ # skills/jenga/playbooks/nested.json:
108
+ # { "id": "nested", ..., "steps": [
109
+ # "src", // declares output_types
110
+ # {"skill": "sink", "forward_from": "outer1"} // forwards from OUTSIDE this file
111
+ # ]}
112
+ #
113
+ # skills/jenga/playbooks/outerchain.json:
114
+ # { "id": "outerchain", ..., "steps": [
115
+ # "outer1", // declares output_types
116
+ # {"playbook": "nested"},
117
+ # {"skill": "outer2", "forward_from": "src"} // forwards from INSIDE "nested"
118
+ # ]}
119
+ #
120
+ # Resolved as its OWN top-level entry, "nested" alone is REJECTED — its `sink` step's
121
+ # `forward_from: "outer1"` has no earlier match within nested's own two steps. But composed
122
+ # inside "outerchain", composition resolution (E53_S05_T01) splices nested's steps in first, so
123
+ # by the time the forward_from/conditional loop (below) runs, "outerchain"'s flattened list is:
124
+ # `["outer1", {"skill": "src", "_origin_playbook": "nested", "_origin_depth": 2}, {"skill":
125
+ # "sink", "forward_from": "outer1", "_origin_playbook": "nested", "_origin_depth": 2},
126
+ # {"skill": "outer2", "forward_from": "src"}]` — a single flat, position-ordered list. "sink"'s
127
+ # forward_from now finds "outer1" earlier in that list (an OUTER step forwarding INTO a nested
128
+ # one), and "outer2"'s forward_from finds "src" earlier too (a NESTED step forwarding OUT to an
129
+ # outer one) — both resolved by the exact same existence check, with no origin-based branching
130
+ # anywhere in this loop. `conditional.depends_on` works identically, by the same mechanism.
131
+ #
132
+ # ---------------------------------------------------------------------------
133
+ # CONDITIONAL RESOLUTION (E53_S04_T02)
134
+ # ---------------------------------------------------------------------------
135
+ # A step's `conditional: {"depends_on": "<name>", "predicate": "<predicate>"}` is validated at load
136
+ # time, mirroring `forward_from`'s existence rule exactly — and, as of `E53_S05_T02`, over the same
137
+ # FULLY FLATTENED step list:
138
+ #
139
+ # 1. SHAPE — `conditional` must be an object carrying exactly `depends_on` (non-empty string) and
140
+ # `predicate` (non-empty string). Any other shape is rejected.
141
+ # 2. EXISTENCE — `depends_on` must equal the skill name of some EARLIER step in the FLATTENED
142
+ # step list (same rule as `forward_from`'s existence check above).
143
+ # 3. PREDICATE GRAMMAR — `predicate` must match one of `non_empty`, `empty`, `equals:<value>`,
144
+ # `not_equals:<value>`. This loader does NOT re-derive that grammar; it is defined once, as the
145
+ # single source of truth, in `run-playbook-step.sh`'s own header (the script that actually
146
+ # EVALUATES it at runtime via `should-skip`) — this loader's job is only to reject an
147
+ # unrecognized predicate string before it ever reaches that runtime evaluation.
148
+ #
149
+ # Unlike `forward_from`, `conditional` does NOT require the depended-on step to have a declared
150
+ # `output_types` — a conditional may legitimately depend on a step whose captured output is simply
151
+ # "did it produce anything at all" (the `empty`/`non_empty` predicates), which needs no declared
152
+ # type to be meaningful. `conditional` and `forward_from` on the SAME step are independent,
153
+ # orthogonal fields and may both be present together (whether this step runs is one question,
154
+ # what data flows into it if it does run is another).
155
+ #
156
+ # ---------------------------------------------------------------------------
157
+ # COMPOSITION RESOLUTION (E53_S05_T01, cross-boundary transparency extended by E53_S05_T02)
158
+ # ---------------------------------------------------------------------------
159
+ # A `{"playbook": "<id>"}` step composes another playbook's own chain into this one. Resolution
160
+ # runs AFTER local shape/skill-existence validation of ordinary steps, and BEFORE the
161
+ # `forward_from`/`conditional` validation loop described above — that ordering is what makes
162
+ # cross-boundary `forward_from`/`conditional` "just work" with no special-casing: by the time that
163
+ # loop runs, every playbook-type step has already been replaced by its resolved contents, so the
164
+ # loop only ever sees a flat, position-ordered list of skill-only steps.
165
+ #
166
+ # 1. EXISTENCE — `<id>` must resolve to a real playbook file (`<id>.json` under
167
+ # `skills/jenga/playbooks/`, excluding `schema.json`) that ALSO passes this script's own local
168
+ # validation (shape, required fields, id/filename match, skill-existence for its own
169
+ # skill-type steps). A reference to a file that plain does not exist, and a reference to a
170
+ # file that exists but failed its own local validation, are both treated as "could not be
171
+ # resolved" and cause the WHOLE REFERENCING playbook to be dropped — same granularity as
172
+ # every other check in this script (a chain with a broken link is not a usable chain).
173
+ #
174
+ # 2. CYCLE DETECTION — resolution is a depth-first walk of each playbook's composition graph
175
+ # (its `playbook`-type steps, transitively). If a step's target id is already on the CURRENT
176
+ # resolution path (the chain of playbook ids being resolved to reach this point, including
177
+ # direct self-reference — a playbook composing itself), that is a cycle: the referencing
178
+ # playbook is dropped with a stderr warning naming the cycle path. Because every playbook file
179
+ # is independently resolved as its own top-level entry point (in addition to being resolved
180
+ # as a nested reference wherever else it's composed), a cycle is caught and reported
181
+ # regardless of which playbook in the cycle happens to be read first.
182
+ #
183
+ # 3. CONFIGURABLE DEPTH LIMIT — nesting depth is tracked as it already is for
184
+ # `_origin_depth` (below): a playbook's own steps are depth 1; a step spliced in from one
185
+ # level of composition is depth 2; two levels is depth 3; and so on. The maximum allowed
186
+ # depth is read from `project/configs/playbook-config.json`'s `max_composition_depth` field
187
+ # (a NEW, DEDICATED config file — kept separate from `project/configs/scope-thresholds.json`,
188
+ # since that file's own fields are specifically `/jenga`/`/do` task execution-scope
189
+ # thresholds, a different concern from playbook nesting-depth safety), defaulting to **3**
190
+ # when the file/field is absent or invalid. This is an explicitly TUNABLE SAFETY DEFAULT, not
191
+ # an architectural ceiling — raise it in `playbook-config.json` if a legitimate composition
192
+ # chain needs to nest deeper. A composition step whose target would be nested past the
193
+ # configured limit is dropped (with a stderr warning) before that target is even resolved.
194
+ # Project-root resolution for this config file mirrors `run-playbook-step.sh`'s own
195
+ # `resolve_project_dir` probing order (`JENGA_PROJECT_DIR` -> `CLAUDE_PROJECT_DIR` -> `git
196
+ # rev-parse --show-toplevel` -> `pwd`), and reuses this script's own existing
197
+ # `JENGA_PLAYBOOKS_TEST_ROOT` fixture override for the same purpose (a fixture wanting a
198
+ # non-default depth limit places its own `project/configs/playbook-config.json` under that
199
+ # same override root — no second test-only variable is introduced).
200
+ #
201
+ # 4. RECURSIVE FLATTENING — once a `{"playbook": "<id>"}` step passes existence/cycle/depth
202
+ # checks, the referenced playbook's OWN already-resolved (recursively flattened) step
203
+ # sequence is spliced into the parent's `steps` array at that position, in original order.
204
+ # The catalog's emitted `steps` array for any playbook therefore never contains a raw
205
+ # `playbook`-type entry — only flattened skill-only steps (bare strings or `{"skill": ...}`
206
+ # StepObjects, the latter possibly carrying the origin annotation below).
207
+ #
208
+ # 5. ORIGIN ANNOTATION — a step that came from an actual nested inclusion (composition depth >
209
+ # 1) carries two new passthrough-only fields once it is spliced into a parent: `_origin_playbook`
210
+ # (the id of the playbook file the step was originally written in) and `_origin_depth` (2 for
211
+ # a step from one level of composition, 3 for two levels, and so on). Needed by `E53_S05_T02`
212
+ # (forward_from/conditional boundary transparency — origin is never consulted, but the field
213
+ # exists for observability), `E53_S05_T03` (nested confirmation display), and `E53_S05_T04`
214
+ # (traceable reporting). **A playbook's own depth-1 steps (its own, un-composed steps) are
215
+ # NEVER annotated and are emitted byte-for-byte exactly as before this story** — this
216
+ # preserves the pre-existing bare-string backward-compatibility guarantee (see STEP SHAPES
217
+ # above) for the common case of a playbook that uses no composition at all: such a playbook's
218
+ # catalog entry is completely unaffected by this story. Only a step that is actually spliced
219
+ # in from a nested playbook (depth > 1) is guaranteed to appear as an object carrying these
220
+ # two fields (a bare string at that depth is converted to `{"skill": "<name>", ...}` to carry
221
+ # them — there is no way to attach fields to a bare string).
222
+ #
223
+ # 6. DUPLICATE-NAME COLLISION CHECK — every downstream script in this chain (`forward_from`,
224
+ # `conditional`, `captured_outputs`, `get-output`) addresses a step purely by its resolved
225
+ # skill name (`step_skill_name`). Once a playbook's own steps are combined with any spliced-in
226
+ # nested content, two or more steps resolving to the SAME skill name would corrupt that
227
+ # addressing. This check runs on every resolution's own flattened result (not just the
228
+ # top-level's) immediately before it is returned — so a collision is caught and reported at
229
+ # the lowest level it first occurs, and a nested playbook is never spliced into a parent
230
+ # unless it is already known to be collision-free on its own. A collision drops the whole
231
+ # playbook being resolved at that point, with a stderr warning naming the duplicate.
232
+ #
233
+ # A playbook that is itself invalid at composition time (cycle, depth limit, unresolved reference,
234
+ # or a duplicate-name collision after flattening) is dropped from the catalog exactly like every
235
+ # other validation failure in this script — a stderr warning, never a hard crash, and no other
236
+ # playbook is affected merely because it happens to reference the dropped one (the REFERENCING
237
+ # playbook is dropped too, but only that one, unless it is in turn referenced by yet another
238
+ # playbook, which cascades the same way).
239
+ #
240
+ # ---------------------------------------------------------------------------
241
+ # CONFIRMATION-GATE CONVENTION (E53_S03_T03 — Blocker 2 v1 scope cut, load-time half)
242
+ # ---------------------------------------------------------------------------
243
+ # The design's Blocker 2 (see the plan doc's Open Decisions) splits `resolve` into two stories:
244
+ # this one ships `resolve` for reshaping/filtering/type-bridging a forwarded value ONLY, never for
245
+ # pre-authorizing a downstream confirmation gate — that combination is explicitly out of scope and
246
+ # rejected here, at load time (in `normalize_step()`, as of E53_S05_T01 — see STEP SHAPES above).
247
+ # `E53_S06` owns `resolve`'s own runtime behavior; this is only the load-time rejection rule.
248
+ #
249
+ # This script's load-time convention for what counts as a "downstream confirmation gate": a
250
+ # `{"playbook": "<id>"}` step. Entering ANY nested playbook always passes through that playbook's
251
+ # own up-front `render-playbook-confirmation.sh` confirmation (E53_S02_T03) before any of its
252
+ # steps run — a playbook-type step therefore IS a confirmation gate by construction, in every
253
+ # case, with no exceptions to enumerate. A step that carries BOTH `resolve` and `playbook` is
254
+ # rejected: `resolve` may only ever shape a value flowing into an ordinary `skill` step.
255
+ #
256
+ # ---------------------------------------------------------------------------
18
257
  # DATA SOURCE
19
258
  # ---------------------------------------------------------------------------
20
259
  # Every `*.json` file directly under `skills/jenga/playbooks/`, EXCLUDING `schema.json` (which
@@ -27,7 +266,45 @@
27
266
  # ---------------------------------------------------------------------------
28
267
  # skills/jenga/scripts/load-playbooks.sh
29
268
  #
30
- # No arguments. Emits the full JSON playbook catalog array to stdout.
269
+ # No arguments (full-catalog mode, unchanged since before E53_S06). Emits the full JSON playbook
270
+ # catalog array to stdout, exactly as documented in "OUTPUT SCHEMA" below.
271
+ #
272
+ # skills/jenga/scripts/load-playbooks.sh lookup <id>
273
+ #
274
+ # Additive sibling mode (E53_S06_T02), for direct-by-id lookup (`j.playbook <id>`,
275
+ # `skills/j-playbook/SKILL.md`, E53_S06_T03) — runs the SAME PASS 1-3 pipeline as the no-argument
276
+ # mode above (never a separate implementation), then emits exactly ONE JSON object to stdout
277
+ # (never the full catalog, never warnings about OTHER playbooks) and exits 0:
278
+ #
279
+ # {"status": "valid", "playbook": {...}} -- <id> resolved to a real file and passed every
280
+ # validation pass; `playbook` has the same field
281
+ # shape as one full-catalog entry.
282
+ # {"status": "invalid", "reason": "..."} -- a file `<id>.json` exists under
283
+ # skills/jenga/playbooks/ but failed validation at
284
+ # some pass; `reason` is the SAME specific,
285
+ # human-readable message this script would already
286
+ # print to stderr for that failure in full-catalog
287
+ # mode -- never a generic message.
288
+ # {"status": "not_found"} -- no `<id>.json` file exists under
289
+ # skills/jenga/playbooks/ (excluding schema.json)
290
+ # at all.
291
+ #
292
+ # Usage errors (`lookup` given with no `<id>`, or an unrecognized first argument) print a usage
293
+ # message to stderr and exit 2 -- the same setup-error exit code documented in "EXIT CODES" below.
294
+ # stderr warnings about OTHER (non-looked-up) playbooks may still be emitted for consistency with
295
+ # the full-catalog mode's existing behavior; only stdout is constrained to exactly one JSON object.
296
+ #
297
+ # TESTING OVERRIDE — JENGA_PLAYBOOKS_TEST_ROOT (E53_S03_T05): when this environment variable is
298
+ # set, it overrides the monorepo/node_modules PKG_ROOT auto-detection below, pointing
299
+ # PLAYBOOKS_DIR/SKILLS_DIR at `<value>/skills/jenga/playbooks` and `<value>/skills` respectively,
300
+ # AND (as of E53_S05_T01) overrides the PROJECT root used to locate
301
+ # `project/configs/playbook-config.json`, so a fixture wanting a non-default composition depth
302
+ # limit places one at `<value>/project/configs/playbook-config.json`. This exists exclusively so
303
+ # `tests/load-playbooks-stepobject.bats` and `tests/load-playbooks-composition.bats` can point this
304
+ # loader at a synthetic, throwaway fixture tree under `$BATS_TEST_TMPDIR` instead of this
305
+ # repository's own `skills/`/`project/` — per this repo's fixture-tree testing convention, a test
306
+ # asserting on candidate sets never targets the repository root. Never set this variable in a real
307
+ # invocation.
31
308
  #
32
309
  # ---------------------------------------------------------------------------
33
310
  # OUTPUT SCHEMA
@@ -41,16 +318,26 @@
41
318
  # "description": "...",
42
319
  # "keywords": ["..."],
43
320
  # "examples": ["..."],
44
- # "steps": ["brainstorm", "todo", "do", "dev-done", "mirror-public"]
321
+ # "steps": ["j-brainstorm", "j-todo", "j-do", "j-dev-done", "j-mirror-public"]
45
322
  # },
46
323
  # ...
47
324
  # ]
48
325
  #
326
+ # `steps` entries are emitted exactly as validated/resolved: a depth-1 bare string stays a bare
327
+ # string; a depth-1 StepObject is emitted as an object carrying only its recognized fields (`skill`
328
+ # XOR `playbook` — though by the time this is emitted, a `playbook`-type step has already been
329
+ # replaced by its resolved contents — plus whichever of `instruction`/`forward_from`/`resolve`/
330
+ # `conditional`/`version`/`schema_version` were present on the source step); a step spliced in from
331
+ # a nested (composed) playbook (depth > 1) additionally carries `_origin_playbook`/`_origin_depth`
332
+ # (see "COMPOSITION RESOLUTION" above).
333
+ #
49
334
  # Nothing but this JSON array is ever written to stdout. Skip warnings go to stderr only and are
50
335
  # non-fatal — a single malformed playbook file never aborts the whole catalog load.
51
336
  #
52
337
  # ---------------------------------------------------------------------------
53
- # VALIDATION / SKIP CONDITIONS (each skip is a stderr warning, never fatal)
338
+ # VALIDATION / SKIP CONDITIONS (each skip is a stderr warning, never fatal — the WHOLE playbook is
339
+ # skipped on any of these, never just the offending step, consistent with this script's existing
340
+ # "a chain with a broken link is not a usable chain" skip granularity)
54
341
  # ---------------------------------------------------------------------------
55
342
  # - File is not valid JSON, or is not a JSON object -> skipped
56
343
  # - Missing any required field: id, name, description, keywords,
@@ -60,10 +347,40 @@
60
347
  # - `id` does not equal the filename's basename without `.json` -> skipped
61
348
  # (prevents a playbook's identity from silently drifting from its
62
349
  # file location)
63
- # - Any entry in `steps` has no corresponding `skills/<name>/SKILL.md`
64
- # on disk -> skipped (the whole
65
- # playbook is skipped, not just the bad step — a chain with a broken
66
- # link is not a usable chain)
350
+ # - A `steps` entry is neither a string nor an object, or is an empty
351
+ # string -> skipped
352
+ # - A StepObject step carries BOTH `skill` and `playbook`, or NEITHER -> skipped
353
+ # - A StepObject's `skill`/`playbook`/`instruction`/`forward_from`/
354
+ # `resolve` field is present but not a non-empty string -> skipped
355
+ # - A StepObject carries BOTH `resolve` and `playbook` -> skipped
356
+ # (resolve targeting a downstream confirmation gate — Blocker 2 v1 scope cut, E53_S03_T03;
357
+ # checked in `normalize_step()` as of E53_S05_T01, before composition can splice the
358
+ # `playbook` field away)
359
+ # - Any `skill`-type step (bare string or StepObject) has no
360
+ # corresponding `skills/<name>/SKILL.md` on disk -> skipped
361
+ # - A `{"playbook": "<id>"}` step's `<id>` does not resolve to an
362
+ # existing, locally-valid playbook file (E53_S05_T01) -> skipped
363
+ # - A playbook's composition graph contains a cycle (including direct
364
+ # self-reference) (E53_S05_T01) -> skipped
365
+ # - A composition's nesting depth exceeds the configured/default
366
+ # `max_composition_depth` (E53_S05_T01) -> skipped
367
+ # - A flattened composition result contains two or more steps
368
+ # resolving to the same skill name (E53_S05_T01) -> skipped
369
+ # - A `forward_from` names a step that is not an EARLIER step in the
370
+ # final flattened step list -> skipped
371
+ # - A `forward_from` names a source step whose skill has no declared
372
+ # `output_types` in its own `SKILL.md` frontmatter -> skipped
373
+ # - A `forward_from` source's `output_types` list has a `{when, type}`
374
+ # entry missing `when`/`type`, or a classifier-script `when` with no
375
+ # matching `skills/<name>/scripts/<when>.sh` on disk (Blocker 1,
376
+ # E53_S03_T04) -> skipped
377
+ # - A `conditional` field is present but not an object with exactly
378
+ # `depends_on` and `predicate` (both non-empty strings) (E53_S04_T02) -> skipped
379
+ # - A `conditional`'s `depends_on` names a step that is not an EARLIER
380
+ # step in the final flattened step list (E53_S04_T02) -> skipped
381
+ # - A `conditional`'s `predicate` does not match the recognized grammar
382
+ # (`non_empty`/`empty`/`equals:<value>`/`not_equals:<value>`, defined
383
+ # in `run-playbook-step.sh`'s own header) (E53_S04_T02) -> skipped
67
384
  #
68
385
  # ---------------------------------------------------------------------------
69
386
  # EXIT CODES
@@ -80,13 +397,42 @@ set -euo pipefail
80
397
 
81
398
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
82
399
 
400
+ # --- E53_S06_T02: additive `lookup <id>` CLI mode ------------------------------------------------
401
+ # Backward compatible: no arguments at all reproduces the original, unchanged full-catalog mode.
402
+ # See header "USAGE" for the full contract.
403
+ MODE="catalog"
404
+ LOOKUP_ID=""
405
+ if [ $# -gt 0 ]; then
406
+ case "$1" in
407
+ lookup)
408
+ if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
409
+ echo "Usage: $(basename "$0") lookup <id>" >&2
410
+ exit 2
411
+ fi
412
+ MODE="lookup"
413
+ LOOKUP_ID="$2"
414
+ ;;
415
+ *)
416
+ echo "Error: unrecognized argument '$1' (usage: $(basename "$0") [lookup <id>])" >&2
417
+ exit 2
418
+ ;;
419
+ esac
420
+ fi
421
+
422
+ if [ -n "${JENGA_PLAYBOOKS_TEST_ROOT:-}" ]; then
423
+ # Test-only override — see "TESTING OVERRIDE" in the header above (E53_S03_T05, extended by
424
+ # E53_S05_T01 to also cover playbook-config.json resolution). Never set in a real invocation.
425
+ PKG_ROOT="$JENGA_PLAYBOOKS_TEST_ROOT"
426
+ PROJECT_DIR="$JENGA_PLAYBOOKS_TEST_ROOT"
83
427
  # Resolve the jenga-agent PACKAGE root (where the canonical skills/ tree actually lives) — same
84
428
  # monorepo-checkout vs. installed-npm-package detection used by
85
429
  # skills/jenga/scripts/load-nl-catalog.sh's PKG_ROOT resolution and skills/init/scripts/init.sh.
86
- if [ -d "$SCRIPT_DIR/../../../templates" ]; then
430
+ elif [ -d "$SCRIPT_DIR/../../../templates" ]; then
87
431
  PKG_ROOT="$SCRIPT_DIR/../../.."
432
+ PROJECT_DIR="${JENGA_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)}}"
88
433
  elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent/templates" ]; then
89
434
  PKG_ROOT="${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent"
435
+ PROJECT_DIR="${JENGA_PROJECT_DIR:-$CLAUDE_PROJECT_DIR}"
90
436
  else
91
437
  echo "Error: could not locate the jenga-agent package root (templates/ not found via monorepo checkout or node_modules/@jenga-ai/agent)." >&2
92
438
  exit 2
@@ -111,15 +457,220 @@ trap 'rm -f "$PY_SCRIPT"' EXIT
111
457
  cat > "$PY_SCRIPT" <<'PY'
112
458
  import json
113
459
  import os
460
+ import re
114
461
  import sys
115
462
 
116
463
  playbooks_dir = sys.argv[1]
117
464
  skills_dir = sys.argv[2]
465
+ project_dir = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None
466
+ # --- E53_S06_T02: additive `lookup <id>` CLI mode --------------------------------------------
467
+ mode = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else "catalog"
468
+ lookup_id = sys.argv[5] if len(sys.argv) > 5 else ""
118
469
 
119
470
  REQUIRED_FIELDS = ["id", "name", "description", "keywords", "examples", "steps"]
120
471
  LIST_FIELDS = ["keywords", "examples", "steps"]
121
472
 
473
+ # Optional StepObject fields that must be non-empty strings when present.
474
+ STEP_STRING_FIELDS = ("instruction", "forward_from", "resolve")
475
+ # Reserved, currently-unused StepObject fields -- accepted verbatim, never validated or acted
476
+ # upon (E53_S03_T01).
477
+ STEP_RESERVED_FIELDS = ("version", "schema_version")
478
+
479
+ # The two built-in {when, type} predicates every skill may use without naming a classifier
480
+ # script. Any other `when` value is a classifier-script reference (E53_S03_T04's Blocker 1
481
+ # check).
482
+ BUILTIN_WHEN_PREDICATES = {"argument_empty", "argument_nonempty"}
483
+
484
+ # Recognized `conditional.predicate` grammar (E53_S04_T02). The single source of truth for this
485
+ # grammar is `run-playbook-step.sh`'s own header -- this regex is a load-time mirror of it, not a
486
+ # second, independent definition.
487
+ CONDITIONAL_PREDICATE_RE = re.compile(r'^(non_empty|empty|equals:.+|not_equals:.+)$')
488
+
489
+ # --- E53_S05_T01: configurable composition depth limit -----------------------------------------
490
+ DEFAULT_MAX_COMPOSITION_DEPTH = 3
491
+
492
+
493
+ def load_max_composition_depth(proj_dir):
494
+ """Read `max_composition_depth` from project/configs/playbook-config.json. A missing file,
495
+ missing field, or non-positive-integer value all fall back to the hardcoded default -- this
496
+ is a tunable safety default, never a required setup file (see header 'COMPOSITION
497
+ RESOLUTION')."""
498
+ if not proj_dir:
499
+ return DEFAULT_MAX_COMPOSITION_DEPTH
500
+ config_path = os.path.join(proj_dir, "project", "configs", "playbook-config.json")
501
+ if not os.path.isfile(config_path):
502
+ return DEFAULT_MAX_COMPOSITION_DEPTH
503
+ try:
504
+ with open(config_path, encoding="utf-8") as fh:
505
+ cfg = json.load(fh)
506
+ value = cfg.get("max_composition_depth", DEFAULT_MAX_COMPOSITION_DEPTH)
507
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 1:
508
+ return value
509
+ except Exception:
510
+ pass
511
+ return DEFAULT_MAX_COMPOSITION_DEPTH
512
+
513
+
514
+ MAX_COMPOSITION_DEPTH = load_max_composition_depth(project_dir)
515
+
122
516
  catalog = []
517
+ # --- E53_S06_T02: per-basename skip-reason capture -------------------------------------------
518
+ # Threaded through PASS 1, PASS 2 (resolve_playbook), and PASS 3 below -- whenever a playbook
519
+ # basename is dropped, at any point in the pipeline, the specific reason string already being
520
+ # printed to stderr is ALSO recorded here, against that basename. This is purely additive: it
521
+ # never changes any existing stderr warning text or the full-catalog mode's JSON output (see
522
+ # header "USAGE"). Powers `lookup <id>`'s "invalid" result (a known id that failed validation,
523
+ # with the SAME specific reason -- never a generic message).
524
+ skip_reasons = {}
525
+
526
+
527
+ def normalize_step(entry, idx):
528
+ """Validate and normalize one `steps` entry. Returns (normalized_step, error_or_None)."""
529
+ if isinstance(entry, str):
530
+ if entry == "":
531
+ return None, f"step {idx} is an empty string"
532
+ return entry, None
533
+
534
+ if not isinstance(entry, dict):
535
+ return None, f"step {idx} is neither a string nor an object"
536
+
537
+ has_skill = "skill" in entry
538
+ has_playbook = "playbook" in entry
539
+ if has_skill and has_playbook:
540
+ return None, f"step {idx} has both 'skill' and 'playbook' (mutually exclusive)"
541
+ if not has_skill and not has_playbook:
542
+ return None, f"step {idx} has neither 'skill' nor 'playbook' (a StepObject requires exactly one)"
543
+
544
+ # --- E53_S05_T01: resolve/playbook conflict (Blocker 2 v1 scope cut, E53_S03_T03) -- must be
545
+ # checked HERE, before composition resolution ever runs, since that pass later splices a
546
+ # `playbook`-type step away entirely -- deferring this check until after flattening would
547
+ # make the conflict unreachable.
548
+ if has_playbook and "resolve" in entry:
549
+ return None, (
550
+ f"step {idx} has 'resolve' targeting a playbook-composition step "
551
+ f"('{entry['playbook']}'), which is always a downstream confirmation gate "
552
+ f"(see header 'CONFIRMATION-GATE CONVENTION') — 'resolve' may never target one"
553
+ )
554
+
555
+ out = {}
556
+ target_field = "skill" if has_skill else "playbook"
557
+ target_value = entry[target_field]
558
+ if not isinstance(target_value, str) or not target_value:
559
+ return None, f"step {idx} '{target_field}' must be a non-empty string"
560
+ out[target_field] = target_value
561
+
562
+ for field in STEP_STRING_FIELDS:
563
+ if field in entry:
564
+ value = entry[field]
565
+ if not isinstance(value, str) or not value:
566
+ return None, f"step {idx} '{field}' must be a non-empty string"
567
+ out[field] = value
568
+
569
+ # `conditional` (E53_S04_T02) -- shape check only here; cross-step existence + predicate
570
+ # grammar checks happen in the forward_from/conditional validation loop below, once the full
571
+ # FLATTENED steps list (E53_S05_T01/T02) is available.
572
+ if "conditional" in entry:
573
+ cond = entry["conditional"]
574
+ if (
575
+ not isinstance(cond, dict)
576
+ or set(cond.keys()) != {"depends_on", "predicate"}
577
+ or not isinstance(cond.get("depends_on"), str)
578
+ or not cond.get("depends_on")
579
+ or not isinstance(cond.get("predicate"), str)
580
+ or not cond.get("predicate")
581
+ ):
582
+ return None, (
583
+ f"step {idx} 'conditional' must be an object with exactly 'depends_on' and "
584
+ f"'predicate' (both non-empty strings)"
585
+ )
586
+ out["conditional"] = {"depends_on": cond["depends_on"], "predicate": cond["predicate"]}
587
+
588
+ for field in STEP_RESERVED_FIELDS:
589
+ if field in entry:
590
+ # Reserved, no-op: accepted and passed through verbatim, no type check.
591
+ out[field] = entry[field]
592
+
593
+ return out, None
594
+
595
+
596
+ def step_skill_name(step):
597
+ """The skill name a step resolves to for forward_from-source/duplicate-collision matching, or
598
+ None for a (pre-flatten) playbook-type step, which has no single skill name to match
599
+ against."""
600
+ if isinstance(step, str):
601
+ return step
602
+ if isinstance(step, dict) and "skill" in step:
603
+ return step["skill"]
604
+ return None
605
+
606
+
607
+ _FRONTMATTER_RE = re.compile(r'^---\r?\n(.*?)\r?\n---', re.DOTALL)
608
+
609
+
610
+ def extract_output_types(skill_md_path):
611
+ """Best-effort extraction of the `output_types` frontmatter field from a SKILL.md.
612
+
613
+ Returns None if the file/field is missing or unparseable, a `str` for the single-static-type
614
+ form, or a `list[dict]` for the `{when, type}` list form. This is a small, targeted parser for
615
+ this repository's own hand-authored frontmatter shape -- not a general YAML parser -- mirroring
616
+ the existing precedent of purpose-built frontmatter extraction over pulling in a YAML
617
+ dependency (see lib/generate-skill-allow-list.js's extractName()).
618
+ """
619
+ try:
620
+ with open(skill_md_path, encoding="utf-8") as fh:
621
+ content = fh.read()
622
+ except OSError:
623
+ return None
624
+
625
+ fm_match = _FRONTMATTER_RE.match(content)
626
+ if not fm_match:
627
+ return None
628
+
629
+ fm_lines = fm_match.group(1).splitlines()
630
+
631
+ for i, line in enumerate(fm_lines):
632
+ key_match = re.match(r'^output_types:\s*(.*)$', line)
633
+ if not key_match:
634
+ continue
635
+
636
+ rest = key_match.group(1).strip()
637
+ if rest:
638
+ return rest.strip('"\'')
639
+
640
+ # Block/list form: gather subsequent, more-indented lines into a list of dicts.
641
+ items = []
642
+ current = {}
643
+ j = i + 1
644
+ while j < len(fm_lines):
645
+ raw_line = fm_lines[j]
646
+ if not raw_line.strip():
647
+ j += 1
648
+ continue
649
+ if not raw_line[0].isspace():
650
+ break # a new top-level frontmatter key ends this block
651
+
652
+ stripped = raw_line.strip()
653
+ item_match = re.match(r'^-\s*(.*)$', stripped)
654
+ if item_match:
655
+ if current:
656
+ items.append(current)
657
+ current = {}
658
+ remainder = item_match.group(1)
659
+ kv = re.match(r'^([a-zA-Z_]+):\s*(.*)$', remainder) if remainder else None
660
+ if kv:
661
+ current[kv.group(1)] = kv.group(2).strip().strip('"\'')
662
+ else:
663
+ kv = re.match(r'^([a-zA-Z_]+):\s*(.*)$', stripped)
664
+ if kv:
665
+ current[kv.group(1)] = kv.group(2).strip().strip('"\'')
666
+ j += 1
667
+
668
+ if current:
669
+ items.append(current)
670
+ return items if items else None
671
+
672
+ return None
673
+
123
674
 
124
675
  try:
125
676
  filenames = sorted(
@@ -130,6 +681,13 @@ except OSError as e:
130
681
  print(f"Error: could not list {playbooks_dir}: {e}", file=sys.stderr)
131
682
  sys.exit(2)
132
683
 
684
+ # --- PASS 1: local (non-composition) validation -------------------------------------------------
685
+ # Builds `raw[pid]` for every playbook that passes purely local validation (parse/shape/id-match/
686
+ # step-shape/skill-existence) -- independent of any OTHER playbook. Composition resolution (PASS
687
+ # 2, below) needs this map fully populated before it can recurse into a referenced playbook.
688
+ raw = {}
689
+ order = [] # preserves the original sorted-filename order for stable catalog/warning output
690
+
133
691
  for filename in filenames:
134
692
  path = os.path.join(playbooks_dir, filename)
135
693
  basename = filename[: -len(".json")]
@@ -138,16 +696,22 @@ for filename in filenames:
138
696
  with open(path, encoding="utf-8") as fh:
139
697
  data = json.load(fh)
140
698
  except Exception as e:
141
- print(f"Warning: {path} is not valid JSON ({e}) — skipped", file=sys.stderr)
699
+ reason = f"is not valid JSON ({e})"
700
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
701
+ skip_reasons[basename] = reason
142
702
  continue
143
703
 
144
704
  if not isinstance(data, dict):
145
- print(f"Warning: {path} is not a JSON object — skipped", file=sys.stderr)
705
+ reason = "is not a JSON object"
706
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
707
+ skip_reasons[basename] = reason
146
708
  continue
147
709
 
148
710
  missing = [f for f in REQUIRED_FIELDS if f not in data]
149
711
  if missing:
150
- print(f"Warning: {path} missing required field(s) {missing} — skipped", file=sys.stderr)
712
+ reason = f"missing required field(s) {missing}"
713
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
714
+ skip_reasons[basename] = reason
151
715
  continue
152
716
 
153
717
  bad_list = [
@@ -155,40 +719,304 @@ for filename in filenames:
155
719
  if not isinstance(data.get(f), list) or len(data.get(f)) == 0
156
720
  ]
157
721
  if bad_list:
158
- print(f"Warning: {path} field(s) {bad_list} must be non-empty lists — skipped", file=sys.stderr)
722
+ reason = f"field(s) {bad_list} must be non-empty lists"
723
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
724
+ skip_reasons[basename] = reason
159
725
  continue
160
726
 
161
727
  if data["id"] != basename:
162
- print(
163
- f"Warning: {path} has id '{data['id']}' which does not match its filename "
164
- f"'{basename}.json' — skipped",
165
- file=sys.stderr,
728
+ reason = (
729
+ f"has id '{data['id']}' which does not match its filename '{basename}.json'"
166
730
  )
731
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
732
+ skip_reasons[basename] = reason
167
733
  continue
168
734
 
735
+ # --- E53_S03_T01: StepObject shape acceptance + bare-string back-compat ---
736
+ normalized_steps = []
737
+ step_error = None
738
+ for idx, raw_step in enumerate(data["steps"]):
739
+ normalized, err = normalize_step(raw_step, idx)
740
+ if err:
741
+ step_error = err
742
+ break
743
+ normalized_steps.append(normalized)
744
+
745
+ if step_error:
746
+ print(f"Warning: {path} {step_error} — skipped", file=sys.stderr)
747
+ skip_reasons[basename] = step_error
748
+ continue
749
+
750
+ # Existence check for skill-type steps only (bare string, or StepObject with `skill`).
751
+ # `playbook`-type steps have no single skill name (step_skill_name returns None for them) and
752
+ # are therefore naturally excluded here -- their existence is composition resolution's job
753
+ # (PASS 2, E53_S05_T01).
754
+ skill_names_to_check = [
755
+ name for name in (step_skill_name(s) for s in normalized_steps)
756
+ if name is not None
757
+ ]
169
758
  missing_skills = [
170
- step for step in data["steps"]
171
- if not os.path.isfile(os.path.join(skills_dir, step, "SKILL.md"))
759
+ name for name in skill_names_to_check
760
+ if not os.path.isfile(os.path.join(skills_dir, name, "SKILL.md"))
172
761
  ]
173
762
  if missing_skills:
174
- print(
175
- f"Warning: {path} references nonexistent skill(s) {missing_skills} "
176
- f"(no skills/<name>/SKILL.md found) — playbook skipped",
177
- file=sys.stderr,
763
+ reason = (
764
+ f"references nonexistent skill(s) {missing_skills} "
765
+ f"(no skills/<name>/SKILL.md found)"
178
766
  )
767
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
768
+ skip_reasons[basename] = reason
179
769
  continue
180
770
 
181
- catalog.append({
182
- "id": data["id"],
771
+ raw[basename] = {
772
+ "path": path,
183
773
  "name": data["name"],
184
774
  "description": data["description"],
185
775
  "keywords": data["keywords"],
186
776
  "examples": data["examples"],
187
- "steps": data["steps"],
777
+ "normalized_steps": normalized_steps,
778
+ }
779
+ order.append(basename)
780
+
781
+ # --- PASS 2: composition resolution (E53_S05_T01) ------------------------------------------------
782
+ # Recursively resolves `{"playbook": "<id>"}` steps into a flat, fully-spliced step list. See
783
+ # header 'COMPOSITION RESOLUTION' for the full algorithm description; this is that algorithm.
784
+
785
+ # Deliberately NOT memoized across calls: the same playbook id can legitimately be composed at
786
+ # different depths by different composers (or resolved fresh as its own top-level catalog entry),
787
+ # and origin annotation/depth-limit checks are context-dependent on the CURRENT resolution path --
788
+ # a cached result from one context would be wrong to reuse in another. The playbook catalog is
789
+ # small, so re-resolving a shared sub-playbook on each reference is cheap.
790
+ def resolve_playbook(pid, depth, visiting_path):
791
+ """Recursively resolve playbook `pid`'s flattened step list at composition `depth` (1 =
792
+ top-level, this playbook's own steps; 2+ = spliced in from one or more levels of
793
+ composition). `visiting_path` is the list of playbook ids on the CURRENT resolution path
794
+ (used for cycle detection -- see header). Returns a `(flattened_step_list, reason)` tuple:
795
+ on success, `(flattened_step_list, None)`; on failure, `(None, reason)` where `reason` is
796
+ THIS call's own specific failure message (E53_S06_T02 -- a stderr warning has already been
797
+ printed, by this call or a nested one, exactly as before this task; the returned `reason` is
798
+ the SAME text as the warning printed by this call, never a nested call's own separate
799
+ message, so a top-level `resolve_playbook(pid, 1, [pid])` call's returned reason is always
800
+ the one specifically attributable to `pid` itself)."""
801
+ entry = raw[pid]
802
+ path = entry["path"]
803
+ normalized_steps = entry["normalized_steps"]
804
+
805
+ flattened = []
806
+ for idx, step in enumerate(normalized_steps):
807
+ if isinstance(step, dict) and "playbook" in step:
808
+ target_id = step["playbook"]
809
+
810
+ if target_id not in raw:
811
+ target_file = os.path.join(playbooks_dir, f"{target_id}.json")
812
+ if os.path.isfile(target_file):
813
+ detail = "exists but failed its own local validation (see earlier warning for that file)"
814
+ else:
815
+ detail = f"does not exist (no {target_id}.json found under {playbooks_dir})"
816
+ reason = f"step {idx} references playbook '{target_id}', which {detail}"
817
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
818
+ return None, reason
819
+
820
+ if target_id in visiting_path:
821
+ cycle_desc = " -> ".join(visiting_path + [target_id])
822
+ reason = (
823
+ f"step {idx} references playbook '{target_id}', which creates a cyclic "
824
+ f"composition reference ({cycle_desc})"
825
+ )
826
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
827
+ return None, reason
828
+
829
+ if depth + 1 > MAX_COMPOSITION_DEPTH:
830
+ reason = (
831
+ f"step {idx} references playbook '{target_id}' at nesting depth "
832
+ f"{depth + 1}, which exceeds the configured max composition depth "
833
+ f"({MAX_COMPOSITION_DEPTH})"
834
+ )
835
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
836
+ return None, reason
837
+
838
+ sub_flattened, _sub_reason = resolve_playbook(target_id, depth + 1, visiting_path + [target_id])
839
+ if sub_flattened is None:
840
+ reason = (
841
+ f"step {idx} references playbook '{target_id}', which could not be resolved "
842
+ f"(see prior warning)"
843
+ )
844
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
845
+ return None, reason
846
+
847
+ flattened.extend(sub_flattened)
848
+ else:
849
+ # A depth-1 step (this playbook's own, un-composed step) is appended completely
850
+ # unchanged here. A step that arrived via `flattened.extend(sub_flattened)` above was
851
+ # already annotated by the deeper call that produced it (see the `if depth > 1` block
852
+ # below, evaluated on THAT call's own `depth`). Origin annotation for THIS playbook's
853
+ # own local steps (if this call itself is depth > 1, i.e. THIS playbook is itself
854
+ # being composed) happens uniformly below, after this loop.
855
+ flattened.append(step)
856
+
857
+ # --- Origin annotation (E53_S05_T01) ---------------------------------------------------------
858
+ # Only applied when THIS resolution itself is nested (depth > 1) -- a top-level playbook's own
859
+ # steps (depth == 1) are left completely untouched, preserving the pre-existing bare-string
860
+ # backward-compatibility guarantee (see header 'ORIGIN ANNOTATION'). `setdefault` ensures a
861
+ # step that was already annotated by a DEEPER call (its true origin) is never overwritten here.
862
+ if depth > 1:
863
+ annotated = []
864
+ for step in flattened:
865
+ new_step = dict(step) if isinstance(step, dict) else {"skill": step}
866
+ new_step.setdefault("_origin_playbook", pid)
867
+ new_step.setdefault("_origin_depth", depth)
868
+ annotated.append(new_step)
869
+ flattened = annotated
870
+
871
+ # --- Duplicate-name collision check (E53_S05_T01 AC #6) --------------------------------------
872
+ seen = {}
873
+ for idx, step in enumerate(flattened):
874
+ name = step_skill_name(step)
875
+ if name is None:
876
+ continue # defensive -- should not happen post-flatten (every playbook-type step was
877
+ # already replaced by its resolved contents above)
878
+ if name in seen:
879
+ reason = (
880
+ f"flattened composition has duplicate skill name '{name}' (steps at position "
881
+ f"{seen[name]} and {idx} both resolve to it)"
882
+ )
883
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
884
+ return None, reason
885
+ seen[name] = idx
886
+
887
+ return flattened, None
888
+
889
+
890
+ # --- PASS 3: forward_from/conditional validation over the FLATTENED list, then catalog assembly -
891
+ # (E53_S03_T03/T04, E53_S04_T02 -- retargeted at the fully flattened, composition-resolved step
892
+ # list as of E53_S05_T01/T02; see header 'FORWARD_FROM RESOLUTION' / 'CONDITIONAL RESOLUTION'.)
893
+
894
+ for pid in order:
895
+ entry = raw[pid]
896
+ path = entry["path"]
897
+
898
+ flattened_steps, composition_reason = resolve_playbook(pid, 1, [pid])
899
+ if flattened_steps is None:
900
+ # a stderr warning was already printed by resolve_playbook (or a nested call);
901
+ # composition_reason is THIS pid's own specific reason (E53_S06_T02).
902
+ if composition_reason:
903
+ skip_reasons[pid] = composition_reason
904
+ continue
905
+
906
+ validation_error = None
907
+ for idx, step in enumerate(flattened_steps):
908
+ if not isinstance(step, dict):
909
+ continue
910
+
911
+ # --- E53_S04_T02: conditional resolution (independent of forward_from -- a step may
912
+ # carry either, both, or neither) ---
913
+ if "conditional" in step:
914
+ cond = step["conditional"]
915
+ depends_on = cond["depends_on"]
916
+ predicate = cond["predicate"]
917
+ earlier_names = [step_skill_name(s) for s in flattened_steps[:idx]]
918
+ if depends_on not in earlier_names:
919
+ validation_error = (
920
+ f"step {idx} 'conditional.depends_on' names '{depends_on}', which is not an "
921
+ f"earlier skill step in this playbook"
922
+ )
923
+ break
924
+ if not CONDITIONAL_PREDICATE_RE.match(predicate):
925
+ validation_error = (
926
+ f"step {idx} 'conditional.predicate' is '{predicate}', which does not match "
927
+ f"the recognized grammar (non_empty/empty/equals:<value>/not_equals:<value>)"
928
+ )
929
+ break
930
+
931
+ if "forward_from" not in step:
932
+ continue
933
+
934
+ source_name = step["forward_from"]
935
+ earlier_names = [step_skill_name(s) for s in flattened_steps[:idx]]
936
+ if source_name not in earlier_names:
937
+ validation_error = (
938
+ f"step {idx} 'forward_from' names '{source_name}', which is not an earlier "
939
+ f"skill step in this playbook"
940
+ )
941
+ break
942
+
943
+ source_skill_md = os.path.join(skills_dir, source_name, "SKILL.md")
944
+ output_types_val = extract_output_types(source_skill_md)
945
+ if not output_types_val:
946
+ validation_error = (
947
+ f"step {idx} 'forward_from' names '{source_name}', which has no declared "
948
+ f"output_types in its SKILL.md frontmatter"
949
+ )
950
+ break
951
+
952
+ # --- E53_S03_T04: Blocker 1's structural {when, type} check ---
953
+ if isinstance(output_types_val, list):
954
+ for ot_entry in output_types_val:
955
+ when_val = ot_entry.get("when") if isinstance(ot_entry, dict) else None
956
+ type_val = ot_entry.get("type") if isinstance(ot_entry, dict) else None
957
+ if not when_val or not type_val:
958
+ validation_error = (
959
+ f"step {idx} forward_from source '{source_name}' declares a malformed "
960
+ f"output_types entry (missing 'when' or 'type')"
961
+ )
962
+ break
963
+ if when_val not in BUILTIN_WHEN_PREDICATES:
964
+ # `when` names a classifier script. Only its EXISTENCE is checkable at load
965
+ # time -- never its actual runtime result. See header 'FORWARD_FROM
966
+ # RESOLUTION' step 3 for the full reasoning behind this structural claim.
967
+ classifier_script = os.path.join(skills_dir, source_name, "scripts", f"{when_val}.sh")
968
+ if not os.path.isfile(classifier_script):
969
+ validation_error = (
970
+ f"step {idx} forward_from source '{source_name}' declares output_types "
971
+ f"with classifier-script when '{when_val}' but no script exists at "
972
+ f"skills/{source_name}/scripts/{when_val}.sh"
973
+ )
974
+ break
975
+ if validation_error:
976
+ break
977
+
978
+ if validation_error:
979
+ print(f"Warning: {path} {validation_error} — skipped", file=sys.stderr)
980
+ skip_reasons[pid] = validation_error
981
+ continue
982
+
983
+ catalog.append({
984
+ "id": pid,
985
+ "name": entry["name"],
986
+ "description": entry["description"],
987
+ "keywords": entry["keywords"],
988
+ "examples": entry["examples"],
989
+ "steps": flattened_steps,
188
990
  })
189
991
 
992
+ # --- E53_S06_T02: `lookup <id>` mode output ------------------------------------------------------
993
+ # Additive sibling to the full-catalog mode below -- see header "USAGE". Reuses the SAME PASS
994
+ # 1-3 pipeline and catalog/skip_reasons results above; never a separate implementation.
995
+ if mode == "lookup":
996
+ result = None
997
+ for lookup_entry in catalog:
998
+ if lookup_entry["id"] == lookup_id:
999
+ result = {"status": "valid", "playbook": lookup_entry}
1000
+ break
1001
+ if result is None:
1002
+ if lookup_id in skip_reasons:
1003
+ result = {"status": "invalid", "reason": skip_reasons[lookup_id]}
1004
+ elif f"{lookup_id}.json" in filenames:
1005
+ # Defensive fallback -- should not normally happen, since every basename scanned
1006
+ # into `filenames` either lands in `catalog` (valid) or `skip_reasons` (invalid) by
1007
+ # this point. Guards against ever silently reporting `not_found` for a file that
1008
+ # does exist on disk.
1009
+ result = {
1010
+ "status": "invalid",
1011
+ "reason": "failed validation (no specific reason captured)",
1012
+ }
1013
+ else:
1014
+ result = {"status": "not_found"}
1015
+ print(json.dumps(result, indent=2))
1016
+ sys.exit(0)
1017
+
190
1018
  print(json.dumps(catalog, indent=2))
191
1019
  PY
192
1020
 
193
- python3 "$PY_SCRIPT" "$PLAYBOOKS_DIR" "$SKILLS_DIR"
1021
+ python3 "$PY_SCRIPT" "$PLAYBOOKS_DIR" "$SKILLS_DIR" "$PROJECT_DIR" "$MODE" "$LOOKUP_ID"
194
1022
  exit $?