@jenga-ai/agent 4.0.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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Jenga AI Dashboard</title>
7
- <script type="module" crossorigin src="/assets/index-C3oiuli_.js"></script>
7
+ <script type="module" crossorigin src="/assets/index-DX2pfTAW.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="/assets/index-BADc5mmH.css">
9
9
  </head>
10
10
  <body>
@@ -330,13 +330,27 @@ run_install "$PKG2C" "$CONSUMER_C" "$FIXTURE/case-v2.log"
330
330
 
331
331
  # Detect whether this filesystem is even case-insensitive; on a case-SENSITIVE fs
332
332
  # both names legitimately coexist and the old one is genuinely stale.
333
- if [ -f "$CONSUMER_C/.agents/skills/alpha/skill.md" ] && \
334
- [ -f "$CONSUMER_C/.agents/skills/alpha/SKILL.md" ] && \
335
- [ "$(cat "$CONSUMER_C/.agents/skills/alpha/skill.md" 2>/dev/null)" != "$(cat "$CONSUMER_C/.agents/skills/alpha/SKILL.md" 2>/dev/null)" ]; then
336
- CASE_INSENSITIVE=0
337
- else
333
+ #
334
+ # This probes the filesystem DIRECTLY rather than inferring the answer from the
335
+ # mirrored tree. The previous inference was circular -- it asked whether both
336
+ # skill.md and SKILL.md exist with DIFFERENT content, but both fixture packages
337
+ # above write the identical body ("# alpha"), so the inequality was false by
338
+ # construction and the branch concluded "case-insensitive" on every filesystem.
339
+ # On macOS that happened to be the right answer; on a case-SENSITIVE fs (any
340
+ # Linux CI runner) it was wrong, and the case-insensitive branch below then ran
341
+ # an assert_grep for "written-this-run" that can only hold when the two names
342
+ # collide -- recording a FAIL that no individual @test asserts, so only
343
+ # "harness reports zero failures overall" caught it. Surfaced by the E28_S17
344
+ # mirror staging gate's first Linux runs (2026-09-28).
345
+ CASE_PROBE="$FIXTURE/case-probe"
346
+ mkdir -p "$CASE_PROBE"
347
+ : > "$CASE_PROBE/probe"
348
+ if [ -e "$CASE_PROBE/PROBE" ]; then
338
349
  CASE_INSENSITIVE=1
350
+ else
351
+ CASE_INSENSITIVE=0
339
352
  fi
353
+ rm -rf "$CASE_PROBE"
340
354
 
341
355
  if [ "$CASE_INSENSITIVE" -eq 1 ]; then
342
356
  # The whole point: the skill must still exist under SOME name after the upgrade.
@@ -353,8 +367,20 @@ if [ "$CASE_INSENSITIVE" -eq 1 ]; then
353
367
  assert_grep "written-this-run" "$FIXTURE/case-v2.log" \
354
368
  "identity guard reports the spared entry as written-this-run"
355
369
  else
356
- pass "case-only rename (skipped: filesystem is case-sensitive)"
357
- pass "case-only rename (skipped: filesystem is case-sensitive) (.claude)"
370
+ # These names must remain PREFIX-COMPATIBLE with the case-insensitive branch
371
+ # above: assert_check in tests/postinstall-delete-reconciliation.bats matches
372
+ # with grep -F on "<tab><name>", a substring match, so a trailing "(n/a ...)"
373
+ # qualifier still satisfies an assertion written against the canonical name
374
+ # while keeping the skip visible in the harness output. The identity-guard
375
+ # line below already followed this convention; the two case-only-rename lines
376
+ # did not -- they were renamed wholesale to "case-only rename (skipped: ...)",
377
+ # which shares no prefix with the canonical name, so on a case-sensitive fs
378
+ # the "case-only rename does not delete the file the run just wrote" @test
379
+ # would fail with "no such check recorded by the harness". That never fired
380
+ # before only because the broken detection above forced every filesystem down
381
+ # the case-insensitive branch.
382
+ pass "case-only rename does not delete the just-written file (.agents) (n/a: filesystem is case-sensitive, both names legitimately coexist)"
383
+ pass "case-only rename does not delete the just-written file (.claude) (n/a: filesystem is case-sensitive, both names legitimately coexist)"
358
384
  pass "identity guard reports the spared entry as written-this-run (n/a on case-sensitive fs)"
359
385
  fi
360
386
 
@@ -6,6 +6,7 @@ import argparse
6
6
  import hashlib
