@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.
Files changed (91) hide show
  1. package/README.md +7 -3
  2. package/agents/developer.md +82 -2
  3. package/agents/scrum-master.md +140 -21
  4. package/agents/tester.md +90 -8
  5. package/hooks/on_session_end.sh +171 -20
  6. package/package.json +1 -1
  7. package/scripts/check-permission-level.sh +107 -0
  8. package/scripts/check-publicignore-match.sh +122 -0
  9. package/scripts/check-worktree-liveness.sh +193 -0
  10. package/scripts/generate-rapport-manifest.sh +43 -0
  11. package/scripts/idea_manager.sh +47 -0
  12. package/scripts/install-worktree-commit-guard.sh +134 -0
  13. package/scripts/jenga-permission-level-switch.sh +109 -0
  14. package/scripts/smoke-harness.sh +139 -0
  15. package/scripts/validate-board.sh +62 -0
  16. package/scripts/with-lock.sh +158 -0
  17. package/scripts/worktree-remove-guard.sh +204 -0
  18. package/skills/clearify/SKILL.md +52 -0
  19. package/skills/commit/SKILL.md +13 -4
  20. package/skills/distribute/CONFIG_SCHEMA.md +60 -2
  21. package/skills/do/SKILL.md +48 -11
  22. package/skills/doc-sync/SKILL.md +16 -0
  23. package/skills/doc-sync/assets/doc_targets.md +11 -0
  24. package/skills/idea/SKILL.md +56 -0
  25. package/skills/idea/assets/idea_handoff_template.md +26 -0
  26. package/skills/idea/assets/idea_template.md +3 -0
  27. package/skills/init/SKILL.md +100 -7
  28. package/skills/init/assets/directory_structure.txt +1 -0
  29. package/skills/init/assets/workflow_template.json +1 -1
  30. package/skills/init/scripts/apply-project-visibility.sh +176 -0
  31. package/skills/init/scripts/detect-existing-codebase.sh +166 -0
  32. package/skills/init/scripts/init.sh +30 -1
  33. package/skills/jenga/SKILL.md +160 -17
  34. package/skills/jenga/scripts/board-scan.sh +238 -0
  35. package/skills/jenga/scripts/cascade-resolve.sh +297 -0
  36. package/skills/jenga/scripts/render-confirmation.sh +679 -0
  37. package/skills/jenga/scripts/render-picker.sh +439 -0
  38. package/skills/jenga/scripts/resolve-id.sh +367 -0
  39. package/skills/jenga-permission-level/SKILL.md +81 -0
  40. package/skills/proceed/SKILL.md +1 -1
  41. package/skills/publish/SKILL.md +8 -5
  42. package/skills/publish/assets/ci-contract.md +2 -2
  43. package/skills/publish/assets/ownership-matrix.md +1 -1
  44. package/skills/publish/scripts/finalize_changelog.sh +115 -0
  45. package/skills/publish/scripts/generate_release_notes.sh +475 -28
  46. package/skills/publish/scripts/npm_ci_pipeline.sh +44 -6
  47. package/skills/publish/scripts/publish_deploy.sh +38 -8
  48. package/skills/publish/scripts/run_gates.sh +2 -2
  49. package/skills/reconcile/SKILL.md +117 -5
  50. package/skills/reconcile/scripts/detect-unlinked-code.sh +741 -0
  51. package/skills/skillify/assets/init-new/assets/directory_structure.txt +5 -1
  52. package/skills/spinoff/SKILL.md +12 -7
  53. package/skills/todo/SKILL.md +2 -0
  54. package/skills/uncharted/SKILL.md +711 -0
  55. package/skills/uncharted/assets/SEGMENT_PROPOSAL_TEMPLATE.md +129 -0
  56. package/skills/uncharted/assets/UNDERSTANDING_DOC_TEMPLATE.md +160 -0
  57. package/skills/uncharted/scripts/apply-subsystem-cap.sh +573 -0
  58. package/skills/uncharted/scripts/detect-dependencies.sh +732 -0
  59. package/skills/uncharted/scripts/detect-tests.sh +553 -0
  60. package/skills/uncharted/scripts/discover-subsystems.sh +1029 -0
  61. package/skills/uncharted/scripts/enumerate-target.sh +470 -0
  62. package/skills/uncharted/scripts/import-source.sh +517 -0
  63. package/skills/uncharted/scripts/inspect-provenance.sh +573 -0
  64. package/skills/uncharted/scripts/resolve-segment-target.sh +640 -0
  65. package/skills/uncharted/scripts/run-engine.sh +655 -0
  66. package/skills/uncharted/scripts/validate-proposed-items.sh +125 -0
  67. package/skills/uncharted/scripts/write-backfilled-epics.sh +498 -0
  68. package/skills/wtf/SKILL.md +20 -0
  69. package/templates/CHANGELOG_TEMPLATE.md +13 -0
  70. package/templates/PROBLEM_RAPPORT_TEMPLATE.md +4 -1
  71. package/templates/SCRUM_BOARD_SCHEMA.md +157 -10
  72. package/templates/permission-levels/README.md +73 -0
  73. package/templates/permission-levels/level-1-locked.json +71 -0
  74. package/templates/permission-levels/level-2-guarded.json +64 -0
  75. package/templates/permission-levels/level-3-standard.json +62 -0
  76. package/templates/permission-levels/level-4-elevated.json +60 -0
  77. package/templates/permission-levels/level-5-unrestricted.json +58 -0
  78. package/skills/convert/SKILL.md +0 -124
  79. package/skills/convert/convert_cli.py +0 -235
  80. package/skills/convert/tests/sample.csv +0 -4
  81. package/skills/convert/tests/sample.json +0 -5
  82. package/skills/convert/tests/sample.jsonl +0 -3
  83. package/skills/convert/tests/sample.yaml +0 -18
  84. package/skills/convert/tests/sample_obj.csv +0 -2
  85. package/skills/convert/tests/sample_obj.json +0 -9
  86. package/skills/mirror-public/SKILL.md +0 -237
  87. package/skills/mirror-public/assets/config.json +0 -5
  88. package/skills/mirror-public/scripts/mirror.sh +0 -374
  89. package/skills/self-sync/SKILL.md +0 -73
  90. package/skills/self-sync/scripts/run.js +0 -136
  91. 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