autonomous-sdlc-harness 0.2.0 → 0.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 (74) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.js +0 -0
  3. package/dist/commands/docs.js +2 -1
  4. package/dist/commands/docs.js.map +1 -1
  5. package/dist/commands/doctor.js +27 -6
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/init.js +102 -20
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/config/check.js +11 -4
  10. package/dist/config/check.js.map +1 -1
  11. package/dist/config/model.js +25 -0
  12. package/dist/config/model.js.map +1 -1
  13. package/dist/core/git.js +29 -0
  14. package/dist/core/git.js.map +1 -1
  15. package/dist/core/paths.js +22 -2
  16. package/dist/core/paths.js.map +1 -1
  17. package/dist/core/prompt.js +6 -2
  18. package/dist/core/prompt.js.map +1 -1
  19. package/dist/core/writer.js +2 -0
  20. package/dist/core/writer.js.map +1 -1
  21. package/dist/doctor/checks.js +343 -28
  22. package/dist/doctor/checks.js.map +1 -1
  23. package/dist/generators/githubWorkflows.js +66 -0
  24. package/dist/generators/githubWorkflows.js.map +1 -0
  25. package/dist/generators/notifications.js +105 -19
  26. package/dist/generators/notifications.js.map +1 -1
  27. package/dist/generators/outerLoopScripts.js +17 -3
  28. package/dist/generators/outerLoopScripts.js.map +1 -1
  29. package/dist/generators/permissionProfile.js +101 -12
  30. package/dist/generators/permissionProfile.js.map +1 -1
  31. package/dist/generators/repoRoot.js +18 -2
  32. package/dist/generators/repoRoot.js.map +1 -1
  33. package/dist/generators/stateDir.js +9 -3
  34. package/dist/generators/stateDir.js.map +1 -1
  35. package/dist/machine/paths.js +4 -1
  36. package/dist/machine/paths.js.map +1 -1
  37. package/dist/remote/githubActions.js +86 -0
  38. package/dist/remote/githubActions.js.map +1 -0
  39. package/dist/retrieval/queryLog.js +6 -4
  40. package/dist/retrieval/queryLog.js.map +1 -1
  41. package/dist/retrieval/runtime.js +12 -22
  42. package/dist/retrieval/runtime.js.map +1 -1
  43. package/dist/retrieval/search.js +21 -10
  44. package/dist/retrieval/search.js.map +1 -1
  45. package/dist/retrieval/server.js +1 -1
  46. package/dist/retrieval/setup.js +2 -1
  47. package/dist/retrieval/setup.js.map +1 -1
  48. package/package.json +1 -1
  49. package/templates/README.md +3 -2
  50. package/templates/claude/context/conventions.md +1 -1
  51. package/templates/claude/context/layer.md +1 -1
  52. package/templates/claude/push-notify.env.example +7 -2
  53. package/templates/claude/settings.autonomous.json +1 -1
  54. package/templates/github/workflows/harness-resume.yml +124 -0
  55. package/templates/github/workflows/harness-run.yml +446 -0
  56. package/templates/repo/gitignore +5 -0
  57. package/templates/scripts/README.md +1 -1
  58. package/templates/scripts/autonomous-watcher.sh +1141 -143
  59. package/templates/scripts/flow-walker.sh +629 -0
  60. package/templates/scripts/flows/task_plan_writing.graph.json +192 -0
  61. package/templates/scripts/lib/flow-walker-gates.sh +165 -0
  62. package/templates/scripts/lib/harness-run-lib.sh +433 -18
  63. package/templates/scripts/remote-run.sh +1785 -0
  64. package/templates/scripts/restart-watcher.sh +21 -1
  65. package/templates/scripts/run-test-suite.sh +182 -0
  66. package/templates/scripts/scratch-run.sh +2 -1
  67. package/templates/state-dir/README-root.md +1 -1
  68. package/templates/state-dir/autonomous_logs/README.md +1 -1
  69. package/templates/state-dir/improvement_observations/README.md +2 -0
  70. package/templates/state-dir/scratch/README.md +1 -1
  71. package/templates/state-dir/test_fix_plan_reviews/README.md +9 -0
  72. package/templates/state-dir/test_fix_plans/README.md +9 -0
  73. package/templates/state-dir/test_fix_point_reviews/README.md +9 -0
  74. package/templates/state-dir/test_run_logs/README.md +11 -0
