@jenga-ai/agent 3.0.0 → 3.1.1

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.
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/detect-nl-intent.sh
4
+ #
5
+ # Deterministic classification wrapper around `resolve-id.sh` for `/jenga`'s
6
+ # Phase 0.75 entry-mode resolution (E53_S01). Phase 0.75's scoped branch used
7
+ # to call `resolve-id.sh` directly and halt the whole invocation the moment
8
+ # ANY comma-delimited segment was rejected by the ID grammar. E53_S01 adds a
9
+ # new outcome: when EVERY segment is rejected, the raw argument is not a
10
+ # malformed ID list at all — it's natural-language intent, which should be
11
+ # routed to /jenga's new NL branch (wired up in E53_S01_T03) instead of
12
+ # halting.
13
+ #
14
+ # This script performs exactly that classification and nothing else. Per
15
+ # CLAUDE.md's "Skill Implementation Principle — Scripts Over Inline Logic",
16
+ # `skills/jenga/SKILL.md` never parses `resolve-id.sh`'s raw JSON array
17
+ # itself for this decision — it only ever reads this script's three output
18
+ # shapes below.
19
+ #
20
+ # ---------------------------------------------------------------------------
21
+ # USAGE
22
+ # ---------------------------------------------------------------------------
23
+ # skills/jenga/scripts/detect-nl-intent.sh "<raw Phase 0.75 argument>"
24
+ #
25
+ # The argument is passed through verbatim to `resolve-id.sh` — this script
26
+ # does not itself split, clean, or reinterpret it. See `resolve-id.sh`'s own
27
+ # header for the exact ID grammar and its per-segment output schema.
28
+ #
29
+ # ---------------------------------------------------------------------------
30
+ # CLASSIFICATION CONTRACT (stable — E53_S01_T03 wires against this exactly)
31
+ # ---------------------------------------------------------------------------
32
+ # `resolve-id.sh`'s JSON array (one object per comma-delimited segment) is
33
+ # reduced to exactly ONE of three classifications, based on the distinct set
34
+ # of `status` values across all segments:
35
+ #
36
+ # 1. every segment "resolved" -> "all_resolved"
37
+ # 2. every segment "rejected" -> "nl_intent"
38
+ # 3. a mix of both -> "mixed"
39
+ #
40
+ # stdout is always a single JSON object. Nothing else is ever written to
41
+ # stdout — errors and warnings go to stderr only.
42
+ #
43
+ # "all_resolved":
44
+ # {
45
+ # "classification": "all_resolved",
46
+ # "resolved_ids": ["E01_S02", ...],
47
+ # "resolved_ids_csv": "E01_S02,..."
48
+ # }
49
+ # exit 0 — the existing scoped-branch confirmation flow in
50
+ # `skills/jenga/SKILL.md` is unaffected by this script's introduction.
51
+ #
52
+ # "nl_intent":
53
+ # {
54
+ # "classification": "nl_intent",
55
+ # "raw_argument": "<the original $1, verbatim>"
56
+ # }
57
+ # exit 0 — this is the signal E53_S01_T03's new branch uses to enter
58
+ # natural-language matching instead of halting.
59
+ #
60
+ # "mixed":
61
+ # {
62
+ # "classification": "mixed",
63
+ # "rejected": [
64
+ # {"input": "<raw segment>", "reason": "<human-readable reason>"},
65
+ # ...
66
+ # ]
67
+ # }
68
+ # exit 1 — preserves the exact current halt-and-report behavior already
69
+ # documented in `skills/jenga/SKILL.md`'s "`resolve-id.sh` rejects one or
70
+ # more segments" edge case. No partial scope is ever assembled from the
71
+ # segments that did resolve.
72
+ #
73
+ # ---------------------------------------------------------------------------
74
+ # EXIT CODES
75
+ # ---------------------------------------------------------------------------
76
+ # 0 "all_resolved" or "nl_intent" classification (see above)
77
+ # 1 "mixed" classification (see above)
78
+ # 2 usage error (no argument given) or a `resolve-id.sh` / `board-scan.sh`
79
+ # setup failure — a real setup problem, not a classification outcome.
80
+ # `resolve-id.sh`'s own stderr message (already emitted by it) is what
81
+ # explains the failure; this script does not re-emit a second message
82
+ # on top of it.
83
+ #
84
+ # ---------------------------------------------------------------------------
85
+
86
+ set -euo pipefail
87
+
88
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
89
+ RESOLVE_ID="$SCRIPT_DIR/resolve-id.sh"
90
+
91
+ if [ $# -lt 1 ] || [ -z "${1:-}" ]; then
92
+ echo 'Usage: detect-nl-intent.sh "<raw Phase 0.75 argument>"' >&2
93
+ exit 2
94
+ fi
95
+
96
+ RAW_INPUT="$1"
97
+
98
+ if [ ! -x "$RESOLVE_ID" ]; then
99
+ echo "Error: resolve-id.sh not found or not executable at $RESOLVE_ID" >&2
100
+ exit 2
101
+ fi
102
+
103
+ if ! command -v python3 >/dev/null 2>&1; then
104
+ echo "Error: python3 is required by detect-nl-intent.sh" >&2
105
+ exit 2
106
+ fi
107
+
108
+ # resolve-id.sh exits 0 (all resolved) or 1 (at least one rejected) as
109
+ # legitimate per-segment outcomes — only exit 2 (usage/setup failure) is a
110
+ # real problem here. Guard the capture explicitly so `set -e` doesn't abort
111
+ # this script on resolve-id.sh's own exit 1.
112
+ set +e
113
+ RESOLVE_JSON="$("$RESOLVE_ID" "$RAW_INPUT")"
114
+ RESOLVE_EXIT=$?
115
+ set -e
116
+
117
+ if [ "$RESOLVE_EXIT" -eq 2 ]; then
118
+ # resolve-id.sh already wrote a plain-text error to stderr — just
119
+ # propagate its exit code rather than re-deriving a second message.
120
+ exit 2
121
+ fi
122
+
123
+ PY_SCRIPT="$(mktemp -t detect-nl-intent-XXXXXX.py)"
124
+ trap 'rm -f "$PY_SCRIPT"' EXIT
125
+
126
+ cat > "$PY_SCRIPT" <<'PY'
127
+ import json
128
+ import sys
129
+
130
+ raw_argument = sys.argv[1]
131
+ resolve_json = sys.stdin.read()
132
+
133
+ try:
134
+ segments = json.loads(resolve_json)
135
+ except Exception as e:
136
+ print(f"Error: could not parse resolve-id.sh output as JSON: {e}", file=sys.stderr)
137
+ sys.exit(2)
138
+
139
+ if not isinstance(segments, list) or len(segments) == 0:
140
+ # resolve-id.sh only emits [] for an empty/whitespace-only argument,
141
+ # which should never reach this script — Phase 0.75 routes an empty
142
+ # argument to the bare branch before detect-nl-intent.sh is ever
143
+ # invoked. Treat this as a setup problem rather than silently guessing
144
+ # a classification for input this script was never meant to see.
145
+ print("Error: resolve-id.sh produced an empty or non-array result", file=sys.stderr)
146
+ sys.exit(2)
147
+
148
+ statuses = {seg.get("status") for seg in segments}
149
+
150
+ if statuses == {"resolved"}:
151
+ resolved_ids = [seg["resolved_id"] for seg in segments]
152
+ print(json.dumps({
153
+ "classification": "all_resolved",
154
+ "resolved_ids": resolved_ids,
155
+ "resolved_ids_csv": ",".join(resolved_ids),
156
+ }))
157
+ sys.exit(0)
158
+
159
+ if statuses == {"rejected"}:
160
+ print(json.dumps({
161
+ "classification": "nl_intent",
162
+ "raw_argument": raw_argument,
163
+ }))
164
+ sys.exit(0)
165
+
166
+ rejected = [
167
+ {"input": seg.get("input"), "reason": seg.get("reason")}
168
+ for seg in segments
169
+ if seg.get("status") == "rejected"
170
+ ]
171
+ print(json.dumps({
172
+ "classification": "mixed",
173
+ "rejected": rejected,
174
+ }))
175
+ sys.exit(1)
176
+ PY
177
+
178
+ python3 "$PY_SCRIPT" "$RAW_INPUT" <<< "$RESOLVE_JSON"
179
+ exit $?
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * skills/jenga/scripts/load-nl-catalog.js
4
+ *
5
+ * Node (ESM) helper behind `load-nl-catalog.sh` — the SINGLE REQUIRED SOURCE of skill-catalog
6
+ * data for `/jenga`'s natural-language branch (E53_S01_T03). `skills/jenga/SKILL.md` must never
7
+ * re-implement its own skill directory scan or hand-maintain a skill list; it only ever reads
8
+ * this script's stdout.
9
+ *
10
+ * The catalog's NAME LIST comes exclusively from `lib/generate-skill-allow-list.js`'s generated
11
+ * inventory (`readSkillAllowList()`, which reads the committed `lib/skill-allow-list.json`
12
+ * artifact) — this script does not independently re-scan `skills/` for a name list of its own,
13
+ * per E53_S01_T02's acceptance criteria and the drift lesson E41_S04 already documented for that
14
+ * generator. For each name in that inventory, this script reads exactly one file —
15
+ * `skills/<name>/SKILL.md` — to populate the remaining catalog fields: `description`, `keywords`,
16
+ * `examples`, and `metadata.prefered_agent`. These are the same fields `/route`'s Step 1
17
+ * ("Discover Available Skills") collects.
18
+ *
19
+ * ---------------------------------------------------------------------------
20
+ * USAGE
21
+ * ---------------------------------------------------------------------------
22
+ * node load-nl-catalog.js <projectRoot> <pkgRoot>
23
+ *
24
+ * <projectRoot> the consuming project's root (passed through to
25
+ * readSkillAllowList's projectRoot fallback candidate)
26
+ * <pkgRoot> the jenga-agent PACKAGE root — where lib/generate-skill-allow-list.js and the
27
+ * canonical skills/ tree actually live (monorepo checkout root, or
28
+ * node_modules/@jenga-ai/agent for an installed consumer)
29
+ *
30
+ * ---------------------------------------------------------------------------
31
+ * OUTPUT SCHEMA
32
+ * ---------------------------------------------------------------------------
33
+ * stdout is a single JSON array, one object per catalog entry, e.g.:
34
+ *
35
+ * [
36
+ * {
37
+ * "name": "btw",
38
+ * "description": "...",
39
+ * "keywords": ["..."],
40
+ * "examples": ["..."],
41
+ * "prefered_agent": "scrum-master" // or null when absent
42
+ * },
43
+ * ...
44
+ * ]
45
+ *
46
+ * Nothing but this JSON array is ever written to stdout. Warnings (a skill skipped because its
47
+ * SKILL.md is missing/unreadable, or its frontmatter lacks `description`) go to stderr only, and
48
+ * are non-fatal.
49
+ *
50
+ * ---------------------------------------------------------------------------
51
+ * EXIT CODES
52
+ * ---------------------------------------------------------------------------
53
+ * 0 catalog written to stdout (possibly with skip warnings already emitted to stderr)
54
+ * 2 usage error, or a real setup failure (allow-list inventory unreadable/empty, or
55
+ * lib/generate-skill-allow-list.js failed to load)
56
+ *
57
+ * ---------------------------------------------------------------------------
58
+ */
59
+
60
+ import { readFileSync, existsSync } from "fs";
61
+ import { join } from "path";
62
+ import { pathToFileURL } from "url";
63
+
64
+ /**
65
+ * Parses YAML frontmatter from a SKILL.md's content into a plain object. This is a hand-rolled,
66
+ * intentionally minimal parser scoped to the small set of shapes SKILL.md frontmatter actually
67
+ * uses — it follows the same overall approach as mcp/router/skill-index.js's `parseFrontmatter`
68
+ * (scalar keys, and array keys introduced by an empty `key:` line followed by ` - item` lines),
69
+ * extended here to also recognize ONE level of nested mapping (the `metadata:` block, e.g.
70
+ * `metadata:\n prefered_agent: developer`) — a shape skill-index.js's parser doesn't need to
71
+ * handle, since it never reads `metadata`.
72
+ *
73
+ * Returns {} if no frontmatter block is found.
74
+ */
75
+ function parseFrontmatter(content) {
76
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
77
+ if (!match) return {};
78
+
79
+ const lines = match[1].split("\n");
80
+ const obj = {};
81
+ let i = 0;
82
+
83
+ const stripQuotes = (s) => s.trim().replace(/^["']|["']$/g, "");
84
+
85
+ while (i < lines.length) {
86
+ const topMatch = lines[i].match(/^(\w+):\s*(.*)$/);
87
+ if (!topMatch) {
88
+ i++;
89
+ continue;
90
+ }
91
+ const [, key, valRaw] = topMatch;
92
+ const val = valRaw.trim();
93
+
94
+ if (val === "[]") {
95
+ obj[key] = [];
96
+ i++;
97
+ continue;
98
+ }
99
+
100
+ if (val !== "") {
101
+ obj[key] = stripQuotes(val);
102
+ i++;
103
+ continue;
104
+ }
105
+
106
+ // Empty value — look ahead at indented child lines to decide whether this key is an array
107
+ // (child lines shaped ` - item`) or a nested map (child lines shaped ` childKey: value`).
108
+ // A block is only ever consistently one or the other in this repo's frontmatter, so the
109
+ // first child line's shape decides it.
110
+ let j = i + 1;
111
+ const arrayItems = [];
112
+ const mapObj = {};
113
+ let sawArray = false;
114
+ let sawMap = false;
115
+
116
+ while (j < lines.length && /^\s+\S/.test(lines[j])) {
117
+ const arrItem = lines[j].match(/^\s+-\s+(.*)$/);
118
+ const mapItem = lines[j].match(/^\s+(\w+):\s*(.*)$/);
119
+ if (arrItem && !sawMap) {
120
+ sawArray = true;
121
+ arrayItems.push(stripQuotes(arrItem[1]));
122
+ } else if (mapItem && !sawArray) {
123
+ sawMap = true;
124
+ mapObj[mapItem[1]] = stripQuotes(mapItem[2]);
125
+ }
126
+ j++;
127
+ }
128
+
129
+ obj[key] = sawArray ? arrayItems : sawMap ? mapObj : [];
130
+ i = j;
131
+ }
132
+
133
+ return obj;
134
+ }
135
+
136
+ async function main() {
137
+ const [projectRoot, pkgRoot] = process.argv.slice(2);
138
+ if (!projectRoot || !pkgRoot) {
139
+ process.stderr.write("Usage: load-nl-catalog.js <projectRoot> <pkgRoot>\n");
140
+ process.exit(2);
141
+ }
142
+
143
+ const generatorPath = join(pkgRoot, "lib", "generate-skill-allow-list.js");
144
+ let readSkillAllowList;
145
+ try {
146
+ ({ readSkillAllowList } = await import(pathToFileURL(generatorPath).href));
147
+ } catch (e) {
148
+ process.stderr.write(`Error: failed to load ${generatorPath}: ${e.message}\n`);
149
+ process.exit(2);
150
+ return;
151
+ }
152
+
153
+ const names = readSkillAllowList(projectRoot, pkgRoot);
154
+ if (!Array.isArray(names) || names.length === 0) {
155
+ process.stderr.write(
156
+ "Error: skill allow-list inventory is empty or unreadable (lib/skill-allow-list.json) — cannot build NL catalog\n"
157
+ );
158
+ process.exit(2);
159
+ return;
160
+ }
161
+
162
+ const catalog = [];
163
+ for (const name of names) {
164
+ const skillMdPath = join(pkgRoot, "skills", name, "SKILL.md");
165
+ if (!existsSync(skillMdPath)) {
166
+ process.stderr.write(
167
+ `Warning: ${skillMdPath} not found for allow-listed skill '${name}' — skipped\n`
168
+ );
169
+ continue;
170
+ }
171
+
172
+ let content;
173
+ try {
174
+ content = readFileSync(skillMdPath, "utf8");
175
+ } catch (e) {
176
+ process.stderr.write(`Warning: failed to read ${skillMdPath}: ${e.message} — skipped\n`);
177
+ continue;
178
+ }
179
+
180
+ const fm = parseFrontmatter(content);
181
+ if (!fm.description) {
182
+ process.stderr.write(
183
+ `Warning: ${skillMdPath} missing 'description' in frontmatter — skipped\n`
184
+ );
185
+ continue;
186
+ }
187
+
188
+ catalog.push({
189
+ name,
190
+ description: fm.description,
191
+ keywords: Array.isArray(fm.keywords) ? fm.keywords : [],
192
+ examples: Array.isArray(fm.examples) ? fm.examples : [],
193
+ prefered_agent:
194
+ fm.metadata && typeof fm.metadata === "object" && fm.metadata.prefered_agent
195
+ ? fm.metadata.prefered_agent
196
+ : null,
197
+ });
198
+ }
199
+
200
+ process.stdout.write(JSON.stringify(catalog, null, 2) + "\n");
201
+ }
202
+
203
+ main().catch((e) => {
204
+ process.stderr.write(`Error: ${e.message}\n`);
205
+ process.exit(2);
206
+ });
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/load-nl-catalog.sh
4
+ #
5
+ # Thin bash entry point for `load-nl-catalog.js` — the SINGLE REQUIRED SOURCE of skill-catalog
6
+ # data for `/jenga`'s natural-language branch (E53_S01_T03). `skills/jenga/SKILL.md` must never
7
+ # re-implement its own skill directory scan or hand-maintain a skill list; it only ever invokes
8
+ # this script and reads its stdout.
9
+ #
10
+ # This script's only job is root resolution: it locates JENGA_PROJECT_DIR (the consuming
11
+ # project's root) and PKG_ROOT (the jenga-agent PACKAGE root — where the canonical skills/ tree
12
+ # and lib/generate-skill-allow-list.js actually live, which may differ from the project root for
13
+ # an npm-installed consumer) and hands both to the Node helper, which does the actual reading and
14
+ # JSON assembly (see load-nl-catalog.js's own header for the full contract).
15
+ #
16
+ # ---------------------------------------------------------------------------
17
+ # USAGE
18
+ # ---------------------------------------------------------------------------
19
+ # skills/jenga/scripts/load-nl-catalog.sh
20
+ #
21
+ # No arguments. Emits the full JSON catalog array to stdout — see load-nl-catalog.js's header for
22
+ # the exact per-entry shape (name/description/keywords/examples/prefered_agent).
23
+ #
24
+ # ---------------------------------------------------------------------------
25
+ # EXIT CODES
26
+ # ---------------------------------------------------------------------------
27
+ # 0 catalog written to stdout
28
+ # 2 setup failure: package root could not be located, node is missing, or the Node helper
29
+ # itself failed (see its own stderr message for the reason)
30
+ #
31
+ # ---------------------------------------------------------------------------
32
+
33
+ set -euo pipefail
34
+
35
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
36
+
37
+ # Resolve JENGA_PROJECT_DIR the same way every other script in skills/jenga/scripts/ does.
38
+ if [ -f "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh" ]; then
39
+ # shellcheck source=/dev/null
40
+ source "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh"
41
+ elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
42
+ JENGA_PROJECT_DIR="$CLAUDE_PROJECT_DIR"
43
+ else
44
+ JENGA_PROJECT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)"
45
+ fi
46
+
47
+ # Resolve the jenga-agent PACKAGE root (where lib/generate-skill-allow-list.js and the canonical
48
+ # skills/ tree actually live) — same monorepo-checkout vs. installed-npm-package detection used
49
+ # by skills/init/scripts/init.sh's PKG_ROOT resolution.
50
+ if [ -d "$SCRIPT_DIR/../../../templates" ]; then
51
+ PKG_ROOT="$SCRIPT_DIR/../../.."
52
+ elif [ -d "$JENGA_PROJECT_DIR/node_modules/@jenga-ai/agent/templates" ]; then
53
+ PKG_ROOT="$JENGA_PROJECT_DIR/node_modules/@jenga-ai/agent"
54
+ else
55
+ echo "Error: could not locate the jenga-agent package root (templates/ not found via monorepo checkout or node_modules/@jenga-ai/agent)." >&2
56
+ exit 2
57
+ fi
58
+
59
+ if ! command -v node >/dev/null 2>&1; then
60
+ echo "Error: node is required by load-nl-catalog.sh" >&2
61
+ exit 2
62
+ fi
63
+
64
+ node "$SCRIPT_DIR/load-nl-catalog.js" "$JENGA_PROJECT_DIR" "$PKG_ROOT"
65
+ exit $?
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/load-playbooks.sh
4
+ #
5
+ # Deterministic loader for `/jenga`'s multi-skill PLAYBOOK catalog (E53_S02_T01) — the SINGLE
6
+ # REQUIRED SOURCE of playbook data for E53_S02_T02's `match-playbook.sh` and E53_S02_T04's wiring
7
+ # into `skills/jenga/SKILL.md`. Neither of those may re-scan `skills/jenga/playbooks/` or
8
+ # hand-maintain a playbook list of their own; they only ever invoke this script and read its
9
+ # stdout, mirroring the single-source contract `load-nl-catalog.sh` already established for the
10
+ # single-skill catalog (E53_S01_T02).
11
+ #
12
+ # A "playbook" is an ORDERED chain of skills (e.g. brainstorm -> todo -> do -> dev-done ->
13
+ # mirror-public) that `/jenga`'s natural-language branch may propose, as an editable, confirmable
14
+ # numbered list (see `render-playbook-confirmation.sh`, E53_S02_T03), when free-text intent spans
15
+ # more than one skill and does not cleanly resolve to a single one.
16
+ #
17
+ # ---------------------------------------------------------------------------
18
+ # DATA SOURCE
19
+ # ---------------------------------------------------------------------------
20
+ # Every `*.json` file directly under `skills/jenga/playbooks/`, EXCLUDING `schema.json` (which
21
+ # documents the required shape — see that file's own header — but is never itself a playbook
22
+ # entry). See `schema.json` for the authoritative field list; this script's validation below is
23
+ # a runtime mirror of that schema, not a substitute for it.
24
+ #
25
+ # ---------------------------------------------------------------------------
26
+ # USAGE
27
+ # ---------------------------------------------------------------------------
28
+ # skills/jenga/scripts/load-playbooks.sh
29
+ #
30
+ # No arguments. Emits the full JSON playbook catalog array to stdout.
31
+ #
32
+ # ---------------------------------------------------------------------------
33
+ # OUTPUT SCHEMA
34
+ # ---------------------------------------------------------------------------
35
+ # stdout is a single JSON array, one object per valid playbook:
36
+ #
37
+ # [
38
+ # {
39
+ # "id": "brainstorm-to-mirror",
40
+ # "name": "Idea to Public Release",
41
+ # "description": "...",
42
+ # "keywords": ["..."],
43
+ # "examples": ["..."],
44
+ # "steps": ["brainstorm", "todo", "do", "dev-done", "mirror-public"]
45
+ # },
46
+ # ...
47
+ # ]
48
+ #
49
+ # Nothing but this JSON array is ever written to stdout. Skip warnings go to stderr only and are
50
+ # non-fatal — a single malformed playbook file never aborts the whole catalog load.
51
+ #
52
+ # ---------------------------------------------------------------------------
53
+ # VALIDATION / SKIP CONDITIONS (each skip is a stderr warning, never fatal)
54
+ # ---------------------------------------------------------------------------
55
+ # - File is not valid JSON, or is not a JSON object -> skipped
56
+ # - Missing any required field: id, name, description, keywords,
57
+ # examples, steps -> skipped
58
+ # - `keywords`, `examples`, or `steps` present but empty (or not a
59
+ # list) -> skipped
60
+ # - `id` does not equal the filename's basename without `.json` -> skipped
61
+ # (prevents a playbook's identity from silently drifting from its
62
+ # file location)
63
+ # - Any entry in `steps` has no corresponding `skills/<name>/SKILL.md`
64
+ # on disk -> skipped (the whole
65
+ # playbook is skipped, not just the bad step — a chain with a broken
66
+ # link is not a usable chain)
67
+ #
68
+ # ---------------------------------------------------------------------------
69
+ # EXIT CODES
70
+ # ---------------------------------------------------------------------------
71
+ # 0 catalog written to stdout (possibly with skip warnings already emitted to stderr;
72
+ # an empty catalog `[]` is a valid, non-error outcome — e.g. every playbook file was
73
+ # malformed, or no playbook files exist yet beyond schema.json)
74
+ # 2 usage error, or a real setup failure (playbooks directory missing entirely, or
75
+ # python3 unavailable)
76
+ #
77
+ # ---------------------------------------------------------------------------
78
+
79
+ set -euo pipefail
80
+
81
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
82
+
83
+ # Resolve the jenga-agent PACKAGE root (where the canonical skills/ tree actually lives) — same
84
+ # monorepo-checkout vs. installed-npm-package detection used by
85
+ # skills/jenga/scripts/load-nl-catalog.sh's PKG_ROOT resolution and skills/init/scripts/init.sh.
86
+ if [ -d "$SCRIPT_DIR/../../../templates" ]; then
87
+ PKG_ROOT="$SCRIPT_DIR/../../.."
88
+ elif [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent/templates" ]; then
89
+ PKG_ROOT="${CLAUDE_PROJECT_DIR}/node_modules/@jenga-ai/agent"
90
+ else
91
+ echo "Error: could not locate the jenga-agent package root (templates/ not found via monorepo checkout or node_modules/@jenga-ai/agent)." >&2
92
+ exit 2
93
+ fi
94
+
95
+ PLAYBOOKS_DIR="$PKG_ROOT/skills/jenga/playbooks"
96
+ SKILLS_DIR="$PKG_ROOT/skills"
97
+
98
+ if [ ! -d "$PLAYBOOKS_DIR" ]; then
99
+ echo "Error: playbooks directory not found at $PLAYBOOKS_DIR" >&2
100
+ exit 2
101
+ fi
102
+
103
+ if ! command -v python3 >/dev/null 2>&1; then
104
+ echo "Error: python3 is required by load-playbooks.sh" >&2
105
+ exit 2
106
+ fi
107
+
108
+ PY_SCRIPT="$(mktemp -t load-playbooks-XXXXXX.py)"
109
+ trap 'rm -f "$PY_SCRIPT"' EXIT
110
+
111
+ cat > "$PY_SCRIPT" <<'PY'
112
+ import json
113
+ import os
114
+ import sys
115
+
116
+ playbooks_dir = sys.argv[1]
117
+ skills_dir = sys.argv[2]
118
+
119
+ REQUIRED_FIELDS = ["id", "name", "description", "keywords", "examples", "steps"]
120
+ LIST_FIELDS = ["keywords", "examples", "steps"]
121
+
122
+ catalog = []
123
+
124
+ try:
125
+ filenames = sorted(
126
+ f for f in os.listdir(playbooks_dir)
127
+ if f.endswith(".json") and f != "schema.json"
128
+ )
129
+ except OSError as e:
130
+ print(f"Error: could not list {playbooks_dir}: {e}", file=sys.stderr)
131
+ sys.exit(2)
132
+
133
+ for filename in filenames:
134
+ path = os.path.join(playbooks_dir, filename)
135
+ basename = filename[: -len(".json")]
136
+
137
+ try:
138
+ with open(path, encoding="utf-8") as fh:
139
+ data = json.load(fh)
140
+ except Exception as e:
141
+ print(f"Warning: {path} is not valid JSON ({e}) — skipped", file=sys.stderr)
142
+ continue
143
+
144
+ if not isinstance(data, dict):
145
+ print(f"Warning: {path} is not a JSON object — skipped", file=sys.stderr)
146
+ continue
147
+
148
+ missing = [f for f in REQUIRED_FIELDS if f not in data]
149
+ if missing:
150
+ print(f"Warning: {path} missing required field(s) {missing} — skipped", file=sys.stderr)
151
+ continue
152
+
153
+ bad_list = [
154
+ f for f in LIST_FIELDS
155
+ if not isinstance(data.get(f), list) or len(data.get(f)) == 0
156
+ ]
157
+ if bad_list:
158
+ print(f"Warning: {path} field(s) {bad_list} must be non-empty lists — skipped", file=sys.stderr)
159
+ continue
160
+
161
+ if data["id"] != basename:
162
+ print(
163
+ f"Warning: {path} has id '{data['id']}' which does not match its filename "
164
+ f"'{basename}.json' — skipped",
165
+ file=sys.stderr,
166
+ )
167
+ continue
168
+
169
+ missing_skills = [
170
+ step for step in data["steps"]
171
+ if not os.path.isfile(os.path.join(skills_dir, step, "SKILL.md"))
172
+ ]
173
+ if missing_skills:
174
+ print(
175
+ f"Warning: {path} references nonexistent skill(s) {missing_skills} "
176
+ f"(no skills/<name>/SKILL.md found) — playbook skipped",
177
+ file=sys.stderr,
178
+ )
179
+ continue
180
+
181
+ catalog.append({
182
+ "id": data["id"],
183
+ "name": data["name"],
184
+ "description": data["description"],
185
+ "keywords": data["keywords"],
186
+ "examples": data["examples"],
187
+ "steps": data["steps"],
188
+ })
189
+
190
+ print(json.dumps(catalog, indent=2))
191
+ PY
192
+
193
+ python3 "$PY_SCRIPT" "$PLAYBOOKS_DIR" "$SKILLS_DIR"
194
+ exit $?