@jenga-ai/agent 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -0
  2. package/agents/developer.md +18 -0
  3. package/agents/scrum-master.md +1 -0
  4. package/agents/tester.md +18 -0
  5. package/hooks/on_session_end.sh +27 -0
  6. package/package.json +18 -17
  7. package/skills/commit/SKILL.md +11 -1
  8. package/skills/dev-done/SKILL.md +46 -0
  9. package/skills/dev-done/scripts/classify-commit-outcome.sh +114 -0
  10. package/skills/init/SKILL.md +7 -6
  11. package/skills/init/assets/scope-thresholds_template.json +7 -0
  12. package/skills/init/scripts/init.sh +6 -0
  13. package/skills/publish/SKILL.md +66 -0
  14. package/skills/publish/adapters/npm-ci.md +34 -0
  15. package/skills/publish/adapters/npm.md +18 -0
  16. package/skills/publish/assets/ci-contract.md +27 -0
  17. package/skills/publish/assets/publish.example.json +27 -0
  18. package/skills/publish/schemas/publish.schema.json +20 -0
  19. package/skills/publish/scripts/npm_ci_pipeline.sh +29 -0
  20. package/skills/publish/scripts/npm_stage_inspect.sh +829 -0
  21. package/skills/publish/scripts/npm_stage_pipeline.sh +427 -0
  22. package/skills/publish/scripts/publish_common.sh +16 -0
  23. package/skills/publish/scripts/show_history.sh +12 -5
  24. package/skills/publish/scripts/validate_npm_stage_env.sh +184 -0
  25. package/skills/publish/scripts/write_ledger_entry.sh +92 -2
  26. package/skills/reconcile/SKILL.md +121 -11
  27. package/skills/reconcile/assets/report_format.md +17 -0
  28. package/skills/reconcile/scripts/resolve-reconcile-scope.sh +489 -0
  29. package/skills/uncharted/SKILL.md +200 -21
  30. package/skills/uncharted/scripts/directory-triage.sh +342 -0
  31. package/skills/uncharted/scripts/elicitation-state.sh +457 -0
  32. package/templates/SCRUM_BOARD_SCHEMA.md +57 -0
  33. package/templates/agent-context.md.tpl +23 -11
  34. package/templates/copilot-instructions.md.tpl +21 -11
  35. package/mcp/router/README.md +0 -19
  36. package/mcp/router/embedder.js +0 -23
  37. package/mcp/router/index.js +0 -204
  38. package/mcp/router/matcher.js +0 -87
  39. package/mcp/router/package-lock.json +0 -1048
  40. package/mcp/router/package.json +0 -11
  41. package/mcp/router/skill-index.js +0 -104
  42. package/skills/route/SKILL.md +0 -180
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/reconcile/scripts/resolve-reconcile-scope.sh
4
+ #
5
+ # Deterministic scope-argument resolver backing `/reconcile`'s new scope
6
+ # argument (story E17_S07). This script owns ALL scope-argument parsing and
7
+ # id-range expansion for `/reconcile` — `skills/reconcile/SKILL.md` (wired in
8
+ # the sibling task E17_S07_T02) only interprets this script's structured
9
+ # stdout output; it must not re-derive any parsing or resolution logic
10
+ # inline.
11
+ #
12
+ # ---------------------------------------------------------------------------
13
+ # REUSE CONTRACT — do not re-derive
14
+ # ---------------------------------------------------------------------------
15
+ # This script NEVER re-scans the board or re-implements id-grammar parsing.
16
+ # It shells out to the two scripts `/reconcile` and `/uncharted` already
17
+ # share:
18
+ #
19
+ # skills/jenga/scripts/board-scan.sh Single source of truth for board
20
+ # inventory (epics/stories/tasks,
21
+ # their statuses and parent ids).
22
+ # Called at most ONCE per invocation
23
+ # of this script.
24
+ #
25
+ # skills/jenga/scripts/resolve-id.sh The documented fuzzy-ID grammar
26
+ # parser. Resolves one or more
27
+ # comma-separated bare/tagged id
28
+ # segments (e.g. "E12", "S03",
29
+ # "E12_S03_T01") against the board
30
+ # inventory. EVERY individual id this
31
+ # script needs resolved — a plain
32
+ # single argument, or every member of
33
+ # an expanded range — goes through
34
+ # one call to resolve-id.sh. This
35
+ # script adds NO second id parser.
36
+ #
37
+ # Range expansion itself (e.g. "S03-05" -> S03, S04, S05) is new logic that
38
+ # belongs here, layered ON TOP OF resolve-id.sh's per-id resolution — neither
39
+ # board-scan.sh nor resolve-id.sh understands ranges. Once expanded, each
40
+ # range member is just another candidate segment fed to resolve-id.sh
41
+ # alongside the plain-argument case, in the same single batch call.
42
+ #
43
+ # ---------------------------------------------------------------------------
44
+ # INVOCATION
45
+ # ---------------------------------------------------------------------------
46
+ # resolve-reconcile-scope.sh [<scope-argument>]
47
+ #
48
+ # ---------------------------------------------------------------------------
49
+ # RESOLUTION RULES (settled — see E17_S07's Background/AC, do not relitigate)
50
+ # ---------------------------------------------------------------------------
51
+ # No argument -> full-board scope, unchanged from today's
52
+ # `/reconcile` behavior.
53
+ # Bare epic id e.g. "E12"
54
+ # -> scope is that epic, in full.
55
+ # Bare story id e.g. "E12_S03", or "S03" if resolve-id.sh
56
+ # resolves it unambiguously
57
+ # -> scope defaults to the story's CONTAINING
58
+ # EPIC, in full (default-scope-to-epic rule:
59
+ # rollup can't be evaluated correctly without
60
+ # seeing all sibling stories/tasks).
61
+ # Bare task id e.g. "E12_S03_T01"
62
+ # -> same default-scope-to-epic rule as above.
63
+ # A range e.g. "S03-05" or "E12_S03-05"
64
+ # -> scope is EXACTLY the named stories (and
65
+ # their tasks), plus rollup limited to only
66
+ # the epic(s) those named stories belong to.
67
+ # Does NOT expand to unrelated stories in the
68
+ # same epic(s). Only STORY-level ranges are
69
+ # supported — an epic-level range (e.g.
70
+ # "E01-03") or a task-level range (e.g.
71
+ # "T01-03") is rejected with a clear reason,
72
+ # never silently reinterpreted.
73
+ # Invalid/unresolvable input unknown id, malformed range, an ambiguous
74
+ # partial that resolve-id.sh itself would
75
+ # reject
76
+ # -> a structured error, non-zero exit, NOTHING
77
+ # else written to stdout. No partial result,
78
+ # no silent full-board fallback.
79
+ #
80
+ # ---------------------------------------------------------------------------
81
+ # RANGE GRAMMAR (new logic owned by this script)
82
+ # ---------------------------------------------------------------------------
83
+ # A range argument matches, case-insensitively:
84
+ #
85
+ # ^(E[0-9]+_?)?S([0-9]{1,3})-([0-9]{1,3})$
86
+ #
87
+ # i.e. an optional epic tag ("E12" or "E12_"), followed by a story tag "S",
88
+ # followed by <start>-<end>. Examples: "S03-05", "E12_S03-05", "e12s3-5".
89
+ #
90
+ # Expansion:
91
+ # 1. start/end are parsed as integers; start must be <= end, and the span
92
+ # is capped at 100 stories (defensive — a wider range is almost
93
+ # certainly a typo, not a real request).
94
+ # 2. Each number in [start, end] is zero-padded to the wider of the two
95
+ # input widths (minimum 2 digits, matching this board's "S03"/"T01"
96
+ # convention) and combined with the (optional) epic tag into a
97
+ # candidate segment, e.g. "E12_S03", "E12_S04", "E12_S05".
98
+ # 3. ALL candidate segments are resolved in a SINGLE comma-separated call
99
+ # to resolve-id.sh (its documented batch interface) — one subprocess,
100
+ # not one per number. A candidate with no epic tag (bare "S03") still
101
+ # works: resolve-id.sh resolves it against the whole board and rejects
102
+ # it as ambiguous if more than one epic has a story numbered 03,
103
+ # exactly like a normal bare "S03" invocation would.
104
+ # 4. If ANY candidate segment comes back "rejected", the whole range fails
105
+ # with that segment's own rejection reason (first rejection found, in
106
+ # input order) — no partial range is ever resolved.
107
+ # 5. resolve-id.sh's own type-restriction rule guarantees every resolved
108
+ # id here is a STORY id (the parsed level_map for each candidate never
109
+ # includes a task chunk), which this script's Python driver still
110
+ # double-checks defensively against the board inventory before trusting
111
+ # it.
112
+ #
113
+ # A non-range argument that still contains "-" (an epic range, a task range,
114
+ # stray punctuation) does NOT match the grammar above and falls straight
115
+ # into the single-id path below, where resolve-id.sh's own grammar produces
116
+ # its own real rejection reason — this script does not add a second,
117
+ # possibly-inconsistent error message for that case.
118
+ #
119
+ # ---------------------------------------------------------------------------
120
+ # OUTPUT CONTRACT (stdout, single JSON object, nothing else)
121
+ # ---------------------------------------------------------------------------
122
+ # {
123
+ # "scope_type": "full" | "epic" | "range",
124
+ # "epic_ids": ["E12"],
125
+ # "story_ids": ["E12_S03", "E12_S04", "E12_S05"],
126
+ # "task_ids": ["E12_S03_T01", "..."],
127
+ # "owned_path_hints": ["skills/reconcile/", "..."]
128
+ # }
129
+ #
130
+ # scope_type "full" epic_ids/story_ids/task_ids are all EMPTY arrays.
131
+ # Consumers treat this exactly like today's unscoped
132
+ # `/reconcile` run.
133
+ # scope_type "epic" epic_ids has exactly one element (the target epic);
134
+ # story_ids/task_ids list every story/task on the
135
+ # board whose epic_id is that epic — the FULL epic,
136
+ # per the default-scope-to-epic rule, regardless of
137
+ # whether the input argument named the epic directly
138
+ # or named a story/task inside it.
139
+ # scope_type "range" epic_ids lists every DISTINCT epic the resolved
140
+ # stories belong to (may be more than one if a bare
141
+ # "S03-05" range happens to resolve across epics);
142
+ # story_ids is EXACTLY the resolved range members
143
+ # (never expanded to siblings); task_ids is every
144
+ # task on the board whose story_id is one of those
145
+ # stories.
146
+ #
147
+ # owned_path_hints Best-effort, NON-AUTHORITATIVE list of path
148
+ # prefixes the resolved scope's own stories/tasks
149
+ # (and, for an epic scope, the epic file itself if it
150
+ # carries one) reference via their `docs:`
151
+ # frontmatter field — the ONLY place this script
152
+ # looks (it does NOT scan Acceptance Criteria prose
153
+ # for paths; that would be a second, heuristic
154
+ # path-finder and this stays intentionally narrow).
155
+ # Sorted, de-duplicated, may be EMPTY — an empty list
156
+ # is a valid, normal result. Downstream phases
157
+ # (E17_S07_T02) must treat a path they cannot
158
+ # attribute to the scope as UNFILTERED evidence,
159
+ # never as proof of exclusion.
160
+ #
161
+ # On error: {"status":"error","reason":"<clear, specific message>"}
162
+ # and a non-zero exit code — matching the same error-contract
163
+ # shape resolve-id.sh and cascade-resolve.sh already use for
164
+ # their own unresolved/malformed cases.
165
+ #
166
+ # ---------------------------------------------------------------------------
167
+ # EXIT CODES
168
+ # ---------------------------------------------------------------------------
169
+ # 0 scope resolved; stdout is the single JSON scope object described
170
+ # above (never the error object on this path)
171
+ # 1 scope could not be resolved (unknown id, malformed range, ambiguous
172
+ # partial); stdout is ONLY the {"status":"error",...} object above
173
+ # 2 environment error (board-scan.sh / resolve-id.sh missing or not
174
+ # executable, python3 unavailable, either subprocess failing outright)
175
+ # — a real setup problem, reported the same way as a resolution error
176
+ # (stdout is the error object) so every non-zero exit is uniformly
177
+ # safe for a caller to treat as "stop, do not touch any board file"
178
+ #
179
+ # Requires: bash, python3. No new dependency beyond what board-scan.sh and
180
+ # resolve-id.sh already require.
181
+ # ---------------------------------------------------------------------------
182
+
183
+ set -euo pipefail
184
+
185
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
186
+
187
+ BOARD_SCAN="$SCRIPT_DIR/../../jenga/scripts/board-scan.sh"
188
+ RESOLVE_ID="$SCRIPT_DIR/../../jenga/scripts/resolve-id.sh"
189
+
190
+ # Resolve JENGA_PROJECT_DIR the same way board-scan.sh does (CLAUDE_PROJECT_DIR -> git toplevel ->
191
+ # cwd) — needed here only to locate the resolved scope's board files for owned_path_hints.
192
+ if [ -f "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh" ]; then
193
+ # shellcheck source=lib/resolve-project-dir.sh
194
+ source "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh"
195
+ elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
196
+ JENGA_PROJECT_DIR="$CLAUDE_PROJECT_DIR"
197
+ else
198
+ JENGA_PROJECT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)"
199
+ fi
200
+
201
+ emit_error() {
202
+ # emit_error <reason>
203
+ # The ONLY thing ever written to stdout on an error path, per the output
204
+ # contract above. Uses python3 for correct JSON string escaping rather
205
+ # than hand-rolled shell quoting.
206
+ python3 -c '
207
+ import json, sys
208
+ print(json.dumps({"status": "error", "reason": sys.argv[1]}))
209
+ ' "$1"
210
+ }
211
+
212
+ die_error() {
213
+ # die_error <reason> <exit-code>
214
+ emit_error "$1"
215
+ exit "${2:-1}"
216
+ }
217
+
218
+ # --- environment checks ------------------------------------------------------------------------
219
+ command -v python3 >/dev/null 2>&1 || die_error "python3 is required but was not found on PATH" 2
220
+ [ -x "$BOARD_SCAN" ] || die_error "board-scan.sh not found or not executable at $BOARD_SCAN" 2
221
+ [ -x "$RESOLVE_ID" ] || die_error "resolve-id.sh not found or not executable at $RESOLVE_ID" 2
222
+
223
+ if [ "$#" -gt 1 ]; then
224
+ die_error "at most one scope-argument is accepted (got $#); combine multiple ids with a range, or invoke separately" 1
225
+ fi
226
+
227
+ RAW_ARG="${1:-}"
228
+
229
+ # --- case: no argument -> full-board scope, no board scan needed --------------------------------
230
+ if [ -z "$RAW_ARG" ]; then
231
+ python3 -c '
232
+ import json
233
+ print(json.dumps({
234
+ "scope_type": "full",
235
+ "epic_ids": [],
236
+ "story_ids": [],
237
+ "task_ids": [],
238
+ "owned_path_hints": []
239
+ }))
240
+ '
241
+ exit 0
242
+ fi
243
+
244
+ # --- detect a story-level range -------------------------------------------------------------
245
+ # Matches "S03-05" and "E12_S03-05" (and loose variants like "e12s3-5"), case-insensitively. Any
246
+ # other use of "-" (epic range, task range, stray punctuation) does NOT match and falls through
247
+ # to the single-id path below, where resolve-id.sh's own grammar produces its own real rejection.
248
+ RANGE_RE='^([Ee][0-9]+_?)?[Ss]([0-9]{1,3})-([0-9]{1,3})$'
249
+
250
+ IS_RANGE=0
251
+ CANDIDATES=""
252
+
253
+ if [[ "$RAW_ARG" =~ $RANGE_RE ]]; then
254
+ IS_RANGE=1
255
+ EPIC_TAG="${BASH_REMATCH[1]}"
256
+ START_STR="${BASH_REMATCH[2]}"
257
+ END_STR="${BASH_REMATCH[3]}"
258
+
259
+ EPIC_TAG_NORM="$(printf '%s' "$EPIC_TAG" | tr '[:lower:]' '[:upper:]' | tr -d '_')"
260
+
261
+ START_NUM=$((10#$START_STR))
262
+ END_NUM=$((10#$END_STR))
263
+
264
+ if [ "$START_NUM" -gt "$END_NUM" ]; then
265
+ die_error "malformed range '$RAW_ARG': start (S$START_STR) is greater than end (S$END_STR)" 1
266
+ fi
267
+ SPAN=$((END_NUM - START_NUM + 1))
268
+ if [ "$SPAN" -gt 100 ]; then
269
+ die_error "malformed range '$RAW_ARG': spans $SPAN stories, which exceeds the 100-story safety cap — check for a typo" 1
270
+ fi
271
+
272
+ WIDTH=${#START_STR}
273
+ if [ "${#END_STR}" -gt "$WIDTH" ]; then
274
+ WIDTH="${#END_STR}"
275
+ fi
276
+ if [ "$WIDTH" -lt 2 ]; then
277
+ WIDTH=2
278
+ fi
279
+
280
+ N="$START_NUM"
281
+ while [ "$N" -le "$END_NUM" ]; do
282
+ PADDED="$(printf "%0${WIDTH}d" "$N")"
283
+ if [ -n "$EPIC_TAG_NORM" ]; then
284
+ SEG="${EPIC_TAG_NORM}_S${PADDED}"
285
+ else
286
+ SEG="S${PADDED}"
287
+ fi
288
+ if [ -z "$CANDIDATES" ]; then
289
+ CANDIDATES="$SEG"
290
+ else
291
+ CANDIDATES="${CANDIDATES},${SEG}"
292
+ fi
293
+ N=$((N + 1))
294
+ done
295
+ elif [[ "$RAW_ARG" == *-* ]]; then
296
+ # Contains a "-" but doesn't match the supported story-range grammar (e.g. an epic range
297
+ # "E01-03", a task range "T01-03", or stray punctuation). Reject explicitly HERE rather than
298
+ # letting it fall through to resolve-id.sh: that script's grammar strips "-" as punctuation
299
+ # (step 1) and can coincidentally re-parse the remaining digits into a DIFFERENT, unintended id
300
+ # that happens to exist on the board (e.g. "E01-03" -> stripped to "E0103" -> misread as
301
+ # epic=E01 alone once the "03" chunk has nowhere else to land) — a silent misresolution, which
302
+ # is exactly what the "no partial result and no silent fallback" requirement rules out.
303
+ die_error "unsupported range syntax '$RAW_ARG': only story-level ranges (e.g. 'S03-05' or 'E12_S03-05') are supported" 1
304
+ else
305
+ CANDIDATES="$RAW_ARG"
306
+ fi
307
+
308
+ # --- board inventory, fetched ONCE ---------------------------------------------------------------
309
+ BOARD_JSON="$("$BOARD_SCAN" 2>/tmp/resolve-reconcile-scope.board-scan.$$.err)" || {
310
+ ERR_MSG="$(cat "/tmp/resolve-reconcile-scope.board-scan.$$.err" 2>/dev/null)"
311
+ rm -f "/tmp/resolve-reconcile-scope.board-scan.$$.err"
312
+ die_error "board-scan.sh failed: ${ERR_MSG:-unknown error}" 2
313
+ }
314
+ rm -f "/tmp/resolve-reconcile-scope.board-scan.$$.err"
315
+
316
+ # --- resolve every candidate segment in ONE batch call to resolve-id.sh -------------------------
317
+ set +e
318
+ RESOLVE_OUT="$("$RESOLVE_ID" "$CANDIDATES" 2>/tmp/resolve-reconcile-scope.resolve-id.$$.err)"
319
+ RESOLVE_RC=$?
320
+ set -e
321
+ RESOLVE_ERR="$(cat "/tmp/resolve-reconcile-scope.resolve-id.$$.err" 2>/dev/null)"
322
+ rm -f "/tmp/resolve-reconcile-scope.resolve-id.$$.err"
323
+
324
+ if [ "$RESOLVE_RC" -eq 2 ]; then
325
+ die_error "resolve-id.sh failed while resolving '$RAW_ARG': ${RESOLVE_ERR:-unknown error}" 2
326
+ fi
327
+ # RESOLVE_RC is 0 (every segment resolved) or 1 (at least one rejected) at this point — both leave
328
+ # valid JSON on stdout, per resolve-id.sh's own documented contract.
329
+
330
+ # --- drive the rest from one Python script (board lookups, scope assembly, owned_path_hints) ----
331
+ # Written to a temp file rather than piped in via `python3 -`, matching resolve-id.sh's own
332
+ # convention: the board JSON is delivered on stdin, and `python3 -` would consume stdin as the
333
+ # script source instead of leaving it for sys.stdin.read().
334
+ PY_SCRIPT="$(mktemp -t resolve-reconcile-scope-XXXXXX.py)"
335
+ trap 'rm -f "$PY_SCRIPT"' EXIT
336
+
337
+ cat > "$PY_SCRIPT" <<'PY'
338
+ import json
339
+ import os
340
+ import re
341
+ import sys
342
+
343
+ raw_arg, is_range_s, resolve_out_s, project_dir = sys.argv[1:5]
344
+ is_range = is_range_s == "1"
345
+ board_json = sys.stdin.read()
346
+
347
+
348
+ def emit_error(reason):
349
+ print(json.dumps({"status": "error", "reason": reason}))
350
+ sys.exit(1)
351
+
352
+
353
+ try:
354
+ resolve_results = json.loads(resolve_out_s)
355
+ except Exception as e:
356
+ emit_error("could not parse resolve-id.sh output: %s" % e)
357
+
358
+ try:
359
+ board = json.loads(board_json)
360
+ except Exception as e:
361
+ emit_error("could not parse board-scan.sh output: %s" % e)
362
+
363
+ by_id = {item["id"]: item for item in board if item.get("id")}
364
+
365
+ rejected = [r for r in resolve_results if r.get("status") != "resolved"]
366
+ if rejected:
367
+ r = rejected[0]
368
+ label = "range member" if is_range else "id"
369
+ emit_error(
370
+ 'could not resolve %s "%s" (from scope argument "%s"): %s'
371
+ % (label, r.get("input", "?"), raw_arg, r.get("reason", "unknown reason"))
372
+ )
373
+
374
+ resolved_ids = [r["resolved_id"] for r in resolve_results]
375
+
376
+ if is_range:
377
+ # resolve-id.sh's own type-restriction rule guarantees every id resolved from a bare/tagged
378
+ # story-level candidate is a STORY id — double-checked here defensively against the board
379
+ # inventory rather than trusted blindly.
380
+ story_ids = sorted(set(resolved_ids))
381
+ epic_ids = set()
382
+ not_stories = []
383
+ for sid in story_ids:
384
+ item = by_id.get(sid)
385
+ if item is None or item.get("type") != "story":
386
+ not_stories.append(sid)
387
+ continue
388
+ if item.get("epic_id"):
389
+ epic_ids.add(item["epic_id"])
390
+ if not_stories:
391
+ emit_error(
392
+ "range member(s) resolved but are not story-type items in the board inventory: %s"
393
+ % ", ".join(not_stories)
394
+ )
395
+ task_ids = sorted(
396
+ item["id"] for item in board
397
+ if item.get("type") == "task" and item.get("story_id") in set(story_ids)
398
+ )
399
+ scope_type = "range"
400
+ epic_ids = sorted(epic_ids)
401
+ else:
402
+ if len(resolved_ids) != 1:
403
+ emit_error("expected exactly one resolved id for a non-range argument, got %d" % len(resolved_ids))
404
+ resolved_id = resolved_ids[0]
405
+ item = by_id.get(resolved_id)
406
+ if item is None:
407
+ emit_error('resolved id "%s" not found in the board inventory' % resolved_id)
408
+
409
+ item_type = item.get("type")
410
+ if item_type == "epic":
411
+ target_epic = resolved_id
412
+ elif item_type in ("story", "task"):
413
+ # Default-scope-to-epic rule: a story/task argument always resolves to its containing
414
+ # epic, in full — rollup can't be evaluated correctly without all sibling stories/tasks.
415
+ target_epic = item.get("epic_id")
416
+ if not target_epic:
417
+ emit_error('resolved id "%s" has no epic_id in the board inventory' % resolved_id)
418
+ else:
419
+ emit_error('resolved id "%s" has an unrecognized type "%s"' % (resolved_id, item_type))
420
+
421
+ epic_item = by_id.get(target_epic)
422
+ if epic_item is None or epic_item.get("type") != "epic":
423
+ emit_error(
424
+ 'could not find containing epic "%s" for resolved id "%s" in the board inventory'
425
+ % (target_epic, resolved_id)
426
+ )
427
+
428
+ story_ids = sorted(
429
+ i["id"] for i in board if i.get("type") == "story" and i.get("epic_id") == target_epic
430
+ )
431
+ task_ids = sorted(
432
+ i["id"] for i in board if i.get("type") == "task" and i.get("epic_id") == target_epic
433
+ )
434
+ epic_ids = [target_epic]
435
+ scope_type = "epic"
436
+
437
+ # --- owned_path_hints: best-effort, derived ONLY from docs: frontmatter -------------------------
438
+ # Reads each resolved scope item's own board file (path already known from board-scan.sh's `file`
439
+ # field) and extracts a single-line `docs: ["a", "b"]`-style frontmatter list if present. This is
440
+ # the ONLY source consulted — Acceptance Criteria prose is deliberately NOT scanned for paths, to
441
+ # keep this a narrow, predictable best-effort lookup rather than a second heuristic path-finder.
442
+ # An empty result is valid and expected when none of the resolved items declare a `docs:` list.
443
+ target_ids = set(epic_ids) | set(story_ids) | set(task_ids)
444
+ files = [item["file"] for item in board if item.get("id") in target_ids and item.get("file")]
445
+
446
+ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
447
+ DOCS_LINE_RE = re.compile(r"^docs:\s*\[(.*)\]\s*$")
448
+
449
+ hints = set()
450
+ for rel_file in files:
451
+ full = os.path.join(project_dir, rel_file)
452
+ try:
453
+ with open(full, encoding="utf-8", errors="replace") as fh:
454
+ text = fh.read()
455
+ except OSError:
456
+ continue
457
+ fm_match = FRONTMATTER_RE.match(text)
458
+ if not fm_match:
459
+ continue
460
+ for line in fm_match.group(1).splitlines():
461
+ m = DOCS_LINE_RE.match(line.strip())
462
+ if not m:
463
+ continue
464
+ inner = m.group(1).strip()
465
+ if not inner:
466
+ continue
467
+ for raw_item in inner.split(","):
468
+ val = raw_item.strip()
469
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
470
+ val = val[1:-1]
471
+ val = val.strip()
472
+ if val:
473
+ hints.add(val)
474
+
475
+ print(json.dumps({
476
+ "scope_type": scope_type,
477
+ "epic_ids": epic_ids,
478
+ "story_ids": story_ids,
479
+ "task_ids": task_ids,
480
+ "owned_path_hints": sorted(hints)
481
+ }))
482
+ sys.exit(0)
483
+ PY
484
+
485
+ set +e
486
+ python3 "$PY_SCRIPT" "$RAW_ARG" "$IS_RANGE" "$RESOLVE_OUT" "$JENGA_PROJECT_DIR" <<< "$BOARD_JSON"
487
+ RC=$?
488
+ set -e
489
+ exit "$RC"