@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,679 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/render-confirmation.sh
4
+ #
5
+ # Turn-by-turn checkbox-tree confirmation renderer for `/jenga`'s
6
+ # interactive scope-selection flow (E45_S02_T02). Renders the full nested
7
+ # Epic > Story > Task resolution tree (with per-level counts) produced by
8
+ # `skills/jenga/scripts/cascade-resolve.sh` (E45_S01_T03) as a
9
+ # checkbox-style confirmation screen, and lets the user toggle individual
10
+ # items off/on before committing to a final execution set.
11
+ #
12
+ # Used by BOTH interactive entry modes that require confirmation:
13
+ # - Bare `/jenga` -> after render-picker.sh (E45_S02_T01) resolves a
14
+ # numbered selection to board IDs.
15
+ # - `/jenga <ids>` -> after resolve-id.sh (E45_S01_T02) resolves the
16
+ # fuzzy-ID grammar to board IDs, bypassing the
17
+ # picker but NOT this confirmation step.
18
+ # Only `/jenga *` (the literal wildcard) skips this script entirely — that
19
+ # branch is wired in E45_S03_T01, not here.
20
+ #
21
+ # ---------------------------------------------------------------------------
22
+ # WHY THIS IS A TWO-INVOCATION, STATE-FILE SCRIPT (READ BEFORE CHANGING)
23
+ # ---------------------------------------------------------------------------
24
+ # Identical constraint to render-picker.sh: Claude Code's Bash tool runs a
25
+ # command to completion and returns — it cannot block mid-script waiting on
26
+ # the next chat message. So this confirmation screen is split across
27
+ # (at least) two invocations of this same script, coordinated by the
28
+ # calling agent, never by this script itself:
29
+ #
30
+ # 1. Turn 1 (start mode): this script resolves the given board IDs via
31
+ # cascade-resolve.sh, builds a numbered checkbox tree, writes that
32
+ # snapshot (plus live checked/unchecked state) to a temp "state file",
33
+ # and prints a human-readable tree + instructions to STDOUT.
34
+ # 2. The calling agent relays this script's literal STDOUT to the user
35
+ # as a chat message, verbatim — no summarizing, no reformatting.
36
+ # 3. The user replies in chat with a command: a toggle ("check 3,5",
37
+ # "uncheck 2", or bare numbers), "confirm", or "cancel".
38
+ # 4. The calling agent re-invokes this script, passing the state file
39
+ # path (captured from the prior turn's STDERR — see OUTPUT CONTRACT
40
+ # below) and the user's raw reply as two arguments (continue mode).
41
+ # 5. This script updates the checked/unchecked state and either:
42
+ # - emits an updated tree for another turn (state file kept), or
43
+ # - emits a plain-text error turn for a bad command/number (state
44
+ # file kept, unchanged, ready for another continue-mode retry), or
45
+ # - emits the final resolved, user-approved execution set as JSON
46
+ # (state file removed) for `/jenga`'s Phase 1/2/3 logic to consume.
47
+ #
48
+ # Do NOT attempt to "fix" this with a blocking read, a stdin prompt loop,
49
+ # or a polling/sleep loop waiting for external input — none of those work
50
+ # with the Bash tool's run-to-completion model, and ad-hoc polling loops as
51
+ # an inter-turn signaling mechanism are explicitly prohibited elsewhere in
52
+ # this repo's agent-coordination conventions (see epic E37).
53
+ #
54
+ # ---------------------------------------------------------------------------
55
+ # USAGE
56
+ # ---------------------------------------------------------------------------
57
+ # skills/jenga/scripts/render-confirmation.sh "<comma-separated-ids>"
58
+ # Start a new confirmation session. The argument is a comma-separated
59
+ # list of exact, already-resolved board IDs — the same input
60
+ # cascade-resolve.sh itself takes (e.g. from render-picker.sh's
61
+ # `resolved_ids_csv` field, or from collecting `resolved_id` values
62
+ # out of resolve-id.sh's per-segment output). This script invokes
63
+ # cascade-resolve.sh itself with this argument — mirroring how
64
+ # render-picker.sh invokes board-scan.sh itself, and how
65
+ # cascade-resolve.sh / resolve-id.sh invoke board-scan.sh itself.
66
+ # Never pass pre-computed cascade-resolve.sh JSON in from outside;
67
+ # that would break the single-source-of-truth invocation chain
68
+ # (board-scan -> cascade-resolve -> render-confirmation) every
69
+ # sibling script in this family relies on.
70
+ #
71
+ # skills/jenga/scripts/render-confirmation.sh <state_file> "<raw_reply>"
72
+ # Continue an existing confirmation session. <state_file> is the
73
+ # path printed on STDERR by the start-mode invocation (or by any
74
+ # prior continue-mode invocation — the path is stable across turns
75
+ # until the session ends). <raw_reply> is the user's raw chat text
76
+ # for this turn, passed as a single argument (quote it).
77
+ #
78
+ # ---------------------------------------------------------------------------
79
+ # TOGGLE COMMAND GRAMMAR
80
+ # ---------------------------------------------------------------------------
81
+ # A reply (case-insensitive, whitespace-insensitive around tokens) is one
82
+ # of:
83
+ #
84
+ # cancel Abort the session. State file removed.
85
+ # confirm Finalize with the CURRENT checked state.
86
+ # check <numbers> Set the listed displayed number(s) to checked.
87
+ # uncheck <numbers> Set the listed displayed number(s) to unchecked.
88
+ # <numbers> Bare numbers (no "check"/"uncheck" prefix)
89
+ # TOGGLE the current checked state of each listed
90
+ # displayed number.
91
+ #
92
+ # <numbers> is a comma- and/or whitespace-separated list of positive
93
+ # integers referring to the numbers printed in THIS session's tree (e.g.
94
+ # "3,5" or "3, 5" or "check 3 5"). Validation is ALL-OR-NOTHING per turn,
95
+ # same philosophy as render-picker.sh: if a reply contains any non-numeric
96
+ # token or any number outside the valid 1..N range for this session, the
97
+ # ENTIRE turn is rejected as an error turn (state left untouched) — there
98
+ # is no partial application of the valid numbers in a mixed-validity reply.
99
+ #
100
+ # Toggling is NOT cascading: unchecking a parent epic/story does not
101
+ # automatically uncheck its children, and vice versa. Each displayed
102
+ # number is an independent checkbox. This is a deliberate simplicity
103
+ # choice — the task only requires "uncheck or re-check individual
104
+ # presented items", not cascading selection logic (that expansion/cascade
105
+ # concern already belongs to cascade-resolve.sh, per its own header).
106
+ #
107
+ # ---------------------------------------------------------------------------
108
+ # OUTPUT CONTRACT (deliberately NOT uniform JSON — see rationale below)
109
+ # ---------------------------------------------------------------------------
110
+ # Same split-by-audience contract as render-picker.sh:
111
+ #
112
+ # - Start mode, and continue-mode TOGGLE / ERROR turns:
113
+ # STDOUT = plain human-readable text (the tree + instructions, or an
114
+ # error message + retry instructions). This is exactly what
115
+ # the calling agent relays verbatim to the user.
116
+ # STDERR = a single line: `STATE_FILE: <absolute path>` — metadata
117
+ # for the calling AGENT to capture for the next invocation.
118
+ # Never intended for the human to read; do not relay it.
119
+ #
120
+ # - Continue mode, CONFIRMED (final) turn:
121
+ # STDOUT = a single JSON object (schema below) — the machine-readable
122
+ # handoff to `/jenga`'s Phase 1/2/3 logic. This is NOT meant
123
+ # to be relayed to the user verbatim.
124
+ # STDERR = empty (state file has been removed; nothing to carry
125
+ # forward).
126
+ #
127
+ # - Continue mode, CANCELLED (`cancel` reply, case-insensitive):
128
+ # STDOUT = plain human-readable cancellation acknowledgement.
129
+ # STDERR = empty (state file has been removed).
130
+ #
131
+ # CONFIRMED JSON schema — deliberately identical field names to
132
+ # cascade-resolve.sh's own output schema (see that script's header), so
133
+ # `/jenga`'s Phase 1/2/3 logic can consume this as a drop-in, scoped-down
134
+ # version of a cascade-resolve.sh result rather than learning a second
135
+ # shape:
136
+ #
137
+ # {
138
+ # "status": "confirmed",
139
+ # "items": [ <board-scan.sh item objects, checked-only,
140
+ # in tree DFS order> ],
141
+ # "resolved_ids": ["E01", "E01_S02", "E01_S02_T01"],
142
+ # "resolved_ids_csv": "E01,E01_S02,E01_S02_T01",
143
+ # "counts": {"epics": 1, "stories": 1, "tasks": 1},
144
+ # "undecomposed": [ <cascade-resolve.sh undecomposed entries,
145
+ # filtered to ids still checked> ],
146
+ # "warnings": [ <cascade-resolve.sh warnings, passthrough,
147
+ # unaffected by checkbox state> ]
148
+ # }
149
+ #
150
+ # ---------------------------------------------------------------------------
151
+ # TREE STRUCTURE (recomputed from `items`, NOT trusted from `items` order)
152
+ # ---------------------------------------------------------------------------
153
+ # Stories are nested under their epic when that epic is ALSO present in the
154
+ # resolved `items` set (epic_id present among resolved epics); tasks are
155
+ # nested under their story when that story is likewise present. This can
156
+ # and does diverge from a literal walk of cascade-resolve.sh's own
157
+ # `resolved_ids` array order — e.g. selecting an individual task directly
158
+ # (without its epic or story) never adds that task's story to `items`
159
+ # (see cascade-resolve.sh's task-input branch), so the task renders as a
160
+ # top-level "Directly Selected Task" here, not nested under a story stub
161
+ # that doesn't exist. This mirrors render-picker.sh's own handling of
162
+ # unlinked stories/tasks.
163
+ #
164
+ # ---------------------------------------------------------------------------
165
+ # STATE FILE
166
+ # ---------------------------------------------------------------------------
167
+ # Created under `mktemp -t jenga-confirm-XXXXXX.json`. Stores:
168
+ # - `numbering`: displayed number -> {item, level, checked} — the ONLY
169
+ # field that mutates across turns is `checked`.
170
+ # - `layout`: the fixed print order (item lines + header markers),
171
+ # computed once at start. Continue mode never recomputes tree shape —
172
+ # it only re-renders from `layout` + the live `checked` flags in
173
+ # `numbering`, and only ever mutates `checked` values.
174
+ # - `undecomposed` / `warnings`: passthrough from cascade-resolve.sh,
175
+ # used only at confirm-time filtering / informational display.
176
+ #
177
+ # If the state file is missing or unreadable when continue mode is invoked
178
+ # (e.g. the OS temp dir was cleared), this is treated as a real setup
179
+ # problem (exit 2), not a per-input validation failure — the user has to
180
+ # restart the confirmation (start mode) from scratch.
181
+ #
182
+ # ---------------------------------------------------------------------------
183
+ # EXIT CODES
184
+ # ---------------------------------------------------------------------------
185
+ # 0 start mode rendered successfully; OR continue mode applied a
186
+ # toggle/check/uncheck command and re-rendered the tree; OR continue
187
+ # mode fully confirmed (final JSON emitted)
188
+ # 1 continue mode: at least one invalid/out-of-range number, or no
189
+ # numbers found, in the reply — an error turn was emitted, retry
190
+ # with the same state file
191
+ # 2 usage error, or a real setup problem (cascade-resolve.sh / python3
192
+ # unavailable, state file missing/corrupt)
193
+ # 3 continue mode: user replied "cancel" — session aborted, state file
194
+ # removed
195
+ #
196
+ # ---------------------------------------------------------------------------
197
+
198
+ set -euo pipefail
199
+
200
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
201
+ CASCADE_RESOLVE="$SCRIPT_DIR/cascade-resolve.sh"
202
+
203
+ if ! command -v python3 >/dev/null 2>&1; then
204
+ echo "Error: python3 is required by render-confirmation.sh" >&2
205
+ exit 2
206
+ fi
207
+
208
+ if [ $# -eq 1 ]; then
209
+ MODE="start"
210
+ RAW_IDS="$1"
211
+ elif [ $# -eq 2 ]; then
212
+ MODE="continue"
213
+ STATE_FILE="$1"
214
+ RAW_REPLY="$2"
215
+ else
216
+ echo "Usage:" >&2
217
+ echo " render-confirmation.sh \"<comma-separated-ids>\" # start a new confirmation session" >&2
218
+ echo " render-confirmation.sh <state_file> \"<raw_reply>\" # continue an existing session" >&2
219
+ exit 2
220
+ fi
221
+
222
+ if [ "$MODE" = "start" ]; then
223
+ if [ ! -x "$CASCADE_RESOLVE" ]; then
224
+ echo "Error: cascade-resolve.sh not found or not executable at $CASCADE_RESOLVE" >&2
225
+ exit 2
226
+ fi
227
+
228
+ if [ -z "${RAW_IDS// /}" ]; then
229
+ echo "Error: no IDs given to render-confirmation.sh" >&2
230
+ exit 2
231
+ fi
232
+
233
+ CASCADE_JSON="$("$CASCADE_RESOLVE" "$RAW_IDS")" || {
234
+ echo "Error: cascade-resolve.sh failed" >&2
235
+ exit 2
236
+ }
237
+
238
+ STATE_FILE="$(mktemp -t jenga-confirm-XXXXXX.json)"
239
+
240
+ # As with render-picker.sh: the rendering/state-writing logic is written
241
+ # to a temp .py file rather than piped in via `python3 -`, because the
242
+ # cascade JSON is delivered on stdin — `python3 -` would consume stdin
243
+ # itself as the script source.
244
+ PY_SCRIPT="$(mktemp -t render-confirmation-start-XXXXXX.py)"
245
+ trap 'rm -f "$PY_SCRIPT"' EXIT
246
+
247
+ cat > "$PY_SCRIPT" <<'PY'
248
+ import json
249
+ import sys
250
+ from datetime import datetime, timezone
251
+
252
+ state_file_path = sys.argv[1]
253
+ cascade_json = sys.stdin.read()
254
+
255
+ try:
256
+ cascade = json.loads(cascade_json)
257
+ except Exception as e:
258
+ print(f"Error: could not parse cascade-resolve.sh output as JSON: {e}", file=sys.stderr)
259
+ sys.exit(2)
260
+
261
+ items = cascade.get("items", [])
262
+ undecomposed = cascade.get("undecomposed", [])
263
+ warnings = cascade.get("warnings", [])
264
+
265
+ undecomposed_ids = {u["id"] for u in undecomposed if u.get("id")}
266
+
267
+ epics = [i for i in items if i.get("type") == "epic"]
268
+ stories = [i for i in items if i.get("type") == "story"]
269
+ tasks = [i for i in items if i.get("type") == "task"]
270
+
271
+ epic_ids_present = {e["id"] for e in epics}
272
+ story_ids_present = {s["id"] for s in stories}
273
+
274
+ stories_by_epic = {}
275
+ orphan_stories = []
276
+ for s in stories:
277
+ epic_id = s.get("epic_id")
278
+ if epic_id in epic_ids_present:
279
+ stories_by_epic.setdefault(epic_id, []).append(s)
280
+ else:
281
+ orphan_stories.append(s)
282
+
283
+ tasks_by_story = {}
284
+ orphan_tasks = []
285
+ for t in tasks:
286
+ story_id = t.get("story_id")
287
+ if story_id in story_ids_present:
288
+ tasks_by_story.setdefault(story_id, []).append(t)
289
+ else:
290
+ orphan_tasks.append(t)
291
+
292
+ INDENT = {"epic": "", "story": " ", "task": " "}
293
+ LEVEL_TAG = {"epic": "EPIC", "story": "STORY", "task": "TASK"}
294
+
295
+ numbering = {}
296
+ layout = []
297
+ counter = 1
298
+
299
+
300
+ def pluralize_story(n):
301
+ return f"{n} stor{'y' if n == 1 else 'ies'}"
302
+
303
+
304
+ def pluralize_task(n):
305
+ return f"{n} task{'' if n == 1 else 's'}"
306
+
307
+
308
+ def add_item(item, level, indent):
309
+ global counter
310
+ n = str(counter)
311
+ counter += 1
312
+ suffix = ""
313
+ if item.get("id") in undecomposed_ids:
314
+ suffix = " [needs decomposition -- no children on the board yet]"
315
+ numbering[n] = {"item": item, "level": level, "checked": True}
316
+ layout.append({"kind": "item", "number": n, "level": level, "indent": indent, "suffix": suffix})
317
+ return n
318
+
319
+
320
+ def append_suffix(extra):
321
+ entry = layout[-1]
322
+ if not extra:
323
+ return
324
+ if entry["suffix"]:
325
+ entry["suffix"] = entry["suffix"] + f" ({extra} in this selection)"
326
+ else:
327
+ entry["suffix"] = f" ({extra} in this selection)"
328
+
329
+
330
+ for e in epics:
331
+ add_item(e, "epic", INDENT["epic"])
332
+ child_stories = stories_by_epic.get(e["id"], [])
333
+ story_count = len(child_stories)
334
+ task_count = sum(len(tasks_by_story.get(s["id"], [])) for s in child_stories)
335
+ parts = []
336
+ if story_count:
337
+ parts.append(pluralize_story(story_count))
338
+ if task_count:
339
+ parts.append(pluralize_task(task_count))
340
+ append_suffix(", ".join(parts))
341
+
342
+ for s in child_stories:
343
+ add_item(s, "story", INDENT["story"])
344
+ child_tasks = tasks_by_story.get(s["id"], [])
345
+ if child_tasks:
346
+ append_suffix(pluralize_task(len(child_tasks)))
347
+ for t in child_tasks:
348
+ add_item(t, "task", INDENT["task"])
349
+
350
+ if orphan_stories:
351
+ layout.append({"kind": "header", "text": ""})
352
+ layout.append({"kind": "header", "text": "-- Directly Selected Stories (no epic in this selection) --"})
353
+ for s in orphan_stories:
354
+ add_item(s, "story", INDENT["story"])
355
+ child_tasks = tasks_by_story.get(s["id"], [])
356
+ if child_tasks:
357
+ append_suffix(pluralize_task(len(child_tasks)))
358
+ for t in child_tasks:
359
+ add_item(t, "task", INDENT["task"])
360
+
361
+ if orphan_tasks:
362
+ layout.append({"kind": "header", "text": ""})
363
+ layout.append({"kind": "header", "text": "-- Directly Selected Tasks (no story in this selection) --"})
364
+ for t in orphan_tasks:
365
+ # Visually promoted to the "story" indent (top-level under this
366
+ # header, matching render-picker.sh's own convention for unlinked
367
+ # tasks) but the LEVEL stays "task" -- level drives the [TASK] tag
368
+ # and the checked/unchecked footer counts, and must reflect the
369
+ # item's real type regardless of indent/display position.
370
+ add_item(t, "task", INDENT["story"])
371
+
372
+ total = len(numbering)
373
+
374
+ state = {
375
+ "version": 1,
376
+ "created_at": datetime.now(timezone.utc).isoformat(),
377
+ "total_items": total,
378
+ "layout": layout,
379
+ "numbering": numbering,
380
+ "undecomposed": undecomposed,
381
+ "warnings": warnings,
382
+ }
383
+
384
+ with open(state_file_path, "w", encoding="utf-8") as f:
385
+ json.dump(state, f, indent=2)
386
+ f.write("\n")
387
+
388
+
389
+ def render_body():
390
+ lines = []
391
+ for entry in layout:
392
+ if entry["kind"] == "header":
393
+ lines.append(entry["text"])
394
+ continue
395
+ rec = numbering[entry["number"]]
396
+ item = rec["item"]
397
+ box = "[x]" if rec["checked"] else "[ ]"
398
+ title = item.get("title") or "(untitled)"
399
+ status = item.get("status") or "(no status)"
400
+ lines.append(
401
+ f"{entry['indent']}{box} {entry['number']}. [{LEVEL_TAG[entry['level']]}] "
402
+ f"{item['id']} — {title} (status: {status}){entry['suffix']}"
403
+ )
404
+ return lines
405
+
406
+
407
+ def render_footer():
408
+ checked = {"epic": 0, "story": 0, "task": 0}
409
+ unchecked = {"epic": 0, "story": 0, "task": 0}
410
+ for rec in numbering.values():
411
+ bucket = checked if rec["checked"] else unchecked
412
+ bucket[rec["level"]] += 1
413
+ total_checked = sum(checked.values())
414
+ total_unchecked = sum(unchecked.values())
415
+ line = (
416
+ f"Currently checked: {checked['epic']} epic(s), {pluralize_story(checked['story'])}, "
417
+ f"{pluralize_task(checked['task'])} ({total_checked} total)."
418
+ )
419
+ if total_unchecked:
420
+ line += f" {total_unchecked} item(s) unchecked and will be excluded."
421
+ return line
422
+
423
+
424
+ header = [
425
+ "=" * 68,
426
+ " /jenga -- Confirm Execution Scope",
427
+ "=" * 68,
428
+ "Everything below is checked [x] by default. Uncheck items you do NOT",
429
+ "want to run, or re-check items you previously unchecked, by number.",
430
+ "",
431
+ "Commands:",
432
+ ' check <numbers> -- check the listed item(s), e.g. "check 3,5"',
433
+ ' uncheck <numbers> -- uncheck the listed item(s), e.g. "uncheck 2"',
434
+ ' <numbers> -- bare numbers toggle their current state',
435
+ ' confirm -- proceed with the currently checked items',
436
+ ' cancel -- abort, nothing will run',
437
+ "-" * 68,
438
+ "",
439
+ ]
440
+
441
+ if warnings:
442
+ header.append("Note -- some inputs were skipped during resolution:")
443
+ for w in warnings:
444
+ header.append(f" - '{w.get('input')}': {w.get('reason')}")
445
+ header.append("")
446
+
447
+ if total == 0:
448
+ body = ["(Nothing resolved -- there is nothing to confirm.)"]
449
+ else:
450
+ body = render_body()
451
+
452
+ footer = [
453
+ "",
454
+ "-" * 68,
455
+ render_footer() if total else "Nothing to select.",
456
+ "",
457
+ 'Reply with a command ("check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel").',
458
+ ]
459
+
460
+ print("\n".join(header + body + footer))
461
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
462
+ PY
463
+
464
+ python3 "$PY_SCRIPT" "$STATE_FILE" <<< "$CASCADE_JSON"
465
+ exit 0
466
+ fi
467
+
468
+ # --------------------------------------------------------------------------
469
+ # continue mode
470
+ # --------------------------------------------------------------------------
471
+
472
+ if [ ! -f "$STATE_FILE" ]; then
473
+ echo "Error: state file not found at $STATE_FILE" >&2
474
+ echo "The confirmation session may have expired (e.g. temp dir was cleared)." >&2
475
+ echo "Start a new confirmation session with: render-confirmation.sh \"<comma-separated-ids>\"" >&2
476
+ exit 2
477
+ fi
478
+
479
+ PY_SCRIPT="$(mktemp -t render-confirmation-continue-XXXXXX.py)"
480
+ trap 'rm -f "$PY_SCRIPT"' EXIT
481
+
482
+ cat > "$PY_SCRIPT" <<'PY'
483
+ import json
484
+ import os
485
+ import re
486
+ import sys
487
+
488
+ state_file_path = sys.argv[1]
489
+ raw_reply = sys.argv[2]
490
+
491
+ try:
492
+ with open(state_file_path, encoding="utf-8") as f:
493
+ state = json.load(f)
494
+ except Exception as e:
495
+ print(f"Error: could not read/parse state file at {state_file_path}: {e}", file=sys.stderr)
496
+ sys.exit(2)
497
+
498
+ numbering = state.get("numbering", {})
499
+ layout = state.get("layout", [])
500
+ undecomposed = state.get("undecomposed", [])
501
+ warnings = state.get("warnings", [])
502
+ total = state.get("total_items", len(numbering))
503
+
504
+ LEVEL_TAG = {"epic": "EPIC", "story": "STORY", "task": "TASK"}
505
+
506
+
507
+ def pluralize_story(n):
508
+ return f"{n} stor{'y' if n == 1 else 'ies'}"
509
+
510
+
511
+ def pluralize_task(n):
512
+ return f"{n} task{'' if n == 1 else 's'}"
513
+
514
+
515
+ def render_body():
516
+ lines = []
517
+ for entry in layout:
518
+ if entry["kind"] == "header":
519
+ lines.append(entry["text"])
520
+ continue
521
+ rec = numbering[entry["number"]]
522
+ item = rec["item"]
523
+ box = "[x]" if rec["checked"] else "[ ]"
524
+ title = item.get("title") or "(untitled)"
525
+ status = item.get("status") or "(no status)"
526
+ lines.append(
527
+ f"{entry['indent']}{box} {entry['number']}. [{LEVEL_TAG[entry['level']]}] "
528
+ f"{item['id']} — {title} (status: {status}){entry.get('suffix', '')}"
529
+ )
530
+ return lines
531
+
532
+
533
+ def render_footer():
534
+ checked = {"epic": 0, "story": 0, "task": 0}
535
+ unchecked = {"epic": 0, "story": 0, "task": 0}
536
+ for rec in numbering.values():
537
+ bucket = checked if rec["checked"] else unchecked
538
+ bucket[rec["level"]] += 1
539
+ total_checked = sum(checked.values())
540
+ total_unchecked = sum(unchecked.values())
541
+ line = (
542
+ f"Currently checked: {checked['epic']} epic(s), {pluralize_story(checked['story'])}, "
543
+ f"{pluralize_task(checked['task'])} ({total_checked} total)."
544
+ )
545
+ if total_unchecked:
546
+ line += f" {total_unchecked} item(s) unchecked and will be excluded."
547
+ return line
548
+
549
+
550
+ stripped = raw_reply.strip()
551
+ lowered = stripped.lower()
552
+
553
+ if lowered == "cancel":
554
+ try:
555
+ os.remove(state_file_path)
556
+ except OSError:
557
+ pass
558
+ print("Confirmation session cancelled. Nothing will run.")
559
+ sys.exit(3)
560
+
561
+ if lowered == "confirm":
562
+ checked_ids_in_order = []
563
+ items_by_id = {}
564
+ for entry in layout:
565
+ if entry["kind"] != "item":
566
+ continue
567
+ rec = numbering[entry["number"]]
568
+ if rec["checked"]:
569
+ item = rec["item"]
570
+ item_id = item["id"]
571
+ if item_id not in items_by_id:
572
+ checked_ids_in_order.append(item_id)
573
+ items_by_id[item_id] = item
574
+
575
+ checked_id_set = set(checked_ids_in_order)
576
+ items_out = [items_by_id[i] for i in checked_ids_in_order]
577
+
578
+ counts = {"epics": 0, "stories": 0, "tasks": 0}
579
+ for it in items_out:
580
+ t = it.get("type")
581
+ if t == "epic":
582
+ counts["epics"] += 1
583
+ elif t == "story":
584
+ counts["stories"] += 1
585
+ elif t == "task":
586
+ counts["tasks"] += 1
587
+
588
+ undecomposed_out = [u for u in undecomposed if u.get("id") in checked_id_set]
589
+
590
+ result = {
591
+ "status": "confirmed",
592
+ "items": items_out,
593
+ "resolved_ids": checked_ids_in_order,
594
+ "resolved_ids_csv": ",".join(checked_ids_in_order),
595
+ "counts": counts,
596
+ "undecomposed": undecomposed_out,
597
+ "warnings": warnings,
598
+ }
599
+
600
+ try:
601
+ os.remove(state_file_path)
602
+ except OSError:
603
+ pass
604
+
605
+ print(json.dumps(result, indent=2))
606
+ sys.exit(0)
607
+
608
+ # Determine command kind: "check"/"uncheck" prefix, or bare numbers (toggle).
609
+ action = "toggle"
610
+ tokens_source = stripped
611
+ prefix_match = re.match(r'^(check|uncheck)\b(.*)$', stripped, re.IGNORECASE)
612
+ if prefix_match:
613
+ action = prefix_match.group(1).lower()
614
+ tokens_source = prefix_match.group(2)
615
+
616
+ raw_tokens = tokens_source.replace(",", " ").split()
617
+ tokens = [tok.strip() for tok in raw_tokens if tok.strip() != ""]
618
+
619
+ if not tokens:
620
+ print("Error: no selection numbers found in your reply.")
621
+ print("")
622
+ print('Reply with a command: "check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel".')
623
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
624
+ sys.exit(1)
625
+
626
+ invalid = []
627
+ valid_tokens = []
628
+ for tok in tokens:
629
+ if not re.fullmatch(r"[0-9]+", tok):
630
+ invalid.append((tok, "not a whole number"))
631
+ continue
632
+ if tok not in numbering:
633
+ invalid.append((tok, f"out of range (valid: 1-{total})"))
634
+ continue
635
+ valid_tokens.append(tok)
636
+
637
+ if invalid:
638
+ print("Error: your reply contains invalid selection number(s):")
639
+ for tok, reason in invalid:
640
+ print(f" - '{tok}': {reason}")
641
+ print("")
642
+ print(f"Valid selection numbers for this session are 1-{total}.")
643
+ print('Reply again with a corrected command ("check N,N", "uncheck N,N", bare numbers, "confirm", or "cancel").')
644
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
645
+ sys.exit(1)
646
+
647
+ # All-or-nothing application, de-duplicated (first-seen wins on dupes).
648
+ seen = set()
649
+ for tok in valid_tokens:
650
+ if tok in seen:
651
+ continue
652
+ seen.add(tok)
653
+ if action == "check":
654
+ numbering[tok]["checked"] = True
655
+ elif action == "uncheck":
656
+ numbering[tok]["checked"] = False
657
+ else:
658
+ numbering[tok]["checked"] = not numbering[tok]["checked"]
659
+
660
+ state["numbering"] = numbering
661
+ with open(state_file_path, "w", encoding="utf-8") as f:
662
+ json.dump(state, f, indent=2)
663
+ f.write("\n")
664
+
665
+ output_lines = ["Updated:", ""] + render_body() + [
666
+ "",
667
+ "-" * 68,
668
+ render_footer(),
669
+ "",
670
+ 'Reply with another command ("check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel").',
671
+ ]
672
+
673
+ print("\n".join(output_lines))
674
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
675
+ sys.exit(0)
676
+ PY
677
+
678
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$RAW_REPLY"
679
+ exit $?