@jenga-ai/agent 3.1.0 → 3.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.
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env bash
2
+ # ---------------------------------------------------------------------------
3
+ # skills/jenga/scripts/match-playbook.sh
4
+ #
5
+ # Deterministic PLAYBOOK matcher for `/jenga`'s natural-language branch (E53_S02_T02). Runs the
6
+ # same three-pass matching *philosophy* as `skills/route/SKILL.md`'s Step 2 (keyword ->
7
+ # example similarity -> description), but scoped to the playbook catalog produced by
8
+ # `load-playbooks.sh` (E53_S02_T01) instead of the single-skill catalog `load-nl-catalog.sh`
9
+ # produces for `/route`/`/jenga`'s existing single-skill matching.
10
+ #
11
+ # ---------------------------------------------------------------------------
12
+ # THIS IS A FALLBACK — READ BEFORE WIRING (E53_S02_T04)
13
+ # ---------------------------------------------------------------------------
14
+ # This script is invoked ONLY after `/jenga`'s existing single-skill match (against
15
+ # `load-nl-catalog.sh`'s catalog, per E53_S01_T03) has already been attempted and did NOT produce
16
+ # a confident result (no match, or an ambiguous multi-way tie). It never runs ahead of, or in
17
+ # place of, single-skill matching — a confident single-skill match always wins and this script is
18
+ # never even invoked in that case. This mirrors the story's own framing: playbooks are proposed
19
+ # only when intent "does not cleanly resolve to one skill."
20
+ #
21
+ # ---------------------------------------------------------------------------
22
+ # USAGE
23
+ # ---------------------------------------------------------------------------
24
+ # skills/jenga/scripts/match-playbook.sh "<raw prompt>"
25
+ #
26
+ # The argument is the same raw natural-language argument `detect-nl-intent.sh` classified as
27
+ # `nl_intent` (its `raw_argument` field) — passed through verbatim, not re-cleaned here.
28
+ #
29
+ # ---------------------------------------------------------------------------
30
+ # MATCHING ALGORITHM (deterministic — a shell/python script cannot do semantic judgment the way
31
+ # an agent can, so this is a concrete, repeatable heuristic standing in for /route's Step 2 prose)
32
+ # ---------------------------------------------------------------------------
33
+ # Three passes are run in order against the full playbook catalog (from `load-playbooks.sh`).
34
+ # Each pass narrows the candidate pool; the first pass to produce a single unique leader commits
35
+ # to that result. A pass that produces a TIE narrows the pool to just the tied candidates and
36
+ # falls through to the next pass as a tie-breaker (rather than immediately declaring ambiguity) —
37
+ # only if the FINAL pass (description match) still can't break the tie is the result "ambiguous".
38
+ # If the final pass finds NO signal at all (every candidate scores 0) but an earlier pass had
39
+ # already established a real tie among 2+ candidates (on a genuine positive score, not a
40
+ # default/empty pool), that pre-existing tie is reported as "ambiguous" rather than discarded as
41
+ # "no_match" — the final pass adding no new information does not erase the real signal a prior
42
+ # pass already found. "no_match" is reserved for when NO pass ever found any positive signal.
43
+ #
44
+ # Pass 1 — Keyword match: score = count of this playbook's `keywords` entries that appear as a
45
+ # case-insensitive substring of the raw prompt. Playbooks scoring 0 are dropped from
46
+ # the pool for this pass. If exactly one playbook has the (positive) max score -> match.
47
+ # If the pool is empty (no playbook matched any keyword) -> proceed to Pass 2 over the
48
+ # FULL catalog. If tied among 2+ -> proceed to Pass 2 restricted to the TIED set.
49
+ #
50
+ # Pass 2 — Example similarity: score = the highest Jaccard token-overlap ratio (lowercased,
51
+ # stopword-filtered word sets) between the prompt and any one of the playbook's
52
+ # `examples`. Scores below MIN_SIMILARITY are treated as 0 (non-candidates). Same
53
+ # unique-max / tie / empty-pool handling as Pass 1, operating on the current pool.
54
+ #
55
+ # Pass 3 — Description match: same Jaccard token-overlap approach, against each playbook's
56
+ # single `description` string instead of its `examples` list. This is the FINAL pass:
57
+ # a unique max -> match; a tie among 2+ -> "ambiguous"; an empty pool / all-zero scores
58
+ # -> "no_match".
59
+ #
60
+ # ---------------------------------------------------------------------------
61
+ # OUTPUT SCHEMA
62
+ # ---------------------------------------------------------------------------
63
+ # stdout is always a single JSON object, one of:
64
+ #
65
+ # {"classification": "playbook_match", "playbook_id": "brainstorm-to-mirror",
66
+ # "name": "Idea to Public Release", "steps": ["brainstorm", "todo", "do", "dev-done", "mirror-public"]}
67
+ #
68
+ # {"classification": "ambiguous", "candidates": [{"playbook_id": "...", "name": "..."}, ...]}
69
+ #
70
+ # {"classification": "no_match"}
71
+ #
72
+ # Nothing else is ever written to stdout — errors/warnings go to stderr only.
73
+ #
74
+ # ---------------------------------------------------------------------------
75
+ # EXIT CODES
76
+ # ---------------------------------------------------------------------------
77
+ # 0 any of the three classifications above (all are legitimate outcomes, not errors)
78
+ # 2 usage error (no argument given), or a real setup failure (`load-playbooks.sh` failed,
79
+ # python3 unavailable)
80
+ #
81
+ # ---------------------------------------------------------------------------
82
+
83
+ set -euo pipefail
84
+
85
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
86
+ LOAD_PLAYBOOKS="$SCRIPT_DIR/load-playbooks.sh"
87
+
88
+ if [ $# -lt 1 ] || [ -z "${1:-}" ]; then
89
+ echo 'Usage: match-playbook.sh "<raw prompt>"' >&2
90
+ exit 2
91
+ fi
92
+
93
+ RAW_PROMPT="$1"
94
+
95
+ if [ ! -x "$LOAD_PLAYBOOKS" ] && [ ! -f "$LOAD_PLAYBOOKS" ]; then
96
+ echo "Error: load-playbooks.sh not found at $LOAD_PLAYBOOKS" >&2
97
+ exit 2
98
+ fi
99
+
100
+ if ! command -v python3 >/dev/null 2>&1; then
101
+ echo "Error: python3 is required by match-playbook.sh" >&2
102
+ exit 2
103
+ fi
104
+
105
+ CATALOG_JSON="$(bash "$LOAD_PLAYBOOKS")" || {
106
+ echo "Error: load-playbooks.sh failed" >&2
107
+ exit 2
108
+ }
109
+
110
+ PY_SCRIPT="$(mktemp -t match-playbook-XXXXXX.py)"
111
+ trap 'rm -f "$PY_SCRIPT"' EXIT
112
+
113
+ cat > "$PY_SCRIPT" <<'PY'
114
+ import json
115
+ import re
116
+ import sys
117
+
118
+ raw_prompt = sys.argv[1]
119
+ catalog_json = sys.stdin.read()
120
+
121
+ try:
122
+ catalog = json.loads(catalog_json)
123
+ except Exception as e:
124
+ print(f"Error: could not parse load-playbooks.sh output as JSON: {e}", file=sys.stderr)
125
+ sys.exit(2)
126
+
127
+ if not isinstance(catalog, list):
128
+ print("Error: load-playbooks.sh produced a non-array result", file=sys.stderr)
129
+ sys.exit(2)
130
+
131
+ if len(catalog) == 0:
132
+ print(json.dumps({"classification": "no_match"}))
133
+ sys.exit(0)
134
+
135
+ MIN_SIMILARITY = 0.15
136
+
137
+ STOPWORDS = {
138
+ "a", "an", "the", "to", "and", "or", "of", "in", "on", "for", "this", "that",
139
+ "is", "it", "its", "with", "from", "into", "i", "my", "me", "we", "our",
140
+ "you", "your", "then", "so", "be", "as", "at", "by", "up", "out", "all",
141
+ "let", "lets", "let's", "go", "want", "please", "help", "would", "like",
142
+ }
143
+
144
+
145
+ def tokenize(text):
146
+ words = re.findall(r"[a-z0-9']+", text.lower())
147
+ return {w for w in words if w not in STOPWORDS and len(w) > 1}
148
+
149
+
150
+ def jaccard(a_tokens, b_tokens):
151
+ if not a_tokens or not b_tokens:
152
+ return 0.0
153
+ inter = len(a_tokens & b_tokens)
154
+ union = len(a_tokens | b_tokens)
155
+ return inter / union if union else 0.0
156
+
157
+
158
+ prompt_lower = raw_prompt.lower()
159
+ prompt_tokens = tokenize(raw_prompt)
160
+
161
+ pool = list(catalog)
162
+
163
+
164
+ def resolve_pool(scored_pool):
165
+ """Given a list of (playbook, score) with score > 0 meaning 'candidate', return
166
+ ('unique', playbook) | ('tie', [playbooks]) | ('empty', None)."""
167
+ positive = [(pb, s) for pb, s in scored_pool if s > 0]
168
+ if not positive:
169
+ return ("empty", None)
170
+ max_score = max(s for _, s in positive)
171
+ leaders = [pb for pb, s in positive if s == max_score]
172
+ if len(leaders) == 1:
173
+ return ("unique", leaders[0])
174
+ return ("tie", leaders)
175
+
176
+
177
+ def emit_match(pb):
178
+ print(json.dumps({
179
+ "classification": "playbook_match",
180
+ "playbook_id": pb["id"],
181
+ "name": pb["name"],
182
+ "steps": pb["steps"],
183
+ }))
184
+ sys.exit(0)
185
+
186
+
187
+ def emit_ambiguous(pbs):
188
+ print(json.dumps({
189
+ "classification": "ambiguous",
190
+ "candidates": [{"playbook_id": pb["id"], "name": pb["name"]} for pb in pbs],
191
+ }))
192
+ sys.exit(0)
193
+
194
+
195
+ def emit_no_match():
196
+ print(json.dumps({"classification": "no_match"}))
197
+ sys.exit(0)
198
+
199
+
200
+ # `narrowed_by_tie` tracks whether ANY earlier pass established a genuine tie among 2+
201
+ # candidates on real signal (a positive score, not a default/empty pool). This matters for the
202
+ # final pass below: if Pass 3 can't further discriminate (all scores drop to 0 — e.g. two
203
+ # playbooks share near-identical description text relative to a short prompt), that must NOT be
204
+ # reported as "no_match" when a real tie already existed upstream — the correct outcome is
205
+ # "ambiguous" over that already-established candidate set. "no_match" is reserved for the case
206
+ # where NO pass ever found any positive signal at all.
207
+ narrowed_by_tie = False
208
+
209
+ # --- Pass 1: keyword match -------------------------------------------------
210
+ pass1_scores = []
211
+ for pb in pool:
212
+ score = sum(1 for kw in pb["keywords"] if kw.lower() in prompt_lower)
213
+ pass1_scores.append((pb, score))
214
+
215
+ outcome, result = resolve_pool(pass1_scores)
216
+ if outcome == "unique":
217
+ emit_match(result)
218
+ elif outcome == "tie":
219
+ pool = result # narrow to tied candidates, fall through to Pass 2 as tie-breaker
220
+ narrowed_by_tie = True
221
+ # else "empty" -> pool stays the FULL catalog for Pass 2
222
+
223
+ # --- Pass 2: example similarity --------------------------------------------
224
+ pass2_scores = []
225
+ for pb in pool:
226
+ best = 0.0
227
+ for example in pb["examples"]:
228
+ sim = jaccard(prompt_tokens, tokenize(example))
229
+ if sim > best:
230
+ best = sim
231
+ pass2_scores.append((pb, best if best >= MIN_SIMILARITY else 0.0))
232
+
233
+ outcome, result = resolve_pool(pass2_scores)
234
+ if outcome == "unique":
235
+ emit_match(result)
236
+ elif outcome == "tie":
237
+ pool = result # narrow further, fall through to Pass 3 as tie-breaker
238
+ narrowed_by_tie = True
239
+ # else "empty" -> pool stays whatever it was entering Pass 2 for Pass 3 (narrowed_by_tie
240
+ # unchanged — an empty pass2 outcome adds no new tie signal of its own)
241
+
242
+ # --- Pass 3: description match (final pass) --------------------------------
243
+ pass3_scores = []
244
+ for pb in pool:
245
+ sim = jaccard(prompt_tokens, tokenize(pb["description"]))
246
+ pass3_scores.append((pb, sim if sim >= MIN_SIMILARITY else 0.0))
247
+
248
+ outcome, result = resolve_pool(pass3_scores)
249
+ if outcome == "unique":
250
+ emit_match(result)
251
+ elif outcome == "tie":
252
+ emit_ambiguous(result)
253
+ elif narrowed_by_tie and len(pool) > 1:
254
+ # Pass 3 added no further discriminating signal, but an earlier pass already established a
255
+ # real tie among these exact candidates -- report that tie rather than discarding it.
256
+ emit_ambiguous(pool)
257
+ else:
258
+ emit_no_match()
259
+ PY
260
+
261
+ python3 "$PY_SCRIPT" "$RAW_PROMPT" <<< "$CATALOG_JSON"
262
+ exit $?
@@ -0,0 +1,363 @@
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>"
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
+ # render-playbook-confirmation.sh <state_file> "<raw_reply>"
32
+ # Continue an existing confirmation session. <state_file> is the path printed on STDERR by
33
+ # the start-mode invocation (or by any prior continue-mode invocation). <raw_reply> is the
34
+ # user's raw chat text for this turn.
35
+ #
36
+ # ---------------------------------------------------------------------------
37
+ # TOGGLE COMMAND GRAMMAR (identical to render-confirmation.sh — no cascade, since steps are flat)
38
+ # ---------------------------------------------------------------------------
39
+ # cancel Abort the session. State file removed.
40
+ # confirm Finalize with the CURRENT checked state.
41
+ # check <numbers> Set the listed displayed number(s) to checked.
42
+ # uncheck <numbers> Set the listed displayed number(s) to unchecked.
43
+ # <numbers> Bare numbers TOGGLE the current checked state of each listed number.
44
+ #
45
+ # <numbers> is a comma- and/or whitespace-separated list of positive integers. Validation is
46
+ # ALL-OR-NOTHING per turn: if a reply contains any non-numeric token or any number outside the
47
+ # valid 1..N range, the ENTIRE turn is rejected as an error turn (state left untouched) — no
48
+ # partial application of the valid numbers in a mixed-validity reply. There is no cascading
49
+ # behavior here (unlike render-confirmation.sh's Epic->Story->Task cascade) — a playbook's steps
50
+ # are a flat ordered list with no parent/child relationship.
51
+ #
52
+ # ---------------------------------------------------------------------------
53
+ # OUTPUT CONTRACT
54
+ # ---------------------------------------------------------------------------
55
+ # - Start mode, and continue-mode TOGGLE / ERROR turns:
56
+ # STDOUT = plain human-readable text (the numbered list + instructions, or an error message
57
+ # + retry instructions) — relayed verbatim to the user.
58
+ # STDERR = `STATE_FILE: <absolute path>` — for the calling AGENT only, never relayed.
59
+ #
60
+ # - Continue mode, CONFIRMED (final) turn:
61
+ # STDOUT = a single JSON object: {"status": "confirmed", "playbook_id": "...",
62
+ # "name": "...", "steps": ["<checked step1>", ...]} — checked-only steps, in
63
+ # ORIGINAL PLAYBOOK ORDER regardless of what order they were un/re-checked in.
64
+ # NOT meant to be relayed to the user verbatim.
65
+ # STDERR = empty (state file removed).
66
+ #
67
+ # - Continue mode, CANCELLED (`cancel` reply, case-insensitive):
68
+ # STDOUT = plain human-readable cancellation acknowledgement — behaviorally identical to
69
+ # render-confirmation.sh's and render-picker.sh's own cancellation handling: same
70
+ # "nothing will run" framing, same exit code (3).
71
+ # STDERR = empty (state file removed).
72
+ #
73
+ # ---------------------------------------------------------------------------
74
+ # EXIT CODES
75
+ # ---------------------------------------------------------------------------
76
+ # 0 start mode rendered successfully; OR continue mode applied a toggle/check/uncheck command
77
+ # and re-rendered the list; OR continue mode fully confirmed (final JSON emitted)
78
+ # 1 continue mode: at least one invalid/out-of-range number, or no numbers found, in the
79
+ # reply — an error turn was emitted, retry with the same state file
80
+ # 2 usage error, or a real setup problem (python3 unavailable, state file missing/corrupt)
81
+ # 3 continue mode: user replied "cancel" — session aborted, state file removed
82
+ #
83
+ # ---------------------------------------------------------------------------
84
+
85
+ set -euo pipefail
86
+
87
+ if ! command -v python3 >/dev/null 2>&1; then
88
+ echo "Error: python3 is required by render-playbook-confirmation.sh" >&2
89
+ exit 2
90
+ fi
91
+
92
+ if [ $# -eq 3 ]; then
93
+ MODE="start"
94
+ PLAYBOOK_ID="$1"
95
+ PLAYBOOK_NAME="$2"
96
+ RAW_STEPS="$3"
97
+ elif [ $# -eq 2 ]; then
98
+ MODE="continue"
99
+ STATE_FILE="$1"
100
+ RAW_REPLY="$2"
101
+ else
102
+ echo "Usage:" >&2
103
+ echo " render-playbook-confirmation.sh \"<playbook_id>\" \"<name>\" \"<comma-separated ordered step names>\" # start" >&2
104
+ echo " render-playbook-confirmation.sh <state_file> \"<raw_reply>\" # continue" >&2
105
+ exit 2
106
+ fi
107
+
108
+ if [ "$MODE" = "start" ]; then
109
+ if [ -z "${RAW_STEPS// /}" ]; then
110
+ echo "Error: no steps given to render-playbook-confirmation.sh" >&2
111
+ exit 2
112
+ fi
113
+
114
+ STATE_FILE="$(mktemp -t jenga-playbook-confirm-XXXXXX.json)"
115
+
116
+ PY_SCRIPT="$(mktemp -t render-playbook-confirmation-start-XXXXXX.py)"
117
+ trap 'rm -f "$PY_SCRIPT"' EXIT
118
+
119
+ cat > "$PY_SCRIPT" <<'PY'
120
+ import json
121
+ import sys
122
+ from datetime import datetime, timezone
123
+
124
+ state_file_path = sys.argv[1]
125
+ playbook_id = sys.argv[2]
126
+ playbook_name = sys.argv[3]
127
+ raw_steps = sys.argv[4]
128
+
129
+ steps = [s.strip() for s in raw_steps.split(",") if s.strip() != ""]
130
+
131
+ if not steps:
132
+ print("Error: no valid step names parsed from the given list", file=sys.stderr)
133
+ sys.exit(2)
134
+
135
+ numbering = {}
136
+ for i, step in enumerate(steps, start=1):
137
+ numbering[str(i)] = {"step": step, "checked": True}
138
+
139
+ total = len(numbering)
140
+
141
+ state = {
142
+ "version": 1,
143
+ "created_at": datetime.now(timezone.utc).isoformat(),
144
+ "playbook_id": playbook_id,
145
+ "playbook_name": playbook_name,
146
+ "total_steps": total,
147
+ "order": [str(i) for i in range(1, total + 1)],
148
+ "numbering": numbering,
149
+ }
150
+
151
+ with open(state_file_path, "w", encoding="utf-8") as f:
152
+ json.dump(state, f, indent=2)
153
+ f.write("\n")
154
+
155
+
156
+ def render_body():
157
+ lines = []
158
+ for n in state["order"]:
159
+ rec = numbering[n]
160
+ box = "[x]" if rec["checked"] else "[ ]"
161
+ lines.append(f" {box} {n}. {rec['step']}")
162
+ return lines
163
+
164
+
165
+ def render_footer():
166
+ checked = sum(1 for rec in numbering.values() if rec["checked"])
167
+ unchecked = total - checked
168
+ line = f"Currently checked: {checked} of {total} step(s)."
169
+ if unchecked:
170
+ line += f" {unchecked} step(s) unchecked and will be skipped."
171
+ return line
172
+
173
+
174
+ header = [
175
+ "=" * 68,
176
+ f" /jenga -- Proposed Playbook: {playbook_name} ({playbook_id})",
177
+ "=" * 68,
178
+ "This looks like a multi-step workflow. Here is the proposed chain, in order.",
179
+ "Everything below is checked [x] by default. Uncheck steps you do NOT want",
180
+ "to run, or re-check steps you previously unchecked, by number.",
181
+ "",
182
+ "Commands:",
183
+ ' check <numbers> -- check the listed step(s), e.g. "check 3,5"',
184
+ ' uncheck <numbers> -- uncheck the listed step(s), e.g. "uncheck 2"',
185
+ ' <numbers> -- bare numbers toggle their current state',
186
+ ' confirm -- proceed with the currently checked steps, in order',
187
+ ' cancel -- abort, nothing will run',
188
+ "-" * 68,
189
+ "",
190
+ ]
191
+
192
+ body = render_body()
193
+
194
+ footer = [
195
+ "",
196
+ "-" * 68,
197
+ render_footer(),
198
+ "",
199
+ 'Reply with a command ("check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel").',
200
+ ]
201
+
202
+ print("\n".join(header + body + footer))
203
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
204
+ PY
205
+
206
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$PLAYBOOK_ID" "$PLAYBOOK_NAME" "$RAW_STEPS"
207
+ exit 0
208
+ fi
209
+
210
+ # --------------------------------------------------------------------------
211
+ # continue mode
212
+ # --------------------------------------------------------------------------
213
+
214
+ if [ ! -f "$STATE_FILE" ]; then
215
+ echo "Error: state file not found at $STATE_FILE" >&2
216
+ echo "The confirmation session may have expired (e.g. temp dir was cleared)." >&2
217
+ echo "Start a new confirmation session with: render-playbook-confirmation.sh \"<playbook_id>\" \"<name>\" \"<steps>\"" >&2
218
+ exit 2
219
+ fi
220
+
221
+ PY_SCRIPT="$(mktemp -t render-playbook-confirmation-continue-XXXXXX.py)"
222
+ trap 'rm -f "$PY_SCRIPT"' EXIT
223
+
224
+ cat > "$PY_SCRIPT" <<'PY'
225
+ import json
226
+ import os
227
+ import re
228
+ import sys
229
+
230
+ state_file_path = sys.argv[1]
231
+ raw_reply = sys.argv[2]
232
+
233
+ try:
234
+ with open(state_file_path, encoding="utf-8") as f:
235
+ state = json.load(f)
236
+ except Exception as e:
237
+ print(f"Error: could not read/parse state file at {state_file_path}: {e}", file=sys.stderr)
238
+ sys.exit(2)
239
+
240
+ numbering = state["numbering"]
241
+ order = state["order"]
242
+ total = state["total_steps"]
243
+ playbook_id = state["playbook_id"]
244
+ playbook_name = state["playbook_name"]
245
+
246
+
247
+ def render_body():
248
+ lines = []
249
+ for n in order:
250
+ rec = numbering[n]
251
+ box = "[x]" if rec["checked"] else "[ ]"
252
+ lines.append(f" {box} {n}. {rec['step']}")
253
+ return lines
254
+
255
+
256
+ def render_footer():
257
+ checked = sum(1 for rec in numbering.values() if rec["checked"])
258
+ unchecked = total - checked
259
+ line = f"Currently checked: {checked} of {total} step(s)."
260
+ if unchecked:
261
+ line += f" {unchecked} step(s) unchecked and will be skipped."
262
+ return line
263
+
264
+
265
+ stripped = raw_reply.strip()
266
+ lowered = stripped.lower()
267
+
268
+ if lowered == "cancel":
269
+ try:
270
+ os.remove(state_file_path)
271
+ except OSError:
272
+ pass
273
+ print("Confirmation session cancelled. Nothing will run.")
274
+ sys.exit(3)
275
+
276
+ if lowered == "confirm":
277
+ checked_steps = [numbering[n]["step"] for n in order if numbering[n]["checked"]]
278
+
279
+ result = {
280
+ "status": "confirmed",
281
+ "playbook_id": playbook_id,
282
+ "name": playbook_name,
283
+ "steps": checked_steps,
284
+ }
285
+
286
+ try:
287
+ os.remove(state_file_path)
288
+ except OSError:
289
+ pass
290
+
291
+ print(json.dumps(result, indent=2))
292
+ sys.exit(0)
293
+
294
+ action = "toggle"
295
+ tokens_source = stripped
296
+ prefix_match = re.match(r'^(check|uncheck)\b(.*)$', stripped, re.IGNORECASE)
297
+ if prefix_match:
298
+ action = prefix_match.group(1).lower()
299
+ tokens_source = prefix_match.group(2)
300
+
301
+ raw_tokens = tokens_source.replace(",", " ").split()
302
+ tokens = [tok.strip() for tok in raw_tokens if tok.strip() != ""]
303
+
304
+ if not tokens:
305
+ print("Error: no selection numbers found in your reply.")
306
+ print("")
307
+ print('Reply with a command: "check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel".')
308
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
309
+ sys.exit(1)
310
+
311
+ invalid = []
312
+ valid_tokens = []
313
+ for tok in tokens:
314
+ if not re.fullmatch(r"[0-9]+", tok):
315
+ invalid.append((tok, "not a whole number"))
316
+ continue
317
+ if tok not in numbering:
318
+ invalid.append((tok, f"out of range (valid: 1-{total})"))
319
+ continue
320
+ valid_tokens.append(tok)
321
+
322
+ if invalid:
323
+ print("Error: your reply contains invalid selection number(s):")
324
+ for tok, reason in invalid:
325
+ print(f" - '{tok}': {reason}")
326
+ print("")
327
+ print(f"Valid selection numbers for this session are 1-{total}.")
328
+ print('Reply again with a corrected command ("check N,N", "uncheck N,N", bare numbers, "confirm", or "cancel").')
329
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
330
+ sys.exit(1)
331
+
332
+ seen = set()
333
+ for tok in valid_tokens:
334
+ if tok in seen:
335
+ continue
336
+ seen.add(tok)
337
+ if action == "check":
338
+ numbering[tok]["checked"] = True
339
+ elif action == "uncheck":
340
+ numbering[tok]["checked"] = False
341
+ else:
342
+ numbering[tok]["checked"] = not numbering[tok]["checked"]
343
+
344
+ state["numbering"] = numbering
345
+ with open(state_file_path, "w", encoding="utf-8") as f:
346
+ json.dump(state, f, indent=2)
347
+ f.write("\n")
348
+
349
+ output_lines = ["Updated:", ""] + render_body() + [
350
+ "",
351
+ "-" * 68,
352
+ render_footer(),
353
+ "",
354
+ 'Reply with another command ("check N,N", "uncheck N,N", bare numbers to toggle, "confirm", or "cancel").',
355
+ ]
356
+
357
+ print("\n".join(output_lines))
358
+ print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
359
+ sys.exit(0)
360
+ PY
361
+
362
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$RAW_REPLY"
363
+ exit $?