@jenga-ai/agent 3.1.1 → 3.4.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 (92) hide show
  1. package/README.md +52 -12
  2. package/agents/developer.md +31 -16
  3. package/agents/scrum-master.md +18 -17
  4. package/agents/tester.md +25 -15
  5. package/bin/jenga.js +10 -0
  6. package/lib/commands/dashboard.js +92 -0
  7. package/lib/skill-allow-list.json +7 -2
  8. package/package.json +21 -2
  9. package/project/app/api/lib/resolve-project-root.js +120 -0
  10. package/project/app/api/package.json +16 -0
  11. package/project/app/api/parsers/architecture.js +72 -0
  12. package/project/app/api/parsers/board.js +141 -0
  13. package/project/app/api/parsers/documentation.js +125 -0
  14. package/project/app/api/parsers/git-log.js +52 -0
  15. package/project/app/api/parsers/ideas.js +62 -0
  16. package/project/app/api/parsers/knowledge-graph.js +73 -0
  17. package/project/app/api/parsers/lib/markdown-dir-reader.js +163 -0
  18. package/project/app/api/parsers/rapports.js +148 -0
  19. package/project/app/api/parsers/todo.js +179 -0
  20. package/project/app/api/response.js +47 -0
  21. package/project/app/api/routes/architecture.js +23 -0
  22. package/project/app/api/routes/board.js +46 -0
  23. package/project/app/api/routes/documentation.js +24 -0
  24. package/project/app/api/routes/health.js +25 -0
  25. package/project/app/api/routes/history.js +55 -0
  26. package/project/app/api/routes/rapports.js +24 -0
  27. package/project/app/api/scripts/capture-snapshot.js +294 -0
  28. package/project/app/api/server.js +112 -0
  29. package/project/app/api/types.js +40 -0
  30. package/project/app/package.json +21 -0
  31. package/project/app/ui/dist/assets/index-7fj-vllY.js +104 -0
  32. package/project/app/ui/dist/assets/index-CdK3Qrep.css +1 -0
  33. package/project/app/ui/dist/index.html +13 -0
  34. package/project/app/ui/package.json +23 -0
  35. package/project/app/ui/scripts/build-snapshot-html.cjs +214 -0
  36. package/project/app/ui/scripts/dashboard-open.cjs +88 -0
  37. package/project/app/ui/scripts/dashboard-start.cjs +87 -0
  38. package/scripts/acquire-concurrency-slot.sh +220 -0
  39. package/scripts/audit-twin-divergence.sh +625 -0
  40. package/scripts/check-public-playbook-steps.sh +136 -0
  41. package/scripts/compute-deploy-reconcile.sh +439 -0
  42. package/scripts/jenga-permission-level-switch.sh +19 -3
  43. package/scripts/mark-deployed.sh +532 -0
  44. package/scripts/populate-knowledge-graph.js +429 -0
  45. package/scripts/release-concurrency-slot.sh +129 -0
  46. package/scripts/validate-board.sh +60 -2
  47. package/scripts/verify-consumer-install.sh +470 -0
  48. package/skills/j-close-story/SKILL.md +1 -1
  49. package/skills/j-cloud-connect/SKILL.md +95 -0
  50. package/skills/j-cloud-connect/scripts/configure-backend.sh +267 -0
  51. package/skills/j-cloud-connect/scripts/install-rclone.sh +153 -0
  52. package/skills/j-dashboard/SKILL.md +144 -0
  53. package/skills/j-dashboard/scripts/launch.sh +121 -0
  54. package/skills/j-dashboard/scripts/resolve-app-dir.sh +164 -0
  55. package/skills/j-dashboard/scripts/snapshot.sh +267 -0
  56. package/skills/j-dashboard-share/SKILL.md +96 -0
  57. package/skills/j-dashboard-share/scripts/upload-snapshot.sh +173 -0
  58. package/skills/j-do/SKILL.md +19 -19
  59. package/skills/j-doc-sync/SKILL.md +12 -1
  60. package/skills/j-idea/SKILL.md +1 -1
  61. package/skills/j-init/SKILL.md +5 -4
  62. package/skills/j-init/assets/directory_structure.txt +1 -0
  63. package/skills/j-init/scripts/detect-existing-codebase.sh +2 -2
  64. package/skills/j-init/scripts/init.sh +13 -2
  65. package/skills/j-playbook/SKILL.md +93 -0
  66. package/skills/j-playbook-new/SKILL.md +155 -0
  67. package/skills/j-playbook-new/scripts/playbook-new.sh +332 -0
  68. package/skills/j-proceed/SKILL.md +1 -1
  69. package/skills/j-publish/SKILL.md +1 -1
  70. package/skills/j-publish/adapters/npm-ci.md +29 -0
  71. package/skills/j-publish/scripts/npm_ci_pipeline.sh +9 -0
  72. package/skills/j-publish/scripts/npm_pipeline.sh +18 -0
  73. package/skills/j-publish/scripts/npm_stage_pipeline.sh +81 -41
  74. package/skills/j-reconcile/SKILL.md +1 -0
  75. package/skills/j-redo/SKILL.md +1 -1
  76. package/skills/j-status/SKILL.md +12 -0
  77. package/skills/j-todo/SKILL.md +2 -2
  78. package/skills/j-uncharted/SKILL.md +8 -7
  79. package/skills/j-uncharted/scripts/validate-proposed-items.sh +18 -2
  80. package/skills/jenga/SKILL.md +55 -16
  81. package/skills/jenga/playbooks/idea-to-committed.json +20 -0
  82. package/skills/jenga/playbooks/schema.json +1 -1
  83. package/skills/jenga/scripts/load-nl-catalog.js +22 -6
  84. package/skills/jenga/scripts/load-playbooks.sh +968 -41
  85. package/skills/jenga/scripts/match-playbook.sh +1 -1
  86. package/skills/jenga/scripts/render-playbook-confirmation.sh +162 -8
  87. package/skills/jenga/scripts/run-playbook-step.sh +535 -42
  88. package/skills/jenga-permission-level/SKILL.md +4 -4
  89. package/templates/KNOWLEDGE_GRAPH_STUB_SCHEMA_TEMPLATE.md +128 -0
  90. package/templates/SCRUM_BOARD_SCHEMA.md +18 -6
  91. package/templates/playbook-types.json +8 -0
  92. package/skills/jenga/playbooks/brainstorm-to-mirror.json +0 -22
