@jenga-ai/agent 3.1.1 → 3.4.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 (92) hide show
  1. package/README.md +52 -12
  2. package/agents/developer.md +31 -16
  3. package/agents/scrum-master.md +18 -17
  4. package/agents/tester.md +25 -15
  5. package/bin/jenga.js +10 -0
  6. package/lib/commands/dashboard.js +92 -0
  7. package/lib/skill-allow-list.json +7 -2
  8. package/package.json +21 -2
  9. package/project/app/api/lib/resolve-project-root.js +120 -0
  10. package/project/app/api/package.json +16 -0
  11. package/project/app/api/parsers/architecture.js +72 -0
  12. package/project/app/api/parsers/board.js +141 -0
  13. package/project/app/api/parsers/documentation.js +125 -0
  14. package/project/app/api/parsers/git-log.js +52 -0
  15. package/project/app/api/parsers/ideas.js +62 -0
  16. package/project/app/api/parsers/knowledge-graph.js +73 -0
  17. package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
  18. package/project/app/api/parsers/rapports.js +148 -0
  19. package/project/app/api/parsers/todo.js +179 -0
  20. package/project/app/api/response.js +47 -0
  21. package/project/app/api/routes/architecture.js +23 -0
  22. package/project/app/api/routes/board.js +46 -0
  23. package/project/app/api/routes/documentation.js +24 -0
  24. package/project/app/api/routes/health.js +25 -0
  25. package/project/app/api/routes/history.js +55 -0
  26. package/project/app/api/routes/rapports.js +24 -0
  27. package/project/app/api/scripts/capture-snapshot.js +294 -0
  28. package/project/app/api/server.js +112 -0
  29. package/project/app/api/types.js +40 -0
  30. package/project/app/package.json +21 -0
  31. package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
  32. package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
  33. package/project/app/ui/dist/index.html +13 -0
  34. package/project/app/ui/package.json +23 -0
  35. package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
  36. package/project/app/ui/scripts/dashboard-open.cjs +88 -0
  37. package/project/app/ui/scripts/dashboard-start.cjs +87 -0
  38. package/scripts/acquire-concurrency-slot.sh +220 -0
  39. package/scripts/audit-twin-divergence.sh +625 -0
  40. package/scripts/check-public-playbook-steps.sh +136 -0
  41. package/scripts/compute-deploy-reconcile.sh +439 -0
  42. package/scripts/jenga-permission-level-switch.sh +19 -3
  43. package/scripts/mark-deployed.sh +532 -0
  44. package/scripts/populate-knowledge-graph.js +429 -0
  45. package/scripts/release-concurrency-slot.sh +129 -0
  46. package/scripts/validate-board.sh +60 -2
  47. package/scripts/verify-consumer-install.sh +470 -0
  48. package/skills/j-close-story/SKILL.md +1 -1
  49. package/skills/j-cloud-connect/SKILL.md +95 -0
  50. package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
  51. package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
  52. package/skills/j-dashboard/SKILL.md +144 -0
  53. package/skills/j-dashboard/scripts/launch.sh +121 -0
  54. package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
  55. package/skills/j-dashboard/scripts/snapshot.sh +267 -0
  56. package/skills/j-dashboard-share/SKILL.md +96 -0
  57. package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
  58. package/skills/j-do/SKILL.md +19 -19
  59. package/skills/j-doc-sync/SKILL.md +12 -1
  60. package/skills/j-idea/SKILL.md +1 -1
  61. package/skills/j-init/SKILL.md +5 -4
  62. package/skills/j-init/assets/directory_structure.txt +1 -0
  63. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  64. package/skills/j-init/scripts/init.sh +13 -2
  65. package/skills/j-playbook/SKILL.md +93 -0
  66. package/skills/j-playbook-new/SKILL.md +155 -0
  67. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  68. package/skills/j-proceed/SKILL.md +1 -1
  69. package/skills/j-publish/SKILL.md +1 -1
  70. package/skills/j-publish/adapters/npm-ci.md +29 -0
  71. package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
  72. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  73. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  74. package/skills/j-reconcile/SKILL.md +1 -0
  75. package/skills/j-redo/SKILL.md +1 -1
  76. package/skills/j-status/SKILL.md +12 -0
  77. package/skills/j-todo/SKILL.md +2 -2
  78. package/skills/j-uncharted/SKILL.md +8 -7
  79. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  80. package/skills/jenga/SKILL.md +55 -16
  81. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  82. package/skills/jenga/playbooks/schema.json +1 -1
  83. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  84. package/skills/jenga/scripts/load-playbooks.sh +968 -41
  85. package/skills/jenga/scripts/match-playbook.sh +1 -1
  86. package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
  87. package/skills/jenga/scripts/run-playbook-step.sh +535 -42
  88. package/skills/jenga-permission-level/SKILL.md +4 -4
  89. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  90. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
  91. package/templates/playbook-types.json +8 -0
  92. package/skills/jenga/playbooks/brainstorm-to-mirror.json +0 -22
@@ -9,25 +9,329 @@
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
- # Every `*.json` file directly under `skills/jenga/playbooks/`, EXCLUDING `schema.json` (which
21
- # documents the required shape — see that file's own header — but is never itself a playbook
22
- # entry). See `schema.json` for the authoritative field list; this script's validation below is
23
- # a runtime mirror of that schema, not a substitute for it.
259
+ # TWO directories are scanned and MERGED into one catalog (E53_S09_T01):
260
+ #
261
+ # 1. `skills/jenga/playbooks/` (BUILTIN, framework-owned) — every `*.json` file directly under
262
+ # it, EXCLUDING `schema.json` (which documents the required shape — see that file's own
263
+ # header — but is never itself a playbook entry). See `schema.json` for the authoritative
264
+ # field list; this script's validation below is a runtime mirror of that schema, not a
265
+ # substitute for it.
266
+ #
267
+ # 2. `project/.playbooks/` (PROJECT, project-owned, resolved relative to this script's own
268
+ # `PROJECT_DIR` — see its resolution below, including the `JENGA_PLAYBOOKS_TEST_ROOT`
269
+ # override) — every `*.json` file directly under it, same `schema.json`-exclusion rule.
270
+ # A MISSING `project/.playbooks/` directory is a SILENT NO-OP: no warning, no error — a
271
+ # project with no custom playbooks is the common case, not an exceptional one.
272
+ #
273
+ # The builtin directory is scanned and locally validated FIRST, in full, before the project
274
+ # directory is touched at all. Every project-directory file is then checked for an `id` collision
275
+ # against the already-loaded builtin set: if a project playbook's basename/id matches a
276
+ # SUCCESSFULLY LOADED builtin playbook's id, the project playbook is SKIPPED — a stderr warning
277
+ # names BOTH the project file's path and the builtin file's path — and the builtin entry is the
278
+ # one that survives into the catalog. This is never a silent override in either direction. A
279
+ # project playbook whose id does not collide goes through the exact same local validation,
280
+ # composition resolution, and `forward_from`/`conditional` validation as a builtin one — no
281
+ # separate or weaker path. See "VALIDATION / SKIP CONDITIONS" below for the exact collision rule,
282
+ # and "OUTPUT SCHEMA" for the `source` field every catalog entry gains as a result of this merge.
24
283
  #
