@jenga-ai/agent 3.1.0 → 3.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 (44) hide show
  1. package/README.md +3 -3
  2. package/agents/developer.md +15 -15
  3. package/agents/scrum-master.md +17 -17
  4. package/agents/tester.md +25 -15
  5. package/lib/skill-allow-list.json +3 -2
  6. package/package.json +1 -1
  7. package/scripts/audit-twin-divergence.sh +625 -0
  8. package/scripts/build-pages-site.sh +268 -0
  9. package/scripts/check-public-playbook-steps.sh +136 -0
  10. package/skills/j-close-story/SKILL.md +1 -1
  11. package/skills/j-do/SKILL.md +19 -19
  12. package/skills/j-doc-sync/SKILL.md +12 -1
  13. package/skills/j-idea/SKILL.md +1 -1
  14. package/skills/j-init/SKILL.md +5 -4
  15. package/skills/j-init/assets/directory_structure.txt +1 -0
  16. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  17. package/skills/j-init/scripts/init.sh +13 -2
  18. package/skills/j-playbook/SKILL.md +81 -0
  19. package/skills/j-proceed/SKILL.md +1 -1
  20. package/skills/j-publish/SKILL.md +1 -1
  21. package/skills/j-publish/adapters/npm-ci.md +29 -0
  22. package/skills/j-publish/scripts/npm_ci_pipeline.sh +3 -0
  23. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  24. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  25. package/skills/j-reconcile/SKILL.md +1 -0
  26. package/skills/j-redo/SKILL.md +1 -1
  27. package/skills/j-status/SKILL.md +12 -0
  28. package/skills/j-todo/SKILL.md +2 -2
  29. package/skills/j-uncharted/SKILL.md +8 -7
  30. package/skills/j-uncharted/scripts/elicitation-state.sh +15 -1
  31. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  32. package/skills/jenga/SKILL.md +80 -9
  33. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  34. package/skills/jenga/playbooks/schema.json +42 -0
  35. package/skills/jenga/scripts/detect-nl-intent.sh +179 -0
  36. package/skills/jenga/scripts/load-nl-catalog.js +206 -0
  37. package/skills/jenga/scripts/load-nl-catalog.sh +65 -0
  38. package/skills/jenga/scripts/load-playbooks.sh +1022 -0
  39. package/skills/jenga/scripts/match-playbook.sh +262 -0
  40. package/skills/jenga/scripts/render-playbook-confirmation.sh +517 -0
  41. package/skills/jenga/scripts/run-playbook-step.sh +766 -0
  42. package/skills/jenga-permission-level/SKILL.md +4 -4
  43. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  44. package/templates/playbook-types.json +8 -0
