@jenga-ai/agent 3.5.0 → 4.0.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 (136) hide show
  1. package/README.md +85 -78
  2. package/agents/developer.md +1 -1
  3. package/agents/scrum-master.md +20 -2
  4. package/agents/tester.md +3 -3
  5. package/hooks/on_session_end.sh +5 -5
  6. package/lib/generate-agent-context.js +2 -2
  7. package/lib/generate-copilot-hooks.js +1 -1
  8. package/lib/generate-skill-allow-list.js +79 -8
  9. package/lib/mirror.js +1 -1
  10. package/lib/postinstall-manifest.js +1 -1
  11. package/lib/skill-allow-list.json +2 -2
  12. package/mcp/help/index.js +8 -17
  13. package/mcp/help/scan.js +73 -0
  14. package/package.json +5 -1
  15. package/project/app/api/lib/resolve-project-root.js +1 -1
  16. package/project/app/api/parsers/knowledge-graph.js +100 -9
  17. package/project/app/api/routes/health.js +36 -0
  18. package/project/app/api/scripts/capture-snapshot.js +9 -6
  19. package/project/app/package.json +4 -0
  20. package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
  21. package/project/app/ui/dist/assets/index-C3oiuli_.js +104 -0
  22. package/project/app/ui/dist/index.html +2 -2
  23. package/project/app/ui/package.json +4 -0
  24. package/project/app/ui/scripts/build-snapshot-html.cjs +63 -2
  25. package/scripts/acquire-concurrency-slot.sh +35 -5
  26. package/scripts/apply-j-prefix.sh +46 -5
  27. package/scripts/build-pages-site.sh +1 -1
  28. package/scripts/check-public-playbook-steps.sh +158 -52
  29. package/scripts/check-publicignore-match.sh +2 -2
  30. package/scripts/compute-deploy-reconcile.sh +5 -5
  31. package/scripts/delete-bare-skill-dirs.sh +329 -0
  32. package/scripts/generate-legacy-shipped-paths.js +2 -2
  33. package/scripts/idea_manager.sh +273 -3
  34. package/scripts/mark-deployed.sh +2 -2
  35. package/scripts/populate-knowledge-graph.entity-resolution.test.js +254 -0
  36. package/scripts/populate-knowledge-graph.js +213 -5
  37. package/scripts/populate-knowledge-graph.staleness.test.js +130 -0
  38. package/scripts/postinstall.js +1 -1
  39. package/scripts/release-concurrency-slot.sh +34 -4
  40. package/scripts/render-ranked-list.sh +270 -0
  41. package/scripts/repoint-dead-bare-path-prose.py +81 -0
  42. package/scripts/repoint-skill-refs.sh +539 -0
  43. package/scripts/rewrite-stale-skill-preambles.py +188 -0
  44. package/scripts/strip-polyfill-frontmatter.py +166 -0
  45. package/scripts/todo_manager.sh +16 -1
  46. package/scripts/validate-typed-object.sh +750 -0
  47. package/scripts/verify-legacy-seed-reconcile.sh +10 -10
  48. package/scripts/verify-postinstall-reconcile.sh +7 -7
  49. package/scripts/write-context-digest.sh +1 -1
  50. package/skills/j-brainstorm/SKILL.md +3 -4
  51. package/skills/j-btw/SKILL.md +3 -4
  52. package/skills/j-clearify/SKILL.md +5 -6
  53. package/skills/j-close-story/SKILL.md +11 -12
  54. package/skills/j-close-story/scripts/check-privatized.sh +4 -4
  55. package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
  56. package/skills/j-commit/SKILL.md +3 -4
  57. package/skills/j-continue/SKILL.md +5 -6
  58. package/skills/j-deep-dive/SKILL.md +3 -4
  59. package/skills/j-distribute/CONFIG_SCHEMA.md +82 -5
  60. package/skills/j-distribute/SKILL.md +3 -4
  61. package/skills/j-do/SKILL.md +100 -18
  62. package/skills/j-doc/SKILL.md +3 -4
  63. package/skills/j-doc-sync/SKILL.md +4 -4
  64. package/skills/j-dooo/SKILL.md +6 -15
  65. package/skills/j-error/SKILL.md +3 -4
  66. package/skills/j-evaluate/SKILL.md +3 -4
  67. package/skills/j-examplify/SKILL.md +3 -4
  68. package/skills/j-gitignore/SKILL.md +157 -0
  69. package/skills/j-gitignore/assets/jenga-paths.txt +50 -0
  70. package/skills/j-gitignore/scripts/_catalog.sh +105 -0
  71. package/skills/j-gitignore/scripts/audit-gitignore.sh +194 -0
  72. package/skills/j-gitignore/scripts/repair-gitignore.sh +226 -0
  73. package/skills/j-gitignore/scripts/untrack-jenga-files.sh +210 -0
  74. package/skills/j-help/SKILL.md +3 -4
  75. package/skills/j-idea/SKILL.md +80 -9
  76. package/skills/j-idea/assets/idea_template.md +1 -1
  77. package/skills/j-improve/SKILL.md +4 -5
  78. package/skills/j-init/SKILL.md +23 -14
  79. package/skills/j-init/assets/scope-thresholds_template.json +5 -2
  80. package/skills/j-init/scripts/apply-scaffold-visibility.sh +9 -7
  81. package/skills/j-init/scripts/init.sh +4 -4
  82. package/skills/j-jbp/SKILL.md +3 -4
  83. package/skills/j-lgtm/SKILL.md +3 -4
  84. package/skills/j-pi-plan/SKILL.md +3 -4
  85. package/skills/j-playbook/SKILL.md +1 -1
  86. package/skills/j-proceed/SKILL.md +3 -4
  87. package/skills/j-publish/SKILL.md +4 -5
  88. package/skills/j-publish/adapters/npm-ci.md +6 -1
  89. package/skills/j-publish/adapters/npm.md +1 -1
  90. package/skills/j-publish/scripts/generate_release_notes.sh +1 -1
  91. package/skills/j-publish/scripts/npm_stage_inspect.sh +61 -0
  92. package/skills/j-publish/scripts/run_gates.sh +1 -1
  93. package/skills/j-reconcile/SKILL.md +40 -7
  94. package/skills/j-reconcile/assets/report_format.md +11 -0
  95. package/skills/j-reconcile/scripts/detect-unlinked-code.sh +13 -13
  96. package/skills/j-reconcile-origin/SKILL.md +3 -4
  97. package/skills/j-redo/SKILL.md +4 -5
  98. package/skills/j-skillify/SKILL.md +3 -4
  99. package/skills/j-spinoff/SKILL.md +4 -5
  100. package/skills/j-status/SKILL.md +18 -4
  101. package/skills/j-todo/SKILL.md +44 -5
  102. package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
  103. package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
  104. package/skills/j-uncharted/SKILL.md +252 -13
  105. package/skills/j-uncharted/scripts/detect-dependencies.sh +80 -22
  106. package/skills/j-uncharted/scripts/detect-tests.sh +1 -1
  107. package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
  108. package/skills/j-uncharted/scripts/elicitation-state.sh +1 -1
  109. package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
  110. package/skills/j-uncharted/scripts/run-engine.sh +36 -2
  111. package/skills/j-uncharted/scripts/validate-proposed-items.sh +1 -1
  112. package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
  113. package/skills/j-wtf/SKILL.md +4 -5
  114. package/skills/jenga/SKILL.md +106 -11
  115. package/skills/jenga/playbooks/board-hygiene.json +32 -0
  116. package/skills/jenga/playbooks/schema.json +73 -6
  117. package/skills/jenga/playbooks/understand-then-commit.json +19 -0
  118. package/skills/jenga/scripts/load-nl-catalog.js +5 -2
  119. package/skills/jenga/scripts/load-nl-catalog.sh +1 -1
  120. package/skills/jenga/scripts/load-playbooks.sh +290 -5
  121. package/skills/jenga/scripts/match-playbook.sh +4 -4
  122. package/skills/jenga/scripts/run-playbook-step.sh +267 -1
  123. package/templates/SCRUM_BOARD_SCHEMA.md +14 -1
  124. package/templates/SKILL_TEMPLATE.md +12 -0
  125. package/templates/permission-levels/level-1-locked.json +1 -1
  126. package/templates/permission-levels/level-2-guarded.json +1 -1
  127. package/templates/permission-levels/level-3-standard.json +1 -1
  128. package/templates/permission-levels/level-4-elevated.json +2 -2
  129. package/templates/permission-levels/level-5-unrestricted.json +2 -2
  130. package/templates/playbook-types.json +40 -6
  131. package/project/app/ui/dist/assets/index-7fj-vllY.js +0 -104
  132. package/project/app/ui/dist/assets/index-CdK3Qrep.css +0 -1
  133. package/scripts/audit-twin-divergence.sh +0 -625
  134. package/scripts/generate-j-alias.sh +0 -333
  135. package/skills/j-dev-done/SKILL.md +0 -53
  136. package/skills/j-dev-done/scripts/classify-commit-outcome.sh +0 -114