25
284
  # ---------------------------------------------------------------------------
26
285
  # USAGE
27
286
  # ---------------------------------------------------------------------------
28
287
  # skills/jenga/scripts/load-playbooks.sh
29
288
  #
30
- # No arguments. Emits the full JSON playbook catalog array to stdout.
289
+ # No arguments (full-catalog mode, unchanged since before E53_S06). Emits the full JSON playbook
290
+ # catalog array to stdout, exactly as documented in "OUTPUT SCHEMA" below.
291
+ #
292
+ # skills/jenga/scripts/load-playbooks.sh lookup <id>
293
+ #
294
+ # Additive sibling mode (E53_S06_T02), for direct-by-id lookup (`j.playbook <id>`,
295
+ # `skills/j-playbook/SKILL.md`, E53_S06_T03) — runs the SAME PASS 1-3 pipeline as the no-argument
296
+ # mode above (never a separate implementation), against the SAME MERGED builtin+project catalog
297
+ # (E53_S09_T01) — an id may resolve from either source, and the returned `playbook` object carries
298
+ # the same `source` field a full-catalog entry would — then emits exactly ONE JSON object to
299
+ # stdout (never the full catalog, never warnings about OTHER playbooks) and exits 0:
300
+ #
301
+ # {"status": "valid", "playbook": {...}} -- <id> resolved to a real file and passed every
302
+ # validation pass; `playbook` has the same field
303
+ # shape as one full-catalog entry.
304
+ # {"status": "invalid", "reason": "..."} -- a file `<id>.json` exists under
305
+ # skills/jenga/playbooks/ but failed validation at
306
+ # some pass; `reason` is the SAME specific,
307
+ # human-readable message this script would already
308
+ # print to stderr for that failure in full-catalog
309
+ # mode -- never a generic message.
310
+ # {"status": "not_found"} -- no `<id>.json` file exists under
311
+ # skills/jenga/playbooks/ (excluding schema.json)
312
+ # at all.
313
+ #
314
+ # Usage errors (`lookup` given with no `<id>`, or an unrecognized first argument) print a usage
315
+ # message to stderr and exit 2 -- the same setup-error exit code documented in "EXIT CODES" below.
316
+ # stderr warnings about OTHER (non-looked-up) playbooks may still be emitted for consistency with
317
+ # the full-catalog mode's existing behavior; only stdout is constrained to exactly one JSON object.
318
+ #
319
+ # TESTING OVERRIDE — JENGA_PLAYBOOKS_TEST_ROOT (E53_S03_T05): when this environment variable is
320
+ # set, it overrides the monorepo/node_modules PKG_ROOT auto-detection below, pointing
321
+ # PLAYBOOKS_DIR/SKILLS_DIR at `<value>/skills/jenga/playbooks` and `<value>/skills` respectively,
322
+ # AND (as of E53_S05_T01) overrides the PROJECT root used to locate
323
+ # `project/configs/playbook-config.json`, so a fixture wanting a non-default composition depth
324
+ # limit places one at `<value>/project/configs/playbook-config.json`. This exists exclusively so
325
+ # `tests/load-playbooks-stepobject.bats` and `tests/load-playbooks-composition.bats` can point this
326
+ # loader at a synthetic, throwaway fixture tree under `$BATS_TEST_TMPDIR` instead of this
327
+ # repository's own `skills/`/`project/` — per this repo's fixture-tree testing convention, a test
328
+ # asserting on candidate sets never targets the repository root. Never set this variable in a real
329
+ # invocation.
330
+ #
331
+ # Because the SAME override also becomes the PROJECT root (see "DATA SOURCE" above), a fixture
332
+ # wanting project-local playbook coverage places its files at
333
+ # `<value>/project/.playbooks/<id>.json` — no second, project-specific test variable is
334
+ # introduced (E53_S09_T01, `tests/load-playbooks-project-source.bats`).
31
335
  #
32
336
  # ---------------------------------------------------------------------------
33
337
  # OUTPUT SCHEMA
@@ -41,16 +345,31 @@
41
345
  # "description": "...",
42
346
  # "keywords": ["..."],
43
347
  # "examples": ["..."],
44
- # "steps": ["brainstorm", "todo", "do", "dev-done", "mirror-public"]
348
+ # "steps": ["j-brainstorm", "j-todo", "j-do", "j-dev-done", "j-mirror-public"],
349
+ # "source": "builtin"
45
350
  # },
46
351
  # ...
47
352
  # ]
48
353
  #
354
+ # `source` (E53_S09_T01) is `"builtin"` for anything loaded from `skills/jenga/playbooks/`, or
355
+ # `"project"` for anything loaded from `project/.playbooks/` — present on EVERY catalog entry,
356
+ # regardless of source, additive to the pre-existing field shape.
357
+ #
358
+ # `steps` entries are emitted exactly as validated/resolved: a depth-1 bare string stays a bare
359
+ # string; a depth-1 StepObject is emitted as an object carrying only its recognized fields (`skill`
360
+ # XOR `playbook` — though by the time this is emitted, a `playbook`-type step has already been
361
+ # replaced by its resolved contents — plus whichever of `instruction`/`forward_from`/`resolve`/
362
+ # `conditional`/`version`/`schema_version` were present on the source step); a step spliced in from
363
+ # a nested (composed) playbook (depth > 1) additionally carries `_origin_playbook`/`_origin_depth`
364
+ # (see "COMPOSITION RESOLUTION" above).
365
+ #
49
366
  # Nothing but this JSON array is ever written to stdout. Skip warnings go to stderr only and are