@@ -9,72 +9,274 @@
9
9
  # Implementation Principle, is the deterministic bookkeeping around that: which step is current,
10
10
  # which have completed, which have failed, and enforcing that a failure halts the sequence
11
11
  # permanently with no silent skip-ahead — exactly the story's acceptance criterion for mid-chain
12
- # failure handling.
12
+ # failure handling. Conditional predicate EVALUATION (E53_S04_T02) is the same kind of
13
+ # deterministic bookkeeping — it belongs here too, never as inline `SKILL.md` reasoning.
13
14
  #
14
- # `/jenga`'s natural-language branch (wired in E53_S02_T04) uses this script as follows, after
15
- # `render-playbook-confirmation.sh` (E53_S02_T03) returns a `confirmed` result:
15
+ # `/jenga`'s natural-language branch (wired in E53_S02_T04, extended by E53_S04_T07) uses this
16
+ # script as follows, after `render-playbook-confirmation.sh` (E53_S02_T03) returns a `confirmed`
17
+ # result:
16
18
  #
17
- # 1. `init` with the confirmed, ordered step list -> get the first step to invoke.
18
- # 2. Invoke that step (as `/route`'s Step 6 already does for a single matched skill).
19
- # 3. `advance <state_file> passed` (step succeeded) or `advance <state_file> failed [note]`
20
- # (step failed) -> get the next step, a "complete" signal, or (on failure) a halt report.
19
+ # 1. `init` with the confirmed, ordered step list (and, optionally, per-step conditional
20
+ # metadata — see CONDITIONALS below) -> get the first step to invoke.
21
+ # 2. `should-skip <state_file>` -> deterministically decide whether the CURRENT step should run.
22
+ # - `{"skip": true, ...}` -> do NOT invoke the step; call `advance <state_file> skipped`.
23
+ # - `{"skip": false, ...}` -> invoke the step (as `/route`'s Step 6 already does for a single
24
+ # matched skill), then call `advance <state_file> passed ["<typed-output-value>"]` (step
25
+ # succeeded) or `advance <state_file> failed [note]` (step failed).
26
+ # 3. Any of the three `advance` outcomes returns the next step, a "complete" signal, or (on
27
+ # failure) a halt report.
21
28
  # 4. Repeat 2-3 until "complete" or a halt report is returned.
22
29
  #