@@ -0,0 +1,629 @@
1
+ #!/usr/bin/env bash
2
+ # flow-walker.sh — the next step of a declared flow, read off its graph instead
3
+ # of worked out from prose: given the flow, the branch and the outcome of the
4
+ # node that just ran, print the next action in one fixed form.
5
+ #
6
+ # THE WALKER ROUTES; THE ORCHESTRATOR JUDGES. It owns the next node, the shared
7
+ # counter and its cap, both skip semantics, each reviewer's `iteration:`
8
+ # argument and the escalation payload, and it rejects an outcome the pending
9
+ # node does not declare. Which outcome a return carries, and what a binding
10
+ # resolves to, stay the orchestrator's.
11
+ #
12
+ # THE SAFETY CONTRACT IS DELIBERATELY NOT THE WALKER'S. The STOP check, the
13
+ # `.dispatch_counter` increment, the PAUSE check and the halt messages stay
14
+ # orchestrator steps, so the heartbeat carries the literal slot
15
+ # `(#<total_dispatches>)` for the orchestrator to fill. A binding is printed by
16
+ # NAME and never resolved; no dispatch prompt is composed — only the values the
17
+ # core's dispatch block lacks (`arg.iteration`, `arg.findings_file`) and the
18
+ # prompt variant.
19
+ #
20
+ # USAGE — each call one literal command from the repository root:
21
+ #
22
+ # bash <scripts_dir>/flow-walker.sh start --flow <flow> --branch <branch> [--entry <node>] [--skipped <ids|none>]
23
+ # bash <scripts_dir>/flow-walker.sh next --flow <flow> --branch <branch> --outcome <outcome> [--findings <path>] [--skipped <ids|none>]
24
+ # bash <scripts_dir>/flow-walker.sh current --flow <flow> --branch <branch>
25
+ #
26
+ # `current` is read-only: it re-prints the saved pending action byte for
27
+ # byte, and is how a planning (re-)entry finds a saved walk to continue. Its
28
+ # output names the saved `awaiting`: `action: dispatch` is `dispatch`,
29
+ # `binding: <ask>` is `answer`, any other `binding:` (`<escalate>`,
30
+ # `<terminal_handoff>`) is `done` — a finished walk, re-printed but continued
31
+ # by nothing: `next` refuses it with exit 1.
32
+ #
33
+ # <outcome> a key of the pending node's `outcomes`, or `answered` while an
34
+ # `<ask>` is pending. `FAIL` requires `--findings`.
35
+ # --skipped the run mode's skipped ids; read only when no flow-progress
36
+ # ledger exists, and re-read on every arrival at a gated node.
37
+ # The graph is `<this script's dir>/flows/<flow>.graph.json`; the root is a
38
+ # bare `git rev-parse --show-toplevel` in the working directory.
39
+ #
40
+ # OUTPUT — stdout, `key: value` lines, in this order:
41
+ #
42
+ # dispatch action: dispatch / node: / agent: / prompt: (writer nodes) /
43
+ # arg.iteration: (reviewer nodes: the next free index in the node's
44
+ # findings folder, never the counter) / arg.findings_file: (with
45
+ # `prompt: revision`) / skip: <node> <skipped|passed-by-exclusion>
46
+ # and ledger: <id> lines for what the walk passed through /
47
+ # heartbeat: <template, {iteration} = the counter> (#<total_dispatches>)
48
+ # binding action: binding / binding: <name> / node: / then
49
+ # <ask> agent: / resume: <node>
50
+ # <escalate> agent: / reason: <cap|error|blocker>, and for
51
+ # a cap: findings_file: / rounds: <node> <count>
52
+ # (a node recorded `skipped` omitted) / evidence
53
+ # lines / summary:
54
+ # <terminal_handoff> report: <key> <value> per recorded key
55
+ # then its skip: / ledger: lines.
56
+ #
57
+ # EXIT CODES
58
+ #
59
+ # 0 an action was printed (and, for start / next, the state written)
60
+ # 1 refusal: bad usage, undeclared outcome, `next` before `start`, `current`
61
+ # with no walk saved (a re-entry's "no saved walk"), a flow or
62
+ # branch other than the state's, unknown flow, formatVersion not 1, FAIL
63
+ # without --findings, no run-mode record, an unresolvable phase flag
64
+ # 2 the configuration, the graph or the state file cannot be resolved or
65
+ # written, or lib/harness-run-lib.sh predates this walker
66
+ # Every non-zero exit prints one `flow-walker: <cause>` line on stderr and
67
+ # leaves the state file byte-identical.
68
+ #
69
+ # STATE — `<state_dir>/.flow_walker_state`, machine-local, flat `key=value`
70
+ # lines: flow, branch, the pending node, `awaiting` (dispatch / answer / done),
71
+ # `counter.<name>`, the prompt variants, the last findings file, the recorded
72
+ # `report.<key>` values, the nodes skipped as `skipped`, and the pending
73
+ # action's printed lines (`out=`), which `current` re-prints unchanged. Only
74
+ # `start` overwrites it and resets every counter to 0 — a fresh write, an
75
+ # extend, a review of a draft and a skip past a loop alike. A re-entry that
76
+ # continues a saved walk (`current`, then `next` or a re-dispatch of the
77
+ # printed action) keeps it, so `counter.iteration`, the prompt variants and
78
+ # the last findings file survive a session boundary. It is a position, never a
79
+ # phase record: the flow-progress ledger stays the only durable record.
80
+ # Replaced by temp file and `mv`.
81
+ #
82
+ # BASH 3.2 AND JQ 1.5 ARE THE FLOORS: no associative array and no array-reading
83
+ # builtin. The graph is flattened by one `jq` per call.
84
+ #
85
+ # REPRO — one PASS-everywhere walk in a throwaway fixture:
86
+ #
87
+ # d=$(mktemp -d); git -C "$d" init -q -b feat_x
88
+ # printf '%s' '{"defaultBranch":"main","stateDir":"sdlc-harness/","phases":{"parity":true,"qa":true}}' > "$d/harness.config.json"
89
+ # mkdir -p "$d/s/lib" "$d/s/flows"
90
+ # cp <scripts_dir>/flow-walker.sh "$d/s/"
91
+ # cp <scripts_dir>/lib/harness-run-lib.sh <scripts_dir>/lib/flow-walker-gates.sh "$d/s/lib/"
92
+ # cp <scripts_dir>/flows/task_plan_writing.graph.json "$d/s/flows/"
93
+ # cd "$d"
94
+ # bash s/flow-walker.sh start --flow task_plan_writing --branch feat_x --skipped none
95
+ # -> dispatch plan_writer, prompt: initial
96
+ # ... next --flow task_plan_writing --branch feat_x --outcome returned --skipped none
97
+ # -> dispatch business_parity_review, arg.iteration: 0
98
+ # ... next ... --outcome PASS --skipped none -> architecture_review, arg.iteration: 0
99
+ # ... next ... --outcome PASS --skipped none -> plan_review, arg.iteration: 0
100
+ # ... next ... --outcome PASS --skipped none -> ledger: P1, ui_writer, prompt: initial
101
+ # ... next ... --outcome returned --skipped none -> ui_review
102
+ # ... next ... --outcome PASS --skipped none -> binding: <terminal_handoff>,
103
+ # four report: lines, ledger: P2
104
+ # ... next ... --outcome PASS -> exit 1: the walk has finished
105
+
106
+ set -uo pipefail
107
+
108
+ fw_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
109
+ for fw_lib in "$fw_dir/lib/harness-run-lib.sh" "$fw_dir/lib/flow-walker-gates.sh"; do
110
+ if [ ! -r "$fw_lib" ]; then
111
+ printf 'flow-walker: cannot read %s\n' "$fw_lib" >&2
112
+ exit 2
113
+ fi
114
+ # shellcheck source=/dev/null
115
+ . "$fw_lib"
116
+ done
117
+ # An older `init` keeps an existing lib/harness-run-lib.sh (create-if-absent), and one written
118
+ # before this walker shipped lacks the phase reader every `skipped` gate calls.
119
+ if ! declare -F hr_phase_enabled >/dev/null; then
120
+ printf 'flow-walker: %s predates this walker (it defines no hr_phase_enabled): delete that file and re-run autonomous-sdlc-harness init, which writes the current copy\n' "$fw_dir/lib/harness-run-lib.sh" >&2
121
+ exit 2
122
+ fi
123
+
124
+ NL='
125
+ '
126
+ TAB=$'\t'
127
+
128
+ refuse() {
129
+ printf 'flow-walker: %s\n' "$1" >&2
130
+ exit 1
131
+ }
132
+
133
+ fault() {
134
+ printf 'flow-walker: %s\n' "$1" >&2
135
+ exit 2
136
+ }
137
+
138
+ # Every lookup below sets a global rather than printing, because `refuse` and
139
+ # `fault` inside a `$(…)` would exit the subshell only.
140
+
141
+ # --- Arguments ------------------------------------------------------------
142
+
143
+ [ "$#" -gt 0 ] || refuse "missing subcommand: start, next or current"
144
+ CMD="$1"
145
+ shift
146
+ case "$CMD" in
147
+ start|next|current) ;;
148
+ *) refuse "unknown subcommand '$CMD': expected start, next or current" ;;
149
+ esac
150
+
151
+ A_FLOW=""
152
+ A_BRANCH=""
153
+ A_ENTRY=""
154
+ A_SKIPPED=""
155
+ A_OUTCOME=""
156
+ A_FINDINGS=""
157
+ while [ "$#" -gt 0 ]; do
158
+ flag="$1"
159
+ case "$CMD:$flag" in
160
+ *:--flow|*:--branch|start:--entry|start:--skipped|next:--skipped|next:--outcome|next:--findings) ;;
161
+ *) refuse "'$flag' is not a flag of '$CMD'" ;;
162
+ esac
163
+ [ "$#" -ge 2 ] || refuse "$flag requires a value"
164
+ value="$2"
165
+ shift 2
166
+ case "$value" in
167
+ *"$NL"*) refuse "the value of $flag carries a newline" ;;
168
+ esac
169
+ case "$flag" in
170
+ --flow) A_FLOW="$value" ;;
171
+ --branch) A_BRANCH="$value" ;;
172
+ --entry) A_ENTRY="$value" ;;
173
+ --skipped) A_SKIPPED="$value" ;;
174
+ --outcome) A_OUTCOME="$value" ;;
175
+ --findings) A_FINDINGS="$value" ;;
176
+ esac
177
+ done
178
+
179
+ [ -n "$A_FLOW" ] || refuse "missing required flag --flow"
180
+ [ -n "$A_BRANCH" ] || refuse "missing required flag --branch"
181
+ if [ "$CMD" = next ]; then
182
+ [ -n "$A_OUTCOME" ] || refuse "missing required flag --outcome"
183
+ case "$A_OUTCOME" in
184
+ *[!A-Za-z0-9_]*) refuse "outcome '$A_OUTCOME' is not an outcome name" ;;
185
+ esac
186
+ if [ "$A_OUTCOME" = FAIL ] && [ -z "$A_FINDINGS" ]; then
187
+ refuse "outcome FAIL requires --findings with the reviewer's findings_file"
188
+ fi
189
+ fi
190
+ case "$A_FLOW" in
191
+ *[!a-z0-9_]*) refuse "unknown flow '$A_FLOW'" ;;
192
+ esac
193
+
194
+ # --- Configuration ----------------------------------------------------------
195
+
196
+ ROOT="$(hr_repo_root)" || fault "not inside a git repository"
197
+ hr_config_load "$ROOT"
198
+ case "$?" in
199
+ 0) ;;
200
+ 1) fault "no harness.config.json at $ROOT" ;;
201
+ *) fault "cannot resolve $ROOT/harness.config.json (invalid JSON, no defaultBranch, or jq missing or older than 1.5)" ;;
202
+ esac
203
+ STATE_DIR="$(hr_state_dir "$ROOT")" || fault "cannot resolve stateDir from $ROOT/harness.config.json"
204
+ STATE_FILE="$ROOT/$STATE_DIR/.flow_walker_state"
205
+
206
+ # --- The graph --------------------------------------------------------------
207
+
208
+ GRAPH_FILE="$fw_dir/flows/$A_FLOW.graph.json"
209
+ [ -f "$GRAPH_FILE" ] || refuse "unknown flow '$A_FLOW': no $GRAPH_FILE"
210
+ hr_have_jq || fault "jq is not on PATH"
211
+
212
+ # One line per scalar: `<dotted path>\t<value>`, array indices as path segments.
213
+ G_RAW="$(jq -r 'paths(scalars) as $p | [($p | map(tostring) | join(".")), (getpath($p) | tostring)] | @tsv' "$GRAPH_FILE" 2>/dev/null)" \
214
+ || fault "cannot parse $GRAPH_FILE"
215
+ G_LINES="$NL$G_RAW$NL"
216
+
217
+ # Set GV to the graph value at <path>; return 1 when absent.
218
+ g_get() {
219
+ local key="$1" rest
220
+ GV=""
221
+ case "$G_LINES" in
222
+ *"$NL$key$TAB"*) ;;
223
+ *) return 1 ;;
224
+ esac
225
+ rest="${G_LINES#*"$NL$key$TAB"}"
226
+ rest="${rest%%"$NL"*}"
227
+ hr_tsv_unescape_var "$rest"
228
+ GV="$HR_CFG_VALUE"
229
+ return 0
230
+ }
231
+
232
+ g_need() {
233
+ g_get "$1" || fault "graph $GRAPH_FILE has no '$1'"
234
+ }
235
+
236
+ g_has_prefix() {
237
+ case "$G_LINES" in
238
+ *"$NL$1"*) return 0 ;;
239
+ esac
240
+ return 1
241
+ }
242
+
243
+ g_get formatVersion || refuse "graph $GRAPH_FILE carries no formatVersion"
244
+ [ "$GV" = 1 ] || refuse "graph $GRAPH_FILE has formatVersion '$GV'; this walker reads 1"
245
+ g_need flow
246
+ [ "$GV" = "$A_FLOW" ] || fault "graph $GRAPH_FILE declares flow '$GV', not '$A_FLOW'"
247
+
248
+ # --- The state store (newline-joined `key=value`) ----------------------------
249
+
250
+ ST_LINES="$NL"
251
+ OUT=""
252
+ PASS=""
253
+
254
+ st_get() {
255
+ local key="$1" rest
256
+ SV=""
257
+ case "$ST_LINES" in
258
+ *"$NL$key="*) ;;
259
+ *) return 1 ;;
260
+ esac
261
+ rest="${ST_LINES#*"$NL$key="}"
262
+ SV="${rest%%"$NL"*}"
263
+ return 0
264
+ }
265
+
266
+ st_set() {
267
+ local key="$1" val="$2" line out="$NL"
268
+ while IFS= read -r line; do
269
+ [ -n "$line" ] || continue
270
+ case "$line" in
271
+ "$key="*) continue ;;
272
+ esac
273
+ out="$out$line$NL"
274
+ done <<EOF
275
+ $ST_LINES
276
+ EOF
277
+ ST_LINES="$out$key=$val$NL"
278
+ }
279
+
280
+ load_state() {
281
+ local line
282
+ [ -f "$STATE_FILE" ] || refuse "no walk in progress at $STATE_FILE: run 'start' first"
283
+ [ -r "$STATE_FILE" ] || fault "cannot read $STATE_FILE"
284
+ while IFS= read -r line; do
285
+ case "$line" in
286
+ out=*) OUT="$OUT${line#out=}$NL" ;;
287
+ *=*) ST_LINES="$ST_LINES$line$NL" ;;
288
+ esac
289
+ done < "$STATE_FILE"
290
+ st_get flow || fault "$STATE_FILE carries no flow"
291
+ [ "$SV" = "$A_FLOW" ] || refuse "the walk in progress is flow '$SV', not '$A_FLOW'"
292
+ st_get branch || fault "$STATE_FILE carries no branch"
293
+ [ "$SV" = "$A_BRANCH" ] || refuse "the walk in progress is for branch '$SV', not '$A_BRANCH'"
294
+ [ -n "$OUT" ] || fault "$STATE_FILE carries no pending action"
295
+ }
296
+
297
+ save_state() {
298
+ local dir tmp line
299
+ dir="${STATE_FILE%/*}"
300
+ [ -d "$dir" ] || mkdir -p "$dir" || fault "cannot create $dir"
301
+ tmp="$STATE_FILE.tmp.$$"
302
+ {
303
+ printf '%s' "${ST_LINES#"$NL"}"
304
+ while IFS= read -r line; do
305
+ if [ -n "$line" ]; then printf 'out=%s\n' "$line"; fi
306
+ done <<EOF
307
+ $OUT
308
+ EOF
309
+ } > "$tmp" 2>/dev/null || { rm -f "$tmp"; fault "cannot write $tmp"; }
310
+ mv -f "$tmp" "$STATE_FILE" 2>/dev/null || { rm -f "$tmp"; fault "cannot replace $STATE_FILE"; }
311
+ }
312
+
313
+ # --- Rendering helpers ------------------------------------------------------
314
+
315
+ # SUBST = <string> with every literal <pattern> replaced by <replacement>.
316
+ subst() {
317
+ local s="$1" pat="$2" rep="$3" out=""
318
+ while :; do
319
+ case "$s" in
320
+ *"$pat"*)
321
+ out="$out${s%%"$pat"*}$rep"
322
+ s="${s#*"$pat"}"
323
+ ;;
324
+ *) break ;;
325
+ esac
326
+ done
327
+ SUBST="$out$s"
328
+ }
329
+
330
+ # SUBST = <template> with {state_dir} and {branch} rendered, repo-relative.
331
+ render_path() {
332
+ subst "$1" "{state_dir}" "$STATE_DIR"
333
+ subst "$SUBST" "{branch}" "$A_BRANCH"
334
+ }
335
+
336
+ record_reports() {
337
+ local prefix="$1.report." line key
338
+ while IFS= read -r line; do
339
+ case "$line" in
340
+ "$prefix"*)
341
+ key="${line#"$prefix"}"
342
+ key="${key%%"$TAB"*}"
343
+ hr_tsv_unescape_var "${line#*"$TAB"}"
344
+ st_set "report.$key" "$HR_CFG_VALUE"
345
+ ;;
346
+ esac
347
+ done <<EOF
348
+ $G_LINES
349
+ EOF
350
+ }
351
+
352
+ add_skipped_node() {
353
+ st_get skipped_nodes
354
+ fw_id_in_list "$1" "$SV" && return 0
355
+ if [ -n "$SV" ]; then st_set skipped_nodes "$SV,$1"; else st_set skipped_nodes "$1"; fi
356
+ }
357
+
358
+ render_dispatch() {
359
+ local node="$1" variant="$2" agent idx hb
360
+ g_need "nodes.$node.agent"
361
+ agent="$GV"
362
+ OUT="action: dispatch${NL}node: $node${NL}agent: $agent$NL"
363
+ st_set node "$node"
364
+ st_set awaiting dispatch
365
+ st_set resume ""
366
+ st_set prompt ""
367
+ if g_get "nodes.$node.prompts.0"; then
368
+ [ -n "$variant" ] || variant=initial
369
+ case "$variant" in
370
+ initial|revision) ;;
371
+ *) fault "graph gives '$node' the unknown prompt variant '$variant'" ;;
372
+ esac
373
+ OUT="${OUT}prompt: $variant$NL"
374
+ st_set prompt "$variant"
375
+ st_set "prompt.$node" "$variant"
376
+ if [ "$variant" = revision ]; then
377
+ st_get findings_file
378
+ [ -n "$SV" ] || fault "a revision of '$node' is due and no findings file is recorded"
379
+ OUT="${OUT}arg.findings_file: $SV$NL"
380
+ fi
381
+ fi
382
+ if g_get "nodes.$node.findingsFolder"; then
383
+ render_path "$GV"
384
+ idx="$(fw_next_review_index "$ROOT/$SUBST")"
385
+ OUT="${OUT}arg.iteration: $idx$NL"
386
+ fi
387
+ OUT="$OUT$PASS"
388
+ g_need "nodes.$node.heartbeat"
389
+ st_get counter.iteration
390
+ subst "$GV" "{iteration}" "$SV"
391
+ hb="$SUBST"
392
+ OUT="${OUT}heartbeat: $hb (#<total_dispatches>)$NL"
393
+ }
394
+
395
+ render_terminal() {
396
+ local node="$1" i=0 key
397
+ g_need "nodes.$node.binding"
398
+ OUT="action: binding${NL}binding: $GV${NL}node: $node$NL"
399
+ while g_get "reports.$i"; do
400
+ key="$GV"
401
+ if st_get "report.$key"; then
402
+ OUT="${OUT}report: $key $SV$NL"
403
+ fi
404
+ i=$((i + 1))
405
+ done
406
+ OUT="$OUT$PASS"
407
+ st_set node "$node"
408
+ st_set awaiting done
409
+ }
410
+
411
+ # Print the binding at graph path <prefix> reached from <node>. <cap> is the
412
+ # cap that fired, for a cap escalation.
413
+ take_binding() {
414
+ local prefix="$1" node="$2" cap="${3-}" binding agent reason i=0 r count ev
415
+ g_need "$prefix.binding"
416
+ binding="$GV"
417
+ g_need "nodes.$node.agent"
418
+ agent="$GV"
419
+ OUT="action: binding${NL}binding: $binding${NL}node: $node${NL}agent: $agent$NL"
420
+ st_set node "$node"
421
+ case "$binding" in
422
+ "<ask>")
423
+ g_need "$prefix.resume"
424
+ OUT="${OUT}resume: $GV$NL"
425
+ st_set resume "$GV"
426
+ st_set awaiting answer
427
+ ;;
428
+ "<escalate>")
429
+ g_need "$prefix.reason"
430
+ reason="$GV"
431
+ OUT="${OUT}reason: $reason$NL"
432
+ if [ "$reason" = cap ]; then
433
+ st_get findings_file
434
+ OUT="${OUT}findings_file: $SV$NL"
435
+ st_get skipped_nodes
436
+ local skipped="$SV"
437
+ while g_get "$prefix.roundCounts.$i"; do
438
+ r="$GV"
439
+ i=$((i + 1))
440
+ fw_id_in_list "$r" "$skipped" && continue
441
+ g_need "nodes.$r.findingsFolder"
442
+ render_path "$GV"
443
+ count="$(fw_round_count "$ROOT/$SUBST")"
444
+ OUT="${OUT}rounds: $r $count$NL"
445
+ done
446
+ if g_get "$prefix.evidence"; then
447
+ g_need "paths.$GV"
448
+ render_path "$GV"
449
+ ev="$(fw_index_evidence "$ROOT/$SUBST")"
450
+ [ -z "$ev" ] || OUT="$OUT$ev$NL"
451
+ fi
452
+ if g_get "$prefix.summary"; then
453
+ subst "$GV" "{cap}" "$cap"
454
+ OUT="${OUT}summary: $SUBST$NL"
455
+ fi
456
+ fi
457
+ st_set awaiting done
458
+ ;;
459
+ *) fault "graph binding '$binding' at '$prefix' is not <ask> or <escalate>" ;;
460
+ esac
461
+ OUT="$OUT$PASS"
462
+ }
463
+
464
+ # --- Transitions ------------------------------------------------------------
465
+
466
+ # Move to <node>, passing its skip gates, and render what is reached.
467
+ arrive() {
468
+ local node="$1" variant="$2" steps=0 i applied construct phase id list status
469
+ while :; do
470
+ steps=$((steps + 1))
471
+ [ "$steps" -le 64 ] || fault "graph loops through skip gates at '$node'"
472
+ g_get "nodes.$node.kind" || fault "graph has no node '$node'"
473
+ case "$GV" in
474
+ terminal)
475
+ render_terminal "$node"
476
+ return 0
477
+ ;;
478
+ dispatch) ;;
479
+ *) fault "node '$node' has unknown kind '$GV'" ;;
480
+ esac
481
+ applied=""
482
+ i=0
483
+ while [ -z "$applied" ] && g_get "nodes.$node.skip.$i.construct"; do
484
+ construct="$GV"
485
+ case "$construct" in
486
+ skipped)
487
+ g_need "nodes.$node.skip.$i.phase"
488
+ phase="$GV"
489
+ fw_phase_on "$ROOT" "$phase"
490
+ status=$?
491
+ case "$status" in
492
+ 0) ;;
493
+ 1) applied=skipped ;;
494
+ *) refuse "cannot resolve phases.$phase in $ROOT/harness.config.json" ;;
495
+ esac
496
+ ;;
497
+ passedByExclusion)
498
+ g_need "nodes.$node.skip.$i.runModeId"
499
+ id="$GV"
500
+ list="$(fw_run_mode_skipped "$ROOT" "$STATE_DIR" "$A_BRANCH" "$A_SKIPPED")"
501
+ status=$?
502
+ case "$status" in
503
+ 0) if fw_id_in_list "$id" "$list"; then applied=passed-by-exclusion; fi ;;
504
+ 1) refuse "no run-mode record: no flow-progress ledger for '$A_BRANCH' and no --skipped" ;;
505
+ *) refuse "the flow-progress ledger for '$A_BRANCH' has no '- skipped:' line under '## Run mode'" ;;
506
+ esac
507
+ ;;
508
+ *) fault "node '$node' has unknown skip construct '$construct'" ;;
509
+ esac
510
+ [ -n "$applied" ] || i=$((i + 1))
511
+ done
512
+ if [ -z "$applied" ]; then
513
+ render_dispatch "$node" "$variant"
514
+ return 0
515
+ fi
516
+ record_reports "nodes.$node.skip.$i"
517
+ PASS="${PASS}skip: $node $applied$NL"
518
+ [ "$applied" != skipped ] || add_skipped_node "$node"
519
+ g_need "nodes.$node.skip.$i.to"
520
+ node="$GV"
521
+ done
522
+ }
523
+
524
+ follow_edge() {
525
+ local edge="$1" node="$2" counter cap n variant=initial
526
+ if g_get "$edge.binding"; then
527
+ take_binding "$edge" "$node"
528
+ return 0
529
+ fi
530
+ if g_get "$edge.reset"; then
531
+ st_set "counter.$GV" 0
532
+ fi
533
+ if g_get "$edge.increment"; then
534
+ counter="$GV"
535
+ st_get "counter.$counter"
536
+ case "$SV" in
537
+ ''|*[!0-9]*) fault "$STATE_FILE carries no numeric counter.$counter" ;;
538
+ esac
539
+ n=$((SV + 1))
540
+ st_set "counter.$counter" "$n"
541
+ g_need "counters.$counter.cap"
542
+ cap="$GV"
543
+ if [ "$n" -ge "$cap" ]; then
544
+ g_get "$edge.onCap.binding" || fault "edge '$edge' reaches its cap with no onCap"
545
+ take_binding "$edge.onCap" "$node" "$cap"
546
+ return 0
547
+ fi
548
+ fi
549
+ record_reports "$edge"
550
+ if g_get "$edge.ledger"; then
551
+ PASS="${PASS}ledger: $GV$NL"
552
+ fi
553
+ if g_get "$edge.prompt"; then variant="$GV"; fi
554
+ g_need "$edge.to"
555
+ arrive "$GV" "$variant"
556
+ }
557
+
558
+ # --- Subcommands ------------------------------------------------------------
559
+
560
+ case "$CMD" in
561
+ start)
562
+ g_need start
563
+ entry="$GV"
564
+ if [ -n "$A_ENTRY" ]; then
565
+ i=0
566
+ found=1
567
+ while g_get "entries.$i"; do
568
+ if [ "$GV" = "$A_ENTRY" ]; then found=0; fi
569
+ i=$((i + 1))
570
+ done
571
+ [ "$found" -eq 0 ] || refuse "'$A_ENTRY' is not an entry of flow '$A_FLOW'"
572
+ entry="$A_ENTRY"
573
+ fi
574
+ st_set flow "$A_FLOW"
575
+ st_set branch "$A_BRANCH"
576
+ while IFS= read -r line; do
577
+ case "$line" in
578
+ counters.*.cap"$TAB"*)
579
+ name="${line#counters.}"
580
+ name="${name%%.cap"$TAB"*}"
581
+ st_set "counter.$name" 0
582
+ ;;
583
+ esac
584
+ done <<EOF
585
+ $G_LINES
586
+ EOF
587
+ st_set findings_file ""
588
+ st_set skipped_nodes ""
589
+ arrive "$entry" initial
590
+ save_state
591
+ ;;
592
+ next)
593
+ load_state
594
+ st_get node
595
+ node="$SV"
596
+ st_get awaiting
597
+ case "$SV" in
598
+ done)
599
+ refuse "the walk finished at '$node'; no outcome is pending (run 'start' to walk again)"
600
+ ;;
601
+ answer)
602
+ [ "$A_OUTCOME" = answered ] \
603
+ || refuse "outcome '$A_OUTCOME' is not declared while an <ask> is pending at '$node'; only 'answered' is"
604
+ [ -z "$A_FINDINGS" ] || st_set findings_file "$A_FINDINGS"
605
+ st_get resume
606
+ resume="$SV"
607
+ [ -n "$resume" ] || fault "$STATE_FILE carries no resume node"
608
+ st_get "prompt.$resume"
609
+ arrive "$resume" "$SV"
610
+ ;;
611
+ dispatch)
612
+ g_has_prefix "nodes.$node.outcomes.$A_OUTCOME." \
613
+ || refuse "outcome '$A_OUTCOME' is not declared by node '$node'"
614
+ [ -z "$A_FINDINGS" ] || st_set findings_file "$A_FINDINGS"
615
+ follow_edge "nodes.$node.outcomes.$A_OUTCOME" "$node"
616
+ ;;
617
+ *)
618
+ fault "$STATE_FILE carries an unknown awaiting value '$SV'"
619
+ ;;
620
+ esac
621
+ save_state
622
+ ;;
623
+ current)
624
+ load_state
625
+ ;;
626
+ esac
627
+
628
+ printf '%s' "$OUT"
629
+ exit 0