@jenga-ai/agent 1.1.0 → 1.2.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 +7 -3
- package/agents/developer.md +82 -2
- package/agents/scrum-master.md +140 -21
- package/agents/tester.md +90 -8
- package/hooks/on_session_end.sh +171 -20
- package/package.json +1 -1
- package/scripts/check-permission-level.sh +107 -0
- package/scripts/check-publicignore-match.sh +122 -0
- package/scripts/check-worktree-liveness.sh +193 -0
- package/scripts/generate-rapport-manifest.sh +43 -0
- package/scripts/idea_manager.sh +47 -0
- package/scripts/install-worktree-commit-guard.sh +134 -0
- package/scripts/jenga-permission-level-switch.sh +109 -0
- package/scripts/smoke-harness.sh +139 -0
- package/scripts/validate-board.sh +62 -0
- package/scripts/with-lock.sh +158 -0
- package/scripts/worktree-remove-guard.sh +204 -0
- package/skills/clearify/SKILL.md +52 -0
- package/skills/commit/SKILL.md +13 -4
- package/skills/distribute/CONFIG_SCHEMA.md +60 -2
- package/skills/do/SKILL.md +48 -11
- package/skills/doc-sync/SKILL.md +16 -0
- package/skills/doc-sync/assets/doc_targets.md +11 -0
- package/skills/idea/SKILL.md +56 -0
- package/skills/idea/assets/idea_handoff_template.md +26 -0
- package/skills/idea/assets/idea_template.md +3 -0
- package/skills/init/SKILL.md +100 -7
- package/skills/init/assets/directory_structure.txt +1 -0
- package/skills/init/assets/workflow_template.json +1 -1
- package/skills/init/scripts/apply-project-visibility.sh +176 -0
- package/skills/init/scripts/detect-existing-codebase.sh +166 -0
- package/skills/init/scripts/init.sh +30 -1
- package/skills/jenga/SKILL.md +160 -17
- package/skills/jenga/scripts/board-scan.sh +238 -0
- package/skills/jenga/scripts/cascade-resolve.sh +297 -0
- package/skills/jenga/scripts/render-confirmation.sh +679 -0
- package/skills/jenga/scripts/render-picker.sh +439 -0
- package/skills/jenga/scripts/resolve-id.sh +367 -0
- package/skills/jenga-permission-level/SKILL.md +81 -0
- package/skills/proceed/SKILL.md +1 -1
- package/skills/publish/SKILL.md +8 -5
- package/skills/publish/assets/ci-contract.md +2 -2
- package/skills/publish/assets/ownership-matrix.md +1 -1
- package/skills/publish/scripts/finalize_changelog.sh +115 -0
- package/skills/publish/scripts/generate_release_notes.sh +475 -28
- package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
- package/skills/publish/scripts/publish_deploy.sh +38 -8
- package/skills/publish/scripts/run_gates.sh +2 -2
- package/skills/reconcile/SKILL.md +117 -5
- package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
- package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
- package/skills/spinoff/SKILL.md +12 -7
- package/skills/todo/SKILL.md +2 -0
- package/skills/uncharted/SKILL.md +711 -0
- package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
- package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
- package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
- package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
- package/skills/uncharted/scripts/detect-tests.sh +553 -0
- package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
- package/skills/uncharted/scripts/enumerate-target.sh +470 -0
- package/skills/uncharted/scripts/import-source.sh +517 -0
- package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
- package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
- package/skills/uncharted/scripts/run-engine.sh +655 -0
- package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
- package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
- package/skills/wtf/SKILL.md +20 -0
- package/templates/CHANGELOG_TEMPLATE.md +13 -0
- package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
- package/templates/SCRUM_BOARD_SCHEMA.md +157 -10
- package/templates/permission-levels/README.md +73 -0
- package/templates/permission-levels/level-1-locked.json +71 -0
- package/templates/permission-levels/level-2-guarded.json +64 -0
- package/templates/permission-levels/level-3-standard.json +62 -0
- package/templates/permission-levels/level-4-elevated.json +60 -0
- package/templates/permission-levels/level-5-unrestricted.json +58 -0
- package/skills/convert/SKILL.md +0 -124
- package/skills/convert/convert_cli.py +0 -235
- package/skills/convert/tests/sample.csv +0 -4
- package/skills/convert/tests/sample.json +0 -5
- package/skills/convert/tests/sample.jsonl +0 -3
- package/skills/convert/tests/sample.yaml +0 -18
- package/skills/convert/tests/sample_obj.csv +0 -2
- package/skills/convert/tests/sample_obj.json +0 -9
- package/skills/mirror-public/SKILL.md +0 -237
- package/skills/mirror-public/assets/config.json +0 -5
- package/skills/mirror-public/scripts/mirror.sh +0 -374
- package/skills/self-sync/SKILL.md +0 -73
- package/skills/self-sync/scripts/run.js +0 -136
- package/skills/strategy/SKILL.md +0 -312
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# skills/jenga/scripts/board-scan.sh
|
|
4
|
+
#
|
|
5
|
+
# Deterministic board inventory for `/jenga`'s interactive scope-selection
|
|
6
|
+
# flow (E45). Walks project/board/epics/, project/board/stories/, and
|
|
7
|
+
# project/board/tasks/ and emits a structured JSON array describing every
|
|
8
|
+
# item on the board.
|
|
9
|
+
#
|
|
10
|
+
# This is the SINGLE SOURCE OF TRUTH for board contents in E45. Per this
|
|
11
|
+
# repo's "Scripts Over Inline Logic" principle, no other script or agent
|
|
12
|
+
# instruction may re-scan the board independently — every consumer below
|
|
13
|
+
# reads this script's stdout instead:
|
|
14
|
+
#
|
|
15
|
+
# - E45_S01_T02 (fuzzy-ID grammar parser) resolves user-typed IDs
|
|
16
|
+
# against the `id`/`type` fields this script emits.
|
|
17
|
+
# - E45_S01_T03 (cascade resolver) expands an epic/story
|
|
18
|
+
# selection to eligible descendants using `epic_id`/`story_id`/`status`.
|
|
19
|
+
# - E45_S02_T01 (interactive picker) renders the numbered
|
|
20
|
+
# hierarchical checklist from this inventory.
|
|
21
|
+
# - E45_S02_T02 (confirmation-tree renderer) renders the nested
|
|
22
|
+
# Epic > Story > Task confirmation tree from this inventory.
|
|
23
|
+
#
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# USAGE
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# skills/jenga/scripts/board-scan.sh
|
|
28
|
+
#
|
|
29
|
+
# No arguments. Reads project/board/ under the resolved project root (see
|
|
30
|
+
# lib/resolve-project-dir.sh — honours CLAUDE_PROJECT_DIR / the git repo
|
|
31
|
+
# root / cwd, in that priority order).
|
|
32
|
+
#
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# OUTPUT SCHEMA (stable — downstream scripts depend on these exact names)
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# stdout is a single JSON array. Nothing else is ever written to stdout.
|
|
37
|
+
# Each element is an object:
|
|
38
|
+
#
|
|
39
|
+
# {
|
|
40
|
+
# "id": "E45_S01_T01", // the item's own board ID
|
|
41
|
+
# "type": "epic" | "story" | "task",
|
|
42
|
+
# "epic_id": "E45", // this item's epic: itself (epic),
|
|
43
|
+
# // its parent (story/task), or
|
|
44
|
+
# // null if the frontmatter omits it
|
|
45
|
+
# "story_id": "E45_S01", // this item's story: itself
|
|
46
|
+
# // (story), its parent (task),
|
|
47
|
+
# // or null for epics / if absent
|
|
48
|
+
# "title": "Board scanner script — structured inventory ...",
|
|
49
|
+
# "status": "Pending", // verbatim value of the
|
|
50
|
+
# // frontmatter `status:` field
|
|
51
|
+
# "summary": "Everything else in E45 reads this script's output ...",
|
|
52
|
+
# "file": "project/board/tasks/E45_S01_T01_board-scanner-script.md"
|
|
53
|
+
# }
|
|
54
|
+
#
|
|
55
|
+
# Field notes:
|
|
56
|
+
# - `epic_id` / `story_id` are ALWAYS PRESENT KEYS, whose value is `null`
|
|
57
|
+
# when not applicable/absent — never an omitted key. Consumers should
|
|
58
|
+
# use a null-safe read (e.g. `.epic_id // empty` in jq), not assume the
|
|
59
|
+
# key exists with a non-null value.
|
|
60
|
+
# - `summary` is a single-line, best-effort excerpt:
|
|
61
|
+
# * epic -> first non-empty, non-heading line after "## Purpose"
|
|
62
|
+
# * story -> first non-empty line after the "# Story: <Title>"
|
|
63
|
+
# heading and before the next "##" heading (the
|
|
64
|
+
# "As a ... I want ... so that ..." statement)
|
|
65
|
+
# * task -> first non-empty, non-heading line after "## Description"
|
|
66
|
+
# `summary` is "" (empty string, never absent) when no matching section
|
|
67
|
+
# is found.
|
|
68
|
+
# - `file` is repo-relative (relative to the resolved project root), using
|
|
69
|
+
# forward slashes, suitable for display or re-opening the source file.
|
|
70
|
+
# - Array order: epics first, then stories, then tasks; within each type,
|
|
71
|
+
# files are sorted lexically by filename (stable across runs).
|
|
72
|
+
#
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# ERROR HANDLING
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# A single board file that fails to parse (unreadable, or missing the `id`
|
|
77
|
+
# frontmatter key) is SKIPPED and reported as a warning on stderr — it does
|
|
78
|
+
# NOT abort the scan or corrupt stdout. This is a deliberate design choice:
|
|
79
|
+
# the picker and confirmation renderer need a scan that degrades gracefully
|
|
80
|
+
# on one malformed file rather than producing no output for the whole board.
|
|
81
|
+
#
|
|
82
|
+
# Exit codes:
|
|
83
|
+
# 0 scan completed (stdout is always valid JSON on this path, even if
|
|
84
|
+
# some files were skipped with stderr warnings, and even if the
|
|
85
|
+
# result is an empty array because no board files exist yet)
|
|
86
|
+
# 1 the board root (project/board/) does not exist at all, or python3
|
|
87
|
+
# is not available — both are real setup problems, not per-file noise
|
|
88
|
+
#
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
set -euo pipefail
|
|
92
|
+
|
|
93
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
94
|
+
|
|
95
|
+
# Resolve JENGA_PROJECT_DIR the same way every other script in this repo
|
|
96
|
+
# does (CLAUDE_PROJECT_DIR -> git toplevel -> cwd). Falls back to locating
|
|
97
|
+
# the repo root relative to this script if the shared helper is missing.
|
|
98
|
+
if [ -f "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh" ]; then
|
|
99
|
+
# shellcheck source=lib/resolve-project-dir.sh
|
|
100
|
+
source "$SCRIPT_DIR/../../../lib/resolve-project-dir.sh"
|
|
101
|
+
elif [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
|
102
|
+
JENGA_PROJECT_DIR="$CLAUDE_PROJECT_DIR"
|
|
103
|
+
else
|
|
104
|
+
JENGA_PROJECT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
BOARD_DIR="$JENGA_PROJECT_DIR/project/board"
|
|
108
|
+
|
|
109
|
+
if [ ! -d "$BOARD_DIR" ]; then
|
|
110
|
+
echo "Error: board directory not found at $BOARD_DIR" >&2
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
115
|
+
echo "Error: python3 is required by board-scan.sh" >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
python3 - "$JENGA_PROJECT_DIR" "$BOARD_DIR" <<'PY'
|
|
120
|
+
import json
|
|
121
|
+
import re
|
|
122
|
+
import sys
|
|
123
|
+
from pathlib import Path
|
|
124
|
+
|
|
125
|
+
project_root = Path(sys.argv[1])
|
|
126
|
+
board_dir = Path(sys.argv[2])
|
|
127
|
+
|
|
128
|
+
FRONTMATTER_RE = re.compile(r'^---\n(.*?)\n---\n?(.*)$', re.DOTALL)
|
|
129
|
+
TOP_LEVEL_KV_RE = re.compile(r'^([A-Za-z_][A-Za-z0-9_]*):[ \t]*(.*)$')
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_frontmatter(text):
|
|
133
|
+
"""Return (fields_dict, body_text). Only top-level `key: value` lines
|
|
134
|
+
are captured (indented list items such as ` - E01_S02` are skipped
|
|
135
|
+
intentionally — this scanner reads each board file directly and does
|
|
136
|
+
not need to follow parent->children ID lists)."""
|
|
137
|
+
m = FRONTMATTER_RE.match(text)
|
|
138
|
+
if not m:
|
|
139
|
+
return {}, text
|
|
140
|
+
fm_text, body = m.group(1), m.group(2)
|
|
141
|
+
fields = {}
|
|
142
|
+
for line in fm_text.splitlines():
|
|
143
|
+
if not line or line[0] in (' ', '\t', '#'):
|
|
144
|
+
continue
|
|
145
|
+
km = TOP_LEVEL_KV_RE.match(line)
|
|
146
|
+
if not km:
|
|
147
|
+
continue
|
|
148
|
+
key, val = km.group(1), km.group(2).strip()
|
|
149
|
+
val = val.strip('"').strip("'")
|
|
150
|
+
fields[key] = val
|
|
151
|
+
return fields, body
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def first_line_after_heading(body, heading_re):
|
|
155
|
+
"""First non-empty line strictly after a line matching heading_re, up
|
|
156
|
+
to (not including) the next '#'-prefixed heading line."""
|
|
157
|
+
lines = body.splitlines()
|
|
158
|
+
for i, line in enumerate(lines):
|
|
159
|
+
if heading_re.match(line.strip()):
|
|
160
|
+
for candidate in lines[i + 1:]:
|
|
161
|
+
stripped = candidate.strip()
|
|
162
|
+
if not stripped:
|
|
163
|
+
continue
|
|
164
|
+
if stripped.startswith('#'):
|
|
165
|
+
return ""
|
|
166
|
+
return stripped
|
|
167
|
+
return ""
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
PURPOSE_HEADING_RE = re.compile(r'^##\s+Purpose\s*$', re.IGNORECASE)
|
|
172
|
+
DESCRIPTION_HEADING_RE = re.compile(r'^##\s+Description\s*$', re.IGNORECASE)
|
|
173
|
+
TITLE_HEADING_RE = re.compile(r'^#\s+Story:.*$', re.IGNORECASE)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def summarize(item_type, body):
|
|
177
|
+
if item_type == "epic":
|
|
178
|
+
return first_line_after_heading(body, PURPOSE_HEADING_RE)
|
|
179
|
+
if item_type == "task":
|
|
180
|
+
return first_line_after_heading(body, DESCRIPTION_HEADING_RE)
|
|
181
|
+
# story: first non-empty line after the "# Story: <Title>" heading and
|
|
182
|
+
# before the next "##" heading.
|
|
183
|
+
return first_line_after_heading(body, TITLE_HEADING_RE)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
TYPE_DIRS = [
|
|
187
|
+
("epic", board_dir / "epics"),
|
|
188
|
+
("story", board_dir / "stories"),
|
|
189
|
+
("task", board_dir / "tasks"),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
items = []
|
|
193
|
+
|
|
194
|
+
for item_type, dir_path in TYPE_DIRS:
|
|
195
|
+
if not dir_path.is_dir():
|
|
196
|
+
continue
|
|
197
|
+
for f in sorted(dir_path.glob("*.md")):
|
|
198
|
+
try:
|
|
199
|
+
text = f.read_text(encoding="utf-8")
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"Warning: skipping {f}: read error: {e}", file=sys.stderr)
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
fields, body = parse_frontmatter(text)
|
|
205
|
+
item_id = fields.get("id", "")
|
|
206
|
+
if not item_id:
|
|
207
|
+
print(f"Warning: skipping {f}: missing 'id' in frontmatter", file=sys.stderr)
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
if item_type == "epic":
|
|
211
|
+
epic_id = item_id
|
|
212
|
+
story_id = None
|
|
213
|
+
elif item_type == "story":
|
|
214
|
+
epic_id = fields.get("epic_id") or None
|
|
215
|
+
story_id = item_id
|
|
216
|
+
else:
|
|
217
|
+
epic_id = fields.get("epic_id") or None
|
|
218
|
+
story_id = fields.get("story_id") or None
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
rel_file = f.relative_to(project_root).as_posix()
|
|
222
|
+
except ValueError:
|
|
223
|
+
rel_file = f.as_posix()
|
|
224
|
+
|
|
225
|
+
items.append({
|
|
226
|
+
"id": item_id,
|
|
227
|
+
"type": item_type,
|
|
228
|
+
"epic_id": epic_id,
|
|
229
|
+
"story_id": story_id,
|
|
230
|
+
"title": fields.get("title", ""),
|
|
231
|
+
"status": fields.get("status", ""),
|
|
232
|
+
"summary": summarize(item_type, body),
|
|
233
|
+
"file": rel_file,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
json.dump(items, sys.stdout, indent=2)
|
|
237
|
+
sys.stdout.write("\n")
|
|
238
|
+
PY
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ---------------------------------------------------------------------------
|
|
3
|
+
# skills/jenga/scripts/cascade-resolve.sh
|
|
4
|
+
#
|
|
5
|
+
# Cascade resolver for `/jenga`'s interactive scope-selection flow (E45).
|
|
6
|
+
# Given a resolved board-ID set (exact `E##` / `E##_S##` / `E##_S##_T##`
|
|
7
|
+
# IDs — already resolved by the picker in E45_S02_T01 or by
|
|
8
|
+
# `resolve-id.sh` in E45_S01_T02; this script does NOT do fuzzy-ID
|
|
9
|
+
# parsing), expands any epic or story selection into its full set of
|
|
10
|
+
# eligible descendants:
|
|
11
|
+
#
|
|
12
|
+
# - An epic selection expands to all of its stories and their tasks.
|
|
13
|
+
# - A story selection expands to all of its tasks.
|
|
14
|
+
# - Expansion only includes descendants in a Pending/eligible state —
|
|
15
|
+
# terminal-status descendants (Passed, Passed with remarks, Failed,
|
|
16
|
+
# Rejected, Blocked, Done) are excluded.
|
|
17
|
+
# - A descendant epic/story that has not yet been broken down on the
|
|
18
|
+
# board (zero stories under an epic, zero tasks under a story) is
|
|
19
|
+
# flagged under `undecomposed` so jenga's existing Phase 1/2
|
|
20
|
+
# decomposition logic can pick it up. This script never decomposes
|
|
21
|
+
# anything itself — it only flags the need.
|
|
22
|
+
#
|
|
23
|
+
# Per this repo's "Scripts Over Inline Logic" principle and the sibling
|
|
24
|
+
# board-scan.sh's own header contract, this script consumes
|
|
25
|
+
# board-scan.sh's JSON output as the SINGLE SOURCE OF TRUTH for board
|
|
26
|
+
# contents — it does not re-scan project/board/ independently.
|
|
27
|
+
#
|
|
28
|
+
# Downstream consumer: E45_S02_T02 (confirmation-tree renderer) reads
|
|
29
|
+
# this script's output to render the nested Epic > Story > Task
|
|
30
|
+
# confirmation tree with per-level counts.
|
|
31
|
+
#
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# USAGE
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# skills/jenga/scripts/cascade-resolve.sh "<comma-separated-ids>"
|
|
36
|
+
#
|
|
37
|
+
# One argument: a comma-separated list of exact, already-resolved board
|
|
38
|
+
# IDs (e.g. "E01,E02_S03,E04_S05_T06"). Whitespace around commas/ids is
|
|
39
|
+
# trimmed. IDs are matched case-sensitively against the board (board IDs
|
|
40
|
+
# are always uppercase per templates/SCRUM_BOARD_SCHEMA.md).
|
|
41
|
+
#
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# OUTPUT SCHEMA (stable — downstream scripts depend on these exact names)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# stdout is a single JSON object. Nothing else is ever written to stdout.
|
|
46
|
+
#
|
|
47
|
+
# {
|
|
48
|
+
# "items": [ <board-scan.sh item objects — see that script's header
|
|
49
|
+
# for the per-item schema — for every epic/story/task
|
|
50
|
+
# node in the resolved tree, deduplicated> ],
|
|
51
|
+
# "resolved_ids": ["E01", "E01_S02", "E01_S02_T01"],
|
|
52
|
+
# "counts": {"epics": 1, "stories": 1, "tasks": 1},
|
|
53
|
+
# "undecomposed": [
|
|
54
|
+
# {"id": "E02", "type": "epic", "missing": "stories"},
|
|
55
|
+
# {"id": "E02_S01", "type": "story", "missing": "tasks"}
|
|
56
|
+
# ],
|
|
57
|
+
# "warnings": [
|
|
58
|
+
# {"input": "E99", "reason": "not found on board"},
|
|
59
|
+
# {"input": "E01_S02_T09", "reason": "excluded: terminal status (Passed)"},
|
|
60
|
+
# {"input": "foo", "reason": "malformed ID (does not match E##, E##_S##, or E##_S##_T## shape)"}
|
|
61
|
+
# ]
|
|
62
|
+
# }
|
|
63
|
+
#
|
|
64
|
+
# Field notes:
|
|
65
|
+
# - `items` includes every node actually present in the resolved tree —
|
|
66
|
+
# epics/stories that were directly selected or expanded into, plus
|
|
67
|
+
# eligible (non-terminal) descendant stories/tasks. It is NOT limited
|
|
68
|
+
# to leaf tasks, because the confirmation-tree renderer needs the
|
|
69
|
+
# full Epic > Story > Task shape, not just an execution list.
|
|
70
|
+
# - `resolved_ids` is the same set as `items`, as a flat list of IDs
|
|
71
|
+
# (convenience for callers that only need IDs).
|
|
72
|
+
# - `counts` reflects unique nodes of each type present in `items` —
|
|
73
|
+
# i.e. what the confirmation tree will actually render — not a count
|
|
74
|
+
# of input arguments.
|
|
75
|
+
# - `undecomposed` entries mean "this epic/story has zero children on
|
|
76
|
+
# the board at all", distinct from "this epic/story has children but
|
|
77
|
+
# none are eligible" (which is not flagged — there is nothing to
|
|
78
|
+
# decompose, the work is simply already done or blocked).
|
|
79
|
+
# - `warnings` covers three non-fatal per-input problems: an ID not
|
|
80
|
+
# found on the board, a directly-selected task/story/epic excluded
|
|
81
|
+
# for being in a terminal status, and a malformed ID shape. None of
|
|
82
|
+
# these abort the run — valid inputs are still resolved and stdout is
|
|
83
|
+
# still a complete, valid JSON object.
|
|
84
|
+
#
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
# ERROR HANDLING
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Exit codes:
|
|
89
|
+
# 0 resolution completed (stdout is always valid JSON on this path,
|
|
90
|
+
# even if some inputs produced warnings, and even if the result is
|
|
91
|
+
# an empty items/resolved_ids set because every input was invalid)
|
|
92
|
+
# 1 a structural failure: no argument given, board-scan.sh itself
|
|
93
|
+
# failed or produced unparseable output, or python3 is unavailable
|
|
94
|
+
# — these are real setup problems, not per-input noise
|
|
95
|
+
#
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
set -euo pipefail
|
|
99
|
+
|
|
100
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
101
|
+
|
|
102
|
+
if [ $# -lt 1 ] || [ -z "${1// /}" ]; then
|
|
103
|
+
echo "Usage: cascade-resolve.sh \"<comma-separated-ids>\"" >&2
|
|
104
|
+
echo "Example: cascade-resolve.sh \"E01,E02_S03,E04_S05_T06\"" >&2
|
|
105
|
+
exit 1
|
|
106
|
+
fi
|
|
107
|
+
|
|
108
|
+
RAW_IDS="$1"
|
|
109
|
+
|
|
110
|
+
# No project-dir resolution needed here: board-scan.sh (invoked below)
|
|
111
|
+
# already resolves JENGA_PROJECT_DIR itself (CLAUDE_PROJECT_DIR -> git
|
|
112
|
+
# toplevel -> cwd) and is the sole source of board contents for this
|
|
113
|
+
# script, per the "Scripts Over Inline Logic" / single-source-of-truth
|
|
114
|
+
# contract documented in its own header.
|
|
115
|
+
BOARD_SCAN="$SCRIPT_DIR/board-scan.sh"
|
|
116
|
+
|
|
117
|
+
if [ ! -x "$BOARD_SCAN" ]; then
|
|
118
|
+
echo "Error: board-scan.sh not found or not executable at $BOARD_SCAN" >&2
|
|
119
|
+
exit 1
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
123
|
+
echo "Error: python3 is required by cascade-resolve.sh" >&2
|
|
124
|
+
exit 1
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
BOARD_JSON_FILE="$(mktemp)"
|
|
128
|
+
trap 'rm -f "$BOARD_JSON_FILE"' EXIT
|
|
129
|
+
|
|
130
|
+
if ! "$BOARD_SCAN" > "$BOARD_JSON_FILE"; then
|
|
131
|
+
echo "Error: board-scan.sh failed" >&2
|
|
132
|
+
exit 1
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
python3 - "$RAW_IDS" "$BOARD_JSON_FILE" <<'PY'
|
|
136
|
+
import json
|
|
137
|
+
import re
|
|
138
|
+
import sys
|
|
139
|
+
|
|
140
|
+
raw_ids_arg = sys.argv[1]
|
|
141
|
+
board_json_file = sys.argv[2]
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
with open(board_json_file, encoding="utf-8") as f:
|
|
145
|
+
board_items = json.load(f)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print(f"Error: could not parse board-scan.sh output as JSON: {e}", file=sys.stderr)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
|
|
150
|
+
# Terminal statuses, verbatim from templates/SCRUM_BOARD_SCHEMA.md's
|
|
151
|
+
# Status Values table. An item in one of these statuses is never
|
|
152
|
+
# included as an expanded/eligible descendant.
|
|
153
|
+
TERMINAL_STATUSES = {
|
|
154
|
+
"Passed",
|
|
155
|
+
"Passed with remarks",
|
|
156
|
+
"Failed",
|
|
157
|
+
"Rejected",
|
|
158
|
+
"Blocked",
|
|
159
|
+
"Done",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
EPIC_RE = re.compile(r'^E\d{2}$')
|
|
163
|
+
STORY_RE = re.compile(r'^E\d{2}_S\d{2}$')
|
|
164
|
+
TASK_RE = re.compile(r'^E\d{2}_S\d{2}_T\d{2}$')
|
|
165
|
+
|
|
166
|
+
by_id = {}
|
|
167
|
+
stories_by_epic = {}
|
|
168
|
+
tasks_by_story = {}
|
|
169
|
+
|
|
170
|
+
for item in board_items:
|
|
171
|
+
item_id = item.get("id", "")
|
|
172
|
+
if not item_id:
|
|
173
|
+
continue
|
|
174
|
+
by_id[item_id] = item
|
|
175
|
+
if item.get("type") == "story":
|
|
176
|
+
epic_id = item.get("epic_id")
|
|
177
|
+
if epic_id:
|
|
178
|
+
stories_by_epic.setdefault(epic_id, []).append(item)
|
|
179
|
+
elif item.get("type") == "task":
|
|
180
|
+
story_id = item.get("story_id")
|
|
181
|
+
if story_id:
|
|
182
|
+
tasks_by_story.setdefault(story_id, []).append(item)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def classify(item_id):
|
|
186
|
+
if TASK_RE.match(item_id):
|
|
187
|
+
return "task"
|
|
188
|
+
if STORY_RE.match(item_id):
|
|
189
|
+
return "story"
|
|
190
|
+
if EPIC_RE.match(item_id):
|
|
191
|
+
return "epic"
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
resolved = {} # id -> item, dedup-preserving insertion order
|
|
196
|
+
resolved_order = []
|
|
197
|
+
undecomposed = []
|
|
198
|
+
undecomposed_seen = set()
|
|
199
|
+
warnings = []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def add_item(item):
|
|
203
|
+
item_id = item["id"]
|
|
204
|
+
if item_id not in resolved:
|
|
205
|
+
resolved[item_id] = item
|
|
206
|
+
resolved_order.append(item_id)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def flag_undecomposed(entry_id, entry_type, missing):
|
|
210
|
+
key = (entry_id, missing)
|
|
211
|
+
if key in undecomposed_seen:
|
|
212
|
+
return
|
|
213
|
+
undecomposed_seen.add(key)
|
|
214
|
+
undecomposed.append({"id": entry_id, "type": entry_type, "missing": missing})
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def expand_story(story_item, include_story_node=True):
|
|
218
|
+
"""Add an eligible story node and its eligible (non-terminal) tasks.
|
|
219
|
+
Flags the story as undecomposed if it has zero tasks at all."""
|
|
220
|
+
if include_story_node:
|
|
221
|
+
add_item(story_item)
|
|
222
|
+
story_id = story_item["id"]
|
|
223
|
+
tasks = tasks_by_story.get(story_id, [])
|
|
224
|
+
if not tasks:
|
|
225
|
+
flag_undecomposed(story_id, "story", "tasks")
|
|
226
|
+
return
|
|
227
|
+
for task_item in tasks:
|
|
228
|
+
if task_item.get("status") in TERMINAL_STATUSES:
|
|
229
|
+
continue
|
|
230
|
+
add_item(task_item)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
raw_inputs = [tok.strip() for tok in raw_ids_arg.split(",")]
|
|
234
|
+
raw_inputs = [tok for tok in raw_inputs if tok]
|
|
235
|
+
|
|
236
|
+
for input_id in raw_inputs:
|
|
237
|
+
item_type = classify(input_id)
|
|
238
|
+
if item_type is None:
|
|
239
|
+
warnings.append({
|
|
240
|
+
"input": input_id,
|
|
241
|
+
"reason": "malformed ID (does not match E##, E##_S##, or E##_S##_T## shape)",
|
|
242
|
+
})
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
item = by_id.get(input_id)
|
|
246
|
+
if item is None:
|
|
247
|
+
warnings.append({"input": input_id, "reason": "not found on board"})
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
if item_type == "epic":
|
|
251
|
+
# Always include the epic node itself for tree display, regardless
|
|
252
|
+
# of the epic's own status — cascading is about its descendants.
|
|
253
|
+
add_item(item)
|
|
254
|
+
stories = stories_by_epic.get(input_id, [])
|
|
255
|
+
if not stories:
|
|
256
|
+
flag_undecomposed(input_id, "epic", "stories")
|
|
257
|
+
continue
|
|
258
|
+
for story_item in stories:
|
|
259
|
+
if story_item.get("status") in TERMINAL_STATUSES:
|
|
260
|
+
continue
|
|
261
|
+
expand_story(story_item, include_story_node=True)
|
|
262
|
+
|
|
263
|
+
elif item_type == "story":
|
|
264
|
+
add_item(item)
|
|
265
|
+
expand_story(item, include_story_node=False)
|
|
266
|
+
|
|
267
|
+
else: # task
|
|
268
|
+
if item.get("status") in TERMINAL_STATUSES:
|
|
269
|
+
warnings.append({
|
|
270
|
+
"input": input_id,
|
|
271
|
+
"reason": f"excluded: terminal status ({item.get('status')})",
|
|
272
|
+
})
|
|
273
|
+
continue
|
|
274
|
+
add_item(item)
|
|
275
|
+
|
|
276
|
+
items_out = [resolved[i] for i in resolved_order]
|
|
277
|
+
counts = {"epics": 0, "stories": 0, "tasks": 0}
|
|
278
|
+
for it in items_out:
|
|
279
|
+
t = it.get("type")
|
|
280
|
+
if t == "epic":
|
|
281
|
+
counts["epics"] += 1
|
|
282
|
+
elif t == "story":
|
|
283
|
+
counts["stories"] += 1
|
|
284
|
+
elif t == "task":
|
|
285
|
+
counts["tasks"] += 1
|
|
286
|
+
|
|
287
|
+
output = {
|
|
288
|
+
"items": items_out,
|
|
289
|
+
"resolved_ids": resolved_order,
|
|
290
|
+
"counts": counts,
|
|
291
|
+
"undecomposed": undecomposed,
|
|
292
|
+
"warnings": warnings,
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
json.dump(output, sys.stdout, indent=2)
|
|
296
|
+
sys.stdout.write("\n")
|
|
297
|
+
PY
|