@@ -0,0 +1,517 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/render-playbook-confirmation.sh
4
+ #
5
+ # Turn-by-turn checkbox-list confirmation renderer for a matched PLAYBOOK's ordered `steps`
6
+ # (E53_S02_T03). Used by `/jenga`'s natural-language branch after `match-playbook.sh`
7
+ # (E53_S02_T02) reports a `playbook_match` — renders the proposed chain as an editable, numbered,
8
+ # checked-by-default list the user must confirm before any step executes, per this story's
9
+ # acceptance criteria and `CLAUDE.md`'s Interaction Pattern. This mirrors
10
+ # `render-confirmation.sh`'s (E45_S02_T02) exact UX idiom and two-invocation state-file mechanics,
11
+ # scoped down from a nested Epic/Story/Task tree to a flat ordered step list.
12
+ #
13
+ # ---------------------------------------------------------------------------
14
+ # WHY THIS IS A TWO-INVOCATION, STATE-FILE SCRIPT
15
+ # ---------------------------------------------------------------------------
16
+ # Same constraint as `render-confirmation.sh`/`render-picker.sh`: the Bash tool runs a command to
17
+ # completion and returns — it cannot block mid-script waiting on the user's next chat message. So
18
+ # this confirmation screen is split across (at least) two invocations of this same script,
19
+ # coordinated by the calling agent (`/jenga`'s SKILL.md, wired in E53_S02_T04), never by this
20
+ # script itself. See `render-confirmation.sh`'s own header for the full rationale — it applies
21
+ # here verbatim; this comment does not re-derive it a second time.
22
+ #
23
+ # ---------------------------------------------------------------------------
24
+ # USAGE
25
+ # ---------------------------------------------------------------------------
26
+ # render-playbook-confirmation.sh "<playbook_id>" "<name>" "<comma-separated ordered step names>" ["<json-conditionals>" ["<json-origins>"]]
27
+ # Start a new confirmation session. `<playbook_id>` and `<name>` come straight from
28
+ # `match-playbook.sh`'s `playbook_match` output; `<comma-separated ordered step names>` is
29
+ # that same output's `steps` array joined with commas, in original playbook order.
30
+ #
31
+ # `<json-conditionals>` is OPTIONAL (E53_S04_T04). When given, it is a JSON object mapping a
32
+ # conditional step's name -> the name of the EARLIER step it depends on, e.g.
33
+ # `{"stepC": "stepA"}`. Only steps that actually carry a conditional (per `load-playbooks.sh`'s
34
+ # StepObject `conditional` field, resolved by the calling agent from the playbook's raw
35
+ # StepObject data before invoking this script -- this script owns only the RENDERING side of
36
+ # that contract, never the resolution of it) appear as keys. Omitting this argument (the
37
+ # original 3-arg form) renders the chain exactly as before this task -- fully backward
38
+ # compatible.
39
+ #
40
+ # `<json-origins>` is OPTIONAL (E53_S05_T03), and may only be given when `<json-conditionals>`
41
+ # is also given (pass `"{}"` for conditionals if there are none, to reach the 5th slot). It is
42
+ # a JSON object mapping a composed/nested step's name -> `{"playbook_id": "...", "depth": N}`,
43
+ # for every step whose `_origin_depth` (per `load-playbooks.sh`'s catalog output, E53_S05_T01)
44
+ # is greater than 1. Steps absent from this object are depth-1 (the top-level playbook's own
45
+ # steps) and render exactly as before this task. The calling agent (`/jenga`'s
46
+ # natural-language branch, wired in E53_S05_T07) resolves this metadata from
47
+ # `load-playbooks.sh`'s catalog output before invoking this script -- this script owns only
48
+ # the RENDERING side of that contract, mirroring `<json-conditionals>`'s own scope boundary.
49
+ # Omitting this argument entirely reproduces the exact pre-this-task behavior, fully backward
50
+ # compatible.
51
+ #
52
+ # render-playbook-confirmation.sh <state_file> "<raw_reply>"
53
+ # Continue an existing confirmation session. <state_file> is the path printed on STDERR by
54
+ # the start-mode invocation (or by any prior continue-mode invocation). <raw_reply> is the
55
+ # user's raw chat text for this turn. The conditional markers and origin/nesting annotations
56
+ # (if any) persist across continue-mode re-renders automatically -- they are stored in the
57
+ # state file, computed once at start, and never recomputed from arguments.
58
+ #
59
+ # ---------------------------------------------------------------------------
60
+ # CONDITIONAL MARKER DISPLAY (E53_S04_T02 / E53_S04_T04)
61
+ # ---------------------------------------------------------------------------
62
+ # A step whose name is a key in the conditionals map gets a visible suffix appended to its line in
63
+ # the rendered chain: " (may be skipped depending on step N's result)", where N is the 1-indexed
64
+ # DISPLAY POSITION (per this session's `order`, not necessarily the original playbook array index)
65
+ # of the step it depends on. This makes the chain's real conditional structure visible before
66
+ # confirmation, per the story's explicit requirement that confirming a chain never hides its real
67
+ # step count or conditional structure from the user. This is display-only: it does not change
68
+ # checked/unchecked mechanics, does not add a new toggle command, and unchecking a conditional step
69
+ # (or the step it depends on) behaves exactly like unchecking any other step -- the ACTUAL runtime
70
+ # skip decision is `run-playbook-step.sh should-skip`'s job, entirely independent of what a user
71
+ # checks/unchecks here.
72
+ #
73
+ # ---------------------------------------------------------------------------
74
+ # NESTED/ORIGIN DISPLAY (E53_S05_T03)
75
+ # ---------------------------------------------------------------------------
76
+ # A step whose name is a key in the origins map (depth > 1, i.e. it was spliced in from a composed
77
+ # playbook) gets TWO visual treatments on its rendered line, applied together:
78
+ # - two extra leading spaces of indentation before the checkbox, visually grouping it apart from
79
+ # depth-1 (top-level) steps, and
80
+ # - a trailing annotation: " (from playbook: <playbook_id>, depth <N>)".
81
+ # This is display-only, exactly like the conditional marker, and composes freely with it on the
82
+ # same line (origin annotation first, conditional marker after) -- both are independent,
83
+ # orthogonal annotations. Numbering stays FLAT 1..N across the WHOLE list regardless of nesting --
84
+ # there is still exactly ONE overall numbered, editable, confirmable list, never a separate
85
+ # confirmation per nested playbook, per `CLAUDE.md`'s Interaction Pattern. A playbook with no
86
+ # depth > 1 steps (the `origins` map is empty or the argument was omitted) renders identically to
87
+ # before this task -- no indentation, no annotation, byte-for-byte unchanged.
88
+ #
89
+ # ---------------------------------------------------------------------------
90
+ # TOGGLE COMMAND GRAMMAR (identical to render-confirmation.sh — no cascade, since steps are flat)
91
+ # ---------------------------------------------------------------------------
92
+ # cancel Abort the session. State file removed.
93
+ # confirm Finalize with the CURRENT checked state.
94
+ # check <numbers> Set the listed displayed number(s) to checked.
95
+ # uncheck <numbers> Set the listed displayed number(s) to unchecked.
96
+ # <numbers> Bare numbers TOGGLE the current checked state of each listed number.
97
+ #
98
+ # <numbers> is a comma- and/or whitespace-separated list of positive integers. Validation is
99
+ # ALL-OR-NOTHING per turn: if a reply contains any non-numeric token or any number outside the
100
+ # valid 1..N range, the ENTIRE turn is rejected as an error turn (state left untouched) — no
101
+ # partial application of the valid numbers in a mixed-validity reply. There is no cascading
102
+ # behavior here (unlike render-confirmation.sh's Epic->Story->Task cascade) — a playbook's steps
103
+ # are a flat ordered list with no parent/child relationship.
104
+ #
105
+ # ---------------------------------------------------------------------------
106
+ # OUTPUT CONTRACT
107
+ # ---------------------------------------------------------------------------
108
+ # - Start mode, and continue-mode TOGGLE / ERROR turns:
109
+ # STDOUT = plain human-readable text (the numbered list + instructions, or an error message
110
+ # + retry instructions) — relayed verbatim to the user.
111
+ # STDERR = `STATE_FILE: <absolute path>` — for the calling AGENT only, never relayed.
112
+ #
113
+ # - Continue mode, CONFIRMED (final) turn:
114
+ # STDOUT = a single JSON object: {"status": "confirmed", "playbook_id": "...",
115
+ # "name": "...", "steps": ["<checked step1>", ...]} — checked-only steps, in
116
+ # ORIGINAL PLAYBOOK ORDER regardless of what order they were un/re-checked in.
117
+ # NOT meant to be relayed to the user verbatim.
118
+ # STDERR = empty (state file removed).
119
+ #
120
+ # - Continue mode, CANCELLED (`cancel` reply, case-insensitive):
121
+ # STDOUT = plain human-readable cancellation acknowledgement — behaviorally identical to
122
+ # render-confirmation.sh's and render-picker.sh's own cancellation handling: same
123
+ # "nothing will run" framing, same exit code (3).
124
+ # STDERR = empty (state file removed).
125
+ #
126
+ # ---------------------------------------------------------------------------
127
+ # EXIT CODES
128
+ # ---------------------------------------------------------------------------
129
+ # 0 start mode rendered successfully; OR continue mode applied a toggle/check/uncheck command
130
+ # and re-rendered the list; OR continue mode fully confirmed (final JSON emitted)
131
+ # 1 continue mode: at least one invalid/out-of-range number, or no numbers found, in the
132
+ # reply — an error turn was emitted, retry with the same state file
133
+ # 2 usage error, or a real setup problem (python3 unavailable, state file missing/corrupt)
134
+ # 3 continue mode: user replied "cancel" — session aborted, state file removed
135
+ #
136
+ # ---------------------------------------------------------------------------
137
+
138
+ set -euo pipefail
139
+
140
+ if ! command -v python3 >/dev/null 2>&1; then
141
+ echo "Error: python3 is required by render-playbook-confirmation.sh" >&2
142
+ exit 2
143
+ fi
144
+
145
+ if [ $# -eq 3 ] || [ $# -eq 4 ] || [ $# -eq 5 ]; then
146
+ MODE="start"
147
+ PLAYBOOK_ID="$1"
148
+ PLAYBOOK_NAME="$2"
149
+ RAW_STEPS="$3"
150
+ RAW_CONDITIONALS="${4:-}"
151
+ RAW_ORIGINS="${5:-}"
152
+ elif [ $# -eq 2 ]; then
153
+ MODE="continue"
154
+ STATE_FILE="$1"
155
+ RAW_REPLY="$2"
156
+ else
157
+ echo "Usage:" >&2
158
+ echo " render-playbook-confirmation.sh \"<playbook_id>\" \"<name>\" \"<comma-separated ordered step names>\" [\"<json-conditionals>\" [\"<json-origins>\"]] # start" >&2
159
+ echo " render-playbook-confirmation.sh <state_file> \"<raw_reply>\" # continue" >&2
160
+ exit 2
161
+ fi
162
+
163
+ if [ "$MODE" = "start" ]; then
164
+ if [ -z "${RAW_STEPS// /}" ]; then
165
+ echo "Error: no steps given to render-playbook-confirmation.sh" >&2
166
+ exit 2
167
+ fi
168
+
169
+ STATE_FILE="$(mktemp -t jenga-playbook-confirm-XXXXXX.json)"
170
+
171
+ PY_SCRIPT="$(mktemp -t render-playbook-confirmation-start-XXXXXX.py)"
172
+ trap 'rm -f "$PY_SCRIPT"' EXIT
173
+
174
+ cat > "$PY_SCRIPT" <<'PY'
175
+ import json
176
+ import sys
177
+ from datetime import datetime, timezone
178
+
179
+ state_file_path = sys.argv[1]
180
+ playbook_id = sys.argv[2]
181
+ playbook_name = sys.argv[3]
182
+ raw_steps = sys.argv[4]
183
+ raw_conditionals = sys.argv[5] if len(sys.argv) > 5 and sys.argv[5] != "" else None
184
+ raw_origins = sys.argv[6] if len(sys.argv) > 6 and sys.argv[6] != "" else None
185
+
186
+ steps = [s.strip() for s in raw_steps.split(",") if s.strip() != ""]
187
+
188
+ if not steps:
189
+ print("Error: no valid step names parsed from the given list", file=sys.stderr)
190
+ sys.exit(2)
191
+
192
+ numbering = {}
193
+ for i, step in enumerate(steps, start=1):
194
+ numbering[str(i)] = {"step": step, "checked": True}
195
+
196
+ total = len(numbering)
197
+
198
+ conditionals = {}
199
+ if raw_conditionals is not None:
200
+ try:
201
+ parsed = json.loads(raw_conditionals)
202
+ except Exception as e:
203
+ print(f"Error: <json-conditionals> is not valid JSON: {e}", file=sys.stderr)
204
+ sys.exit(2)
205
+ if not isinstance(parsed, dict):
206
+ print("Error: <json-conditionals> must be a JSON object", file=sys.stderr)
207
+ sys.exit(2)
208
+ for step_name, depends_on in parsed.items():
209
+ if step_name not in steps:
210
+ print(f"Error: conditional given for '{step_name}', which is not in the step list", file=sys.stderr)
211
+ sys.exit(2)
212
+ if not isinstance(depends_on, str) or depends_on not in steps:
213
+ print(f"Error: conditional for '{step_name}' depends_on '{depends_on}', which is not a step in this playbook", file=sys.stderr)
214
+ sys.exit(2)
215
+ conditionals[step_name] = depends_on
216
+
217
+ # --- E53_S05_T03: origin/nesting metadata (depth > 1 steps only) ---
218
+ origins = {}
219
+ if raw_origins is not None:
220
+ try:
221
+ parsed = json.loads(raw_origins)
222
+ except Exception as e:
223
+ print(f"Error: <json-origins> is not valid JSON: {e}", file=sys.stderr)
224
+ sys.exit(2)
225
+ if not isinstance(parsed, dict):
226
+ print("Error: <json-origins> must be a JSON object", file=sys.stderr)
227
+ sys.exit(2)
228
+ for step_name, meta in parsed.items():
229
+ if step_name not in steps:
230
+ print(f"Error: origin metadata given for '{step_name}', which is not in the step list", file=sys.stderr)
231
+ sys.exit(2)
232
+ if (
233
+ not isinstance(meta, dict)
234
+ or not isinstance(meta.get("playbook_id"), str)
235
+ or not meta.get("playbook_id")
236
+ or not isinstance(meta.get("depth"), int)
237
+ or isinstance(meta.get("depth"), bool)
238
+ or meta.get("depth") <= 1
239
+ ):
240
+ print(
241
+ f"Error: origin metadata for '{step_name}' must be an object with a non-empty "
242
+ f"string 'playbook_id' and an integer 'depth' > 1",
243
+ file=sys.stderr,
244
+ )
245
+ sys.exit(2)
246
+ origins[step_name] = {"playbook_id": meta["playbook_id"], "depth": meta["depth"]}
247
+
248
+ state = {
249
+ "version": 1,
250
+ "created_at": datetime.now(timezone.utc).isoformat(),
251
+ "playbook_id": playbook_id,
252
+ "playbook_name": playbook_name,
253
+ "total_steps": total,
254
+ "order": [str(i) for i in range(1, total + 1)],
255
+ "numbering": numbering,
256
+ "conditionals": conditionals,
257
+ "origins": origins,
258
+ }
259
+
260
+ with open(state_file_path, "w", encoding="utf-8") as f:
261
+ json.dump(state, f, indent=2)
262
+ f.write("\n")
263
+
264
+
265
+ def conditional_marker(step_name):
266
+ """" (may be skipped depending on step N's result)" for a conditional step, else ''."""
267
+ depends_on = conditionals.get(step_name)
268
+ if not depends_on:
269
+ return ""
270
+ for n in state["order"]:
271
+ if numbering[n]["step"] == depends_on:
272
+ return f" (may be skipped depending on step {n}'s result)"
273
+ return "" # depends_on step not found in this display's order -- defensive, should not happen
274
+
275
+
276
+ def origin_indent_and_marker(step_name):
277
+ """(indent, suffix) for a depth>1 (composed/nested) step, else ('', ''). See header 'NESTED/
278
+ ORIGIN DISPLAY' -- indentation groups nested steps visually; the suffix names the originating
279
+ playbook and depth. Numbering itself is never affected -- still flat 1..N."""
280
+ meta = origins.get(step_name)
281
+ if not meta:
282
+ return "", ""
283
+ return " ", f" (from playbook: {meta['playbook_id']}, depth {meta['depth']})"
284
+
285
+
286
+ def render_body():
287
+ lines = []
288
+ for n in state["order"]:
289
+ rec = numbering[n]
290
+ box = "[x]" if rec["checked"] else "[ ]"
291
+ indent, origin_suffix = origin_indent_and_marker(rec["step"])
292
+ lines.append(f"{indent} {box} {n}. {rec['step']}{origin_suffix}{conditional_marker(rec['step'])}")
293
+ return lines
294
+
295
+
296
+ def render_footer():
297
+ checked = sum(1 for rec in numbering.values() if rec["checked"])
298
+ unchecked = total - checked
299
+ line = f"Currently checked: {checked} of {total} step(s)."
300
+ if unchecked:
301
+ line += f" {unchecked} step(s) unchecked and will be skipped."
302
+ return line
303
+
304
+
305
+ header = [
306
+ "=" * 68,
307
+ f" /jenga -- Proposed Playbook: {playbook_name} ({playbook_id})",
308
+ "=" * 68,
309
+ "This looks like a multi-step workflow. Here is the proposed chain, in order.",
310
+ "Everything below is checked [x] by default. Uncheck steps you do NOT want",
311
+ "to run, or re-check steps you previously unchecked, by number.",
312
+ "",
313
+ "Commands:",
314
+ ' check <numbers> -- check the listed step(s), e.g. "check 3,5"',
315
+ ' uncheck <numbers> -- uncheck the listed step(s), e.g. "uncheck 2"',
316
+ ' <numbers> -- bare numbers toggle their current state',
317
+ ' confirm -- proceed with the currently checked steps, in order',
318
+ ' cancel -- abort, nothing will run',
319
+ "-" * 68,
320
+ "",
321
+ ]
322
+
323
+ body = render_body()
324
+
325
+ footer = [
326
+ "",
327
+ "-" * 68,
328
+ render_footer(),
329
+ "",
330
+ 'Reply with a command ("check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel").',
331
+ ]
332
+
333
+ print("\n".join(header + body + footer))
334
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
335
+ PY
336
+
337
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$PLAYBOOK_ID" "$PLAYBOOK_NAME" "$RAW_STEPS" "$RAW_CONDITIONALS" "$RAW_ORIGINS"
338
+ exit 0
339
+ fi
340
+
341
+ # --------------------------------------------------------------------------
342
+ # continue mode
343
+ # --------------------------------------------------------------------------
344
+
345
+ if [ ! -f "$STATE_FILE" ]; then
346
+ echo "Error: state file not found at $STATE_FILE" >&2
347
+ echo "The confirmation session may have expired (e.g. temp dir was cleared)." >&2
348
+ echo "Start a new confirmation session with: render-playbook-confirmation.sh \"<playbook_id>\" \"<name>\" \"<steps>\"" >&2
349
+ exit 2
350
+ fi
351
+
352
+ PY_SCRIPT="$(mktemp -t render-playbook-confirmation-continue-XXXXXX.py)"
353
+ trap 'rm -f "$PY_SCRIPT"' EXIT
354
+
355
+ cat > "$PY_SCRIPT" <<'PY'
356
+ import json
357
+ import os
358
+ import re
359
+ import sys
360
+
361
+ state_file_path = sys.argv[1]
362
+ raw_reply = sys.argv[2]
363
+
364
+ try:
365
+ with open(state_file_path, encoding="utf-8") as f:
366
+ state = json.load(f)
367
+ except Exception as e:
368
+ print(f"Error: could not read/parse state file at {state_file_path}: {e}", file=sys.stderr)
369
+ sys.exit(2)
370
+
371
+ numbering = state["numbering"]
372
+ order = state["order"]
373
+ total = state["total_steps"]
374
+ playbook_id = state["playbook_id"]
375
+ playbook_name = state["playbook_name"]
376
+ conditionals = state.get("conditionals", {})
377
+ origins = state.get("origins", {})
378
+
379
+
380
+ def conditional_marker(step_name):
381
+ """" (may be skipped depending on step N's result)" for a conditional step, else ''."""
382
+ depends_on = conditionals.get(step_name)
383
+ if not depends_on:
384
+ return ""
385
+ for n in order:
386
+ if numbering[n]["step"] == depends_on:
387
+ return f" (may be skipped depending on step {n}'s result)"
388
+ return "" # depends_on step not found in this display's order -- defensive, should not happen
389
+
390
+
391
+ def origin_indent_and_marker(step_name):
392
+ """(indent, suffix) for a depth>1 (composed/nested) step, else ('', ''). Read from the state
393
+ file's persisted `origins` map -- never recomputed from arguments on a continue-mode turn."""
394
+ meta = origins.get(step_name)
395
+ if not meta:
396
+ return "", ""
397
+ return " ", f" (from playbook: {meta['playbook_id']}, depth {meta['depth']})"
398
+
399
+
400
+ def render_body():
401
+ lines = []
402
+ for n in order:
403
+ rec = numbering[n]
404
+ box = "[x]" if rec["checked"] else "[ ]"
405
+ indent, origin_suffix = origin_indent_and_marker(rec["step"])
406
+ lines.append(f"{indent} {box} {n}. {rec['step']}{origin_suffix}{conditional_marker(rec['step'])}")
407
+ return lines
408
+
409
+
410
+ def render_footer():
411
+ checked = sum(1 for rec in numbering.values() if rec["checked"])
412
+ unchecked = total - checked
413
+ line = f"Currently checked: {checked} of {total} step(s)."
414
+ if unchecked:
415
+ line += f" {unchecked} step(s) unchecked and will be skipped."
416
+ return line
417
+
418
+
419
+ stripped = raw_reply.strip()
420
+ lowered = stripped.lower()
421
+
422
+ if lowered == "cancel":
423
+ try:
424
+ os.remove(state_file_path)
425
+ except OSError:
426
+ pass
427
+ print("Confirmation session cancelled. Nothing will run.")
428
+ sys.exit(3)
429
+
430
+ if lowered == "confirm":
431
+ checked_steps = [numbering[n]["step"] for n in order if numbering[n]["checked"]]
432
+
433
+ result = {
434
+ "status": "confirmed",
435
+ "playbook_id": playbook_id,
436
+ "name": playbook_name,
437
+ "steps": checked_steps,
438
+ }
439
+
440
+ try:
441
+ os.remove(state_file_path)
442
+ except OSError:
443
+ pass
444
+
445
+ print(json.dumps(result, indent=2))
446
+ sys.exit(0)
447
+
448
+ action = "toggle"
449
+ tokens_source = stripped
450
+ prefix_match = re.match(r'^(check|uncheck)\b(.*)$', stripped, re.IGNORECASE)
451
+ if prefix_match:
452
+ action = prefix_match.group(1).lower()
453
+ tokens_source = prefix_match.group(2)
454
+
455
+ raw_tokens = tokens_source.replace(",", " ").split()
456
+ tokens = [tok.strip() for tok in raw_tokens if tok.strip() != ""]
457
+
458
+ if not tokens:
459
+ print("Error: no selection numbers found in your reply.")
460
+ print("")
461
+ print('Reply with a command: "check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel".')
462
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
463
+ sys.exit(1)
464
+
465
+ invalid = []
466
+ valid_tokens = []
467
+ for tok in tokens:
468
+ if not re.fullmatch(r"[0-9]+", tok):
469
+ invalid.append((tok, "not a whole number"))
470
+ continue
471
+ if tok not in numbering:
472
+ invalid.append((tok, f"out of range (valid: 1-{total})"))
473
+ continue
474
+ valid_tokens.append(tok)
475
+
476
+ if invalid:
477
+ print("Error: your reply contains invalid selection number(s):")
478
+ for tok, reason in invalid:
479
+ print(f" - '{tok}': {reason}")
480
+ print("")
481
+ print(f"Valid selection numbers for this session are 1-{total}.")
482
+ print('Reply again with a corrected command ("check N,N", "uncheck N,N", bare numbers, "confirm", or "cancel").')
483
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
484
+ sys.exit(1)
485
+
486
+ seen = set()
487
+ for tok in valid_tokens:
488
+ if tok in seen:
489
+ continue
490
+ seen.add(tok)
491
+ if action == "check":
492
+ numbering[tok]["checked"] = True
493
+ elif action == "uncheck":
494
+ numbering[tok]["checked"] = False
495
+ else:
496
+ numbering[tok]["checked"] = not numbering[tok]["checked"]
497
+
498
+ state["numbering"] = numbering
499
+ with open(state_file_path, "w", encoding="utf-8") as f:
500
+ json.dump(state, f, indent=2)
501
+ f.write("\n")
502
+
503
+ output_lines = ["Updated:", ""] + render_body() + [
504
+ "",
505
+ "-" * 68,
506
+ render_footer(),
507
+ "",
508
+ 'Reply with another command ("check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel").',
509
+ ]
510
+
511
+ print("\n".join(output_lines))
512
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
513
+ sys.exit(0)
514
+ PY
515
+
516
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$RAW_REPLY"
517
+ exit $?