30
+ # A `forward_from` step (E53_S03) resolves its actual invocation input via
31
+ # `get-output <state_file> <step_name>` (see GET-OUTPUT below) before being invoked in step 2.
32
+ #
33
+ # ---------------------------------------------------------------------------
34
+ # CAPTURED TYPED-OUTPUT ARTIFACTS (E53_S04_T01)
35
+ # ---------------------------------------------------------------------------
36
+ # `forward_from` (E53_S03) and conditional step execution (E53_S04_T02) both need a single,
37
+ # shared record of "what typed output did step X actually produce when it ran" -- this is that
38
+ # record, and there is no second, separate capture path. Every step that produces a forwardable
39
+ # typed output value (per its own `output_types` declaration, see `docs/skill-authoring.md`)
40
+ # reports that value on its `advance ... passed` call; this script stores it in the state file's
41
+ # `captured_outputs` map, keyed by the step name that just completed. A step with no typed output
42
+ # to report simply omits the argument -- `captured_outputs` only ever gains entries for steps that
43
+ # actually supplied a value. A `skipped` step (see CONDITIONALS below) never runs, so it can never
44
+ # populate `captured_outputs` for itself either -- see GET-OUTPUT for the defined failure mode this
45
+ # causes for a downstream `forward_from` that names a skipped step.
46
+ #
47
+ # ---------------------------------------------------------------------------
48
+ # CONDITIONALS AND THE `skipped` STATUS (E53_S04_T02)
49
+ # ---------------------------------------------------------------------------
50
+ # A step may declare a conditional predicate, evaluated against a NAMED PRIOR STEP's captured
51
+ # typed-output artifact (the same `captured_outputs` map above), that determines whether it
52
+ # executes. `init`'s optional 4th argument carries this metadata (see USAGE); a step absent from
53
+ # that map has no conditional and always runs, exactly as before this task.
54
+ #
55
+ # PREDICATE GRAMMAR (the simplest grammar that satisfies "evaluated against a named prior step's
56
+ # captured typed-output artifact" -- documented here as the single source of truth; playbook
57
+ # authors declare it via `load-playbooks.sh`'s StepObject `conditional` field, see that script's
58
+ # own header):
59
+ #
60
+ # non_empty true if the depended-on step's captured output is a non-empty string
61
+ # empty true if the depended-on step's captured output is absent or an empty string
62
+ # equals:<value> true if the captured output string equals <value> exactly
63
+ # not_equals:<value> true if the captured output string does NOT equal <value> exactly
64
+ #
65
+ # A depended-on step with NO captured output at all (never ran, ran but reported no value, or was
66
+ # itself skipped) is treated as an empty string for evaluation purposes -- `empty` matches,
67
+ # `non_empty` does not, `equals:<anything-nonblank>` does not, `not_equals:<anything-nonblank>`
68
+ # does.
69
+ #
70
+ # `should-skip <state_file>` evaluates the CURRENT step's conditional (if any) and reports whether
71
+ # it should be skipped, WITHOUT mutating the state file or advancing anything -- pure evaluation.
72
+ # The calling agent then either invokes the step normally, or calls `advance <state_file> skipped`
73
+ # directly without ever invoking the step. A `skipped` step is recorded in a `skipped` list
74
+ # (distinct from `completed`), advances the pointer exactly like `passed`, and NEVER halts the
75
+ # chain -- it is non-blocking, non-failing, per the story's explicit requirement.
76
+ #
77
+ # ---------------------------------------------------------------------------
78
+ # COMPOSED/NESTED STEP HANDLING (E53_S05_T04)
79
+ # ---------------------------------------------------------------------------
80
+ # A composed/nested playbook chain (built by `load-playbooks.sh`'s composition resolution,
81
+ # E53_S05_T01) arrives here already fully flattened into ONE ordered `steps` list before it is ever
82
+ # passed to `init` -- this script never sees a raw `{"playbook": ...}` reference, only a flat,
83
+ # comma-separated list of step names, exactly as it already did before this story. Consequently the
84
+ # sequencing/halt state machine required ZERO behavioral changes: it was already format-agnostic to
85
+ # where each step name originated.
86
+ #
87
+ # The only addition is bookkeeping/traceability: `init`'s optional 5th argument (`<json-origins>`,
88
+ # see USAGE) stores per-step `{"playbook_id": ..., "depth": N}` metadata verbatim in the state
89
+ # file's `origins` map. This map is a PURE PASSTHROUGH -- no subcommand in this script (`init`,
90
+ # `should-skip`, `advance`, `get-output`) ever reads or branches on it. In particular:
91
+ # - HALT-ON-FAILURE is origin-agnostic: `advance <state_file> failed` halts the entire chain
92
+ # based purely on `current_index` -- a step's origin plays no role in this decision. Verified
93
+ # against a manually constructed composed scenario (three steps, the middle one tagged with
94
+ # `origins` metadata, `failed` called on it): the chain halted exactly as it would for an
95
+ # un-composed chain, with `failed_step` correctly naming the composed step and `never_run`
96
+ # correctly listing everything after it.
97
+ # - REPORTING SHAPES are unaffected: `completed`/`skipped`/`failed_step`/`failed_note`/`never_run`
98
+ # never include or omit anything based on `origins` -- their shape is identical whether or not
99
+ # any step in the run has an `origins` entry.
100
+ # - `should-skip`/`get-output`/`captured_outputs` addressing is unaffected: all three already key
101
+ # purely off step NAME (`current_step`/`step_name`), never consulting `origins` at all.
102
+ #
103
+ # ---------------------------------------------------------------------------
104
+ # ARTIFACT PERSISTENCE AND REDACTION POLICY (E53_S04_T05)
105
+ # ---------------------------------------------------------------------------
106
+ # Every captured typed-output VALUE (a `passed` advance call that supplies one -- the exact same
107
+ # data `captured_outputs` holds, no new data captured by this policy) is ALSO persisted to a
108
+ # per-run log file, separate from the ephemeral temp state file:
109
+ #
110
+ # project/logs/playbook-runs/<run_id>/artifacts.jsonl
111
+ #
112
+ # `<run_id>` (`<UTC-timestamp>-<playbook_id>-<8-hex>`) is generated once at `init` and stored in
113
+ # the state file; every `advance` call re-derives the same directory path from it. This reuses this
114
+ # repository's existing per-run/per-event JSON-LINES logging convention (the same append-one-line
115
+ # format `project/queue/scrum_triggers.jsonl` already uses) rather than inventing a new format.
116
+ # Each line is one JSON object: `{"step": "<name>", "captured_at": "<iso8601>", "value":
117
+ # "<redacted-or-original>"}`.
118
+ #
119
+ # PROJECT ROOT RESOLUTION mirrors `scripts/write-context-digest.sh`'s existing probing order:
120
+ # `JENGA_PROJECT_DIR` -> `CLAUDE_PROJECT_DIR` -> `git rev-parse --show-toplevel` -> `pwd`.
121
+ #
122
+ # TESTING OVERRIDE -- `JENGA_PLAYBOOK_RUNS_TEST_ROOT`: when set, overrides the project-root
123
+ # resolution above for this persistence path only (mirrors `load-playbooks.sh`'s own
124
+ # `JENGA_PLAYBOOKS_TEST_ROOT` convention). Fixture tests always set this to a throwaway
125
+ # `$BATS_TEST_TMPDIR` location -- never to this repository's own `project/logs/`.
126
+ #
127
+ # REDACTION (narrow, documented, BEST-EFFORT -- not exhaustive, not a guarantee) is applied ONLY to
128
+ # the PERSISTED copy, before it is written to `artifacts.jsonl`:
129
+ # - an absolute path OUTSIDE the resolved project root -> replaced with `[REDACTED_ABS_PATH]`
130
+ # - an email-address-shaped substring -> replaced with `[REDACTED_EMAIL]`
131
+ # - a phone-number-shaped digit sequence -> replaced with `[REDACTED_PHONE]`
132
+ # The LIVE `captured_outputs` value inside the temp state file is NEVER redacted -- `should-skip`
133
+ # and `get-output` need the real, unredacted value to evaluate predicates and resolve
134
+ # `forward_from` correctly. Redaction is a persistence-time-only transform on a separate copy of
135
+ # the data, never a mutation of the functional state.
136
+ #
137
+ # RETENTION/ROTATION POSTURE: identical to `project/logs/events.json`'s own actual current
138
+ # posture -- append-only, unbounded growth, no automatic deletion or rotation of old run
139
+ # directories. This is a deliberate, documented BEST-EFFORT policy, not a guarantee of bounded
140
+ # storage or of redaction completeness -- a narrow initial pattern set will not catch every
141
+ # possible sensitive value.
142
+ #
23
143
  # ---------------------------------------------------------------------------
24
144
  # USAGE
25
145
  # ---------------------------------------------------------------------------
26
- # run-playbook-step.sh init "<playbook_id>" "<name>" "<comma-separated confirmed step names>"
146
+ # run-playbook-step.sh init "<playbook_id>" "<name>" "<comma-separated confirmed step names>" ["<json-conditionals>" ["<json-origins>"]]
27
147
  # Starts a new run. Creates a state file tracking the ordered step list, a current-step
28
- # pointer (starts at the first step), and empty completed/failed lists. Emits the first
29
- # step's info as JSON on stdout and a `STATE_FILE:` path on stderr.
148
+ # pointer (starts at the first step), an empty `captured_outputs` map, empty
149
+ # completed/skipped/failed lists, and (if given) per-step conditional metadata and origin
150
+ # metadata. Emits the first step's info as JSON on stdout and a `STATE_FILE:` path on stderr.
151
+ #
152
+ # `<json-conditionals>` is OPTIONAL. When given, it is a JSON object mapping step name ->
153
+ # `{"depends_on": "<earlier step name>", "predicate": "<predicate>"}` for ONLY the steps
154
+ # that carry a conditional (steps absent from the object always run). Example:
155
+ # '{"stepC": {"depends_on": "stepA", "predicate": "non_empty"}}'
156
+ # Omitting this argument entirely (a 3-arg `init` call, E53_S04_T01's original form) means no
157
+ # step in this run carries a conditional -- fully backward compatible.
30
158
  #