7
7
  import json
8
8
  import re
9
+ import subprocess
9
10
  import sys
10
11
  from pathlib import Path
11
12
  from typing import Any
@@ -33,7 +34,34 @@ EDGE_TYPES = {
33
34
  "parent_doc",
34
35
  }
35
36
  FRONTMATTER_BOUNDARY = "---"
36
- ROOT_DIR = Path(__file__).resolve().parents[3]
37
+
38
+
39
+ def _resolve_project_root() -> Path:
40
+ """Locate the project root via git, not a fixed relative climb.
41
+
42
+ This script is deployed both at its source location
43
+ (skills/index/scripts/) and mirrored one directory deeper into
44
+ .claude/skills/index/scripts/ / .agents/skills/index/scripts/ -- a fixed
45
+ parents[N] climb from __file__ only lands on the real repo root from the
46
+ source location, not from a mirror. Same approach as
47
+ skills/j-close-story/scripts/check-story-closeable.sh,
48
+ check-privatized.sh:138, and compute-scope-divergence.sh:54 use for the
49
+ equivalent bash case.
50
+ """
51
+ try:
52
+ result = subprocess.run(
53
+ ["git", "rev-parse", "--show-toplevel"],
54
+ cwd=Path(__file__).resolve().parent,
55
+ capture_output=True,
56
+ text=True,
57
+ check=True,
58
+ )
59
+ return Path(result.stdout.strip())
60
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
61
+ return Path.cwd()
62
+
63
+
64
+ ROOT_DIR = _resolve_project_root()
37
65
  BOARD_ID_RE = re.compile(r"^E\d{2}(?:_S\d{2})?(?:_T\d{2})?$")
38
66
  PLAN_RE = re.compile(r"^(E\d{2}_S\d{2}(?:_T\d{2})?)-plan$")
39
67
  SUMMARY_RE = re.compile(r"^(E\d{2}_S\d{2}(?:_T\d{2})?)-summary$")
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
 
6
6
  import argparse
7
7
  import json
8
+ import subprocess
8
9
  import sys
9
10
  from datetime import date
10
11
  from pathlib import Path
@@ -18,6 +19,31 @@ BOARD_DIRS = (
18
19
  )
19
20
 
20
21
 
22
+ def _resolve_project_root() -> Path:
23
+ """Locate the project root via git, not a fixed relative climb.
24
+
25
+ This script is deployed both at its source location
26
+ (skills/j-doc/scripts/) and mirrored one directory deeper into
27
+ .claude/skills/j-doc/scripts/ / .agents/skills/j-doc/scripts/ -- a fixed
28
+ parents[N] climb from __file__ only lands on the real repo root from the
29
+ source location, not from a mirror. Same approach as
30
+ skills/j-close-story/scripts/check-story-closeable.sh,
31
+ check-privatized.sh:138, and compute-scope-divergence.sh:54 use for the
32
+ equivalent bash case.
33
+ """
34
+ try:
35
+ result = subprocess.run(
36
+ ["git", "rev-parse", "--show-toplevel"],
37
+ cwd=Path(__file__).resolve().parent,
38
+ capture_output=True,
39
+ text=True,
40
+ check=True,
41
+ )
42
+ return Path(result.stdout.strip())
43
+ except (subprocess.CalledProcessError, FileNotFoundError, OSError):
44
+ return Path.cwd()
45
+
46
+
21
47
  def parse_args() -> argparse.Namespace:
22
48
  parser = argparse.ArgumentParser(
23
49
  description="Resolve the latest completed board date for a documentation target.",
@@ -25,7 +51,7 @@ def parse_args() -> argparse.Namespace:
25
51
  parser.add_argument("target_path", help="Repo-relative documentation target path, e.g. README.md")
26
52
  parser.add_argument(
27
53
  "--root",
28
- default=Path(__file__).resolve().parents[3],
54
+ default=_resolve_project_root(),
29
55
  type=Path,
30
56
  help="Repository root containing project/board/",
31
57
  )
@@ -28,7 +28,18 @@
28
28
 
29
29
  set -euo pipefail
30
30
 
31
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
31
+ # Locate the project root via git, not a fixed relative climb — this script
32
+ # is deployed both at its source location (skills/j-todo/scripts/) and
33
+ # mirrored to .claude/skills/j-todo/scripts/ / .agents/skills/j-todo/scripts/,
34
+ # and a fixed "three levels up" climb lands in the wrong place from a mirror.
35
+ # Same approach as skills/j-close-story/scripts/check-story-closeable.sh,
36
+ # check-privatized.sh:138, and compute-scope-divergence.sh:54.
37
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
38
+ REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)"
39
+ if [[ -z "$REPO_ROOT" ]]; then
40
+ echo "ERROR: could not locate project root (git rev-parse --show-toplevel failed from $SCRIPT_DIR)" >&2
41
+ exit 1
42
+ fi
32
43
  cd "$REPO_ROOT"