50
367
  # non-fatal — a single malformed playbook file never aborts the whole catalog load.
51
368
  #
52
369
  # ---------------------------------------------------------------------------
53
- # VALIDATION / SKIP CONDITIONS (each skip is a stderr warning, never fatal)
370
+ # VALIDATION / SKIP CONDITIONS (each skip is a stderr warning, never fatal — the WHOLE playbook is
371
+ # skipped on any of these, never just the offending step, consistent with this script's existing
372
+ # "a chain with a broken link is not a usable chain" skip granularity)
54
373
  # ---------------------------------------------------------------------------
55
374
  # - File is not valid JSON, or is not a JSON object -> skipped
56
375
  # - Missing any required field: id, name, description, keywords,
@@ -60,10 +379,45 @@
60
379
  # - `id` does not equal the filename's basename without `.json` -> skipped
61
380
  # (prevents a playbook's identity from silently drifting from its
62
381
  # 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)
382
+ # - A PROJECT playbook's id/basename matches a SUCCESSFULLY LOADED
383
+ # BUILTIN playbook's id (E53_S09_T01) -> project playbook
384
+ # skipped; stderr warning names BOTH the project file's path and the
385
+ # builtin file's path; the builtin entry is the one that survives into
386
+ # the catalog (never a silent override in either direction)
387
+ # - A `steps` entry is neither a string nor an object, or is an empty
388
+ # string -> skipped
389
+ # - A StepObject step carries BOTH `skill` and `playbook`, or NEITHER -> skipped
390
+ # - A StepObject's `skill`/`playbook`/`instruction`/`forward_from`/
391
+ # `resolve` field is present but not a non-empty string -> skipped
392
+ # - A StepObject carries BOTH `resolve` and `playbook` -> skipped
393
+ # (resolve targeting a downstream confirmation gate — Blocker 2 v1 scope cut, E53_S03_T03;
394
+ # checked in `normalize_step()` as of E53_S05_T01, before composition can splice the
395
+ # `playbook` field away)
396
+ # - Any `skill`-type step (bare string or StepObject) has no
397
+ # corresponding `skills/<name>/SKILL.md` on disk -> skipped
398
+ # - A `{"playbook": "<id>"}` step's `<id>` does not resolve to an
399
+ # existing, locally-valid playbook file (E53_S05_T01) -> skipped
400
+ # - A playbook's composition graph contains a cycle (including direct
401
+ # self-reference) (E53_S05_T01) -> skipped
402
+ # - A composition's nesting depth exceeds the configured/default
403
+ # `max_composition_depth` (E53_S05_T01) -> skipped
404
+ # - A flattened composition result contains two or more steps
405
+ # resolving to the same skill name (E53_S05_T01) -> skipped
406
+ # - A `forward_from` names a step that is not an EARLIER step in the
407
+ # final flattened step list -> skipped
408
+ # - A `forward_from` names a source step whose skill has no declared
409
+ # `output_types` in its own `SKILL.md` frontmatter -> skipped
410
+ # - A `forward_from` source's `output_types` list has a `{when, type}`
411
+ # entry missing `when`/`type`, or a classifier-script `when` with no
412
+ # matching `skills/<name>/scripts/<when>.sh` on disk (Blocker 1,
413
+ # E53_S03_T04) -> skipped
414
+ # - A `conditional` field is present but not an object with exactly
415
+ # `depends_on` and `predicate` (both non-empty strings) (E53_S04_T02) -> skipped
416
+ # - A `conditional`'s `depends_on` names a step that is not an EARLIER
417
+ # step in the final flattened step list (E53_S04_T02) -> skipped
418
+ # - A `conditional`'s `predicate` does not match the recognized grammar
419
+ # (`non_empty`/`empty`/`equals:<value>`/`not_equals:<value>`, defined
420
+ # in `run-playbook-step.sh`'s own header) (E53_S04_T02) -> skipped
67
421
  #
68
422
  # ---------------------------------------------------------------------------
69
423
  # EXIT CODES
@@ -80,13 +434,42 @@ set -euo pipefail
80
434
 
81
435
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
82
436
 
437
+ # --- E53_S06_T02: additive `lookup <id>` CLI mode ------------------------------------------------
438
+ # Backward compatible: no arguments at all reproduces the original, unchanged full-catalog mode.
439
+ # See header "USAGE" for the full contract.
440
+ MODE="catalog"
441
+ LOOKUP_ID=""
442
+ if [ $# -gt 0 ]; then
443
+ case "$1" in
444
+ lookup)
445
+ if [ $# -lt 2 ] || [ -z "${2:-}" ]; then
446
+ echo "Usage: $(basename "$0") lookup <id>" >&2
447
+ exit 2
448
+ fi
449
+ MODE="lookup"
450
+ LOOKUP_ID="$2"
451
+ ;;
452
+ *)
453
+ echo "Error: unrecognized argument '$1' (usage: $(basename "$0") [lookup <id>])" >&2
454
+ exit 2
455
+ ;;
456
+ esac
457
+ fi
458
+
459
+ if [ -n "${JENGA_PLAYBOOKS_TEST_ROOT:-}" ]; then
460
+ # Test-only override — see "TESTING OVERRIDE" in the header above (E53_S03_T05, extended by
461
+ # E53_S05_T01 to also cover playbook-config.json resolution). Never set in a real invocation.
462
+ PKG_ROOT="$JENGA_PLAYBOOKS_TEST_ROOT"
463
+ PROJECT_DIR="$JENGA_PLAYBOOKS_TEST_ROOT"
83
464
  # Resolve the jenga-agent PACKAGE root (where the canonical skills/ tree actually lives) — same
84
465
  # monorepo-checkout vs. installed-npm-package detection used by
85
466
  # skills/jenga/scripts/load-nl-catalog.sh's PKG_ROOT resolution and skills/init/scripts/init.sh.
86
- if [ -d "$SCRIPT_DIR/../../../templates" ]; then
467
+ elif [ -d "$SCRIPT_DIR/../../../templates" ]; then
87
468
  PKG_ROOT="$SCRIPT_DIR/../../.."
469
+ PROJECT_DIR="${JENGA_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)}}"
88
470
  elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent/templates" ]; then
89
471
  PKG_ROOT="${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent"
472
+ PROJECT_DIR="${JENGA_PROJECT_DIR:-$CLAUDE_PROJECT_DIR}"
90
473
  else
