@jenga-ai/agent 3.6.0 → 4.1.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 (86) hide show
  1. package/README.md +32 -1
  2. package/lib/generate-skill-allow-list.js +42 -5
  3. package/lib/skill-allow-list.json +1 -1
  4. package/package.json +1 -2
  5. package/project/app/api/lib/resolve-project-root.js +1 -1
  6. package/project/app/api/scripts/capture-snapshot.js +12 -7
  7. package/project/app/package.json +4 -0
  8. package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
  9. package/project/app/ui/dist/assets/index-DX2pfTAW.js +104 -0
  10. package/project/app/ui/dist/index.html +2 -2
  11. package/scripts/acquire-concurrency-slot.sh +34 -4
  12. package/scripts/apply-j-prefix.sh +1 -1
  13. package/scripts/delete-bare-skill-dirs.sh +1 -2
  14. package/scripts/idea_manager.sh +16 -1
  15. package/scripts/release-concurrency-slot.sh +34 -4
  16. package/scripts/render-ranked-list.sh +270 -0
  17. package/scripts/repoint-dead-bare-path-prose.py +81 -0
  18. package/scripts/rewrite-stale-skill-preambles.py +188 -0
  19. package/scripts/strip-polyfill-frontmatter.py +166 -0
  20. package/scripts/todo_manager.sh +16 -1
  21. package/scripts/validate-typed-object.sh +750 -0
  22. package/scripts/verify-postinstall-reconcile.sh +33 -7
  23. package/skills/index/scripts/board_index.py +29 -1
  24. package/skills/j-brainstorm/SKILL.md +3 -4
  25. package/skills/j-btw/SKILL.md +3 -4
  26. package/skills/j-clearify/SKILL.md +3 -4
  27. package/skills/j-close-story/SKILL.md +11 -12
  28. package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
  29. package/skills/j-commit/SKILL.md +3 -4
  30. package/skills/j-continue/SKILL.md +5 -6
  31. package/skills/j-deep-dive/SKILL.md +3 -4
  32. package/skills/j-distribute/SKILL.md +3 -4
  33. package/skills/j-do/SKILL.md +13 -15
  34. package/skills/j-doc/SKILL.md +3 -4
  35. package/skills/j-doc/scripts/resolve_last_update.py +27 -1
  36. package/skills/j-doc-sync/SKILL.md +3 -4
  37. package/skills/j-dooo/SKILL.md +6 -15
  38. package/skills/j-error/SKILL.md +3 -4
  39. package/skills/j-evaluate/SKILL.md +3 -4
  40. package/skills/j-examplify/SKILL.md +3 -4
  41. package/skills/j-help/SKILL.md +3 -4
  42. package/skills/j-idea/SKILL.md +3 -4
  43. package/skills/j-improve/SKILL.md +3 -4
  44. package/skills/j-init/SKILL.md +20 -11
  45. package/skills/j-init/scripts/apply-scaffold-visibility.sh +8 -6
  46. package/skills/j-jbp/SKILL.md +3 -4
  47. package/skills/j-lgtm/SKILL.md +3 -4
  48. package/skills/j-pi-plan/SKILL.md +3 -4
  49. package/skills/j-proceed/SKILL.md +3 -4
  50. package/skills/j-publish/SKILL.md +3 -4
  51. package/skills/j-publish/scripts/run_gates.sh +1 -1
  52. package/skills/j-reconcile/SKILL.md +38 -5
  53. package/skills/j-reconcile/assets/report_format.md +11 -0
  54. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +2 -2
  55. package/skills/j-reconcile-origin/SKILL.md +3 -4
  56. package/skills/j-redo/SKILL.md +3 -4
  57. package/skills/j-skillify/SKILL.md +3 -4
  58. package/skills/j-spinoff/SKILL.md +3 -4
  59. package/skills/j-status/SKILL.md +4 -5
  60. package/skills/j-todo/SKILL.md +42 -5
  61. package/skills/j-todo/scripts/add_trivial_task.sh +12 -1
  62. package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
  63. package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
  64. package/skills/j-uncharted/SKILL.md +251 -12
  65. package/skills/j-uncharted/scripts/detect-dependencies.sh +79 -21
  66. package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
  67. package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
  68. package/skills/j-uncharted/scripts/run-engine.sh +36 -2
  69. package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
  70. package/skills/j-wtf/SKILL.md +3 -4
  71. package/skills/jenga/SKILL.md +106 -10
  72. package/skills/jenga/playbooks/schema.json +4 -4
  73. package/skills/jenga/scripts/enrich-nl-prompt.sh +225 -0
  74. package/skills/jenga/scripts/load-nl-catalog.js +6 -3
  75. package/skills/jenga/scripts/load-playbooks.sh +289 -4
  76. package/skills/jenga/scripts/match-playbook.sh +6 -5
  77. package/skills/jenga/scripts/run-playbook-step.sh +270 -3
  78. package/templates/permission-levels/level-1-locked.json +1 -1
  79. package/templates/permission-levels/level-2-guarded.json +1 -1
  80. package/templates/permission-levels/level-3-standard.json +1 -1
  81. package/templates/permission-levels/level-4-elevated.json +1 -1
  82. package/templates/permission-levels/level-5-unrestricted.json +1 -1
  83. package/templates/playbook-types.json +6 -0
  84. package/project/app/ui/dist/assets/index-BVR_7Owg.css +0 -1
  85. package/project/app/ui/dist/assets/index-CtU2xLQm.js +0 -104
  86. package/scripts/audit-twin-divergence.sh +0 -693
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/enrich-nl-prompt.sh
4
+ #
5
+ # Deterministic board + documentation enrichment scan for `/jenga`'s natural-language branch,
6
+ # ported from `/route`'s Steps 3-5 (E53_S13_T01). `/route` is being retired in this same story
7
+ # (E53_S13_T02) — this script is how its board-context and documentation enrichment survives, as
8
+ # an OPT-IN capability behind `/jenga`'s `--enrich` flag (see `skills/jenga/SKILL.md`'s Phase 0.75
9
+ # natural-language branch). The default, unflagged NL path never invokes this script.
10
+ #
11
+ # Per CLAUDE.md's "Scripts Over Inline Logic" principle, the board scan and docs scan are
12
+ # deterministic and belong here — the agent's job is limited to invoking this script and
13
+ # assembling the enriched prompt from its structured output, exactly as it already does for
14
+ # `board-scan.sh`/`detect-nl-intent.sh`/`match-playbook.sh` elsewhere in this directory.
15
+ #
16
+ # ---------------------------------------------------------------------------
17
+ # USAGE
18
+ # ---------------------------------------------------------------------------
19
+ # skills/jenga/scripts/enrich-nl-prompt.sh "<raw prompt text>"
20
+ #
21
+ # The argument is the same raw natural-language text `detect-nl-intent.sh` classified as
22
+ # `nl_intent` (its `raw_argument` field) — passed through verbatim, not re-cleaned here.
23
+ #
24
+ # ---------------------------------------------------------------------------
25
+ # ALGORITHM
26
+ # ---------------------------------------------------------------------------
27
+ # Board half (`/route`'s Step 3) — reuses `skills/jenga/scripts/board-scan.sh` verbatim for the
28
+ # board inventory (no duplicate board-scanning logic is introduced here). The prompt is tokenized
29
+ # (lowercased, stopword-filtered) and an item is a match if any prompt token appears as a substring
30
+ # of its `title` or `summary` field. Items with `status` of `Archived` or `Cancelled` are excluded.
31
+ # Results are capped at the top 5, in `board-scan.sh`'s own stable order (epics, then stories, then
32
+ # tasks; lexical by filename within each type).
33
+ #
34
+ # Docs half (`/route`'s Step 4) — scans `project/documentation/plans/`,
35
+ # `project/documentation/summaries/`, `project/documentation/examples/`, and `docs/` (non-recursive
36
+ # within each) for files whose filename OR first top-level heading contains a prompt token.
37
+ # Results are capped at the top 3, in directory-then-lexical-filename order.
38
+ #
39
+ # ---------------------------------------------------------------------------
40
+ # OUTPUT SCHEMA (stable)
41
+ # ---------------------------------------------------------------------------
42
+ # stdout is always a single JSON object. Nothing else is ever written to stdout.
43
+ #
44
+ # {
45
+ # "board_items": [
46
+ # {"id": "E12_S03", "type": "story", "status": "Pending", "title": "...",
47
+ # "file": "project/board/stories/E12_S03_....md"},
48
+ # ... // up to 5
49
+ # ],
50
+ # "docs": [
51
+ # {"path": "docs/skill-authoring.md", "summary": "<first heading or filename>"},
52
+ # ... // up to 3
53
+ # ],
54
+ # "board_items_found": 2, // total matches BEFORE the top-5 cap
55
+ # "docs_found": 1 // total matches BEFORE the top-3 cap
56
+ # }
57
+ #
58
+ # An empty result (`board_items: []`, `docs: []`, both counts 0) is a normal, non-error outcome —
59
+ # it means the prompt simply didn't match anything on the board or in docs. Exit code is 0 in that
60
+ # case, same as any other successful scan.
61
+ #
62
+ # ---------------------------------------------------------------------------
63
+ # EXIT CODES
64
+ # ---------------------------------------------------------------------------
65
+ # 0 scan completed (stdout is always valid JSON on this path, including the empty-match case)
66
+ # 1 usage error (no argument given), or a setup problem: `board-scan.sh` missing/failing, or
67
+ # python3 unavailable — real setup problems, not classification outcomes.
68
+ #
69
+ # ---------------------------------------------------------------------------
70
+
71
+ set -euo pipefail
72
+
73
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
74
+ BOARD_SCAN="$SCRIPT_DIR/board-scan.sh"
75
+
76
+ if [ $# -lt 1 ] || [ -z "${1:-}" ]; then
77
+ echo 'Usage: enrich-nl-prompt.sh "<raw prompt text>"' >&2
78
+ exit 1
79
+ fi
80
+
81
+ RAW_PROMPT="$1"
82
+
83
+ if [ ! -x "$BOARD_SCAN" ]; then
84
+ echo "Error: board-scan.sh not found or not executable at $BOARD_SCAN" >&2
85
+ exit 1
86
+ fi
87
+
88
+ if ! command -v python3 >/dev/null 2>&1; then
89
+ echo "Error: python3 is required by enrich-nl-prompt.sh" >&2
90
+ exit 1
91
+ fi
92
+
93
+ # Resolve JENGA_PROJECT_DIR the same way every other script in this directory does
94
+ # (CLAUDE_PROJECT_DIR -> git toplevel -> cwd).
95
+ if [ -f "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh" ]; then
96
+ # shellcheck source=lib/resolve-project-dir.sh
97
+ source "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh"
98
+ elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
99
+ JENGA_PROJECT_DIR="$CLAUDE_PROJECT_DIR"
100
+ else
101
+ JENGA_PROJECT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)"
102
+ fi
103
+
104
+ BOARD_JSON="$("$BOARD_SCAN")"
105
+
106
+ PY_SCRIPT="$(mktemp -t enrich-nl-prompt-XXXXXX.py)"
107
+ trap 'rm -f "$PY_SCRIPT"' EXIT
108
+
109
+ cat > "$PY_SCRIPT" <<'PY'
110
+ import json
111
+ import re
112
+ import sys
113
+ from pathlib import Path
114
+
115
+ project_root = Path(sys.argv[1])
116
+ raw_prompt = sys.argv[2]
117
+ board_json = sys.stdin.read()
118
+
119
+ STOPWORDS = {
120
+ "a", "an", "the", "to", "and", "or", "of", "in", "on", "for", "this", "that",
121
+ "is", "it", "its", "with", "from", "into", "i", "my", "me", "we", "our",
122
+ "you", "your", "then", "so", "be", "as", "at", "by", "up", "out", "all",
123
+ "let", "lets", "let's", "go", "want", "please", "help", "would", "like",
124
+ }
125
+
126
+
127
+ def tokenize(text):
128
+ words = re.findall(r"[a-z0-9']+", text.lower())
129
+ return {w for w in words if w not in STOPWORDS and len(w) > 1}
130
+
131
+
132
+ prompt_tokens = tokenize(raw_prompt)
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Board half
136
+ # ---------------------------------------------------------------------------
137
+ try:
138
+ board_items = json.loads(board_json)
139
+ except Exception as e:
140
+ print(f"Error: could not parse board-scan.sh output as JSON: {e}", file=sys.stderr)
141
+ sys.exit(1)
142
+
143
+ EXCLUDED_STATUSES = {"Archived", "Cancelled"}
144
+
145
+
146
+ def item_matches(item):
147
+ haystack = f"{item.get('title', '')} {item.get('summary', '')}".lower()
148
+ return any(tok in haystack for tok in prompt_tokens)
149
+
150
+
151
+ matched_board = [
152
+ item for item in board_items
153
+ if item.get("status") not in EXCLUDED_STATUSES and item_matches(item)
154
+ ]
155
+
156
+ board_items_found = len(matched_board)
157
+ top_board_items = [
158
+ {
159
+ "id": item.get("id", ""),
160
+ "type": item.get("type", ""),
161
+ "status": item.get("status", ""),
162
+ "title": item.get("title", ""),
163
+ "file": item.get("file", ""),
164
+ }
165
+ for item in matched_board[:5]
166
+ ]
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Docs half
170
+ # ---------------------------------------------------------------------------
171
+ DOC_DIRS = [
172
+ "project/documentation/plans",
173
+ "project/documentation/summaries",
174
+ "project/documentation/examples",
175
+ "docs",
176
+ ]
177
+
178
+ HEADING_RE = re.compile(r'^#+\s+(.*\S)\s*$')
179
+
180
+
181
+ def first_heading(path):
182
+ try:
183
+ with path.open(encoding="utf-8") as f:
184
+ for line in f:
185
+ m = HEADING_RE.match(line.rstrip("\n"))
186
+ if m:
187
+ return m.group(1)
188
+ except Exception:
189
+ pass
190
+ return ""
191
+
192
+
193
+ matched_docs = []
194
+ for rel_dir in DOC_DIRS:
195
+ dir_path = project_root / rel_dir
196
+ if not dir_path.is_dir():
197
+ continue
198
+ for f in sorted(dir_path.glob("*.md")):
199
+ heading = first_heading(f)
200
+ haystack = f"{f.stem} {heading}".lower()
201
+ if any(tok in haystack for tok in prompt_tokens):
202
+ try:
203
+ rel_file = f.relative_to(project_root).as_posix()
204
+ except ValueError:
205
+ rel_file = f.as_posix()
206
+ matched_docs.append({
207
+ "path": rel_file,
208
+ "summary": heading or f.stem,
209
+ })
210
+
211
+ docs_found = len(matched_docs)
212
+ top_docs = matched_docs[:3]
213
+
214
+ result = {
215
+ "board_items": top_board_items,
216
+ "docs": top_docs,
217
+ "board_items_found": board_items_found,
218
+ "docs_found": docs_found,
219
+ }
220
+
221
+ json.dump(result, sys.stdout, indent=2)
222
+ sys.stdout.write("\n")
223
+ PY
224
+
225
+ python3 "$PY_SCRIPT" "$JENGA_PROJECT_DIR" "$RAW_PROMPT" <<< "$BOARD_JSON"
@@ -20,7 +20,7 @@
20
20
  * `keywords`, `examples`, and `metadata.prefered_agent`, and emits `dirName` (not the bare
21
21
  * identifier) as the catalog entry's `name` — callers like `/jenga`'s Skill invocation and
