@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.
- package/README.md +3 -3
- package/lib/skill-allow-list.json +1 -1
- package/package.json +1 -1
- package/scripts/build-pages-site.sh +268 -0
- package/skills/j-uncharted/scripts/elicitation-state.sh +15 -1
- package/skills/jenga/SKILL.md +39 -7
- package/skills/jenga/playbooks/brainstorm-to-mirror.json +22 -0
- package/skills/jenga/playbooks/schema.json +42 -0
- package/skills/jenga/scripts/detect-nl-intent.sh +179 -0
- package/skills/jenga/scripts/load-nl-catalog.js +206 -0
- package/skills/jenga/scripts/load-nl-catalog.sh +65 -0
- package/skills/jenga/scripts/load-playbooks.sh +194 -0
- package/skills/jenga/scripts/match-playbook.sh +262 -0
- package/skills/jenga/scripts/render-playbook-confirmation.sh +363 -0
- package/skills/jenga/scripts/run-playbook-step.sh +273 -0
|
@@ -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 $?
|