91
474
  echo "Error: could not locate the jenga-agent package root (templates/ not found via monorepo checkout or node_modules/@jenga-ai/agent)." >&2
92
475
  exit 2
@@ -111,18 +494,229 @@ trap 'rm -f "$PY_SCRIPT"' EXIT
111
494
  cat > "$PY_SCRIPT" <<'PY'
112
495
  import json
113
496
  import os
497
+ import re
114
498
  import sys
115
499
 
116
500
  playbooks_dir = sys.argv[1]
117
501
  skills_dir = sys.argv[2]
502
+ project_dir = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None
503
+ # --- E53_S06_T02: additive `lookup <id>` CLI mode --------------------------------------------
504
+ mode = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else "catalog"
505
+ lookup_id = sys.argv[5] if len(sys.argv) > 5 else ""
506
+
507
+ # --- E53_S09_T01: project-local playbook source directory ------------------------------------
508
+ # `project/.playbooks/`, resolved relative to the SAME project_dir already threaded above (which
509
+ # already honors JENGA_PLAYBOOKS_TEST_ROOT -- see header "TESTING OVERRIDE"). A missing directory
510
+ # is a silent no-op, not an error -- see header "DATA SOURCE".
511
+ project_playbooks_dir = os.path.join(project_dir, "project", ".playbooks") if project_dir else None
118
512
 
119
513
  REQUIRED_FIELDS = ["id", "name", "description", "keywords", "examples", "steps"]
120
514
  LIST_FIELDS = ["keywords", "examples", "steps"]
121
515
 
516
+ # Optional StepObject fields that must be non-empty strings when present.
517
+ STEP_STRING_FIELDS = ("instruction", "forward_from", "resolve")
518
+ # Reserved, currently-unused StepObject fields -- accepted verbatim, never validated or acted
519
+ # upon (E53_S03_T01).
520
+ STEP_RESERVED_FIELDS = ("version", "schema_version")
521
+
522
+ # The two built-in {when, type} predicates every skill may use without naming a classifier
523
+ # script. Any other `when` value is a classifier-script reference (E53_S03_T04's Blocker 1
524
+ # check).
525
+ BUILTIN_WHEN_PREDICATES = {"argument_empty", "argument_nonempty"}
526
+
527
+ # Recognized `conditional.predicate` grammar (E53_S04_T02). The single source of truth for this
528
+ # grammar is `run-playbook-step.sh`'s own header -- this regex is a load-time mirror of it, not a
529
+ # second, independent definition.
530
+ CONDITIONAL_PREDICATE_RE = re.compile(r'^(non_empty|empty|equals:.+|not_equals:.+)$')
531
+
532
+ # --- E53_S05_T01: configurable composition depth limit -----------------------------------------
533
+ DEFAULT_MAX_COMPOSITION_DEPTH = 3
534
+
535
+
536
+ def load_max_composition_depth(proj_dir):
537
+ """Read `max_composition_depth` from project/configs/playbook-config.json. A missing file,
538
+ missing field, or non-positive-integer value all fall back to the hardcoded default -- this
539
+ is a tunable safety default, never a required setup file (see header 'COMPOSITION
540
+ RESOLUTION')."""
541
+ if not proj_dir:
542
+ return DEFAULT_MAX_COMPOSITION_DEPTH
543
+ config_path = os.path.join(proj_dir, "project", "configs", "playbook-config.json")
544
+ if not os.path.isfile(config_path):
545
+ return DEFAULT_MAX_COMPOSITION_DEPTH
546
+ try:
547
+ with open(config_path, encoding="utf-8") as fh:
548
+ cfg = json.load(fh)
549
+ value = cfg.get("max_composition_depth", DEFAULT_MAX_COMPOSITION_DEPTH)
550
+ if isinstance(value, int) and not isinstance(value, bool) and value >= 1:
551
+ return value
552
+ except Exception:
553
+ pass
554
+ return DEFAULT_MAX_COMPOSITION_DEPTH
555
+
556
+
557
+ MAX_COMPOSITION_DEPTH = load_max_composition_depth(project_dir)
558
+
122
559
  catalog = []