31
- # run-playbook-step.sh advance <state_file> passed
32
- # Records the CURRENT step as completed and advances the pointer. If more steps remain,
33
- # emits the next step's info as JSON (same shape as `init`'s stdout). If that was the last
34
- # step, emits a completion report instead (see OUTPUT SCHEMA) and removes the state file.
159
+ # `<json-origins>` is OPTIONAL (E53_S05_T04), and may only be given when `<json-conditionals>`
160
+ # is also given (pass `"{}"` for conditionals if there are none, to reach the 5th slot). It is
161
+ # a JSON object mapping a composed/nested step's name -> `{"playbook_id": "...", "depth": N}`,
162
+ # mirroring `render-playbook-confirmation.sh`'s own `<json-origins>` argument (E53_S05_T03).
163
+ # Stored verbatim in the state file's `origins` map -- a PURE PASSTHROUGH annotation, never
164
+ # read or branched on by ANY subcommand in this script (see "COMPOSED/NESTED STEP HANDLING"
165
+ # below). Omitting this argument reproduces the exact pre-this-task behavior, fully backward
166
+ # compatible.
167
+ #
168
+ # run-playbook-step.sh should-skip <state_file>
169
+ # Deterministically evaluates the CURRENT step's conditional against `captured_outputs`.
170
+ # Does NOT mutate the state file or advance the pointer -- pure evaluation, safe to call
171
+ # repeatedly. Emits, on stdout:
172
+ # {"skip": false, "step": "<name>"} (no conditional, or it evaluated true)
173
+ # {"skip": true, "step": "<name>", "depends_on": "...", "predicate": "...", "observed_value": "..." }
174
+ #
175
+ # run-playbook-step.sh advance <state_file> passed ["<typed-output-value>"]
176
+ # Records the CURRENT step as completed and advances the pointer. If a typed-output value is
177
+ # given, it is stored in `captured_outputs[<current step name>]` before advancing -- this
178
+ # argument is OPTIONAL; omitting it (a step with no declared output type) leaves
179
+ # `captured_outputs` untouched for that step and behaves exactly as before this argument
180
+ # existed. If more steps remain, emits the next step's info as JSON (same shape as `init`'s
181
+ # stdout). If that was the last step, emits a completion report instead (see OUTPUT SCHEMA)
182
+ # and removes the state file.
183
+ #
184
+ # run-playbook-step.sh advance <state_file> skipped
185
+ # Records the CURRENT step as SKIPPED (a new `skipped` list, distinct from `completed`) and
186
+ # advances the pointer exactly like `passed` -- a skipped step never halts the chain and is
187
+ # treated as non-blocking, non-failing. No typed-output value is ever accepted for `skipped`
188
+ # (the step never ran, so it has nothing to report). Same next-step/`complete` result shape
189
+ # as `passed`, except the `complete`/`step_ready` bookkeeping now also reflects the `skipped`
190
+ # list (see OUTPUT SCHEMA).
35
191
  #
36
192
  # run-playbook-step.sh advance <state_file> failed ["<note>"]
37
193
  # Records the CURRENT step as failed (optionally with a free-text note) and halts the
38
194
  # sequence PERMANENTLY — the state file is marked `halted: true` rather than removed, so a
39
195
  # further `advance` call against it is rejected (see EXIT CODES). Emits a halt report (see
40
- # OUTPUT SCHEMA) listing completed / failed / never-run steps.
196
+ # OUTPUT SCHEMA) listing completed / skipped / failed / never-run steps. (The optional 3rd
197
+ # argument means something different per outcome: a typed-output VALUE for `passed`, a
198
+ # free-text NOTE for `failed`, and is not accepted at all for `skipped` -- never ambiguous,
199
+ # since only one outcome word is given per call.)
200
+ #
201
+ # run-playbook-step.sh get-output <state_file> <step_name>
202
+ # Looks up `captured_outputs[<step_name>]` for a `forward_from` step to resolve its actual
203
+ # invocation input. Emits, on stdout, one of:
204
+ # {"status": "found", "step": "<step_name>", "value": "<value>"}
205
+ # {"status": "unavailable", "step": "<step_name>", "reason": "step_skipped"}
206
+ # {"status": "unavailable", "step": "<step_name>", "reason": "not_captured"}
207
+ # `reason: step_skipped` is the DEFINED, DOCUMENTED failure mode for "a skipped step followed
208
+ # by a step that attempts to forward_from it" (E53_S04_T06 fixture-covers this exact case) --
209
+ # this is never silently treated as an empty string; the calling agent must decide how to
210
+ # handle an unavailable forward source (e.g. halt the chain via `advance ... failed`).
211
+ # `reason: not_captured` covers every other case: the named step hasn't run yet, ran but
212
+ # reported no typed-output value, or does not exist in this run's step list at all.
41
213
  #
42
214
  # ---------------------------------------------------------------------------
43
215
  # OUTPUT SCHEMA
44
216
  # ---------------------------------------------------------------------------
45
- # `init` and a `passed` `advance` call that has more steps remaining both emit, on stdout:
217
+ # `init` and a `passed`/`skipped` `advance` call that has more steps remaining both emit, on
218
+ # stdout:
46
219
  #
47
220
  # {"status": "step_ready", "step": "<skill name>", "step_index": 2, "total_steps": 5}
48
221
  #
49
- # A `passed` `advance` call on the FINAL step emits, on stdout (state file removed):
222
+ # A `passed`/`skipped` `advance` call on the FINAL step emits, on stdout (state file removed):
50
223
  #
51
224
  # {"status": "complete", "playbook_id": "...", "name": "...",
52
- # "completed": ["<step1>", "<step2>", ...]}
225
+ # "completed": ["<step1>", ...], "skipped": ["<step2>", ...]}
53
226
  #
54
227
  # A `failed` `advance` call emits, on stdout (state file retained, marked halted):
55
228
  #
56
229
  # {"status": "halted", "playbook_id": "...", "name": "...",
57
- # "completed": ["<step1>", ...], "failed_step": "<stepN>", "failed_note": "<note or null>",
58
- # "never_run": ["<stepN+1>", ...]}
230
+ # "completed": ["<step1>", ...], "skipped": ["<step2>", ...], "failed_step": "<stepN>",
231
+ # "failed_note": "<note or null>", "never_run": ["<stepN+1>", ...]}
59
232
  #
