@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.
- package/README.md +3 -3
- package/agents/developer.md +15 -15
- package/agents/scrum-master.md +17 -17
- package/agents/tester.md +25 -15
- package/lib/skill-allow-list.json +3 -2
- package/package.json +1 -1
- package/scripts/audit-twin-divergence.sh +625 -0
- package/scripts/build-pages-site.sh +268 -0
- package/scripts/check-public-playbook-steps.sh +136 -0
- package/skills/j-close-story/SKILL.md +1 -1
- package/skills/j-do/SKILL.md +19 -19
- package/skills/j-doc-sync/SKILL.md +12 -1
- package/skills/j-idea/SKILL.md +1 -1
- package/skills/j-init/SKILL.md +5 -4
- package/skills/j-init/assets/directory_structure.txt +1 -0
- package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
- package/skills/j-init/scripts/init.sh +13 -2
- package/skills/j-playbook/SKILL.md +81 -0
- package/skills/j-proceed/SKILL.md +1 -1
- package/skills/j-publish/SKILL.md +1 -1
- package/skills/j-publish/adapters/npm-ci.md +29 -0
- package/skills/j-publish/scripts/npm_ci_pipeline.sh +3 -0
- package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
- package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
- package/skills/j-reconcile/SKILL.md +1 -0
- package/skills/j-redo/SKILL.md +1 -1
- package/skills/j-status/SKILL.md +12 -0
- package/skills/j-todo/SKILL.md +2 -2
- package/skills/j-uncharted/SKILL.md +8 -7
- package/skills/j-uncharted/scripts/elicitation-state.sh +15 -1
- package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
- package/skills/jenga/SKILL.md +80 -9
- package/skills/jenga/playbooks/idea-to-committed.json +20 -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 +1022 -0
- package/skills/jenga/scripts/match-playbook.sh +262 -0
- package/skills/jenga/scripts/render-playbook-confirmation.sh +517 -0
- package/skills/jenga/scripts/run-playbook-step.sh +766 -0
- package/skills/jenga-permission-level/SKILL.md +4 -4
- package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
- 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 $?
|