33
44
 
34
45
  TASKS_DIR="project/board/tasks"
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: j.wtf
3
- description: Alias of /clearify — clarifies ambiguous, dense, or under-specified prompts and conversation on request. This folder exists only so the `/wtf` slash command resolves to a skill; behaviour is identical to `/clearify`.
3
+ description: Alias of /clearify — clarifies ambiguous, dense, or under-specified prompts and conversation on request. This folder exists so `j.wtf` (and its `/j-wtf` directory form) resolves to a skill; behaviour is identical to `/clearify`.
4
4
  keywords:
5
5
  - wtf
6
6
  - confused
@@ -137,11 +137,33 @@ If all applicable rules pass (or the task is a legacy task), proceed to the next
137
137
 
138
138
  This phase determines **how `/jenga` was invoked** and, for two of the four entry modes, produces a **scoped set** — a confirmed list of board IDs (epics/stories/tasks) that Phases 1-4 must restrict themselves to. All board scanning, ID parsing, cascade expansion, and rendering used by this phase already live in `skills/jenga/scripts/` per this repo's "Scripts Over Inline Logic" principle — this phase never re-implements any of that logic inline. The executing agent's job here is limited to: invoking the right script with the right arguments, relaying its STDOUT verbatim to the user when the contract calls for that, capturing the `STATE_FILE:` line from STDERR for the next turn, and forwarding the user's raw reply back into the next invocation unmodified.
139
139
 