60
233
  # Nothing else is ever written to stdout — errors/warnings go to stderr only.
61
234
  #
235
+ # `captured_outputs` itself is internal state-file bookkeeping, never emitted directly on stdout by
236
+ # `init`/`advance` -- a consumer that needs a captured value (e.g. a `forward_from` step resolving
237
+ # its input) reads it back via `get-output` (see USAGE above).
238
+ #
62
239
  # ---------------------------------------------------------------------------
63
240
  # EXIT CODES
64
241
  # ---------------------------------------------------------------------------
65
- # 0 `init` succeeded; OR `advance passed` succeeded (whether it returned the next step or a
66
- # "complete" report); OR `advance failed` succeeded in recording the halt (a "halted" report
67
- # IS the expected, successful outcome of this call — exit 0, not an error)
68
- # 2 usage error (missing/malformed arguments, unrecognized outcome word), or a real setup
69
- # problem (python3 unavailable, state file missing/corrupt)
70
- # 3 `advance` called against a state file already marked `halted: true` from a prior `failed`
71
- # call — rejected outright rather than silently resuming; this is the "no skip-ahead, no
72
- # silent resumption after a halt" guard the story's acceptance criteria require
242
+ # 0 `init` succeeded; OR `should-skip` succeeded (regardless of its `skip` verdict — a `true`
243
+ # verdict is a normal, expected outcome, not an error); OR `advance passed`/`advance skipped`
244
+ # succeeded (whether it returned the next step or a "complete" report); OR `advance failed`
245
+ # succeeded in recording the halt (a "halted" report IS the expected, successful outcome of
246
+ # this call — exit 0, not an error); OR `get-output` succeeded (an "unavailable" result IS a
247
+ # normal, expected outcome of this call — exit 0, not an error; the calling agent decides how
248
+ # to react to unavailability)
249
+ # 2 usage error (missing/malformed arguments, unrecognized outcome word, malformed
250
+ # `<json-conditionals>`), or a real setup problem (python3 unavailable, state file
251
+ # missing/corrupt)
252
+ # 3 `advance`/`should-skip`/`get-output` called against a state file already marked `halted:
253
+ # true` from a prior `failed` call — rejected outright rather than silently resuming; this is
254
+ # the "no skip-ahead, no silent resumption after a halt" guard the story's acceptance
255
+ # criteria require
73
256
  #
74
257
  # ---------------------------------------------------------------------------
75
258
 
76
259
  set -euo pipefail
77
260
 
261
+ # Resolve project root (E53_S04_T05, mirrors scripts/write-context-digest.sh's own probing order).
262
+ # JENGA_PLAYBOOK_RUNS_TEST_ROOT is a TEST-ONLY override for the artifact-persistence path -- never
263
+ # set it in a real invocation (see "ARTIFACT PERSISTENCE AND REDACTION POLICY" above).
264
+ resolve_project_dir() {
265
+ if [ -n "${JENGA_PLAYBOOK_RUNS_TEST_ROOT:-}" ]; then
266
+ printf '%s\n' "$JENGA_PLAYBOOK_RUNS_TEST_ROOT"
267
+ return 0
268
+ fi
269
+ if [ -n "${JENGA_PROJECT_DIR:-}" ]; then
270
+ printf '%s\n' "$JENGA_PROJECT_DIR"
271
+ return 0
272
+ fi
273
+ if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
274
+ printf '%s\n' "$CLAUDE_PROJECT_DIR"
275
+ return 0
276
+ fi
277
+ git rev-parse --show-toplevel 2>/dev/null || pwd
278
+ }
279
+
78
280
  if ! command -v python3 >/dev/null 2>&1; then
79
281
  echo "Error: python3 is required by run-playbook-step.sh" >&2
80
282
  exit 2
@@ -82,9 +284,12 @@ fi
82
284
 