22
22
  * `playbook-new.sh`'s `validate-skill` need the real, invokable directory name. These are the
23
- * same fields `/route`'s Step 1 ("Discover Available Skills") collects.
23
+ * same fields `/jenga`'s own Skill Matching & Invocation Contract needs for matching and invocation.
24
24
  *
25
25
  * ---------------------------------------------------------------------------
26
26
  * USAGE
@@ -68,8 +68,11 @@ import { join } from "path";
68
68
  import { pathToFileURL } from "url";
69
69
 
70
70
  // The three permanent exceptions to the `j-<name>` canonical directory convention — see
71
- // docs/skill-authoring.md's Canonical Naming Contract and scripts/audit-twin-divergence.sh's
72
- // NEVER_TWINNED list, which this mirrors.
71
+ // docs/skill-authoring.md's Canonical Naming Contract, and scripts/repoint-skill-refs.sh's
72
+ // REPOINT_SKILL_REFS_EXCEPTIONS list, which this mirrors. (It previously mirrored the
73
+ // twin-divergence audit script's NEVER_TWINNED tuple; that script was deleted when
74
+ // E42_S07 retired the twin-parity gate, so both this comment and the cross-check in
75
+ // tests/load-nl-catalog-twin-resolution.bats were repointed at the surviving list.)
73
76
  const NEVER_TWINNED = new Set(["jenga", "jenga-permission-level", "index"]);