140
- **Determine the invocation form** from the raw argument (if any) passed to `/jenga`:
140
+ **First, check for the `--enrich` flag** (`E53_S13_T01`, ported from the now-retired `/route`'s board
141
+ + docs enrichment — see the Natural-language branch's step 3 and the Skill Matching & Invocation
142
+ Contract's Report format below for what it actually does). If the raw argument passed to `/jenga`
143
+ begins with the exact leading token `--enrich` followed by at least one space, strip that token
144
+ (and the single space after it) before anything else runs, and remember `enrichment_requested =
145
+ true` for the remainder of this invocation. The **remaining text** — never the original argument
146
+ with the flag still attached — is what every step below (including `detect-nl-intent.sh`) treats as
147
+ "the raw argument passed to `/jenga`"; this is what keeps the flag from ever being visible to
148
+ `detect-nl-intent.sh`'s `all_resolved`/`nl_intent`/`mixed` classification. If `--enrich` is not
149
+ present as a leading token, `enrichment_requested = false` and the argument is used as-is —
150
+ byte-for-byte the same behavior as before this flag existed.
151
+
152
+ `--enrich` alone (nothing after it, once whitespace is stripped) reduces to an empty remaining
153
+ argument — treat this exactly like no argument at all (**bare branch**); the flag has nothing to
154
+ enrich without free-form text and bare `/jenga` never does skill matching. `--enrich *` reduces to
155
+ the **wildcard branch** the same way (remaining text is the literal `*`); the wildcard branch also
156
+ never does skill matching, so `enrichment_requested` is simply never consulted there. Concretely,
157
+ `enrichment_requested` is only ever read in the **natural-language branch** below, and only once
158
+ that branch's own step 3 confirms a single-skill match — it is inert everywhere else. Passing
159
+ `--enrich` on a scoped (`<ids>`) argument is likewise a no-op: the scoped branch does not perform
160
+ skill matching either, so there is nothing for the flag to attach to.
161
+
162
+ **Then, determine the invocation form** from the remaining argument (if any) passed to `/jenga`:
141
163
 
142
164
  - No argument at all → **bare branch**.
143
165
  - The argument is the literal string `*` → **wildcard branch**.
144
- - Any other non-empty argument → invoke `skills/jenga/scripts/detect-nl-intent.sh "<raw argument>"` (E53_S01_T01) and branch on its `classification` field:
166
+ - Any other non-empty argument → invoke `skills/jenga/scripts/detect-nl-intent.sh "<remaining argument>"` (E53_S01_T01) and branch on its `classification` field:
145
167
  - `all_resolved` or `mixed` → **scoped branch** (below) — this is the same branch as before; only its internal mechanics changed (see below).
146
168
  - `nl_intent` → **natural-language branch** (below) — new for E53_S01, no new sigil or entry point, purely a new outcome of this same argument-shape detection.
147
169
 
@@ -170,7 +192,12 @@ This branch is entered when `detect-nl-intent.sh` classifies the argument as `nl
170
192
 
171
193
  1. **Load the catalog** — invoke `skills/jenga/scripts/load-nl-catalog.sh` with no arguments (E53_S01_T02). Its stdout is the full skill catalog (`name`/`description`/`keywords`/`examples`/`prefered_agent` per skill), sourced exclusively from `lib/generate-skill-allow-list.js`'s generated inventory — see the script's own header for the full contract. Never re-derive this catalog by re-scanning `skills/` inline.
172
194
  2. **Match** — run this section's own **Skill Matching & Invocation Contract** (below) — the three-pass keyword → example-similarity → description match, including its tie-break and no-match handling — against this catalog, treating `detect-nl-intent.sh`'s `raw_argument` field as the prompt.
173
- 3. **Confident single match** — report the routing decision using the **Skill Matching & Invocation Contract**'s **Report** format, then invoke the matched skill exactly as its **Invoke** rule already does: load `agents/<prefered_agent>.md` when the matched skill specifies `metadata.prefered_agent`, otherwise execute the skill instructions directly. The matched skill's own execution takes over from here — do not continue into this `/jenga` invocation's Phase 1.
195
+ 3. **Confident single match** — if `enrichment_requested` is `true` (the `--enrich` flag was passed, per Phase 0.75's preamble above), first invoke `skills/jenga/scripts/enrich-nl-prompt.sh "<raw_argument>"` (`E53_S13_T01`, ported from `/route`'s Steps 3-5) and assemble the enriched composite message from its JSON output, in this exact order:
196
+ - **Part A — Matched skill (full content)** — the matched skill's full `SKILL.md` body (everything after its YAML front-matter), wrapped as `<!-- SKILL: /<matched-skill-name> --> ... <!-- END SKILL -->`.
197
+ - **Part B — Board & documentation context** — a `## 📋 Relevant Board Context` list (one line per `board_items` entry: `- [<status>] **<id>** — <title> (\`<file>\`)`) followed by a `## 📄 Relevant Documentation` list (one line per `docs` entry: `` - `<path>` — <summary> ``). Omit either sub-list entirely (not an empty heading) when its array is empty.
198
+ - **Part C — Original prompt** — `## 🗣 Original Prompt` followed by the raw prompt, verbatim, in a blockquote.
199
+
200
+ Then report the routing decision using the **Skill Matching & Invocation Contract**'s **Report** format (including its `Board items found`/`Docs found` lines, populated from `enrich-nl-prompt.sh`'s `board_items_found`/`docs_found` fields, since `enrichment_requested` is `true` here), and invoke the matched skill exactly as the **Invoke** rule already does — but deliver the enriched composite message as the working input instead of the raw prompt when enrichment ran. When `enrichment_requested` is `false` (the default, unflagged path — unchanged from before this flag existed), skip `enrich-nl-prompt.sh` entirely: report using the **Report** format's default (no `Board items found`/`Docs found` lines) and invoke the matched skill directly with the raw prompt, exactly as before. Either way: load `agents/<prefered_agent>.md` when the matched skill specifies `metadata.prefered_agent`, otherwise execute the skill instructions directly. The matched skill's own execution takes over from here — do not continue into this `/jenga` invocation's Phase 1.
174
201
  4. **No match, or an ambiguous multi-way tie (single-skill match)** — before surfacing the **Skill Matching & Invocation Contract**'s generic disambiguation options, attempt a **playbook fallback** (E53_S02): invoke `skills/jenga/scripts/match-playbook.sh "<raw_argument>"`. This step only ever runs when step 3 above did NOT already commit to a confident single-skill match — a confident single-skill match always wins outright and this playbook fallback is never even invoked in that case. Branch on `match-playbook.sh`'s `classification` field:
175
202
  - `playbook_match` → continue to **step 5 (Playbook proposal and execution)** below.
