@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.
- package/README.md +32 -1
- package/lib/generate-skill-allow-list.js +42 -5
- package/lib/skill-allow-list.json +1 -1
- package/package.json +1 -2
- package/project/app/api/lib/resolve-project-root.js +1 -1
- package/project/app/api/scripts/capture-snapshot.js +12 -7
- package/project/app/package.json +4 -0
- package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
- package/project/app/ui/dist/assets/index-DX2pfTAW.js +104 -0
- package/project/app/ui/dist/index.html +2 -2
- package/scripts/acquire-concurrency-slot.sh +34 -4
- package/scripts/apply-j-prefix.sh +1 -1
- package/scripts/delete-bare-skill-dirs.sh +1 -2
- package/scripts/idea_manager.sh +16 -1
- package/scripts/release-concurrency-slot.sh +34 -4
- package/scripts/render-ranked-list.sh +270 -0
- package/scripts/repoint-dead-bare-path-prose.py +81 -0
- package/scripts/rewrite-stale-skill-preambles.py +188 -0
- package/scripts/strip-polyfill-frontmatter.py +166 -0
- package/scripts/todo_manager.sh +16 -1
- package/scripts/validate-typed-object.sh +750 -0
- package/scripts/verify-postinstall-reconcile.sh +33 -7
- package/skills/index/scripts/board_index.py +29 -1
- package/skills/j-brainstorm/SKILL.md +3 -4
- package/skills/j-btw/SKILL.md +3 -4
- package/skills/j-clearify/SKILL.md +3 -4
- package/skills/j-close-story/SKILL.md +11 -12
- package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
- package/skills/j-commit/SKILL.md +3 -4
- package/skills/j-continue/SKILL.md +5 -6
- package/skills/j-deep-dive/SKILL.md +3 -4
- package/skills/j-distribute/SKILL.md +3 -4
- package/skills/j-do/SKILL.md +13 -15
- package/skills/j-doc/SKILL.md +3 -4
- package/skills/j-doc/scripts/resolve_last_update.py +27 -1
- package/skills/j-doc-sync/SKILL.md +3 -4
- package/skills/j-dooo/SKILL.md +6 -15
- package/skills/j-error/SKILL.md +3 -4
- package/skills/j-evaluate/SKILL.md +3 -4
- package/skills/j-examplify/SKILL.md +3 -4
- package/skills/j-help/SKILL.md +3 -4
- package/skills/j-idea/SKILL.md +3 -4
- package/skills/j-improve/SKILL.md +3 -4
- package/skills/j-init/SKILL.md +20 -11
- package/skills/j-init/scripts/apply-scaffold-visibility.sh +8 -6
- package/skills/j-jbp/SKILL.md +3 -4
- package/skills/j-lgtm/SKILL.md +3 -4
- package/skills/j-pi-plan/SKILL.md +3 -4
- package/skills/j-proceed/SKILL.md +3 -4
- package/skills/j-publish/SKILL.md +3 -4
- package/skills/j-publish/scripts/run_gates.sh +1 -1
- package/skills/j-reconcile/SKILL.md +38 -5
- package/skills/j-reconcile/assets/report_format.md +11 -0
- package/skills/j-reconcile/scripts/detect-unlinked-code.sh +2 -2
- package/skills/j-reconcile-origin/SKILL.md +3 -4
- package/skills/j-redo/SKILL.md +3 -4
- package/skills/j-skillify/SKILL.md +3 -4
- package/skills/j-spinoff/SKILL.md +3 -4
- package/skills/j-status/SKILL.md +4 -5
- package/skills/j-todo/SKILL.md +42 -5
- package/skills/j-todo/scripts/add_trivial_task.sh +12 -1
- package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
- package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
- package/skills/j-uncharted/SKILL.md +251 -12
- package/skills/j-uncharted/scripts/detect-dependencies.sh +79 -21
- package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
- package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
- package/skills/j-uncharted/scripts/run-engine.sh +36 -2
- package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
- package/skills/j-wtf/SKILL.md +3 -4
- package/skills/jenga/SKILL.md +106 -10
- package/skills/jenga/playbooks/schema.json +4 -4
- package/skills/jenga/scripts/enrich-nl-prompt.sh +225 -0
- package/skills/jenga/scripts/load-nl-catalog.js +6 -3
- package/skills/jenga/scripts/load-playbooks.sh +289 -4
- package/skills/jenga/scripts/match-playbook.sh +6 -5
- package/skills/jenga/scripts/run-playbook-step.sh +270 -3
- package/templates/permission-levels/level-1-locked.json +1 -1
- package/templates/permission-levels/level-2-guarded.json +1 -1
- package/templates/permission-levels/level-3-standard.json +1 -1
- package/templates/permission-levels/level-4-elevated.json +1 -1
- package/templates/permission-levels/level-5-unrestricted.json +1 -1
- package/templates/playbook-types.json +6 -0
- package/project/app/ui/dist/assets/index-BVR_7Owg.css +0 -1
- package/project/app/ui/dist/assets/index-CtU2xLQm.js +0 -104
- 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 `/
|
|
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/
|
|
72
|
-
//
|
|
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
|
|
666
|
-
"""Best-effort extraction of
|
|
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'
|
|
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/
|
|
7
|
-
# example similarity -> description), but scoped to the playbook
|
|
8
|
-
# `load-playbooks.sh` (E53_S02_T01) instead of the single-skill catalog
|
|
9
|
-
# produces for `/
|
|
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
|
|
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
|