83
285
  if [ $# -lt 1 ]; then
84
286
  echo "Usage:" >&2
85
- echo " run-playbook-step.sh init \"<playbook_id>\" \"<name>\" \"<comma-separated confirmed step names>\"" >&2
86
- echo " run-playbook-step.sh advance <state_file> passed" >&2
287
+ echo " run-playbook-step.sh init \"<playbook_id>\" \"<name>\" \"<comma-separated confirmed step names>\" [\"<json-conditionals>\" [\"<json-origins>\"]]" >&2
288
+ echo " run-playbook-step.sh should-skip <state_file>" >&2
289
+ echo " run-playbook-step.sh advance <state_file> passed [\"<typed-output-value>\"]" >&2
290
+ echo " run-playbook-step.sh advance <state_file> skipped" >&2
87
291
  echo " run-playbook-step.sh advance <state_file> failed [\"<note>\"]" >&2
292
+ echo " run-playbook-step.sh get-output <state_file> <step_name>" >&2
88
293
  exit 2
89
294
  fi
90
295
 
@@ -92,13 +297,15 @@ SUBCOMMAND="$1"
92
297
  shift
93
298
 
94
299
  if [ "$SUBCOMMAND" = "init" ]; then
95
- if [ $# -ne 3 ]; then
96
- echo 'Usage: run-playbook-step.sh init "<playbook_id>" "<name>" "<comma-separated steps>"' >&2
300
+ if [ $# -lt 3 ] || [ $# -gt 5 ]; then
301
+ echo 'Usage: run-playbook-step.sh init "<playbook_id>" "<name>" "<comma-separated steps>" ["<json-conditionals>" ["<json-origins>"]]' >&2
97
302
  exit 2
98
303
  fi
99
304
  PLAYBOOK_ID="$1"
100
305
  PLAYBOOK_NAME="$2"
101
306
  RAW_STEPS="$3"
307
+ RAW_CONDITIONALS="${4:-}"
308
+ RAW_ORIGINS="${5:-}"
102
309
 
103
310
  if [ -z "${RAW_STEPS// /}" ]; then
104
311
  echo "Error: no steps given to run-playbook-step.sh init" >&2
@@ -112,6 +319,8 @@ if [ "$SUBCOMMAND" = "init" ]; then
112
319
 
113
320
  cat > "$PY_SCRIPT" <<'PY'
114
321
  import json
322
+ import re
323
+ import secrets
115
324
  import sys
116
325
  from datetime import datetime, timezone
117
326
 
@@ -119,20 +328,93 @@ state_file_path = sys.argv[1]
119
328
  playbook_id = sys.argv[2]
120
329
  playbook_name = sys.argv[3]
121
330
  raw_steps = sys.argv[4]
331
+ raw_conditionals = sys.argv[5] if len(sys.argv) > 5 and sys.argv[5] != "" else None
332
+ raw_origins = sys.argv[6] if len(sys.argv) > 6 and sys.argv[6] != "" else None
122
333
 
123
334
  steps = [s.strip() for s in raw_steps.split(",") if s.strip() != ""]
124
335
  if not steps:
125
336
  print("Error: no valid step names parsed from the given list", file=sys.stderr)
126
337
  sys.exit(2)
127
338
 
339
+ PREDICATE_RE = re.compile(r'^(non_empty|empty|equals:.+|not_equals:.+)$')
340
+
341
+ conditionals = {}
342
+ if raw_conditionals is not None:
343
+ try:
344
+ parsed = json.loads(raw_conditionals)
345
+ except Exception as e:
346
+ print(f"Error: <json-conditionals> is not valid JSON: {e}", file=sys.stderr)
347
+ sys.exit(2)
348
+ if not isinstance(parsed, dict):
349
+ print("Error: <json-conditionals> must be a JSON object", file=sys.stderr)
350
+ sys.exit(2)
351
+ for step_name, cond in parsed.items():
352
+ if step_name not in steps:
353
+ print(f"Error: conditional given for '{step_name}', which is not in the step list", file=sys.stderr)
354
+ sys.exit(2)
355
+ if not isinstance(cond, dict) or "depends_on" not in cond or "predicate" not in cond:
356
+ print(f"Error: conditional for '{step_name}' must be an object with 'depends_on' and 'predicate'", file=sys.stderr)
357
+ sys.exit(2)
358
+ depends_on = cond["depends_on"]
359
+ predicate = cond["predicate"]
360
+ if depends_on not in steps or steps.index(depends_on) >= steps.index(step_name):
361
+ print(f"Error: conditional for '{step_name}' depends_on '{depends_on}', which is not an earlier step", file=sys.stderr)
362
+ sys.exit(2)
363
+ if not isinstance(predicate, str) or not PREDICATE_RE.match(predicate):
364
+ print(f"Error: conditional for '{step_name}' has an unrecognized predicate '{predicate}'", file=sys.stderr)
365
+ sys.exit(2)
366
+ conditionals[step_name] = {"depends_on": depends_on, "predicate": predicate}
367
+
368
+ # --- E53_S05_T04: origin/nesting metadata -- a PURE PASSTHROUGH, never read by any subcommand in
369
+ # this script (see header 'COMPOSED/NESTED STEP HANDLING'). ---
370
+ origins = {}
371
+ if raw_origins is not None:
372
+ try:
373
+ parsed_origins = json.loads(raw_origins)
374
+ except Exception as e:
375
+ print(f"Error: <json-origins> is not valid JSON: {e}", file=sys.stderr)
376
+ sys.exit(2)
377
+ if not isinstance(parsed_origins, dict):
378
+ print("Error: <json-origins> must be a JSON object", file=sys.stderr)
379
+ sys.exit(2)
380
+ for step_name, meta in parsed_origins.items():
381
+ if step_name not in steps:
382
+ print(f"Error: origin metadata given for '{step_name}', which is not in the step list", file=sys.stderr)
383
+ sys.exit(2)
384
+ if (
385
+ not isinstance(meta, dict)
386
+ or not isinstance(meta.get("playbook_id"), str)
387
+ or not meta.get("playbook_id")
388
+ or not isinstance(meta.get("depth"), int)
389
+ or isinstance(meta.get("depth"), bool)
390
+ or meta.get("depth") <= 1
391
+ ):
392
+ print(
393
+ f"Error: origin metadata for '{step_name}' must be an object with a non-empty "
394
+ f"string 'playbook_id' and an integer 'depth' > 1",
395
+ file=sys.stderr,
396
+ )
397
+ sys.exit(2)
398
+ origins[step_name] = {"playbook_id": meta["playbook_id"], "depth": meta["depth"]}
399
+
400
+ # run_id (E53_S04_T05) -- identifies this run's persisted-artifact directory
401
+ # (project/logs/playbook-runs/<run_id>/), stored here so every later `advance` call (a fresh
402
+ # process) can re-derive the same path without needing it passed again.
403
+ run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{playbook_id}-{secrets.token_hex(4)}"
404
+
128
405
  state = {
129
406
  "version": 1,
130
407
  "created_at": datetime.now(timezone.utc).isoformat(),
131
408
  "playbook_id": playbook_id,
132
409
  "playbook_name": playbook_name,
410
+ "run_id": run_id,
133
411
  "steps": steps,
134
412
  "current_index": 0,
135
413
  "completed": [],
414
+ "skipped": [],
415
+ "captured_outputs": {},
416
+ "conditionals": conditionals,
417
+ "origins": origins,
136
418
  "halted": False,
137
419
  "failed_step": None,
138
420
  "failed_note": None,
@@ -151,20 +433,159 @@ print(json.dumps({
151
433
  print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
152
434
  PY
153
435
 
154
- python3 "$PY_SCRIPT" "$STATE_FILE" "$PLAYBOOK_ID" "$PLAYBOOK_NAME" "$RAW_STEPS"
436
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$PLAYBOOK_ID" "$PLAYBOOK_NAME" "$RAW_STEPS" "$RAW_CONDITIONALS" "$RAW_ORIGINS"
155
437
  exit 0
156
438
 
439
+ elif [ "$SUBCOMMAND" = "should-skip" ]; then
440
+ if [ $# -ne 1 ]; then
441
+ echo 'Usage: run-playbook-step.sh should-skip <state_file>' >&2
442
+ exit 2
443
+ fi
444
+ STATE_FILE="$1"
445
+
446
+ if [ ! -f "$STATE_FILE" ]; then
447
+ echo "Error: state file not found at $STATE_FILE" >&2
448
+ exit 2
449
+ fi
450
+
451
+ PY_SCRIPT="$(mktemp -t run-playbook-step-should-skip-XXXXXX.py)"
452
+ trap 'rm -f "$PY_SCRIPT"' EXIT
453
+
454
+ cat > "$PY_SCRIPT" <<'PY'
455
+ import json
456
+ import sys
457
+
458
+ state_file_path = sys.argv[1]
459
+
460
+ try:
461
+ with open(state_file_path, encoding="utf-8") as f:
462
+ state = json.load(f)
463
+ except Exception as e:
464
+ print(f"Error: could not read/parse state file at {state_file_path}: {e}", file=sys.stderr)
465
+ sys.exit(2)
466
+
467
+ if state.get("halted"):
468
+ print(
469
+ f"Error: this playbook run already halted on step '{state.get('failed_step')}'. "
470
+ "No further evaluation is meaningful after a halt.",
471
+ file=sys.stderr,
472
+ )
473
+ sys.exit(3)
474
+
475
+ steps = state["steps"]
476
+ idx = state["current_index"]
477
+
478
+ if idx >= len(steps):
479
+ print(f"Error: state file at {state_file_path} has no current step (already complete).", file=sys.stderr)
480
+ sys.exit(2)
481
+
482
+ current_step = steps[idx]
483
+ conditionals = state.get("conditionals", {})
484
+ cond = conditionals.get(current_step)
485
+
486
+ if not cond:
487
+ print(json.dumps({"skip": False, "step": current_step}))
488
+ sys.exit(0)
489
+
490
+ depends_on = cond["depends_on"]
491
+ predicate = cond["predicate"]
492
+ observed = state.get("captured_outputs", {}).get(depends_on, "")
493
+
494
+
495
+ def evaluate(predicate, value):
496
+ if predicate == "non_empty":
497
+ return bool(value)
498
+ if predicate == "empty":
499
+ return not bool(value)
500
+ if predicate.startswith("equals:"):
501
+ return value == predicate[len("equals:"):]
502
+ if predicate.startswith("not_equals:"):
503
+ return value != predicate[len("not_equals:"):]
504
+ # Load-time validation (load-playbooks.sh) and init's own validation above should have already
505
+ # rejected any other shape -- this is a defensive fallback, never expected to trigger.
506
+ return False
507
+
508
+
509
+ predicate_true = evaluate(predicate, observed)
510
+ skip = not predicate_true
511
+
512
+ print(json.dumps({
513
+ "skip": skip,
514
+ "step": current_step,
515
+ "depends_on": depends_on,
516
+ "predicate": predicate,
517
+ "observed_value": observed,
518
+ }))
519
+ sys.exit(0)
520
+ PY
521
+
522
+ python3 "$PY_SCRIPT" "$STATE_FILE"
523
+ exit $?
524
+
525
+ elif [ "$SUBCOMMAND" = "get-output" ]; then
526
+ if [ $# -ne 2 ]; then
527
+ echo 'Usage: run-playbook-step.sh get-output <state_file> <step_name>' >&2
528
+ exit 2
529
+ fi
530
+ STATE_FILE="$1"
531
+ STEP_NAME="$2"
532
+
533
+ if [ ! -f "$STATE_FILE" ]; then
534
+ echo "Error: state file not found at $STATE_FILE" >&2
535
+ exit 2
536
+ fi
537
+
538
+ PY_SCRIPT="$(mktemp -t run-playbook-step-get-output-XXXXXX.py)"
539
+ trap 'rm -f "$PY_SCRIPT"' EXIT
540
+
541
+ cat > "$PY_SCRIPT" <<'PY'
542
+ import json
543
+ import sys
544
+
545
+ state_file_path = sys.argv[1]
546
+ step_name = sys.argv[2]
547
+
548
+ try:
549
+ with open(state_file_path, encoding="utf-8") as f:
550
+ state = json.load(f)
551
+ except Exception as e:
552
+ print(f"Error: could not read/parse state file at {state_file_path}: {e}", file=sys.stderr)
553
+ sys.exit(2)
554
+
555
+ captured = state.get("captured_outputs", {})
556
+ skipped = state.get("skipped", [])
557
+
558
+ if step_name in captured:
559
+ print(json.dumps({"status": "found", "step": step_name, "value": captured[step_name]}))
560
+ sys.exit(0)
561
+
562
+ if step_name in skipped:
563
+ print(json.dumps({"status": "unavailable", "step": step_name, "reason": "step_skipped"}))
564
+ sys.exit(0)
565
+
566
+ print(json.dumps({"status": "unavailable", "step": step_name, "reason": "not_captured"}))
567
+ sys.exit(0)
568
+ PY
569
+
570
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$STEP_NAME"
571
+ exit $?
572
+
157
573
  elif [ "$SUBCOMMAND" = "advance" ]; then
158
574
  if [ $# -lt 2 ] || [ $# -gt 3 ]; then
159
- echo 'Usage: run-playbook-step.sh advance <state_file> passed|failed ["<note>"]' >&2
575
+ echo 'Usage: run-playbook-step.sh advance <state_file> passed|skipped|failed ["<value-or-note>"]' >&2
160
576
  exit 2
161
577
  fi
162
578
  STATE_FILE="$1"
163
579
  OUTCOME="$2"
164
- NOTE="${3:-}"
580
+ EXTRA="${3:-}"
581
+
582
+ if [ "$OUTCOME" != "passed" ] && [ "$OUTCOME" != "skipped" ] && [ "$OUTCOME" != "failed" ]; then
583
+ echo "Error: outcome must be 'passed', 'skipped', or 'failed', got '$OUTCOME'" >&2
584
+ exit 2
585
+ fi
165
586
 
166
- if [ "$OUTCOME" != "passed" ] && [ "$OUTCOME" != "failed" ]; then
167
- echo "Error: outcome must be 'passed' or 'failed', got '$OUTCOME'" >&2
587
+ if [ "$OUTCOME" = "skipped" ] && [ -n "$EXTRA" ]; then
588
+ echo "Error: 'skipped' does not accept a 3rd argument -- a skipped step never ran and has nothing to report" >&2
168
589
  exit 2
169
590
  fi
170
591
 
@@ -174,17 +595,77 @@ elif [ "$SUBCOMMAND" = "advance" ]; then
174
595
  exit 2
175
596
  fi
176
597
 
598
+ PROJECT_DIR="$(resolve_project_dir)"
599
+
177
600
  PY_SCRIPT="$(mktemp -t run-playbook-step-advance-XXXXXX.py)"
178
601
  trap 'rm -f "$PY_SCRIPT"' EXIT
179
602
 
180
603
  cat > "$PY_SCRIPT" <<'PY'
181
604
  import json
182
605
  import os
606
+ import re
183
607
  import sys
608
+ from datetime import datetime, timezone
184
609
 
185
610
  state_file_path = sys.argv[1]
186
611
  outcome = sys.argv[2]
187
- note = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] != "" else None
612
+ # The optional 3rd argument means something different per outcome: a typed-output VALUE for
613
+ # `passed` (E53_S04_T01), a free-text NOTE for `failed` -- never accepted for `skipped` (rejected
614
+ # by the bash-level guard above). Only one of the two is ever read below, selected by `outcome`.
615
+ extra = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] != "" else None
616
+ note = extra
617
+ project_dir = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] != "" else None
618
+
619
+ # --- E53_S04_T05: artifact persistence + redaction (persisted copy only -- see header) ---
620
+ _ABS_PATH_RE = re.compile(r'(/[^\s"\']+)')
621
+ _EMAIL_RE = re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+')
622
+ _PHONE_RE = re.compile(r'\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b')
623
+
624
+
625
+ def _redact(value, project_root):
626
+ """Best-effort, narrow redaction of a value before it is PERSISTED -- never applied to the
627
+ live, functional captured_outputs value in the state file. See header 'ARTIFACT PERSISTENCE
628
+ AND REDACTION POLICY' for the full policy and its explicit best-effort framing."""
629
+ if not isinstance(value, str):
630
+ return value
631
+
632
+ def _replace_path(m):
633
+ p = m.group(1)
634
+ try:
635
+ abs_p = os.path.abspath(p)
636
+ abs_root = os.path.abspath(project_root)
637
+ if abs_p == abs_root or abs_p.startswith(abs_root + os.sep):
638
+ return p # inside the project root -- not redacted
639
+ except Exception:
640
+ pass
641
+ return "[REDACTED_ABS_PATH]"
642
+
643
+ redacted = _ABS_PATH_RE.sub(_replace_path, value) if project_root else value
644
+ redacted = _EMAIL_RE.sub("[REDACTED_EMAIL]", redacted)
645
+ redacted = _PHONE_RE.sub("[REDACTED_PHONE]", redacted)
646
+ return redacted
647
+
648
+
649
+ def _persist_artifact(state, step_name, value, project_root):
650
+ """Append one redacted record to project/logs/playbook-runs/<run_id>/artifacts.jsonl.
651
+ Best-effort: any failure to persist (unwritable filesystem, missing run_id on an old state
652
+ file) is swallowed with a stderr warning -- persistence is a durability nicety, never a reason
653
+ to fail a playbook run that otherwise succeeded."""
654
+ run_id = state.get("run_id")
655
+ if not run_id or not project_root:
656
+ return
657
+ try:
658
+ run_dir = os.path.join(project_root, "project", "logs", "playbook-runs", run_id)
659
+ os.makedirs(run_dir, exist_ok=True)
660
+ record = {
661
+ "step": step_name,
662
+ "captured_at": datetime.now(timezone.utc).isoformat(),
663
+ "value": _redact(value, project_root),
664
+ }
665
+ with open(os.path.join(run_dir, "artifacts.jsonl"), "a", encoding="utf-8") as f:
666
+ f.write(json.dumps(record) + "\n")
667
+ except OSError as e:
668
+ print(f"Warning: could not persist captured-output artifact for '{step_name}': {e}", file=sys.stderr)
188
669
 
189
670
  try:
190
671
  with open(state_file_path, encoding="utf-8") as f:
@@ -210,6 +691,7 @@ if idx >= len(steps):
210
691
  sys.exit(2)
211
692
 
212
693
  current_step = steps[idx]
694
+ state.setdefault("skipped", [])
213
695
 
214
696
  if outcome == "failed":
215
697
  state["halted"] = True
@@ -226,14 +708,24 @@ if outcome == "failed":
226
708
  "playbook_id": state["playbook_id"],
227
709
  "name": state["playbook_name"],
228
710
  "completed": state["completed"],
711
+ "skipped": state["skipped"],
229
712
  "failed_step": current_step,
230
713
  "failed_note": note,
231
714
  "never_run": never_run,
232
715
  }))
233
716
  sys.exit(0)
234
717
 
235
- # outcome == "passed"
236
- state["completed"].append(current_step)
718
+ if outcome == "skipped":
719
+ # A skipped step never ran -- it is non-blocking, non-failing, and never captures an output.
720
+ state["skipped"].append(current_step)
721
+ else:
722
+ # outcome == "passed"
723
+ typed_output_value = extra
724
+ if typed_output_value is not None:
725
+ state["captured_outputs"][current_step] = typed_output_value
726
+ _persist_artifact(state, current_step, typed_output_value, project_dir)
727
+ state["completed"].append(current_step)
728
+
237
729
  state["current_index"] = idx + 1
238
730
 
239
731
  if state["current_index"] >= len(steps):
@@ -242,6 +734,7 @@ if state["current_index"] >= len(steps):
242
734
  "playbook_id": state["playbook_id"],
243
735
  "name": state["playbook_name"],
244
736
  "completed": state["completed"],
737
+ "skipped": state["skipped"],
245
738
  }
246
739
  try:
247
740
  os.remove(state_file_path)
@@ -264,10 +757,10 @@ print(json.dumps({
264
757
  print(f"STATE_FILE: {state_file_path}", file=sys.stderr)
265
758
  PY
266
759
 
267
- python3 "$PY_SCRIPT" "$STATE_FILE" "$OUTCOME" "$NOTE"
760
+ python3 "$PY_SCRIPT" "$STATE_FILE" "$OUTCOME" "$EXTRA" "$PROJECT_DIR"
268
761
  exit $?
269
762
 
270
763
  else
271
- echo "Error: unrecognized subcommand '$SUBCOMMAND' (expected 'init' or 'advance')" >&2
764
+ echo "Error: unrecognized subcommand '$SUBCOMMAND' (expected 'init', 'should-skip', 'advance', or 'get-output')" >&2
272
765
  exit 2
273
766
  fi