@@ -0,0 +1,545 @@
1
+ #!/usr/bin/env bash
2
+ # find-scan-baseline.sh — locate the baseline `refresh` mode should diff against
3
+ #
4
+ # Usage: find-scan-baseline.sh [options] <root>
5
+ # find-scan-baseline.sh --help
6
+ #
7
+ # `refresh` mode (E40_S07_T04, not yet implemented) needs SOMETHING to diff the current codebase
8
+ # against. This script is the "find that something" step, and only that step — it does not diff
9
+ # (E40_S07_T03, a separate not-yet-started script) and does not decide what `refresh` does with
10
+ # the result (that belongs in skills/j-uncharted/SKILL.md's refresh mode section, E40_S07_T04).
11
+ # It is read-only: it never writes to the analysed codebase or the board.
12
+ #
13
+ # Baseline is located in this order, most precise first:
14
+ #
15
+ # 1. SCAN-RECORD. The most recent scan-record written by write-scan-record.sh (E40_S07_T01)
16
+ # under project/rapports/analysis/ whose own `root` field matches the requested root. If
17
+ # found, its `baseline_commit`, `scanned_at`, and `candidates` are emitted directly, tagged
18
+ # `source: "scan-record"` (precise — it recorded exactly what a real scan found).
19
+ #
20
+ # 2. BOARD-EVIDENCE INFERENCE. Only tried when no scan-record exists (e.g. `onboard` ran
21
+ # before `refresh` mode existed, so no scan-record was ever written). Board items are
22
+ # searched for two independent signals: an epic-level `provenance: backfilled` frontmatter
23
+ # field, or a `[ARCH]` title-prefix tag at any level (epic/story/task) — see
24
+ # templates/SCRUM_BOARD_SCHEMA.md's "provenance" and "[ARCH]" sections. A qualifying item
25
+ # whose `docs:` frontmatter list overlaps the requested root is evidence that the item
26
+ # describes (part of) that root. For each such item, the commit that FIRST added that
27
+ # board file to git history (`git log --follow --diff-filter=A --format=%h -- <file> |
28
+ # tail -1`) is an approximate stand-in for "when this part of the codebase was first
29
+ # understood" — the EARLIEST such commit across all matches is taken as the inferred
30
+ # baseline, tagged `source: "inferred"` (approximate — it is a proxy, not a real scan).
31
+ #
32
+ # 3. NO BASELINE. Neither a scan-record nor board evidence exists for the requested root.
33
+ # Exits 3 (see EXIT CODES) so the caller knows to fall back to running a full `onboard`
34
+ # instead of attempting a `refresh`.
35
+ #
36
+ # ---------------------------------------------------------------------------
37
+ # INPUT
38
+ # ---------------------------------------------------------------------------
39
+ # <root> Required. The repo-relative root `refresh` is being asked to investigate (e.g.
40
+ # "skills/j-uncharted", or "." for the whole repo). Matched against scan-records'
41
+ # `root` field and against board items' `docs:` entries — both are treated as
42
+ # repo-relative paths, normalized by stripping a trailing slash and collapsing
43
+ # "", ".", "./" to a single canonical "." meaning "the whole repository".
44
+ #
45
+ # ---------------------------------------------------------------------------
46
+ # OPTIONS
47
+ # ---------------------------------------------------------------------------
48
+ # --analysis-dir DIR Where scan-records are looked for (Tier 1). Default:
49
+ # <repo-root>/project/rapports/analysis — the same directory
50
+ # write-scan-record.sh writes to.
51
+ # --board-dir DIR Where board items are looked for (Tier 2). Default:
52
+ # <repo-root>/project/board — expects epics/, stories/, tasks/
53
+ # subdirectories underneath, matching the real board layout.
54
+ # --json-out FILE Also write the same JSON this script prints on stdout to this exact
55
+ # path. Not written unless requested. Pre-flighted before any real work,
56
+ # same fail-fast contract as write-scan-record.sh / run-engine.sh.
57
+ # -h, --help Show this help and exit 0.
58
+ #
59
+ # ---------------------------------------------------------------------------
60
+ # OUTPUT
61
+ # ---------------------------------------------------------------------------
62
+ # stdout : on success (Tier 1 or Tier 2), a single JSON object, one line, nothing else — so
63
+ # `RESULT=$(find-scan-baseline.sh ...)` is safe. Shape:
64
+ #
65
+ # Tier 1 (source: "scan-record"):
66
+ # {
67
+ # "source": "scan-record",
68
+ # "root": "<repo-relative root>",
69
+ # "baseline_commit": "<short SHA>" | null,
70
+ # "scanned_at": "<ISO 8601 UTC timestamp>",
71
+ # "candidates": [ { "path": "...", "files": <int>, "lines": <int> }, ... ],
72
+ # "candidate_count": <int>,
73
+ # "record_path": "<absolute path of the scan-record used>",
74
+ # "notices": [ ... ]
75
+ # }
76
+ #
77
+ # Tier 2 (source: "inferred"):
78
+ # {
79
+ # "source": "inferred",
80
+ # "root": "<repo-relative root>",
81
+ # "baseline_commit": "<short SHA>",
82
+ # "inferred_from": [
83
+ # { "id": "<board item id>", "level": "epic"|"story"|"task",
84
+ # "file": "<repo-relative board file path>", "commit": "<short SHA>" }, ...
85
+ # ],
86
+ # "notices": [ ... ]
87
+ # }
88
+ #
89
+ # On Tier 3 (no baseline) or any error, stdout is EMPTY — nothing is printed there.
90
+ #
91
+ # stderr : notices and diagnostics only, never document/record content. On Tier 3 or an error,
92
+ # a clear explanation of what was (and wasn't) found is written here.
93
+ #
94
+ # ---------------------------------------------------------------------------
95
+ # EXIT CODES
96
+ # ---------------------------------------------------------------------------
97
+ # 0 — success; a baseline was located (Tier 1 or Tier 2) and printed as JSON on stdout
98
+ # 1 — usage error: unknown flag, missing value, missing or duplicate <root> argument
99
+ # 2 — environment error: --board-dir does not exist or is not readable. (An unreadable or
100
+ # missing --analysis-dir is NOT an error — it just means Tier 1 has nothing to find, which
101
+ # is a normal, expected state handled by falling through to Tier 2.)
102
+ # 3 — NO BASELINE FOUND: neither a scan-record nor board evidence exists for <root>. The
103
+ # caller should fall back to running a full `onboard` (and its own first scan-record
104
+ # write) rather than attempting a `refresh`. This is a distinct code from every sibling
105
+ # script's 0/1/2/4 range specifically so callers can branch on it without string-matching
106
+ # stderr.
107
+ # 4 — write failure: --json-out's directory could not be found or is not writable.
108
+ #
109
+ # Examples:
110
+ # find-scan-baseline.sh skills/j-uncharted
111
+ # find-scan-baseline.sh --board-dir /tmp/scratch-board .
112
+ # RESULT=$(find-scan-baseline.sh skills/j-uncharted) || {
113
+ # rc=$?
114
+ # [ "$rc" -eq 3 ] && echo "no baseline -- falling back to onboard"
115
+ # }
116
+ #
117
+ # Requires: bash, git, python3. jq is NOT required — JSON is parsed and emitted by python3.
118
+
119
+ set -euo pipefail
120
+
121
+ ANALYSIS_DIR=""
122
+ BOARD_DIR=""
123
+ JSON_OUT=""
124
+ ROOT=""
125
+
126
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
127
+
128
+ usage() {
129
+ cat <<EOF
130
+ Usage: $(basename "$0") [options] <root>
131
+
132
+ Locate the baseline /uncharted refresh should diff against for <root> (a repo-relative path):
133
+ prefer the most recent matching scan-record under project/rapports/analysis/ (source:
134
+ "scan-record"); if none exists, infer an approximate baseline commit from provenance:backfilled
135
+ epics or [ARCH]-tagged board items whose docs: overlaps <root> (source: "inferred"); if neither
136
+ exists, exit 3 so the caller falls back to a full onboard.
137
+
138
+ Arguments:
139
+ <root> Repo-relative root to find a baseline for (e.g. "skills/j-uncharted", or "." for the
140
+ whole repo). Required.
141
+
142
+ Options:
143
+ --analysis-dir DIR Directory to search for scan-records (default: <repo-root>/project/rapports/analysis)
144
+ --board-dir DIR Directory containing epics/, stories/, tasks/ (default: <repo-root>/project/board)
145
+ --json-out FILE Also write the result JSON to this exact path
146
+ -h, --help Show this help and exit
147
+
148
+ Exit codes: 0 success, 1 usage error, 2 environment error, 3 no baseline found, 4 write failure.
149
+ EOF
150
+ }
151
+
152
+ die_usage() {
153
+ echo "Error: $1" >&2
154
+ echo >&2
155
+ usage >&2
156
+ exit 1
157
+ }
158
+
159
+ require_value() {
160
+ # require_value <flag> <remaining-arg-count>
161
+ [ "$2" -ge 2 ] || die_usage "$1 requires a value"
162
+ }
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Argument parsing
166
+ # ---------------------------------------------------------------------------
167
+
168
+ while [ "$#" -gt 0 ]; do
169
+ case "$1" in
170
+ --analysis-dir) require_value "--analysis-dir" "$#"; ANALYSIS_DIR="$2"; shift 2 ;;
171
+ --analysis-dir=*) ANALYSIS_DIR="${1#*=}"; shift ;;
172
+ --board-dir) require_value "--board-dir" "$#"; BOARD_DIR="$2"; shift 2 ;;
173
+ --board-dir=*) BOARD_DIR="${1#*=}"; shift ;;
174
+ --json-out) require_value "--json-out" "$#"; JSON_OUT="$2"; shift 2 ;;
175
+ --json-out=*) JSON_OUT="${1#*=}"; shift ;;
176
+ -h|--help) usage; exit 0 ;;
177
+ --)
178
+ shift
179
+ [ "$#" -le 1 ] || die_usage "at most one <root> argument is accepted"
180
+ [ "$#" -eq 0 ] || ROOT="$1"
181
+ break ;;
182
+ -*)
183
+ die_usage "unknown option \"$1\"" ;;
184
+ *)
185
+ [ -z "$ROOT" ] || die_usage "at most one <root> argument is accepted (got \"$ROOT\" and \"$1\")"
186
+ ROOT="$1"; shift ;;
187
+ esac
188
+ done
189
+
190
+ [ -n "$ROOT" ] || die_usage "missing required <root> argument"
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Directory resolution — anchored on THIS SCRIPT, not on the requested root, same convention
194
+ # write-scan-record.sh uses: the engine repo owns this lookup even when the analysed root lives
195
+ # elsewhere.
196
+ # ---------------------------------------------------------------------------
197
+
198
+ REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
199
+ [ -n "$REPO_ROOT" ] || REPO_ROOT="$(pwd -P)"
200
+
201
+ [ -n "$ANALYSIS_DIR" ] || ANALYSIS_DIR="$REPO_ROOT/project/rapports/analysis"
202
+ [ -n "$BOARD_DIR" ] || BOARD_DIR="$REPO_ROOT/project/board"
203
+
204
+ # A missing/unreadable analysis dir is NOT an error -- Tier 1 simply finds nothing and Tier 2
205
+ # runs. Normalize it if it exists; leave it as-is (the python side treats a non-existent dir as
206
+ # "no scan-records") if it doesn't.
207
+ if [ -d "$ANALYSIS_DIR" ]; then
208
+ ANALYSIS_DIR=$(cd -- "$ANALYSIS_DIR" && pwd -P)
209
+ fi
210
+
211
+ # A missing/unreadable board dir IS an environment error -- Tier 2 cannot run at all without it,
212
+ # and silently treating it as "no board evidence" would make a caller misdiagnose a broken
213
+ # --board-dir as a genuine Tier 3 "no baseline" result.
214
+ if [ ! -d "$BOARD_DIR" ] || [ ! -r "$BOARD_DIR" ]; then
215
+ echo "Error: --board-dir does not exist or is not readable: $BOARD_DIR" >&2
216
+ exit 2
217
+ fi
218
+ BOARD_DIR=$(cd -- "$BOARD_DIR" && pwd -P)
219
+
220
+ if [ -n "$JSON_OUT" ]; then
221
+ JSON_OUT_DIR=$(dirname -- "$JSON_OUT")
222
+ [ -d "$JSON_OUT_DIR" ] || { echo "Error: --json-out directory does not exist: $JSON_OUT_DIR" >&2; exit 4; }
223
+ [ -w "$JSON_OUT_DIR" ] || { echo "Error: --json-out directory is not writable: $JSON_OUT_DIR" >&2; exit 4; }
224
+ JSON_OUT=$(cd -- "$JSON_OUT_DIR" && pwd -P)/$(basename -- "$JSON_OUT")
225
+ fi
226
+
227
+ # ---------------------------------------------------------------------------
228
+ # Do the work in python3, exactly as write-scan-record.sh / resolve-reconcile-scope.sh /
229
+ # detect-unlinked-code.sh do: captured into a variable and run with `python3 -c` (never a
230
+ # `python3 - <<PY` heredoc, which would occupy this script's own stdin).
231
+ # ---------------------------------------------------------------------------
232
+
233
+ PY_SRC=$(cat <<'PY'
234
+ import glob
235
+ import json
236
+ import os
237
+ import re
238
+ import subprocess
239
+ import sys
240
+
241
+ (REPO_ROOT, ANALYSIS_DIR, BOARD_DIR, ROOT_ARG, JSON_OUT) = sys.argv[1:6]
242
+
243
+ notices = []
244
+
245
+
246
+ def norm(path):
247
+ """Normalize a repo-relative path for comparison: strip trailing slash, collapse
248
+ "", ".", "./" to the canonical "." meaning 'the whole repository'."""
249
+ p = (path or "").strip()
250
+ if p in ("", ".", "./"):
251
+ return "."
252
+ return p.rstrip("/")
253
+
254
+
255
+ ROOT = norm(ROOT_ARG)
256
+
257
+ # ===================================================================================
258
+ # TIER 1 -- scan-record lookup
259
+ # ===================================================================================
260
+
261
+ def tier1_scan_record():
262
+ if not os.path.isdir(ANALYSIS_DIR):
263
+ return None
264
+
265
+ pattern = os.path.join(ANALYSIS_DIR, "uncharted-scan-record-*.scan-record.json")
266
+ matches = []
267
+ for path in sorted(glob.glob(pattern)):
268
+ try:
269
+ with open(path, "r", encoding="utf-8") as fh:
270
+ record = json.load(fh)
271
+ except (OSError, ValueError) as exc:
272
+ notices.append("skipped unreadable/malformed scan-record %s: %s" % (path, exc))
273
+ continue
274
+ if not isinstance(record, dict):
275
+ continue
276
+ if norm(record.get("root", "")) != ROOT:
277
+ continue
278
+ matches.append((path, record))
279
+
280
+ if not matches:
281
+ return None
282
+
283
+ # Most recent by scanned_at (ISO 8601 UTC strings sort correctly as plain strings); ties
284
+ # broken by filename (which itself embeds a timestamp), descending.
285
+ matches.sort(key=lambda pr: (pr[1].get("scanned_at") or "", pr[0]), reverse=True)
286
+ path, record = matches[0]
287
+
288
+ return {
289
+ "source": "scan-record",
290
+ "root": ROOT,
291
+ "baseline_commit": record.get("baseline_commit"),
292
+ "scanned_at": record.get("scanned_at"),
293
+ "candidates": record.get("candidates", []),
294
+ "candidate_count": record.get("candidate_count", len(record.get("candidates", []))),
295
+ "record_path": path,
296
+ "notices": notices,
297
+ }
298
+
299
+
300
+ # ===================================================================================
301
+ # TIER 2 -- board-evidence inference
302
+ # ===================================================================================
303
+
304
+ FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
305
+ FIELD_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$")
306
+ LIST_ITEM_RE = re.compile(r"^\s*-\s*(.+)$")
307
+
308
+
309
+ def strip_quotes(val):
310
+ val = val.strip()
311
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'):
312
+ val = val[1:-1]
313
+ return val.strip()
314
+
315
+
316
+ def split_inline_list(inner):
317
+ items = []
318
+ for raw in inner.split(","):
319
+ val = strip_quotes(raw)
320
+ if val:
321
+ items.append(val)
322
+ return items
323
+
324
+
325
+ def parse_frontmatter(text):
326
+ """Hand-rolled frontmatter parser (matching this repo's existing convention in
327
+ resolve-reconcile-scope.sh), extended to handle BOTH YAML list styles
328
+ templates/SCRUM_BOARD_SCHEMA.md documents as valid for `docs:` -- inline
329
+ (docs: ["a", "b"]) and expanded (docs:\\n - a\\n - b) -- since real board files use
330
+ both (e.g. E20_S08_T01 uses the expanded style)."""
331
+ m = FRONTMATTER_RE.match(text)
332
+ if not m:
333
+ return {}
334
+ lines = m.group(1).splitlines()
335
+ fields = {}
336
+ i = 0
337
+ while i < len(lines):
338
+ fm = FIELD_RE.match(lines[i])
339
+ if not fm:
340
+ i += 1
341
+ continue
342
+ key, rest = fm.group(1), fm.group(2).strip()
343
+ if key == "docs":
344
+ if rest.startswith("["):
345
+ inline = rest
346
+ j = i
347
+ while "]" not in inline and j + 1 < len(lines):
348
+ j += 1
349
+ inline += lines[j]
350
+ if "]" in inline:
351
+ inner = inline[inline.index("[") + 1:inline.rindex("]")]
352
+ fields["docs"] = split_inline_list(inner)
353
+ else:
354
+ fields["docs"] = []
355
+ i = j + 1
356
+ continue
357
+ elif rest == "":
358
+ items = []
359
+ j = i + 1
360
+ while j < len(lines):
361
+ lm = LIST_ITEM_RE.match(lines[j])
362
+ if not lm:
363
+ break
364
+ items.append(strip_quotes(lm.group(1)))
365
+ j += 1
366
+ fields["docs"] = items
367
+ i = j
368
+ continue
369
+ else:
370
+ fields["docs"] = [strip_quotes(rest)] if rest else []
371
+ i += 1
372
+ continue
373
+ else:
374
+ fields[key] = strip_quotes(rest) if rest else rest
375
+ i += 1
376
+ fields.setdefault("docs", [])
377
+ return fields
378
+
379
+
380
+ def overlaps(root, docs_list):
381
+ if root == ".":
382
+ return True
383
+ for d in docs_list:
384
+ dn = norm(d)
385
+ if dn == ".":
386
+ return True
387
+ if dn == root or dn.startswith(root + "/") or root.startswith(dn + "/"):
388
+ return True
389
+ return False
390
+
391
+
392
+ def qualifies(level, fields):
393
+ if level == "epic" and fields.get("provenance") == "backfilled":
394
+ return True
395
+ title = fields.get("title", "")
396
+ if title.startswith("[ARCH]"):
397
+ return True
398
+ return False
399
+
400
+
401
+ def collect_matches():
402
+ matches = []
403
+ for level, subdir in (("epic", "epics"), ("story", "stories"), ("task", "tasks")):
404
+ dir_path = os.path.join(BOARD_DIR, subdir)
405
+ if not os.path.isdir(dir_path):
406
+ continue
407
+ for path in sorted(glob.glob(os.path.join(dir_path, "*.md"))):
408
+ try:
409
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
410
+ text = fh.read()
411
+ except OSError as exc:
412
+ notices.append("skipped unreadable board file %s: %s" % (path, exc))
413
+ continue
414
+ fields = parse_frontmatter(text)
415
+ if not fields:
416
+ continue
417
+ if not qualifies(level, fields):
418
+ continue
419
+ if not overlaps(ROOT, fields.get("docs", [])):
420
+ continue
421
+ matches.append({
422
+ "id": fields.get("id", os.path.basename(path)),
423
+ "level": level,
424
+ "file": path,
425
+ })
426
+ return matches
427
+
428
+
429
+ def first_added_commit(repo_root, board_file_abs):
430
+ rel = os.path.relpath(board_file_abs, repo_root)
431
+ try:
432
+ proc = subprocess.run(
433
+ ["git", "-C", repo_root, "log", "--follow", "--diff-filter=A",
434
+ "--format=%h", "--", rel],
435
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, universal_newlines=True,
436
+ )
437
+ except OSError as exc:
438
+ notices.append("git log failed for %s: %s" % (rel, exc))
439
+ return None
440
+ if proc.returncode != 0:
441
+ notices.append("git log exited %d for %s" % (proc.returncode, rel))
442
+ return None
443
+ shas = [line.strip() for line in proc.stdout.splitlines() if line.strip()]
444
+ if not shas:
445
+ notices.append("no commit history found for %s (uncommitted?)" % rel)
446
+ return None
447
+ return shas[-1]
448
+
449
+
450
+ def commit_timestamp(repo_root, sha):
451
+ try:
452
+ proc = subprocess.run(
453
+ ["git", "-C", repo_root, "show", "-s", "--format=%ct", sha],
454
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, universal_newlines=True,
455
+ )
456
+ except OSError as exc:
457
+ notices.append("git show failed for %s: %s" % (sha, exc))
458
+ return None
459
+ if proc.returncode != 0 or not proc.stdout.strip():
460
+ notices.append("could not resolve a timestamp for commit %s" % sha)
461
+ return None
462
+ try:
463
+ return int(proc.stdout.strip())
464
+ except ValueError:
465
+ return None
466
+
467
+
468
+ def tier2_inferred():
469
+ matches = collect_matches()
470
+ if not matches:
471
+ return None
472
+
473
+ resolved = []
474
+ for match in matches:
475
+ sha = first_added_commit(REPO_ROOT, match["file"])
476
+ if not sha:
477
+ continue
478
+ ts = commit_timestamp(REPO_ROOT, sha)
479
+ if ts is None:
480
+ continue
481
+ resolved.append({
482
+ "id": match["id"],
483
+ "level": match["level"],
484
+ "file": os.path.relpath(match["file"], REPO_ROOT),
485
+ "commit": sha,
486
+ "_ts": ts,
487
+ })
488
+
489
+ if not resolved:
490
+ notices.append(
491
+ "%d qualifying board item(s) overlapped root %r but none resolved a commit"
492
+ % (len(matches), ROOT)
493
+ )
494
+ return None
495
+
496
+ resolved.sort(key=lambda r: (r["_ts"], r["id"]))
497
+ earliest = resolved[0]
498
+
499
+ for r in resolved:
500
+ del r["_ts"]
501
+
502
+ return {
503
+ "source": "inferred",
504
+ "root": ROOT,
505
+ "baseline_commit": earliest["commit"],
506
+ "inferred_from": resolved,
507
+ "notices": notices,
508
+ }
509
+
510
+
511
+ # ===================================================================================
512
+ # Run tiers in order, emit result
513
+ # ===================================================================================
514
+
515
+ result = tier1_scan_record()
516
+ if result is None:
517
+ result = tier2_inferred()
518
+
519
+ if result is None:
520
+ notices.append(
521
+ "no scan-record and no provenance:backfilled/[ARCH] board evidence found for root %r "
522
+ "-- caller should fall back to a full onboard" % ROOT
523
+ )
524
+ for n in notices:
525
+ sys.stderr.write("Notice: %s\n" % n)
526
+ sys.exit(3)
527
+
528
+ body = json.dumps(result, ensure_ascii=False)
529
+
530
+ if JSON_OUT:
531
+ try:
532
+ with open(JSON_OUT, "w", encoding="utf-8") as fh:
533
+ fh.write(json.dumps(result, indent=2, ensure_ascii=False) + "\n")
534
+ except OSError as exc:
535
+ sys.stderr.write("Error: could not write --json-out file: %s\n" % exc)
536
+ sys.exit(4)
537
+
538
+ for n in result.get("notices", []):
539
+ sys.stderr.write("Notice: %s\n" % n)
540
+
541
+ print(body)
542
+ PY
543
+ )
544
+
545
+ python3 -c "$PY_SRC" "$REPO_ROOT" "$ANALYSIS_DIR" "$BOARD_DIR" "$ROOT" "$JSON_OUT"
@@ -366,10 +366,41 @@ def board_linkage():
366
366
  # A substring scan for "." would match every board file; say nothing rather than lie.
