@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.
- package/README.md +1 -0
- package/agents/developer.md +18 -0
- package/agents/scrum-master.md +1 -0
- package/agents/tester.md +18 -0
- package/hooks/on_session_end.sh +27 -0
- package/package.json +18 -17
- package/skills/commit/SKILL.md +11 -1
- package/skills/dev-done/SKILL.md +46 -0
- package/skills/dev-done/scripts/classify-commit-outcome.sh +114 -0
- package/skills/init/SKILL.md +7 -6
- package/skills/init/assets/scope-thresholds_template.json +7 -0
- package/skills/init/scripts/init.sh +6 -0
- package/skills/publish/SKILL.md +66 -0
- package/skills/publish/adapters/npm-ci.md +34 -0
- package/skills/publish/adapters/npm.md +18 -0
- package/skills/publish/assets/ci-contract.md +27 -0
- package/skills/publish/assets/publish.example.json +27 -0
- package/skills/publish/schemas/publish.schema.json +20 -0
- package/skills/publish/scripts/npm_ci_pipeline.sh +29 -0
- package/skills/publish/scripts/npm_stage_inspect.sh +829 -0
- package/skills/publish/scripts/npm_stage_pipeline.sh +427 -0
- package/skills/publish/scripts/publish_common.sh +16 -0
- package/skills/publish/scripts/show_history.sh +12 -5
- package/skills/publish/scripts/validate_npm_stage_env.sh +184 -0
- package/skills/publish/scripts/write_ledger_entry.sh +92 -2
- package/skills/reconcile/SKILL.md +121 -11
- package/skills/reconcile/assets/report_format.md +17 -0
- package/skills/reconcile/scripts/resolve-reconcile-scope.sh +489 -0
- package/skills/uncharted/SKILL.md +200 -21
- package/skills/uncharted/scripts/directory-triage.sh +342 -0
- package/skills/uncharted/scripts/elicitation-state.sh +457 -0
- package/templates/SCRUM_BOARD_SCHEMA.md +57 -0
- package/templates/agent-context.md.tpl +23 -11
- package/templates/copilot-instructions.md.tpl +21 -11
- package/mcp/router/README.md +0 -19
- package/mcp/router/embedder.js +0 -23
- package/mcp/router/index.js +0 -204
- package/mcp/router/matcher.js +0 -87
- package/mcp/router/package-lock.json +0 -1048
- package/mcp/router/package.json +0 -11
- package/mcp/router/skill-index.js +0 -104
- 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"
|