176
203
  - `ambiguous` or `no_match` → continue to **step 6 (Fall through to the Skill Matching & Invocation Contract's disambiguation)** below — the exact behavior this branch already had before E53_S02, unchanged.
@@ -217,14 +244,11 @@ This branch is entered when `detect-nl-intent.sh` classifies the argument as `nl
217
244
 
218
245
  ##### Skill Matching & Invocation Contract
219
246
 
220
- This contract is inlined here — rather than referenced by path to `skills/j-route/SKILL.md` — because
221
- `j.route` is one of six skills that never ship publicly in either naming form (see
222
- `docs/public-mirror-content-parity.md`'s "Which skills are never public"), while `/jenga` itself ships
223
- publicly. A path reference from a shipped file to an unshipped one is a dead reference in the public
224
- package even though it resolves fine in this private repo (filed via `j.error` 2026-09-26).
225
- `skills/j-route/SKILL.md`'s own Step 2/6/7 carry the authoritative copy of this same contract for
226
- `/route`'s own use — the two are intentionally duplicated for public-mirror reasons; keep them in sync
227
- by hand if either changes.
247
+ `/jenga` is the **sole owner** of this contract (`E53_S13_T02`). `/route` — which previously carried
248
+ an independent, hand-synced copy of this same three-pass matching/tie-break/report logic in its own
249
+ Step 2/6/7 — has been retired outright (hard break, no shim; see `E53_S13`'s story). There is no
250
+ other copy of this contract anywhere in the codebase to keep in sync with; if you change the matching
251
+ logic here, this section is the only place that needs updating.
228
252
 
229
253
  **Matching** (three passes, stop at first confident match):
230
254
 
@@ -269,9 +293,20 @@ Routing to: /<matched-skill-name>
269
293
  Reason: <one sentence explaining why this skill was chosen>
270
294
  ```
271
295
 
272
- (`skills/j-route/SKILL.md`'s own Step 7 additionally reports `Board items found`/`Docs found` counts
273
- from its Steps 3-5 board/doc enrichment — this `/jenga` natural-language branch never performs that
274
- enrichment, so those two fields do not apply here and are intentionally omitted.)
296
+ **`Board items found`/`Docs found` (opt-in, `E53_S13_T01`)** — these two lines are appended to the
297
+ Report above, in this order, **only** when the natural-language branch's `enrichment_requested` is
298
+ `true` (i.e. the caller passed `--enrich`, per Phase 0.75's preamble):
299
+
300
+ ```
301
+ Board items found: <count>
302
+ Docs found: <count>
303
+ ```
304
+
305
+ `<count>` is `enrich-nl-prompt.sh`'s `board_items_found`/`docs_found` field respectively (the total
306
+ match count before the top-5/top-3 cap, not the number of items actually listed in the enriched
307
+ prompt's Part B). When `enrichment_requested` is `false` — the default path, unchanged from before
308
+ this flag existed — neither line is emitted; the Report is exactly the two lines above and nothing
309
+ more.
275
310
 
276
311
  Then proceed immediately — do not wait for user confirmation unless the match was ambiguous (the
277
312
  tie-break above already handled that).
@@ -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
@@ -6,7 +6,7 @@
6
6
  # same three-pass matching *philosophy* as `skills/jenga/SKILL.md`'s inlined Skill Matching &
7
7
  # Invocation Contract (keyword -> example similarity -> description), but scoped to the playbook
8
8
  # catalog produced by `load-playbooks.sh` (E53_S02_T01) instead of the single-skill catalog
9
- # `load-nl-catalog.sh` produces for `/route`/`/jenga`'s existing single-skill matching.
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
@@ -20,8 +20,9 @@
20
20
  # metadata — see CONDITIONALS below) -> get the first step to invoke.
21
21
  # 2. `should-skip <state_file>` -> deterministically decide whether the CURRENT step should run.
22
22
  # - `{"skip": true, ...}` -> do NOT invoke the step; call `advance <state_file> skipped`.
23
- # - `{"skip": false, ...}` -> invoke the step (as `/route`'s Step 6 already does for a single
24
- # matched skill), then call `advance <state_file> passed ["<typed-output-value>"]` (step
23
+ # - `{"skip": false, ...}` -> invoke the step (as the Skill Matching & Invocation Contract's
24
+ # Invoke rule already does for a single matched skill), then call
25
+ # `advance <state_file> passed ["<typed-output-value>"]` (step
25
26
  # succeeded) or `advance <state_file> failed [note]` (step failed).
26
27
  # 3. Any of the three `advance` outcomes returns the next step, a "complete" signal, or (on
27
28
  # failure) a halt report.