367
367
  return "n/a — target is the repository root"
368
368
  if not (target_abs == root or target_abs.startswith(root + "/")):
369
- return "unlinked — target is outside this repository"
369
+ # Never checked, not verified absent -- this repo's board/ was never searched for an
370
+ # out-of-repo target. Keep this wording aligned with resolve-segment-target.sh's own
371
+ # not_checked / "target is outside this repository" convention (see that script's Step 1
372
+ # role in SKILL.md) rather than claiming "unlinked", which asserts a verified absence.
373
+ return "not_checked — target is outside this repository"
370
374
  board = os.path.join(root, "project", "board")
371
375
  if not os.path.isdir(board):
372
376
  return "unlinked (no `project/board/` in this repository)"
377
+ # Path-boundary test, not a bare substring test -- matches resolve-segment-target.sh's own
378
+ # linkage_for() semantics (its "MATCH SEMANTICS" header comment) so the two never contradict
379
+ # each other for the same in-repo target. A board file references target_rel when one of its
380
+ # path-like tokens (trailing "."/"-" stripped) either equals target_rel or is a path
381
+ # descendant of it -- "docs/hooks/guide.md" does NOT make "hooks" linked; "hooks/foo.sh" does.
382
+ # Full unification with resolve-segment-target.sh's BOARD_INDEX is a deliberate, separate,
383
+ # larger follow-up (see SKILL.md) -- this is the same boundary rule, reimplemented minimally.
384
+ PATH_TOKEN_RE = re.compile(r"[A-Za-z0-9_./-]+")
385
+
386
+ def path_tokens(text):
387
+ for m in PATH_TOKEN_RE.finditer(text):
388
+ tok = m.group(0).rstrip("./-")
389
+ if tok:
390
+ yield tok
391
+
392
+ def references(tok, target):
393
+ return tok == target or tok.startswith(target + "/")
394
+
395
+ # Mirror-path equivalence: a board item that names only the generated .agents/ or .claude/
396
+ # mirror of this target also counts as referencing the canonical root -- those mirrors are
397
+ # build outputs of the root file (CLAUDE.md's Canonical Naming Contract), not a distinct
398
+ # path-descendant relationship a bare boundary test would ever catch on its own.
399
+ mirror_targets = [target_rel]
400
+ for _prefix in (".agents/", ".claude/"):
401
+ if not target_rel.startswith(_prefix):
402
+ mirror_targets.append(_prefix + target_rel)
403
+
373
404
  ids = set()