74
77
 
75
78
  function canonicalSkillDir(name) {
@@ -130,6 +130,84 @@
130
130
  # anywhere in this loop. `conditional.depends_on` works identically, by the same mechanism.
131
131
  #
132
132
  # ---------------------------------------------------------------------------
133
+ # TYPE REGISTRY (E62_S01_T04)
134
+ # ---------------------------------------------------------------------------
135
+ # The canonical type vocabulary this script validates against is `templates/playbook-types.json`
136
+ # (E53_S03_T02, converted into a map of type descriptors by E62_S01_T01), resolved as
137
+ # `<PKG_ROOT>/templates/playbook-types.json` — the SAME `PKG_ROOT` that already resolves
138
+ # `PLAYBOOKS_DIR`/`SKILLS_DIR` above, so the `JENGA_PLAYBOOKS_TEST_ROOT` override (see "TESTING
139
+ # OVERRIDE") redirects it too, and a fixture tree may supply its own vocabulary. The vocabulary is
140
+ # ALWAYS read from that file's `types` map — this script never hardcodes a type name, and adding a
141
+ # type to the registry is a data-only edit that requires no change here (see that file's own
142
+ # `_comment`, "EXTENDING").
143
+ #
144
+ # Two load-time checks are driven from it. Both are about DECLARATIONS ONLY — what a SKILL.md
145
+ # claims — never about an actual runtime VALUE. Verifying a value against its declared type is a
146
+ # separate concern owned by `scripts/validate-typed-object.sh` (E62_S01_T02) and consumed at
147
+ # runtime by `run-playbook-step.sh` (E62_S02); this script never calls it and never sees a value.
148
+ #
149
+ # CHECK 1 — REGISTRY VOCABULARY. For EVERY skill named by a step in the final flattened step
150
+ # list (bare-string steps included, not only StepObjects, and not only `forward_from` sources),
151
+ # every type value the skill declares in its `output_types` or `input_types` frontmatter must be
152
+ # a key in the registry's `types` map. An unrecognized value (e.g. `output_types: banana`) skips
153
+ # the whole playbook with a stderr warning naming the offending value, the field it came from,
154
+ # and — for the `{when, type}` list form — the branch it came from. This check is INDEPENDENT of
155
+ # `forward_from`: it has repo-wide value on a playbook with no forward edges at all.
156
+ #
157
+ # CHECK 2 — OUTPUT/INPUT COMPATIBILITY. Where a step declares `forward_from`, the SOURCE's
158
+ # declared `output_types` must be accepted by the CONSUMER's (i.e. this step's own skill's)
159
+ # declared `input_types`. This runs AFTER the existing declaredness and Blocker-1 structural
160
+ # checks above, so a source that declares nothing, or declares a malformed `{when, type}` entry,
161
+ # is still rejected by its own pre-existing message rather than by this one.
162
+ #
163
+ # THE ALL-BRANCHES RULE — both sides. Either side's declaration may be a single static type string
164
+ # or a list of `{when, type}` branches. This check never needs to know which branch will actually
165
+ # fire on either side, and never executes a classifier script to find out:
166
+ #
167
+ # Compatible IFF every type the SOURCE could produce is accepted under EVERY CONSUMER branch.
168
+ # Equivalently: the set of source types must be a subset of the INTERSECTION of the consumer's
169
+ # accepted types.
170
+ #
171
+ # The source half of that rule (every source branch must produce a type the consumer accepts; any
172
+ # branch that does not rejects the playbook, naming the offending branch) is the rule as originally
173
+ # specified in E53. The CONSUMER half — what a conditional consumer, one whose own `input_types` is
174
+ # a `{when, type}` list, means for compatibility — was left open by E62_S01_T03 and is RATIFIED
175
+ # HERE (2026-09-19, E62_S01_T04) as the conservative dual stated above.
176
+ #
177
+ # Rationale: which consumer branch applies at runtime is no more knowable at load time than which
178
+ # source branch applies. The only answer that is safe regardless of BOTH is the one that holds
179
+ # under both universally. It stays fully deterministic and executes no classifier script. No
180
+ # skill in this repository declares a conditional `input_types` today, so the ruling costs
181
+ # nothing now — it exists to close the ambiguity before it can bite.
182
+ #
183
+ # Mechanically, each consumer branch accepts exactly one type, so the intersection is `{t}` when
184
+ # every branch declares the same `t`, and EMPTY when the branches disagree. An empty intersection
185
+ # rejects every forward into that consumer. That is the intended conservative outcome of the
186
+ # ruling, not an accident of the implementation.
187
+ #
188
+ # BACKWARD COMPATIBILITY — BINDING. A consumer that declares NO `input_types` at all retains
189
+ # today's behavior EXACTLY: the forward is allowed on the existing source-declaredness check alone,
190
+ # and Check 2 short-circuits before any comparison is made. The ABSENCE of a declaration is never a
191
+ # violation. Adding the input side must not, and does not, retroactively break a single existing
192
+ # playbook. (`docs/skill-authoring.md`'s `input_types` section states the same guarantee from the
193
+ # skill author's side.)
194
+ #
195
+ # `text` IS NOT A WILDCARD. `text` carries `"verify": null` in the registry because prose has no
196
+ # checkable shape, but it is a specific type in this compatibility lattice — never an `any`. It is
197
+ # neither an `output_types` that satisfies every `input_types` nor an `input_types` that accepts
198
+ # every `output_types`. Treating it as a universal acceptor would make every text-declaring skill
199
+ # compatible with everything and render this check decorative, which is precisely what E53_S11's
200
+ # honesty-over-breadth policy exists to prevent. Nothing in this script special-cases it.
201
+ #
202
+ # MISSING REGISTRY — DELIBERATE FAIL-OPEN. If the registry file is absent or unparseable, CHECK 1
203
+ # is a silent no-op (no warning, no rejection); Check 2 is unaffected, since comparing declared
204
+ # type NAMES needs no vocabulary. This matches this script's existing convention for an absent
205
+ # optional input (a missing `project/.playbooks/` directory, a missing `playbook-config.json`), and
206
+ # it is unreachable in a real invocation: PKG_ROOT detection is itself keyed on `templates/`
207
+ # existing. It exists so a fixture tree under `JENGA_PLAYBOOKS_TEST_ROOT` may supply its own
208
+ # registry, or deliberately supply none.
209
+ #
210
+ # ---------------------------------------------------------------------------
133
211
  # CONDITIONAL RESOLUTION (E53_S04_T02)
134
212
  # ---------------------------------------------------------------------------
135
213
  # A step's `conditional: {"depends_on": "<name>", "predicate": "<predicate>"}` is validated at load
@@ -418,6 +496,20 @@
418
496
  # - A `conditional`'s `predicate` does not match the recognized grammar
419
497
  # (`non_empty`/`empty`/`equals:<value>`/`not_equals:<value>`, defined
420
498
  # in `run-playbook-step.sh`'s own header) (E53_S04_T02) -> skipped
499
+ # - ANY step's skill declares an `output_types`/`input_types` value
500
+ # that is not a key in `templates/playbook-types.json`'s `types`
501
+ # map (Check 1) (E62_S01_T04) -> skipped
502
+ # (applies to every skill-type step, bare string included -- not only
503
+ # `forward_from` sources; a silent no-op if the registry file itself is
504
+ # missing/unparseable, see "TYPE REGISTRY" above)
505
+ # - A `forward_from` source declares an `output_types` the consumer step's
506
+ # own `input_types` does not accept under EVERY consumer branch
507
+ # (Check 2, the all-branches rule) (E62_S01_T04) -> skipped
508
+ # (a consumer declaring NO `input_types` is never a violation -- the
509
+ # forward is allowed on the source-declaredness check alone, exactly as
510
+ # before this task)
511
+ # - A consumer's `input_types` list carries an entry missing `when`/`type`
512
+ # (E62_S01_T04) -> skipped
421
513
  #
422
514
  # ---------------------------------------------------------------------------
423
515
  # EXIT CODES
@@ -515,6 +607,11 @@ project_dir = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None
515
607
  # --- E53_S06_T02: additive `lookup <id>` CLI mode --------------------------------------------
516
608
  mode = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else "catalog"
517
609
  lookup_id = sys.argv[5] if len(sys.argv) > 5 else ""
610
+ # --- E62_S01_T04: the jenga-agent PACKAGE root, where `templates/playbook-types.json` lives ---
611
+ # Already resolved by the bash wrapper above (and already honoring JENGA_PLAYBOOKS_TEST_ROOT) --
612
+ # threaded in here rather than re-derived, so there is exactly one PKG_ROOT resolution in this
613
+ # script. See header "TYPE REGISTRY".
614
+ pkg_root = sys.argv[6] if len(sys.argv) > 6 and sys.argv[6] else None
518
615
 
519
616
  # --- E53_S09_T01: project-local playbook source directory ------------------------------------
520
617
  # `project/.playbooks/`, resolved relative to the SAME project_dir already threaded above (which
@@ -568,6 +665,32 @@ def load_max_composition_depth(proj_dir):
568
665
 
569
666
  MAX_COMPOSITION_DEPTH = load_max_composition_depth(project_dir)
570
667
 
668
+
669
+ # --- E62_S01_T04: the canonical type vocabulary -------------------------------------------------
670
+ def load_type_registry(root):
671
+ """Read the set of known type names from `<root>/templates/playbook-types.json`'s `types` map.
672
+
673
+ Returns None when the registry is missing or unparseable, which makes the vocabulary check a
674
+ deliberate silent no-op -- see header 'TYPE REGISTRY' ("MISSING REGISTRY"). The vocabulary is
675
+ ALWAYS read from that file; no type name is ever hardcoded here, so adding a type to the
676
+ registry is a data-only edit requiring no change to this script.
677
+ """
678
+ if not root:
679
+ return None
680
+ registry_path = os.path.join(root, "templates", "playbook-types.json")
681
+ try:
682
+ with open(registry_path, encoding="utf-8") as fh:
683
+ registry = json.load(fh)
684
+ except (OSError, ValueError):
685
+ return None
686
+ types = registry.get("types") if isinstance(registry, dict) else None
687
+ if not isinstance(types, dict):
688
+ return None
689
+ return set(types.keys())
690
+
691
+
692
+ TYPE_REGISTRY = load_type_registry(pkg_root)
693
+
571
694
  catalog = []
572
695
  # --- E53_S06_T02: per-basename skip-reason capture -------------------------------------------
573
696
  # Threaded through PASS 1, PASS 2 (resolve_playbook), and PASS 3 below -- whenever a playbook
@@ -662,8 +785,11 @@ def step_skill_name(step):
662
785
  _FRONTMATTER_RE = re.compile(r'^---\r?\n(.*?)\r?\n---', re.DOTALL)
663
786
 
664
787
 
665
- def extract_output_types(skill_md_path):
666
- """Best-effort extraction of the `output_types` frontmatter field from a SKILL.md.
788
+ def extract_types_field(skill_md_path, field):
789
+ """Best-effort extraction of a type-declaration frontmatter field from a SKILL.md.
790
+
791
+ `field` is `output_types` or `input_types` (E62_S01_T04) -- both take the SAME two shapes (see
792
+ docs/skill-authoring.md), so they are read by this one parser rather than two copies of it.
667
793
 
668
794
  Returns None if the file/field is missing or unparseable, a `str` for the single-static-type
669
795
  form, or a `list[dict]` for the `{when, type}` list form. This is a small, targeted parser for
@@ -684,7 +810,7 @@ def extract_output_types(skill_md_path):
684
810
  fm_lines = fm_match.group(1).splitlines()
685
811
 
686
812
  for i, line in enumerate(fm_lines):
687
- key_match = re.match(r'^output_types:\s*(.*)$', line)
813
+ key_match = re.match(r'^%s:\s*(.*)$' % re.escape(field), line)
688
814
  if not key_match:
689
815
  continue
690
816
 
@@ -727,6 +853,104 @@ def extract_output_types(skill_md_path):
727
853
  return None
728
854
 
729
855
 
856
+ def extract_output_types(skill_md_path):
857
+ """The `output_types` half of extract_types_field -- kept as a named wrapper so every
858
+ pre-existing call site (E53_S03_T03/T04) reads exactly as it did before E62_S01_T04."""
859
+ return extract_types_field(skill_md_path, "output_types")
860
+
861
+
862
+ def extract_input_types(skill_md_path):
863
+ """The `input_types` half of extract_types_field (E62_S01_T04)."""
864
+ return extract_types_field(skill_md_path, "input_types")
865
+
866
+
867
+ # --- E62_S01_T04: shared normalization of either declaration shape ------------------------------
868
+ def declared_type_entries(value):
869
+ """Normalize an `output_types`/`input_types` value into `[(type_or_None, when_or_None), ...]`.
870
+
871
+ A single static type string yields ONE entry whose `when` is None; a `{when, type}` list yields
872
+ one entry per branch. A malformed list entry (not an object, or missing `type`) yields a
873
+ `(None, when_or_None)` entry rather than being dropped, so a caller can tell "no branches" from
874
+ "a branch this function could not read". Both checks in header 'TYPE REGISTRY' consume this, so
875
+ neither re-derives the two shapes.
876
+ """
877
+ if not value:
878
+ return []
879
+ if isinstance(value, str):
880
+ return [(value, None)]
881
+ if isinstance(value, list):
882
+ entries = []
883
+ for item in value:
884
+ if isinstance(item, dict):
885
+ entries.append((item.get("type") or None, item.get("when") or None))
886
+ else:
887
+ entries.append((None, None))
888
+ return entries
889
+ return []
890
+
891
+
892
+ def validate_declared_vocabulary(idx, skill_name, skill_md_path, registry):
893
+ """CHECK 1 -- every type value this skill declares must be a key in the registry's `types` map.
894
+
895
+ Returns an error string naming the offending value (and its branch, for the list form), or None.
896
+ A None `registry` never reaches here (the caller skips the check entirely -- see header
897
+ 'TYPE REGISTRY', "MISSING REGISTRY").
898
+ """
899
+ known = ", ".join(sorted(registry)) if registry else "<none>"
900
+ for field in ("output_types", "input_types"):
901
+ declared = extract_types_field(skill_md_path, field)
902
+ for type_name, when_val in declared_type_entries(declared):
903
+ if type_name is None:
904
+ # A malformed `{when, type}` entry. Deliberately NOT this check's business: the
905
+ # pre-existing Blocker-1 structural check (E53_S03_T04) already owns that rejection
906
+ # for a forward source, with its own message.
907
+ continue
908
+ if type_name not in registry:
909
+ branch = " (branch when='%s')" % when_val if when_val else ""
910
+ return (
911
+ f"step {idx} skill '{skill_name}' declares {field} '{type_name}'{branch}, "
912
+ f"which is not a type in templates/playbook-types.json "
913
+ f"(known types: {known})"
914
+ )
915
+ return None
916
+
917
+
918
+ def accepted_input_types(value):
919
+ """The CONSUMER side of the all-branches rule (E62_S01_T04, ratified 2026-09-19).
920
+
921
+ Returns `(accepted_set_or_None, error_or_None)`:
922
+
923
+ - `(None, None)` -- the consumer declares NO `input_types`. This is the BINDING backward-
924
+ compatible case: the caller must allow the forward on the source-
925
+ declaredness check alone, exactly as before this task. It is distinct
926
+ from `(set(), None)` ("declares branches that agree on nothing"), and
927
+ conflating the two would be precisely the retroactive break the task
928
+ forbids.
929
+ - `(set, None)` -- the set of types accepted under EVERY declared branch, i.e. the
930
+ INTERSECTION. A single static type yields `{t}`. A `{when, type}` list
931
+ yields `{t}` when every branch declares the same `t`, and an EMPTY set
932
+ when they disagree -- an empty set rejects every forward, which is the
933
+ intended conservative outcome of the ruling (see header 'TYPE REGISTRY').
934
+ - `(None, str)` -- a malformed declaration; the string is the reason.
935
+ """
936
+ if not value:
937
+ return None, None
938
+ if isinstance(value, str):
939
+ return {value}, None
940
+ if not isinstance(value, list):
941
+ return None, "declares a malformed input_types (neither a type string nor a list)"
942
+
943
+ accepted = None
944
+ for item in value:
945
+ if not isinstance(item, dict) or not item.get("when") or not item.get("type"):
946
+ return None, "declares a malformed input_types entry (missing 'when' or 'type')"
947
+ branch_set = {item["type"]}
948
+ accepted = branch_set if accepted is None else (accepted & branch_set)
949
+ if accepted is None:
950
+ return None, "declares an empty input_types list"
951
+ return accepted, None
952
+
953
+
730
954
  try:
731
955
  builtin_filenames = sorted(
732
956
  f for f in os.listdir(playbooks_dir)
@@ -1014,7 +1238,26 @@ for pid in order:
1014
1238
  continue
1015
1239
 
1016
1240
  validation_error = None
1241
+
1242
+ # --- E62_S01_T04 CHECK 1: registry vocabulary validation (see header 'TYPE REGISTRY') -------
1243
+ # Its own loop, deliberately separate from the forward_from/conditional loop below: that loop
1244
+ # skips every non-dict step, and this check must cover BARE-STRING steps too -- it is keyed on
1245
+ # the step's resolved skill name, not on the step carrying any particular field. A None
1246
+ # registry (missing/unparseable file) makes the whole check a silent no-op.
1247
+ if TYPE_REGISTRY is not None:
1248
+ for idx, step in enumerate(flattened_steps):
1249
+ step_name = step_skill_name(step)
1250
+ if not step_name:
1251
+ continue
1252
+ validation_error = validate_declared_vocabulary(
1253
+ idx, step_name, os.path.join(skills_dir, step_name, "SKILL.md"), TYPE_REGISTRY
1254
+ )
1255
+ if validation_error:
1256
+ break
1257
+
1017
1258
  for idx, step in enumerate(flattened_steps):
1259
+ if validation_error:
1260
+ break
1018
1261
  if not isinstance(step, dict):
1019
1262
  continue
1020
1263
 
@@ -1085,6 +1328,48 @@ for pid in order:
1085
1328
  if validation_error:
1086
1329
  break
1087
1330
 
1331
+ # --- E62_S01_T04 CHECK 2: output/input compatibility under the all-branches rule -------
1332
+ # Runs AFTER the two checks above on purpose: a source that declares nothing, or declares
1333
+ # a malformed {when, type} entry, is still rejected by its own pre-existing message, never
1334
+ # by this one. See header 'TYPE REGISTRY'.
1335
+ consumer_name = step_skill_name(step)
1336
+ consumer_input_val = (
1337
+ extract_input_types(os.path.join(skills_dir, consumer_name, "SKILL.md"))
1338
+ if consumer_name
1339
+ else None
1340
+ )
1341
+ accepted, accepted_error = accepted_input_types(consumer_input_val)
1342
+ if accepted_error:
1343
+ validation_error = f"step {idx} forward_from consumer '{consumer_name}' {accepted_error}"
1344
+ break
1345
+ if accepted is not None:
1346
+ # `accepted is None` is the BINDING backward-compatible path: a consumer declaring no
1347
+ # `input_types` is allowed on the source-declaredness check alone, exactly as before
1348
+ # this task. Nothing below runs for it.
1349
+ offending = next(
1350
+ (
1351
+ (type_val, when_val)
1352
+ for type_val, when_val in declared_type_entries(output_types_val)
1353
+ if type_val is not None and type_val not in accepted
1354
+ ),
1355
+ None,
1356
+ )
1357
+ if offending:
1358
+ offending_type, offending_when = offending
1359
+ branch_desc = (
1360
+ f"output_types branch when='{offending_when}'"
1361
+ if offending_when
1362
+ else "static output_types"
1363
+ )
1364
+ accepted_desc = ", ".join(sorted(accepted)) if accepted else "<none>"
1365
+ validation_error = (
1366
+ f"step {idx} 'forward_from' source '{source_name}' {branch_desc} produces "
1367
+ f"type '{offending_type}', which consumer '{consumer_name}' does not accept "
1368
+ f"under every input_types branch (accepted under all branches: "
1369
+ f"{accepted_desc})"
1370
+ )
1371
+ break
1372
+
1088
1373
  if validation_error:
1089
1374
  print(f"Warning: {path} {validation_error} — skipped", file=sys.stderr)
1090
1375
  skip_reasons[pid] = validation_error
@@ -1129,5 +1414,5 @@ if mode == "lookup":
1129
1414
  print(json.dumps(catalog, indent=2))
1130
1415
  PY
1131
1416
 
1132
- python3 "$PY_SCRIPT" "$PLAYBOOKS_DIR" "$SKILLS_DIR" "$PROJECT_DIR" "$MODE" "$LOOKUP_ID"
1417
+ python3 "$PY_SCRIPT" "$PLAYBOOKS_DIR" "$SKILLS_DIR" "$PROJECT_DIR" "$MODE" "$LOOKUP_ID" "$PKG_ROOT"
1133
1418
  exit $?
@@ -3,10 +3,10 @@
3
3
  # skills/jenga/scripts/match-playbook.sh
4
4
  #
5
5
  # Deterministic PLAYBOOK matcher for `/jenga`'s natural-language branch (E53_S02_T02). Runs the
6
- # same three-pass matching *philosophy* as `skills/j-route/SKILL.md`'s Step 2 (keyword ->
7
- # example similarity -> description), but scoped to the playbook catalog produced by
8
- # `load-playbooks.sh` (E53_S02_T01) instead of the single-skill catalog `load-nl-catalog.sh`
9
- # produces for `/route`/`/jenga`'s existing single-skill matching.
6
+ # same three-pass matching *philosophy* as `skills/jenga/SKILL.md`'s inlined Skill Matching &
7
+ # Invocation Contract (keyword -> example similarity -> description), but scoped to the playbook
8
+ # catalog produced by `load-playbooks.sh` (E53_S02_T01) instead of the single-skill catalog
9
+ # `load-nl-catalog.sh` produces for `/jenga`'s existing single-skill matching.
10
10
  #
11
11
  # ---------------------------------------------------------------------------
12
12
  # THIS IS A FALLBACK — READ BEFORE WIRING (E53_S02_T04)
@@ -28,7 +28,8 @@
28
28
  #
29
29
  # ---------------------------------------------------------------------------
30
30
  # MATCHING ALGORITHM (deterministic — a shell/python script cannot do semantic judgment the way
31
- # an agent can, so this is a concrete, repeatable heuristic standing in for /route's Step 2 prose)
31
+ # an agent can, so this is a concrete, repeatable heuristic standing in for the Skill Matching &
32
+ # Invocation Contract's Pass 1/2/3 prose, applied to playbooks instead of single skills)
32
33
  # ---------------------------------------------------------------------------
33
34
  # Three passes are run in order against the full playbook catalog (from `load-playbooks.sh`).
34
35
  # Each pass narrows the candidate pool; the first pass to produce a single unique leader commits