@jenga-ai/agent 1.1.0 → 1.1.1

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,439 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/render-picker.sh
4
+ #
5
+ # Turn-by-turn numbered checklist renderer for `/jenga`'s bare-invocation
6
+ # interactive picker (E45_S02_T01). Renders a numbered hierarchical
7
+ # checklist of the full board (epics, stories, tasks, with summaries),
8
+ # sourced from `skills/jenga/scripts/board-scan.sh` (E45_S01_T01) — this
9
+ # script never re-scans the board directly.
10
+ #
11
+ # ---------------------------------------------------------------------------
12
+ # WHY THIS IS A TWO-INVOCATION, STATE-FILE SCRIPT (READ BEFORE CHANGING)
13
+ # ---------------------------------------------------------------------------
14
+ # Claude Code's Bash tool runs a command to completion and returns — it
15
+ # cannot block mid-script waiting on the next chat message. A single script
16
+ # that prints a checklist, then reads stdin for the user's selection, would
17
+ # never see real chat input; it would only ever see whatever the Bash tool
18
+ # call happened to pipe in (nothing, in the normal interactive case).
19
+ #
20
+ # So the picker is split across (at least) two invocations of this same
21
+ # script, coordinated by the calling agent, never by this script itself:
22
+ #
23
+ # 1. Turn 1 (start mode, no arguments): this script scans the board,
24
+ # assigns every item a sequential number, writes that numbered
25
+ # snapshot to a temp "state file", and prints a human-readable
26
+ # checklist + instructions to STDOUT.
27
+ # 2. The calling agent relays this script's literal STDOUT to the user
28
+ # as a chat message, verbatim — no summarizing, no reformatting.
29
+ # 3. The user replies in chat with their raw selection text (e.g.
30
+ # "3, 17, 42").
31
+ # 4. The calling agent re-invokes this script, passing the state file
32
+ # path (captured from turn 1's STDERR — see OUTPUT CONTRACT below)
33
+ # and the user's raw reply as two arguments (continue mode).
34
+ # 5. This script reads the prior state, validates the reply against the
35
+ # numbering-to-item snapshot it already captured, and either:
36
+ # - emits a plain-text error turn (state file left untouched, same
37
+ # path, ready for another continue-mode retry), or
38
+ # - emits the final resolved selection as JSON (state file removed)
39
+ # for the confirmation-tree renderer (E45_S02_T02) to consume.
40
+ #
41
+ # Do NOT attempt to "fix" this with a blocking read, a stdin prompt loop,
42
+ # or a polling/sleep loop waiting for external input — none of those work
43
+ # with the Bash tool's run-to-completion model, and ad-hoc polling loops as
44
+ # an inter-turn signaling mechanism are explicitly prohibited elsewhere in
45
+ # this repo's agent-coordination conventions (see epic E37).
46
+ #
47
+ # ---------------------------------------------------------------------------
48
+ # USAGE
49
+ # ---------------------------------------------------------------------------
50
+ # skills/jenga/scripts/render-picker.sh
51
+ # Start a new picker session. No arguments.
52
+ #
53
+ # skills/jenga/scripts/render-picker.sh <state_file> "<raw_reply>"
54
+ # Continue an existing picker session. <state_file> is the path
55
+ # printed on STDERR by the start-mode invocation (or by a previous
56
+ # error-turn continue-mode invocation — the path is stable across
57
+ # retries). <raw_reply> is the user's raw chat text for this turn,
58
+ # passed as a single argument (quote it).
59
+ #
60
+ # ---------------------------------------------------------------------------
61
+ # OUTPUT CONTRACT (deliberately NOT uniform JSON — see rationale below)
62
+ # ---------------------------------------------------------------------------
63
+ # Unlike board-scan.sh / resolve-id.sh / cascade-resolve.sh (which always
64
+ # emit pure JSON on stdout), this script splits its output by *audience*,
65
+ # because it is the one script in this family with a human in the loop for
66
+ # most of its turns:
67
+ #
68
+ # - Start mode, and continue-mode ERROR turns:
69
+ # STDOUT = plain human-readable text (the checklist, or an error
70
+ # message + retry instructions). This is exactly what the
71
+ # calling agent relays verbatim to the user — nothing to
72
+ # parse, nothing to strip.
73
+ # STDERR = a single line: `STATE_FILE: <absolute path>` — metadata
74
+ # for the calling AGENT to capture for the next invocation.
75
+ # Never intended for the human to read; do not relay it.
76
+ #
77
+ # - Continue mode, RESOLVED (fully valid) turn:
78
+ # STDOUT = a single JSON object (schema below) — the machine-readable
79
+ # handoff to the confirmation-tree renderer (E45_S02_T02).
80
+ # This is NOT meant to be relayed to the user verbatim.
81
+ # STDERR = empty (state file has been removed; nothing to carry
82
+ # forward).
83
+ #
84
+ # - Continue mode, CANCELLED (`cancel` reply, case-insensitive):
85
+ # STDOUT = plain human-readable cancellation acknowledgement.
86
+ # STDERR = empty (state file has been removed).
87
+ #
88
+ # RESOLVED JSON schema:
89
+ # {
90
+ # "status": "resolved",
91
+ # "selected_numbers": [3, 17, 42], // as typed, de-duplicated,
92
+ # // in first-seen order
93
+ # "resolved_ids": ["E01", "E04_S05_T06"],
94
+ # "resolved_ids_csv": "E01,E04_S05_T06" // ready to pass directly
95
+ # // as cascade-resolve.sh's
96
+ # // single argument
97
+ # }
98
+ #
99
+ # ---------------------------------------------------------------------------
100
+ # SELECTION GRAMMAR (deliberately distinct from resolve-id.sh's ID grammar)
101
+ # ---------------------------------------------------------------------------
102
+ # A reply is a comma-separated list of positive integers referring to the
103
+ # numbers printed in THIS session's checklist (whitespace around commas and
104
+ # numbers is insignificant; purely empty segments from stray/trailing
105
+ # commas are ignored, not rejected). This is NOT the `E##_S##_T##` fuzzy-ID
106
+ # text grammar `resolve-id.sh` implements — that grammar belongs to the
107
+ # separate `/jenga <ids>` explicit-argument entry mode (E45_S01_T02), which
108
+ # bypasses this picker entirely. Reusing resolve-id.sh here would require
109
+ # it to parse a second, unrelated micro-grammar (bare numbers) its own
110
+ # header contract says nothing about, so this script builds and validates
111
+ # its own numbering-to-item mapping directly from the state file snapshot
112
+ # instead of calling into resolve-id.sh.
113
+ #
114
+ # Validation is ALL-OR-NOTHING per turn: if a reply contains any non-numeric
115
+ # token or any number outside the valid 1..N range for this session, the
116
+ # ENTIRE turn is rejected as an error turn (state file left untouched) —
117
+ # there is no partial application of the valid numbers in a mixed-validity
118
+ # reply, and no accumulation of a selection across multiple turns. A turn is
119
+ # either fully consumed (resolved) or fully retried (error) — this keeps the
120
+ # loop's semantics simple and satisfies the requirement that invalid/out-of-
121
+ # range numbers produce a clear error rather than being silently dropped.
122
+ #
123
+ # ---------------------------------------------------------------------------
124
+ # STATE FILE
125
+ # ---------------------------------------------------------------------------
126
+ # Created under `mktemp -t jenga-picker-XXXXXX.json`. It is a SELF-CONTAINED
127
+ # SNAPSHOT — each numbered entry stores the full board-scan.sh item object
128
+ # (id, type, title, status, summary, epic_id, story_id) as it looked at
129
+ # start-of-picker time, not just the id. This means an error-turn retry, or
130
+ # a re-print, never needs to re-invoke board-scan.sh and can never disagree
131
+ # with what the user was actually shown, even if the live board changes
132
+ # mid-session.
133
+ #
134
+ # If the state file is missing or unreadable when continue mode is invoked
135
+ # (e.g. the OS temp dir was cleared), this is treated as a real setup
136
+ # problem (exit 2), not a per-input validation failure — the user has to
137
+ # restart the picker (start mode) from scratch.
138
+ #
139
+ # ---------------------------------------------------------------------------
140
+ # NO CASCADE / DEDUP LOGIC HERE
141
+ # ---------------------------------------------------------------------------
142
+ # Selecting an epic's number and one of its own descendant task's numbers
143
+ # in the same reply is allowed; `resolved_ids` will contain both exactly as
144
+ # typed. Expansion of a parent selection to its eligible descendants,
145
+ # cross-level deduplication, and terminal-status filtering are the job of
146
+ # `cascade-resolve.sh` (E45_S01_T03, already built) and the confirmation-
147
+ # tree renderer (E45_S02_T02, not yet built) — this script's only
148
+ # responsibility is reporting exactly which board IDs the user typed a
149
+ # number for.
150
+ #
151
+ # ---------------------------------------------------------------------------
152
+ # EXIT CODES
153
+ # ---------------------------------------------------------------------------
154
+ # 0 start mode rendered successfully; OR continue mode fully resolved
155
+ # 1 continue mode: at least one invalid/out-of-range number in the
156
+ # reply — an error turn was emitted, retry with the same state file
157
+ # 2 usage error, or a real setup problem (board-scan.sh / python3
158
+ # unavailable, state file missing/corrupt)
159
+ # 3 continue mode: user replied "cancel" — picker session aborted,
160
+ # state file removed
161
+ #
162
+ # ---------------------------------------------------------------------------
163
+
164
+ set -euo pipefail
165
+
166
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
167
+ BOARD_SCAN="$SCRIPT_DIR/board-scan.sh"
168
+
169
+ if ! command -v python3 >/dev/null 2>&1; then
170
+ echo "Error: python3 is required by render-picker.sh" >&2
171
+ exit 2
172
+ fi
173
+
174
+ if [ $# -eq 0 ]; then
175
+ MODE="start"
176
+ elif [ $# -eq 2 ]; then
177
+ MODE="continue"
178
+ STATE_FILE="$1"
179
+ RAW_REPLY="$2"
180
+ else
181
+ echo "Usage:" >&2
182
+ echo " render-picker.sh # start a new picker session" >&2
183
+ echo " render-picker.sh <state_file> \"<raw_reply>\" # continue an existing session" >&2
184
+ exit 2
185
+ fi
186
+
187
+ if [ "$MODE" = "start" ]; then
188
+ if [ ! -x "$BOARD_SCAN" ]; then
189
+ echo "Error: board-scan.sh not found or not executable at $BOARD_SCAN" >&2
190
+ exit 2
191
+ fi
192
+
193
+ BOARD_JSON="$("$BOARD_SCAN")" || {
194
+ echo "Error: board-scan.sh failed" >&2
195
+ exit 2
196
+ }
197
+
198
+ STATE_FILE="$(mktemp -t jenga-picker-XXXXXX.json)"
199
+
200
+ # The rendering/state-writing logic is written to a temp .py file rather
201
+ # than piped in via `python3 -` because the board JSON is delivered on
202
+ # stdin — `python3 -` would consume stdin itself as the script source,
203
+ # leaving nothing for the script's own sys.stdin.read() to see. Same
204
+ # pattern as resolve-id.sh.
205
+ PY_SCRIPT="$(mktemp -t render-picker-start-XXXXXX.py)"
206
+ trap 'rm -f "$PY_SCRIPT"' EXIT
207
+
208
+ cat > "$PY_SCRIPT" <<'PY'
209
+ import json
210
+ import sys
211
+ from datetime import datetime, timezone
212
+
213
+ state_file_path = sys.argv[1]
214
+ board_json = sys.stdin.read()
215
+
216
+ try:
217
+ items = json.loads(board_json)
218
+ except Exception as e:
219
+ print(f"Error: could not parse board-scan.sh output as JSON: {e}", file=sys.stderr)
220
+ sys.exit(2)
221
+
222
+ epics = [i for i in items if i.get("type") == "epic"]
223
+ stories = [i for i in items if i.get("type") == "story"]
224
+ tasks = [i for i in items if i.get("type") == "task"]
225
+
226
+ valid_epic_ids = {e["id"] for e in epics}
227
+ valid_story_ids = {s["id"] for s in stories}
228
+
229
+ stories_by_epic = {}
230
+ unlinked_stories = []
231
+ for s in stories:
232
+ epic_id = s.get("epic_id")
233
+ if epic_id in valid_epic_ids:
234
+ stories_by_epic.setdefault(epic_id, []).append(s)
235
+ else:
236
+ unlinked_stories.append(s)
237
+
238
+ tasks_by_story = {}
239
+ unlinked_tasks = []
240
+ for t in tasks:
241
+ story_id = t.get("story_id")
242
+ if story_id in valid_story_ids:
243
+ tasks_by_story.setdefault(story_id, []).append(t)
244
+ else:
245
+ unlinked_tasks.append(t)
246
+
247
+ TYPE_TAG = {"epic": "EPIC", "story": "STORY", "task": "TASK"}
248
+ INDENT = {"epic": "", "story": " ", "task": " "}
249
+
250
+ numbering = {}
251
+ lines = []
252
+ counter = 1
253
+
254
+
255
+ def render_item(item, level):
256
+ global counter
257
+ n = counter
258
+ counter += 1
259
+ numbering[str(n)] = item
260
+ indent = INDENT[level]
261
+ title = item.get("title") or "(untitled)"
262
+ status = item.get("status") or "(no status)"
263
+ summary = item.get("summary") or "(no summary)"
264
+ lines.append(f"{indent}{n}. [{TYPE_TAG[level]}] {item['id']} — {title} (status: {status})")
265
+ lines.append(f"{indent} {summary}")
266
+
267
+
268
+ for e in epics:
269
+ render_item(e, "epic")
270
+ for s in stories_by_epic.get(e["id"], []):
271
+ render_item(s, "story")
272
+ for t in tasks_by_story.get(s["id"], []):
273
+ render_item(t, "task")
274
+
275
+ if unlinked_stories:
276
+ lines.append("")
277
+ lines.append("-- Unlinked Stories (no matching epic on the board) --")
278
+ for s in unlinked_stories:
279
+ render_item(s, "story")
280
+ for t in tasks_by_story.get(s["id"], []):
281
+ render_item(t, "task")
282
+
283
+ if unlinked_tasks:
284
+ lines.append("")
285
+ lines.append("-- Unlinked Tasks (no matching story on the board) --")
286
+ for t in unlinked_tasks:
287
+ render_item(t, "story") # top-level indent for an orphaned task
288
+
289
+ total = len(numbering)
290
+
291
+ state = {
292
+ "version": 1,
293
+ "created_at": datetime.now(timezone.utc).isoformat(),
294
+ "total_items": total,
295
+ "numbering": numbering,
296
+ }
297
+
298
+ with open(state_file_path, "w", encoding="utf-8") as f:
299
+ json.dump(state, f, indent=2)
300
+ f.write("\n")
301
+
302
+ header = [
303
+ "=" * 68,
304
+ " /jenga — Board Picker",
305
+ "=" * 68,
306
+ "Select items by number, comma-separated. You may mix levels freely",
307
+ '— e.g. "3,17,42" can select an epic, a story, and a task all in',
308
+ "the same reply. Selecting a parent (epic/story) later cascades to",
309
+ "its eligible descendants during confirmation.",
310
+ "",
311
+ 'Reply "cancel" at any time to abort the picker.',
312
+ "-" * 68,
313
+ "",
314
+ ]
315
+
316
+ if total == 0:
317
+ body = ["(The board is empty — there is nothing to select.)"]
318
+ else:
319
+ body = lines
320
+
321
+ footer = [
322
+ "",
323
+ "-" * 68,
324
+ f"Total: {len(epics)} epic(s), {len(stories)} stor{'y' if len(stories) == 1 else 'ies'}, "
325
+ f"{len(tasks)} task(s) ({total} item(s) numbered 1-{total})." if total else "Nothing to select.",
326
+ "",
327
+ 'Reply with your selection (comma-separated numbers), or "cancel".',
328
+ ]
329
+
330
+ print("\n".join(header + body + footer))
331
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
332
+ PY
333
+
334
+ python3 "$PY_SCRIPT" "$STATE_FILE" <<< "$BOARD_JSON"
335
+ exit 0
336
+ fi
337
+
338
+ # --------------------------------------------------------------------------
339
+ # continue mode
340
+ # --------------------------------------------------------------------------
341
+
342
+ if [ ! -f "$STATE_FILE" ]; then
343
+ echo "Error: state file not found at $STATE_FILE" >&2
344
+ echo "The picker session may have expired (e.g. temp dir was cleared)." >&2
345
+ echo "Start a new picker session with: render-picker.sh" >&2
346
+ exit 2
347
+ fi
348
+
349
+ PY_SCRIPT="$(mktemp -t render-picker-continue-XXXXXX.py)"
350
+ trap 'rm -f "$PY_SCRIPT"' EXIT
351
+
352
+ cat > "$PY_SCRIPT" <<'PY'
353
+ import json
354
+ import re
355
+ import sys
356
+
357
+ state_file_path = sys.argv[1]
358
+ raw_reply = sys.argv[2]
359
+
360
+ try:
361
+ with open(state_file_path, encoding="utf-8") as f:
362
+ state = json.load(f)
363
+ except Exception as e:
364
+ print(f"Error: could not read/parse state file at {state_file_path}: {e}", file=sys.stderr)
365
+ sys.exit(2)
366
+
367
+ numbering = state.get("numbering", {})
368
+ total = state.get("total_items", len(numbering))
369
+
370
+ if raw_reply.strip().lower() == "cancel":
371
+ import os
372
+ try:
373
+ os.remove(state_file_path)
374
+ except OSError:
375
+ pass
376
+ print("Picker session cancelled. No selection was made.")
377
+ sys.exit(3)
378
+
379
+ raw_tokens = raw_reply.split(",")
380
+ tokens = [tok.strip() for tok in raw_tokens]
381
+ tokens = [tok for tok in tokens if tok != ""]
382
+
383
+ if not tokens:
384
+ print("Error: no selection numbers found in your reply.")
385
+ print("")
386
+ print('Reply with comma-separated numbers from the checklist above (e.g. "3,17,42"), or "cancel".')
387
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
388
+ sys.exit(1)
389
+
390
+ invalid = []
391
+ valid_numbers = []
392
+ for tok in tokens:
393
+ if not re.fullmatch(r"[0-9]+", tok):
394
+ invalid.append((tok, "not a whole number"))
395
+ continue
396
+ if tok not in numbering:
397
+ invalid.append((tok, f"out of range (valid: 1-{total})"))
398
+ continue
399
+ valid_numbers.append(int(tok))
400
+
401
+ if invalid:
402
+ print("Error: your reply contains invalid selection number(s):")
403
+ for tok, reason in invalid:
404
+ print(f" - '{tok}': {reason}")
405
+ print("")
406
+ print(f"Valid selection numbers for this session are 1-{total}.")
407
+ print('Reply again with a corrected, comma-separated list (e.g. "3,17,42"), or "cancel".')
408
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
409
+ sys.exit(1)
410
+
411
+ # All-or-nothing success: de-duplicate while preserving first-seen order.
412
+ seen = set()
413
+ selected_numbers = []
414
+ resolved_ids = []
415
+ for n in valid_numbers:
416
+ if n in seen:
417
+ continue
418
+ seen.add(n)
419
+ selected_numbers.append(n)
420
+ resolved_ids.append(numbering[str(n)]["id"])
421
+
422
+ import os
423
+ try:
424
+ os.remove(state_file_path)
425
+ except OSError:
426
+ pass
427
+
428
+ result = {
429
+ "status": "resolved",
430
+ "selected_numbers": selected_numbers,
431
+ "resolved_ids": resolved_ids,
432
+ "resolved_ids_csv": ",".join(resolved_ids),
433
+ }
434
+ print(json.dumps(result, indent=2))
435
+ sys.exit(0)
436
+ PY
437
+
438
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$RAW_REPLY"
439
+ exit $?