@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,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 $?
|