560
+ # --- E53_S06_T02: per-basename skip-reason capture -------------------------------------------
561
+ # Threaded through PASS 1, PASS 2 (resolve_playbook), and PASS 3 below -- whenever a playbook
562
+ # basename is dropped, at any point in the pipeline, the specific reason string already being
563
+ # printed to stderr is ALSO recorded here, against that basename. This is purely additive: it
564
+ # never changes any existing stderr warning text or the full-catalog mode's JSON output (see
565
+ # header "USAGE"). Powers `lookup <id>`'s "invalid" result (a known id that failed validation,
566
+ # with the SAME specific reason -- never a generic message).
567
+ skip_reasons = {}
568
+
569
+
570
+ def normalize_step(entry, idx):
571
+ """Validate and normalize one `steps` entry. Returns (normalized_step, error_or_None)."""
572
+ if isinstance(entry, str):
573
+ if entry == "":
574
+ return None, f"step {idx} is an empty string"
575
+ return entry, None
576
+
577
+ if not isinstance(entry, dict):
578
+ return None, f"step {idx} is neither a string nor an object"
579
+
580
+ has_skill = "skill" in entry
581
+ has_playbook = "playbook" in entry
582
+ if has_skill and has_playbook:
583
+ return None, f"step {idx} has both 'skill' and 'playbook' (mutually exclusive)"
584
+ if not has_skill and not has_playbook:
585
+ return None, f"step {idx} has neither 'skill' nor 'playbook' (a StepObject requires exactly one)"
586
+
587
+ # --- E53_S05_T01: resolve/playbook conflict (Blocker 2 v1 scope cut, E53_S03_T03) -- must be
588
+ # checked HERE, before composition resolution ever runs, since that pass later splices a
589
+ # `playbook`-type step away entirely -- deferring this check until after flattening would
590
+ # make the conflict unreachable.
591
+ if has_playbook and "resolve" in entry:
592
+ return None, (
593
+ f"step {idx} has 'resolve' targeting a playbook-composition step "
594
+ f"('{entry['playbook']}'), which is always a downstream confirmation gate "
595
+ f"(see header 'CONFIRMATION-GATE CONVENTION') — 'resolve' may never target one"
596
+ )
597
+
598
+ out = {}
599
+ target_field = "skill" if has_skill else "playbook"
600
+ target_value = entry[target_field]
601
+ if not isinstance(target_value, str) or not target_value:
602
+ return None, f"step {idx} '{target_field}' must be a non-empty string"
603
+ out[target_field] = target_value
604
+
605
+ for field in STEP_STRING_FIELDS:
606
+ if field in entry:
607
+ value = entry[field]
608
+ if not isinstance(value, str) or not value:
609
+ return None, f"step {idx} '{field}' must be a non-empty string"
610
+ out[field] = value
611
+
612
+ # `conditional` (E53_S04_T02) -- shape check only here; cross-step existence + predicate
613
+ # grammar checks happen in the forward_from/conditional validation loop below, once the full
614
+ # FLATTENED steps list (E53_S05_T01/T02) is available.
615
+ if "conditional" in entry:
616
+ cond = entry["conditional"]
617
+ if (
618
+ not isinstance(cond, dict)
619
+ or set(cond.keys()) != {"depends_on", "predicate"}
620
+ or not isinstance(cond.get("depends_on"), str)
621
+ or not cond.get("depends_on")
622
+ or not isinstance(cond.get("predicate"), str)
623
+ or not cond.get("predicate")
624
+ ):
625
+ return None, (
626
+ f"step {idx} 'conditional' must be an object with exactly 'depends_on' and "
627
+ f"'predicate' (both non-empty strings)"
628
+ )
629
+ out["conditional"] = {"depends_on": cond["depends_on"], "predicate": cond["predicate"]}
630
+
631
+ for field in STEP_RESERVED_FIELDS:
632
+ if field in entry:
633
+ # Reserved, no-op: accepted and passed through verbatim, no type check.
634
+ out[field] = entry[field]
635
+
636
+ return out, None
637
+
638
+
639
+ def step_skill_name(step):
640
+ """The skill name a step resolves to for forward_from-source/duplicate-collision matching, or
641
+ None for a (pre-flatten) playbook-type step, which has no single skill name to match
642
+ against."""
643
+ if isinstance(step, str):
644
+ return step
645
+ if isinstance(step, dict) and "skill" in step:
646
+ return step["skill"]
647
+ return None
648
+
649
+
650
+ _FRONTMATTER_RE = re.compile(r'^---\r?\n(.*?)\r?\n---', re.DOTALL)
651
+
652
+
653
+ def extract_output_types(skill_md_path):
654
+ """Best-effort extraction of the `output_types` frontmatter field from a SKILL.md.
655
+
656
+ Returns None if the file/field is missing or unparseable, a `str` for the single-static-type
657
+ form, or a `list[dict]` for the `{when, type}` list form. This is a small, targeted parser for
658
+ this repository's own hand-authored frontmatter shape -- not a general YAML parser -- mirroring
659
+ the existing precedent of purpose-built frontmatter extraction over pulling in a YAML
660
+ dependency (see lib/generate-skill-allow-list.js's extractName()).
661
+ """
662
+ try:
663
+ with open(skill_md_path, encoding="utf-8") as fh:
664
+ content = fh.read()
665
+ except OSError:
666
+ return None
667
+
668
+ fm_match = _FRONTMATTER_RE.match(content)
669
+ if not fm_match:
670
+ return None
671
+
672
+ fm_lines = fm_match.group(1).splitlines()
673
+
674
+ for i, line in enumerate(fm_lines):
675
+ key_match = re.match(r'^output_types:\s*(.*)$', line)
676
+ if not key_match:
677
+ continue
678
+
679
+ rest = key_match.group(1).strip()
680
+ if rest:
681
+ return rest.strip('"\'')
682
+
683
+ # Block/list form: gather subsequent, more-indented lines into a list of dicts.
684
+ items = []
685
+ current = {}
686
+ j = i + 1
687
+ while j < len(fm_lines):
688
+ raw_line = fm_lines[j]
689
+ if not raw_line.strip():
690
+ j += 1
691
+ continue
692
+ if not raw_line[0].isspace():
693
+ break # a new top-level frontmatter key ends this block
694
+
695
+ stripped = raw_line.strip()
696
+ item_match = re.match(r'^-\s*(.*)$', stripped)
697
+ if item_match:
698
+ if current:
699
+ items.append(current)
700
+ current = {}
701
+ remainder = item_match.group(1)
702
+ kv = re.match(r'^([a-zA-Z_]+):\s*(.*)$', remainder) if remainder else None
703
+ if kv:
704
+ current[kv.group(1)] = kv.group(2).strip().strip('"\'')
705
+ else:
706
+ kv = re.match(r'^([a-zA-Z_]+):\s*(.*)$', stripped)
707
+ if kv:
708
+ current[kv.group(1)] = kv.group(2).strip().strip('"\'')
709
+ j += 1
710
+
711
+ if current:
712
+ items.append(current)
713
+ return items if items else None
714
+
715
+ return None
716
+
123
717
 
124
718
  try:
125
- filenames = sorted(
719
+ builtin_filenames = sorted(
126
720
  f for f in os.listdir(playbooks_dir)
127
721
  if f.endswith(".json") and f != "schema.json"
128
722
  )
@@ -130,65 +724,398 @@ except OSError as e:
130
724
  print(f"Error: could not list {playbooks_dir}: {e}", file=sys.stderr)
131
725
  sys.exit(2)
132
726
 
133
- for filename in filenames:
134
- path = os.path.join(playbooks_dir, filename)
135
- basename = filename[: -len(".json")]
727
+ # --- E53_S09_T01: project-local playbook directory listing --------------------------------------
728
+ # Non-fatal, unlike the builtin listing above: a missing directory (the common case -- most
729
+ # projects have no custom playbooks) or an OSError while listing it both yield an empty list,
730
+ # never a setup-error exit. See header "DATA SOURCE".
731
+ project_filenames = []
732
+ if project_playbooks_dir and os.path.isdir(project_playbooks_dir):
733
+ try:
734
+ project_filenames = sorted(
735
+ f for f in os.listdir(project_playbooks_dir)
736
+ if f.endswith(".json") and f != "schema.json"
737
+ )
738
+ except OSError as e:
739
+ print(f"Warning: could not list {project_playbooks_dir}: {e}", file=sys.stderr)
740
+ project_filenames = []
741
+
742
+ # --- PASS 1: local (non-composition) validation -------------------------------------------------
743
+ # Builds `raw[pid]` for every playbook that passes purely local validation (parse/shape/id-match/
744
+ # step-shape/skill-existence) -- independent of any OTHER playbook. Composition resolution (PASS
745
+ # 2, below) needs this map fully populated before it can recurse into a referenced playbook.
746
+ #
747
+ # As of E53_S09_T01, `raw`/`order` are populated from BOTH the builtin and project directories
748
+ # (builtin first, in full, then project -- see the id-collision check below). Every playbook run
749
+ # through `process_playbook_file()` carries a `source` tag ("builtin" or "project") in its `raw`
750
+ # entry, which flows through unchanged into the final catalog entry (PASS 3, below).
751
+ raw = {}
752
+ order = [] # preserves the original builtin-then-project, sorted-within-source filename order
753
+
136
754
 
755
+ def process_playbook_file(path, basename, source):
756
+ """Runs PASS 1's local validation for one playbook file (identical logic regardless of which
757
+ source directory it came from -- see header 'DATA SOURCE': project playbooks go through the
758
+ exact same pipeline as builtin ones, never a separate or weaker path). On success, populates
759
+ `raw[basename]` (tagged with `source`) and appends to `order`. On failure, prints the same
760
+ stderr warning this script has always printed for that condition and records the reason in
761
+ `skip_reasons[basename]`."""
137
762
  try:
138
763
  with open(path, encoding="utf-8") as fh:
139
764
  data = json.load(fh)
140
765
  except Exception as e:
141
- print(f"Warning: {path} is not valid JSON ({e}) — skipped", file=sys.stderr)
142
- continue
766
+ reason = f"is not valid JSON ({e})"
767
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
768
+ skip_reasons[basename] = reason
769
+ return
143
770
 
144
771
  if not isinstance(data, dict):
145
- print(f"Warning: {path} is not a JSON object — skipped", file=sys.stderr)
146
- continue
772
+ reason = "is not a JSON object"
773
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
774
+ skip_reasons[basename] = reason
775
+ return
147
776
 
148
777
  missing = [f for f in REQUIRED_FIELDS if f not in data]
149
778
  if missing:
150
- print(f"Warning: {path} missing required field(s) {missing} — skipped", file=sys.stderr)
151
- continue
779
+ reason = f"missing required field(s) {missing}"
780
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
781
+ skip_reasons[basename] = reason
782
+ return
152
783
 
153
784
  bad_list = [
154
785
  f for f in LIST_FIELDS
155
786
  if not isinstance(data.get(f), list) or len(data.get(f)) == 0
156
787
  ]
157
788
  if bad_list:
158
- print(f"Warning: {path} field(s) {bad_list} must be non-empty lists — skipped", file=sys.stderr)
159
- continue
789
+ reason = f"field(s) {bad_list} must be non-empty lists"
790
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
791
+ skip_reasons[basename] = reason
792
+ return
160
793
 
161
794
  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,
795
+ reason = (
796
+ f"has id '{data['id']}' which does not match its filename '{basename}.json'"
166
797
  )
167
- continue
798
+ print(f"Warning: {path} {reason} — skipped", file=sys.stderr)
799
+ skip_reasons[basename] = reason
800
+ return
801
+
802
+ # --- E53_S03_T01: StepObject shape acceptance + bare-string back-compat ---
803
+ normalized_steps = []
804
+ step_error = None
805
+ for idx, raw_step in enumerate(data["steps"]):
806
+ normalized, err = normalize_step(raw_step, idx)
807
+ if err:
808
+ step_error = err
809
+ break
810
+ normalized_steps.append(normalized)
811
+
812
+ if step_error:
813
+ print(f"Warning: {path} {step_error} — skipped", file=sys.stderr)
814
+ skip_reasons[basename] = step_error
815
+ return
168
816
 
817
+ # Existence check for skill-type steps only (bare string, or StepObject with `skill`).
818
+ # `playbook`-type steps have no single skill name (step_skill_name returns None for them) and
819
+ # are therefore naturally excluded here -- their existence is composition resolution's job
820
+ # (PASS 2, E53_S05_T01).
821
+ skill_names_to_check = [
822
+ name for name in (step_skill_name(s) for s in normalized_steps)
823
+ if name is not None
824
+ ]
169
825
  missing_skills = [
170
- step for step in data["steps"]
171
- if not os.path.isfile(os.path.join(skills_dir, step, "SKILL.md"))
826
+ name for name in skill_names_to_check
827
+ if not os.path.isfile(os.path.join(skills_dir, name, "SKILL.md"))
172
828
  ]
173
829
  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,
830
+ reason = (
831
+ f"references nonexistent skill(s) {missing_skills} "
832
+ f"(no skills/<name>/SKILL.md found)"
178
833
  )
179
- continue
834
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
835
+ skip_reasons[basename] = reason
836
+ return
180
837
 
181
- catalog.append({
182
- "id": data["id"],
838
+ raw[basename] = {
839
+ "path": path,
840
+ "source": source,
183
841
  "name": data["name"],
184
842
  "description": data["description"],
185
843
  "keywords": data["keywords"],
186
844
  "examples": data["examples"],
187
- "steps": data["steps"],
845
+ "normalized_steps": normalized_steps,
846
+ }
847
+ order.append(basename)
848
+
849
+
850
+ for filename in builtin_filenames:
851
+ path = os.path.join(playbooks_dir, filename)
852
+ basename = filename[: -len(".json")]
853
+ process_playbook_file(path, basename, "builtin")
854
+
855
+ # --- E53_S09_T01: project playbooks, id-collision check against the already-loaded builtin set --
856
+ # Runs AFTER the builtin loop above has fully populated `raw`, so a collision check here is
857
+ # checking against every SUCCESSFULLY LOADED builtin playbook -- never a builtin file that itself
858
+ # failed validation (that basename never made it into `raw`, so a project playbook may legitimately
859
+ # claim that id instead). See header 'VALIDATION / SKIP CONDITIONS'.
860
+ for filename in project_filenames:
861
+ path = os.path.join(project_playbooks_dir, filename)
862
+ basename = filename[: -len(".json")]
863
+
864
+ if basename in raw:
865
+ builtin_path = raw[basename]["path"]
866
+ reason = (
867
+ f"id '{basename}' collides with a built-in playbook already loaded from "
868
+ f"{builtin_path} — the built-in entry is retained, never silently overridden"
869
+ )
870
+ print(
871
+ f"Warning: {path} {reason} (project file skipped)",
872
+ file=sys.stderr,
873
+ )
874
+ skip_reasons[basename] = reason
875
+ continue
876
+
877
+ process_playbook_file(path, basename, "project")
878
+
879
+ # --- PASS 2: composition resolution (E53_S05_T01) ------------------------------------------------
880
+ # Recursively resolves `{"playbook": "<id>"}` steps into a flat, fully-spliced step list. See
881
+ # header 'COMPOSITION RESOLUTION' for the full algorithm description; this is that algorithm.
882
+
883
+ # Deliberately NOT memoized across calls: the same playbook id can legitimately be composed at
884
+ # different depths by different composers (or resolved fresh as its own top-level catalog entry),
885
+ # and origin annotation/depth-limit checks are context-dependent on the CURRENT resolution path --
886
+ # a cached result from one context would be wrong to reuse in another. The playbook catalog is
887
+ # small, so re-resolving a shared sub-playbook on each reference is cheap.
888
+ def resolve_playbook(pid, depth, visiting_path):
889
+ """Recursively resolve playbook `pid`'s flattened step list at composition `depth` (1 =
890
+ top-level, this playbook's own steps; 2+ = spliced in from one or more levels of
891
+ composition). `visiting_path` is the list of playbook ids on the CURRENT resolution path
892
+ (used for cycle detection -- see header). Returns a `(flattened_step_list, reason)` tuple:
893
+ on success, `(flattened_step_list, None)`; on failure, `(None, reason)` where `reason` is
894
+ THIS call's own specific failure message (E53_S06_T02 -- a stderr warning has already been
895
+ printed, by this call or a nested one, exactly as before this task; the returned `reason` is
896
+ the SAME text as the warning printed by this call, never a nested call's own separate
897
+ message, so a top-level `resolve_playbook(pid, 1, [pid])` call's returned reason is always
898
+ the one specifically attributable to `pid` itself)."""
899
+ entry = raw[pid]
900
+ path = entry["path"]
901
+ normalized_steps = entry["normalized_steps"]
902
+
903
+ flattened = []
904
+ for idx, step in enumerate(normalized_steps):
905
+ if isinstance(step, dict) and "playbook" in step:
906
+ target_id = step["playbook"]
907
+
908
+ if target_id not in raw:
909
+ target_file = os.path.join(playbooks_dir, f"{target_id}.json")
910
+ if os.path.isfile(target_file):
911
+ detail = "exists but failed its own local validation (see earlier warning for that file)"
912
+ else:
913
+ detail = f"does not exist (no {target_id}.json found under {playbooks_dir})"
914
+ reason = f"step {idx} references playbook '{target_id}', which {detail}"
915
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
916
+ return None, reason
917
+
918
+ if target_id in visiting_path:
919
+ cycle_desc = " -> ".join(visiting_path + [target_id])
920
+ reason = (
921
+ f"step {idx} references playbook '{target_id}', which creates a cyclic "
922
+ f"composition reference ({cycle_desc})"
923
+ )
924
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
925
+ return None, reason
926
+
927
+ if depth + 1 > MAX_COMPOSITION_DEPTH:
928
+ reason = (
929
+ f"step {idx} references playbook '{target_id}' at nesting depth "
930
+ f"{depth + 1}, which exceeds the configured max composition depth "
931
+ f"({MAX_COMPOSITION_DEPTH})"
932
+ )
933
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
934
+ return None, reason
935
+
936
+ sub_flattened, _sub_reason = resolve_playbook(target_id, depth + 1, visiting_path + [target_id])
937
+ if sub_flattened is None:
938
+ reason = (
939
+ f"step {idx} references playbook '{target_id}', which could not be resolved "
940
+ f"(see prior warning)"
941
+ )
942
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
943
+ return None, reason
944
+
945
+ flattened.extend(sub_flattened)
946
+ else:
947
+ # A depth-1 step (this playbook's own, un-composed step) is appended completely
948
+ # unchanged here. A step that arrived via `flattened.extend(sub_flattened)` above was
949
+ # already annotated by the deeper call that produced it (see the `if depth > 1` block
950
+ # below, evaluated on THAT call's own `depth`). Origin annotation for THIS playbook's
951
+ # own local steps (if this call itself is depth > 1, i.e. THIS playbook is itself
952
+ # being composed) happens uniformly below, after this loop.
953
+ flattened.append(step)
954
+
955
+ # --- Origin annotation (E53_S05_T01) ---------------------------------------------------------
956
+ # Only applied when THIS resolution itself is nested (depth > 1) -- a top-level playbook's own
957
+ # steps (depth == 1) are left completely untouched, preserving the pre-existing bare-string
958
+ # backward-compatibility guarantee (see header 'ORIGIN ANNOTATION'). `setdefault` ensures a
959
+ # step that was already annotated by a DEEPER call (its true origin) is never overwritten here.
960
+ if depth > 1:
961
+ annotated = []
962
+ for step in flattened:
963
+ new_step = dict(step) if isinstance(step, dict) else {"skill": step}
964
+ new_step.setdefault("_origin_playbook", pid)
965
+ new_step.setdefault("_origin_depth", depth)
966
+ annotated.append(new_step)
967
+ flattened = annotated
968
+
969
+ # --- Duplicate-name collision check (E53_S05_T01 AC #6) --------------------------------------
970
+ seen = {}
971
+ for idx, step in enumerate(flattened):
972
+ name = step_skill_name(step)
973
+ if name is None:
974
+ continue # defensive -- should not happen post-flatten (every playbook-type step was
975
+ # already replaced by its resolved contents above)
976
+ if name in seen:
977
+ reason = (
978
+ f"flattened composition has duplicate skill name '{name}' (steps at position "
979
+ f"{seen[name]} and {idx} both resolve to it)"
980
+ )
981
+ print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
982
+ return None, reason
983
+ seen[name] = idx
984
+
985
+ return flattened, None
986
+
987
+
988
+ # --- PASS 3: forward_from/conditional validation over the FLATTENED list, then catalog assembly -
989
+ # (E53_S03_T03/T04, E53_S04_T02 -- retargeted at the fully flattened, composition-resolved step
990
+ # list as of E53_S05_T01/T02; see header 'FORWARD_FROM RESOLUTION' / 'CONDITIONAL RESOLUTION'.)
991
+
992
+ for pid in order:
993
+ entry = raw[pid]
994
+ path = entry["path"]
995
+
996
+ flattened_steps, composition_reason = resolve_playbook(pid, 1, [pid])
997
+ if flattened_steps is None:
998
+ # a stderr warning was already printed by resolve_playbook (or a nested call);
999
+ # composition_reason is THIS pid's own specific reason (E53_S06_T02).
1000
+ if composition_reason:
1001
+ skip_reasons[pid] = composition_reason
1002
+ continue
1003
+
1004
+ validation_error = None
1005
+ for idx, step in enumerate(flattened_steps):
1006
+ if not isinstance(step, dict):
1007
+ continue
1008
+
1009
+ # --- E53_S04_T02: conditional resolution (independent of forward_from -- a step may
1010
+ # carry either, both, or neither) ---
1011
+ if "conditional" in step:
1012
+ cond = step["conditional"]
1013
+ depends_on = cond["depends_on"]
1014
+ predicate = cond["predicate"]
1015
+ earlier_names = [step_skill_name(s) for s in flattened_steps[:idx]]
1016
+ if depends_on not in earlier_names:
1017
+ validation_error = (
1018
+ f"step {idx} 'conditional.depends_on' names '{depends_on}', which is not an "
1019
+ f"earlier skill step in this playbook"
1020
+ )
1021
+ break
1022
+ if not CONDITIONAL_PREDICATE_RE.match(predicate):
1023
+ validation_error = (
1024
+ f"step {idx} 'conditional.predicate' is '{predicate}', which does not match "
1025
+ f"the recognized grammar (non_empty/empty/equals:<value>/not_equals:<value>)"
1026
+ )
1027
+ break
1028
+
1029
+ if "forward_from" not in step:
1030
+ continue
1031
+
1032
+ source_name = step["forward_from"]
1033
+ earlier_names = [step_skill_name(s) for s in flattened_steps[:idx]]
1034
+ if source_name not in earlier_names:
1035
+ validation_error = (
1036
+ f"step {idx} 'forward_from' names '{source_name}', which is not an earlier "
1037
+ f"skill step in this playbook"
1038
+ )
1039
+ break
1040
+
1041
+ source_skill_md = os.path.join(skills_dir, source_name, "SKILL.md")
1042
+ output_types_val = extract_output_types(source_skill_md)
1043
+ if not output_types_val:
1044
+ validation_error = (
1045
+ f"step {idx} 'forward_from' names '{source_name}', which has no declared "
1046
+ f"output_types in its SKILL.md frontmatter"
1047
+ )
1048
+ break
1049
+
1050
+ # --- E53_S03_T04: Blocker 1's structural {when, type} check ---
1051
+ if isinstance(output_types_val, list):
1052
+ for ot_entry in output_types_val:
1053
+ when_val = ot_entry.get("when") if isinstance(ot_entry, dict) else None
1054
+ type_val = ot_entry.get("type") if isinstance(ot_entry, dict) else None
1055
+ if not when_val or not type_val:
1056
+ validation_error = (
1057
+ f"step {idx} forward_from source '{source_name}' declares a malformed "
1058
+ f"output_types entry (missing 'when' or 'type')"
1059
+ )
1060
+ break
1061
+ if when_val not in BUILTIN_WHEN_PREDICATES:
1062
+ # `when` names a classifier script. Only its EXISTENCE is checkable at load
1063
+ # time -- never its actual runtime result. See header 'FORWARD_FROM
1064
+ # RESOLUTION' step 3 for the full reasoning behind this structural claim.
1065
+ classifier_script = os.path.join(skills_dir, source_name, "scripts", f"{when_val}.sh")
1066
+ if not os.path.isfile(classifier_script):
1067
+ validation_error = (
1068
+ f"step {idx} forward_from source '{source_name}' declares output_types "
1069
+ f"with classifier-script when '{when_val}' but no script exists at "
1070
+ f"skills/{source_name}/scripts/{when_val}.sh"
1071
+ )
1072
+ break
1073
+ if validation_error:
1074
+ break
1075
+
1076
+ if validation_error:
1077
+ print(f"Warning: {path} {validation_error} — skipped", file=sys.stderr)
1078
+ skip_reasons[pid] = validation_error
1079
+ continue
1080
+
1081
+ catalog.append({
1082
+ "id": pid,
1083
+ "name": entry["name"],
1084
+ "description": entry["description"],
1085
+ "keywords": entry["keywords"],
1086
+ "examples": entry["examples"],
1087
+ "steps": flattened_steps,
1088
+ "source": entry["source"],
188
1089
  })
189
1090
 
1091
+ # --- E53_S06_T02: `lookup <id>` mode output ------------------------------------------------------
1092
+ # Additive sibling to the full-catalog mode below -- see header "USAGE". Reuses the SAME PASS
1093
+ # 1-3 pipeline and catalog/skip_reasons results above; never a separate implementation.
1094
+ if mode == "lookup":
1095
+ result = None
1096
+ for lookup_entry in catalog:
1097
+ if lookup_entry["id"] == lookup_id:
1098
+ result = {"status": "valid", "playbook": lookup_entry}
1099
+ break
1100
+ if result is None:
1101
+ if lookup_id in skip_reasons:
1102
+ result = {"status": "invalid", "reason": skip_reasons[lookup_id]}
1103
+ elif f"{lookup_id}.json" in builtin_filenames or f"{lookup_id}.json" in project_filenames:
1104
+ # Defensive fallback -- should not normally happen, since every basename scanned from
1105
+ # EITHER source directory (E53_S09_T01) either lands in `catalog` (valid) or
1106
+ # `skip_reasons` (invalid) by this point. Guards against ever silently reporting
1107
+ # `not_found` for a file that does exist on disk.
1108
+ result = {
1109
+ "status": "invalid",
1110
+ "reason": "failed validation (no specific reason captured)",
1111
+ }
1112
+ else:
1113
+ result = {"status": "not_found"}
1114
+ print(json.dumps(result, indent=2))
1115
+ sys.exit(0)
1116
+
190
1117
  print(json.dumps(catalog, indent=2))
191
1118
  PY
192
1119
 
193
- python3 "$PY_SCRIPT" "$PLAYBOOKS_DIR" "$SKILLS_DIR"
1120
+ python3 "$PY_SCRIPT" "$PLAYBOOKS_DIR" "$SKILLS_DIR" "$PROJECT_DIR" "$MODE" "$LOOKUP_ID"
194
1121
  exit $?