374
405
  for dirpath, _dirnames, filenames in os.walk(board):
375
406
  for fn in filenames:
@@ -380,7 +411,10 @@ def board_linkage():
380
411
  text = fh.read()
381
412
  except OSError:
382
413
  continue
383
- if target_rel in text:
414
+ matched = any(
415
+ references(tok, t) for tok in path_tokens(text) for t in mirror_targets
416
+ )
417
+ if matched:
384
418
  m = re.match(r"^(E\d+(?:_S\d+)?(?:_T\d+)?)", fn)
385
419
  ids.add(m.group(1) if m else fn[:-3])
386
420
  if not ids:
@@ -53,7 +53,7 @@ REPO_ROOT=$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || true)
53
53
  # and .agents/ — scripts/ (which owns both validators) is never copied there,
54
54
  # so this script — itself shipped under skills/j-uncharted/scripts/ and mirrored
55
55
  # alongside it — cannot assume "$REPO_ROOT/scripts/..." exists. Mirrors
56
- # skills/init/scripts/init.sh's PKG_ROOT fallback (same pattern already
56
+ # skills/j-init/scripts/init.sh's PKG_ROOT fallback (same pattern already
57
57
  # applied to this skill's elicitation-state.sh WITH_LOCK resolution): prefer
58
58
  # a monorepo checkout's sibling scripts/ dir, else fall back to the installed
59
59
  # npm package under node_modules/@jenga-ai/agent.