@jenga-ai/agent 3.6.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.
- package/lib/generate-skill-allow-list.js +42 -5
- package/lib/skill-allow-list.json +1 -1
- package/package.json +1 -2
- package/project/app/api/lib/resolve-project-root.js +1 -1
- package/project/app/package.json +4 -0
- package/project/app/ui/dist/assets/index-BADc5mmH.css +1 -0
- package/project/app/ui/dist/assets/index-C3oiuli_.js +104 -0
- package/project/app/ui/dist/index.html +2 -2
- package/scripts/acquire-concurrency-slot.sh +34 -4
- package/scripts/apply-j-prefix.sh +1 -1
- package/scripts/delete-bare-skill-dirs.sh +1 -2
- package/scripts/idea_manager.sh +16 -1
- package/scripts/release-concurrency-slot.sh +34 -4
- package/scripts/render-ranked-list.sh +270 -0
- package/scripts/repoint-dead-bare-path-prose.py +81 -0
- package/scripts/rewrite-stale-skill-preambles.py +188 -0
- package/scripts/strip-polyfill-frontmatter.py +166 -0
- package/scripts/todo_manager.sh +16 -1
- package/scripts/validate-typed-object.sh +750 -0
- package/skills/j-brainstorm/SKILL.md +3 -4
- package/skills/j-btw/SKILL.md +3 -4
- package/skills/j-clearify/SKILL.md +3 -4
- package/skills/j-close-story/SKILL.md +11 -12
- package/skills/j-close-story/scripts/check-story-closeable.sh +11 -4
- package/skills/j-commit/SKILL.md +3 -4
- package/skills/j-continue/SKILL.md +5 -6
- package/skills/j-deep-dive/SKILL.md +3 -4
- package/skills/j-distribute/SKILL.md +3 -4
- package/skills/j-do/SKILL.md +13 -15
- package/skills/j-doc/SKILL.md +3 -4
- package/skills/j-doc-sync/SKILL.md +3 -4
- package/skills/j-dooo/SKILL.md +6 -15
- package/skills/j-error/SKILL.md +3 -4
- package/skills/j-evaluate/SKILL.md +3 -4
- package/skills/j-examplify/SKILL.md +3 -4
- package/skills/j-help/SKILL.md +3 -4
- package/skills/j-idea/SKILL.md +3 -4
- package/skills/j-improve/SKILL.md +3 -4
- package/skills/j-init/SKILL.md +20 -11
- package/skills/j-init/scripts/apply-scaffold-visibility.sh +8 -6
- package/skills/j-jbp/SKILL.md +3 -4
- package/skills/j-lgtm/SKILL.md +3 -4
- package/skills/j-pi-plan/SKILL.md +3 -4
- package/skills/j-proceed/SKILL.md +3 -4
- package/skills/j-publish/SKILL.md +3 -4
- package/skills/j-publish/scripts/run_gates.sh +1 -1
- package/skills/j-reconcile/SKILL.md +38 -5
- package/skills/j-reconcile/assets/report_format.md +11 -0
- package/skills/j-reconcile/scripts/detect-unlinked-code.sh +2 -2
- package/skills/j-reconcile-origin/SKILL.md +3 -4
- package/skills/j-redo/SKILL.md +3 -4
- package/skills/j-skillify/SKILL.md +3 -4
- package/skills/j-spinoff/SKILL.md +3 -4
- package/skills/j-status/SKILL.md +4 -5
- package/skills/j-todo/SKILL.md +42 -5
- package/skills/j-todo/scripts/argument-is-not-ranked-list.sh +92 -0
- package/skills/j-todo/scripts/argument-is-ranked-list.sh +78 -0
- package/skills/j-uncharted/SKILL.md +251 -12
- package/skills/j-uncharted/scripts/detect-dependencies.sh +79 -21
- package/skills/j-uncharted/scripts/diff-since-baseline.sh +600 -0
- package/skills/j-uncharted/scripts/find-scan-baseline.sh +545 -0
- package/skills/j-uncharted/scripts/run-engine.sh +36 -2
- package/skills/j-uncharted/scripts/write-scan-record.sh +361 -0
- package/skills/j-wtf/SKILL.md +3 -4
- package/skills/jenga/SKILL.md +69 -8
- package/skills/jenga/playbooks/schema.json +4 -4
- package/skills/jenga/scripts/load-nl-catalog.js +5 -2
- package/skills/jenga/scripts/load-playbooks.sh +289 -4
- package/skills/jenga/scripts/match-playbook.sh +4 -4
- package/skills/jenga/scripts/run-playbook-step.sh +267 -1
- package/templates/permission-levels/level-1-locked.json +1 -1
- package/templates/permission-levels/level-2-guarded.json +1 -1
- package/templates/permission-levels/level-3-standard.json +1 -1
- package/templates/permission-levels/level-4-elevated.json +1 -1
- package/templates/permission-levels/level-5-unrestricted.json +1 -1
- package/templates/playbook-types.json +6 -0
- package/project/app/ui/dist/assets/index-BVR_7Owg.css +0 -1
- package/project/app/ui/dist/assets/index-CtU2xLQm.js +0 -104
- package/scripts/audit-twin-divergence.sh +0 -693
|
@@ -0,0 +1,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
|
-
|
|
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
|
-
|
|
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:
|