@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.
- package/README.md +52 -12
- package/agents/developer.md +31 -16
- package/agents/scrum-master.md +18 -17
- package/agents/tester.md +25 -15
- package/bin/jenga.js +10 -0
- package/lib/commands/dashboard.js +92 -0
- package/lib/skill-allow-list.json +7 -2
- package/package.json +21 -2
- package/project/app/api/lib/resolve-project-root.js +120 -0
- package/project/app/api/package.json +16 -0
- package/project/app/api/parsers/architecture.js +72 -0
- package/project/app/api/parsers/board.js +141 -0
- package/project/app/api/parsers/documentation.js +125 -0
- package/project/app/api/parsers/git-log.js +52 -0
- package/project/app/api/parsers/ideas.js +62 -0
- package/project/app/api/parsers/knowledge-graph.js +73 -0
- package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
- package/project/app/api/parsers/rapports.js +148 -0
- package/project/app/api/parsers/todo.js +179 -0
- package/project/app/api/response.js +47 -0
- package/project/app/api/routes/architecture.js +23 -0
- package/project/app/api/routes/board.js +46 -0
- package/project/app/api/routes/documentation.js +24 -0
- package/project/app/api/routes/health.js +25 -0
- package/project/app/api/routes/history.js +55 -0
- package/project/app/api/routes/rapports.js +24 -0
- package/project/app/api/scripts/capture-snapshot.js +294 -0
- package/project/app/api/server.js +112 -0
- package/project/app/api/types.js +40 -0
- package/project/app/package.json +21 -0
- package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
- package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
- package/project/app/ui/dist/index.html +13 -0
- package/project/app/ui/package.json +23 -0
- package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
- package/project/app/ui/scripts/dashboard-open.cjs +88 -0
- package/project/app/ui/scripts/dashboard-start.cjs +87 -0
- package/scripts/acquire-concurrency-slot.sh +220 -0
- package/scripts/audit-twin-divergence.sh +625 -0
- package/scripts/check-public-playbook-steps.sh +136 -0
- package/scripts/compute-deploy-reconcile.sh +439 -0
- package/scripts/jenga-permission-level-switch.sh +19 -3
- package/scripts/mark-deployed.sh +532 -0
- package/scripts/populate-knowledge-graph.js +429 -0
- package/scripts/release-concurrency-slot.sh +129 -0
- package/scripts/validate-board.sh +60 -2
- package/scripts/verify-consumer-install.sh +470 -0
- package/skills/j-close-story/SKILL.md +1 -1
- package/skills/j-cloud-connect/SKILL.md +95 -0
- package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
- package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
- package/skills/j-dashboard/SKILL.md +144 -0
- package/skills/j-dashboard/scripts/launch.sh +121 -0
- package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
- package/skills/j-dashboard/scripts/snapshot.sh +267 -0
- package/skills/j-dashboard-share/SKILL.md +96 -0
- package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
- package/skills/j-do/SKILL.md +19 -19
- package/skills/j-doc-sync/SKILL.md +12 -1
- package/skills/j-idea/SKILL.md +1 -1
- package/skills/j-init/SKILL.md +5 -4
- package/skills/j-init/assets/directory_structure.txt +1 -0
- package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
- package/skills/j-init/scripts/init.sh +13 -2
- package/skills/j-playbook/SKILL.md +93 -0
- package/skills/j-playbook-new/SKILL.md +155 -0
- package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
- package/skills/j-proceed/SKILL.md +1 -1
- package/skills/j-publish/SKILL.md +1 -1
- package/skills/j-publish/adapters/npm-ci.md +29 -0
- package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
- package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
- package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
- package/skills/j-reconcile/SKILL.md +1 -0
- package/skills/j-redo/SKILL.md +1 -1
- package/skills/j-status/SKILL.md +12 -0
- package/skills/j-todo/SKILL.md +2 -2
- package/skills/j-uncharted/SKILL.md +8 -7
- package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
- package/skills/jenga/SKILL.md +55 -16
- package/skills/jenga/playbooks/idea-to-committed.json +20 -0
- package/skills/jenga/playbooks/schema.json +1 -1
- package/skills/jenga/scripts/load-nl-catalog.js +22 -6
- package/skills/jenga/scripts/load-playbooks.sh +968 -41
- package/skills/jenga/scripts/match-playbook.sh +1 -1
- package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
- package/skills/jenga/scripts/run-playbook-step.sh +535 -42
- package/skills/jenga-permission-level/SKILL.md +4 -4
- package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
- package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
- package/templates/playbook-types.json +8 -0
- 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
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
|
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
|
-
# -
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
163
|
-
f"
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
if not os.path.isfile(os.path.join(skills_dir,
|
|
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
|
-
|
|
175
|
-
f"
|
|
176
|
-
f"(no skills/<name>/SKILL.md found)
|
|
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
|
-
|
|
834
|
+
print(f"Warning: {path} {reason} — playbook skipped", file=sys.stderr)
|
|
835
|
+
skip_reasons[basename] = reason
|
|
836
|
+
return
|
|
180
837
|
|
|
181
|
-
|
|
182
|
-
"
|
|
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
|
-
"
|
|
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 $?
|