@indigoai-us/hq-cli 5.345.118 → 5.345.119

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 (37) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/scaffold/core/scripts/pipeline-conductor.sh +2671 -0
  3. package/assets/scaffold/core/scripts/pipeline-driver.sh +820 -0
  4. package/assets/scaffold/core/scripts/pipeline-envelope.sh +211 -0
  5. package/dist/command-catalog.generated.d.ts +34 -0
  6. package/dist/command-catalog.generated.js +40 -0
  7. package/dist/commands/core-checkpoint-native.d.ts +17 -3
  8. package/dist/commands/core-checkpoint-native.js +48 -8
  9. package/dist/commands/core-derive-trigger-facts-native.js +15 -7
  10. package/dist/commands/core-migrate-policy-triggers-native.js +3 -1
  11. package/dist/commands/core-pipeline-conductor-command.d.ts +5 -0
  12. package/dist/commands/core-pipeline-conductor-command.js +138 -0
  13. package/dist/commands/core-pipeline-conductor-native.d.ts +7 -0
  14. package/dist/commands/core-pipeline-conductor-native.js +2950 -0
  15. package/dist/commands/core-pipeline-driver-native.d.ts +24 -0
  16. package/dist/commands/core-pipeline-driver-native.js +843 -0
  17. package/dist/commands/core-pipeline-driver-process.d.ts +4 -0
  18. package/dist/commands/core-pipeline-driver-process.js +68 -0
  19. package/dist/commands/core-pipeline-envelope-native.d.ts +4 -0
  20. package/dist/commands/core-pipeline-envelope-native.js +87 -0
  21. package/dist/commands/core-policy.js +3 -0
  22. package/dist/commands/core-worker-lint-native.d.ts +0 -1
  23. package/dist/commands/core-worker-lint-native.js +0 -1
  24. package/dist/commands/core-worker-share-native.js +2 -2
  25. package/dist/commands/core-worker-skills-command.d.ts +1 -1
  26. package/dist/commands/core-worker-skills-command.js +3 -20
  27. package/dist/commands/core.d.ts +5 -1
  28. package/dist/commands/core.js +8 -9
  29. package/dist/commands/storage-gate.d.ts +4 -3
  30. package/dist/commands/storage-gate.js +4 -3
  31. package/dist/fast-scaffold.js +0 -2
  32. package/dist/lib/storage-feature-flag.d.ts +7 -7
  33. package/package.json +1 -1
  34. package/assets/scaffold/core/scripts/lint-shared-worker-skills.sh +0 -197
  35. package/assets/scaffold/core/scripts/share-worker-skill.sh +0 -178
  36. package/dist/commands/core-native-worker-skills-flag.d.ts +0 -4
  37. package/dist/commands/core-native-worker-skills-flag.js +0 -7
@@ -0,0 +1,2671 @@
1
+ #!/usr/bin/env bash
2
+ # hq-core: public
3
+ # pipeline-conductor.sh - deterministic helpers for the pipeline-conductor
4
+ # worker (/run-project --pipeline). The conductor owns a story queue and routes
5
+ # each phase of each story to an hq-cli loop lane. It never spawns a child
6
+ # agent: every phase runs in a lane the loop controller owns.
7
+ #
8
+ # Usage (every subcommand takes --state <dir>; most take --prd <prd.json>):
9
+ # next --prd P --state S [--limit N] eligible story ids, one per line
10
+ # classify --prd P --state S --story ID [--worktree DIR | --worktree REPO=DIR ...]
11
+ # [--overlay F] [--table T] write stories/<id>.json (phases, repo,
12
+ # worktree, classified_by); print phases
13
+ # classify --prd P --state S [--overlay F] [--table T]
14
+ # (no --story) check every story that is not
15
+ # passing or skipped, write nothing; prints
16
+ # OK/WARN/ERROR lines, exit 1 on any ERROR
17
+ # overlay set --state S --story ID --sequence a,b,c | overlay show --state S
18
+ # the run's classification overlay
19
+ # (<state>/overlay.json); the prd is not edited
20
+ # skip --state S --story ID [--note TEXT] [--force] [--prd P]
21
+ # unskip --state S --story ID leave a story out of the run (skips.json)
22
+ # worktree --state S --repo R --shared [--prd P] [--story ID] [--story-branches]
23
+ # [--worktree DIR | --worktree R=DIR] [--allow-repos-worktree]
24
+ # cut the run's worktree for repo R: one per
25
+ # repo on the run's feature branch (default),
26
+ # or one per story with --story-branches.
27
+ # Default target, every mode:
28
+ # <HQ>/workspace/worktrees/<project>/<repo-name>/
29
+ # (<repo-name>-<story> with --story-branches)
30
+ # worktree --state S --repo R [--prd P] without --shared: with --prd, the same cut
31
+ # at the default target; without it, the repo
32
+ # itself (refused under repos/, see below)
33
+ # route --prd P --state S --story ID queue the story's current phase
34
+ # accept --state S --story ID --handoff F record a phase handoff and advance
35
+ # recheck --prd P --state S --story ID re-read literal ACs against ac_evidence
36
+ # gate tick|status --state S
37
+ # gate result --state S pass|fail [--note TEXT]
38
+ # release --state S (--story ID [--worker W] | --worker W)
39
+ # resolve an approval decision item
40
+ # report story --state S --story ID | report final --state S
41
+ # tick --prd P --state S [--worktree DIR | --worktree REPO=DIR ...] [--story-branches]
42
+ # [--table T] (the confirmed worker table; recorded in run.json)
43
+ # one routing pass over the queue
44
+ # go --state S --story ID [--note TEXT] release a story held awaiting_go
45
+ # interrupt --state S [--story ID] [--note TEXT] mark every (or one) in_flight story interrupted (phase,
46
+ # time) and withdraw its envelope from the lane
47
+ # queue when no lane picked it up; prints
48
+ # INTERRUPTED <id> <phase> withdrawn|picked_up
49
+ # stop --state S [--note TEXT] ask the run's driver to stop (driver/stop.json);
50
+ # with no live driver, runs interrupt itself
51
+ #
52
+ # Run worktrees under repos/: the core Write/Edit guard blocks editor-tool writes
53
+ # under <HQ>/repos/, so a lane that edits with Write or Edit stalls there. An
54
+ # explicit --worktree (any command) that resolves under repos/ is refused
55
+ # (exit 2) unless --allow-repos-worktree is passed; with it, every phase
56
+ # envelope routed into that tree carries one constraint line telling the worker
57
+ # to edit through the shell or apply_patch. A worktree must still sit inside
58
+ # the HQ root for lanes to cd into it.
59
+ #
60
+ # Owner resolutions (the parent runs these after the owner answers; the
61
+ # conductor and the driver never apply one on their own):
62
+ # resolve --state S --story ID --as accepted-partial --note TEXT [--prd P]
63
+ # accept a blocked story as a partial
64
+ # draft; dependents may route; passes
65
+ # is never set for it
66
+ # resolve --state S --story ID --as retry [--note TEXT] [--prd P]
67
+ # route the blocked phase again as a
68
+ # fresh call; the note reaches the worker;
69
+ # re-reads the story's phases from the prd
70
+ # (P, else the prd run.json records), resumes
71
+ # at the first phase without a passed handoff,
72
+ # and starts the failed-attempt count over
73
+ # failcap --state S --story ID the current phase returned failed too often:
74
+ # hold the story as blocked_needs_owner and write
75
+ # decisions/<id>-blocked-<phase>.md with each
76
+ # failed handoff's text (exit 10)
77
+ # park --state S --story ID [--note TEXT] [--prd P] [--force]
78
+ # set the story aside; every story that
79
+ # depends on it is held as parked_dependency.
80
+ # Prints PARK_CLOSURE <id> <n>: <ids> first; more
81
+ # than PC_PARK_CONFIRM (default 5) dependents
82
+ # needs --force
83
+ # unpark --state S --story ID [--prd P] reverse park; prints UNPARK_RELEASES <id> <n>: <ids>
84
+ # park, unpark, skip and unskip each append a line to <state>/decisions.log.
85
+ # reopen --state S --story ID --note TEXT [--from-phase PHASE] [--prd P]
86
+ # send a verified or accepted_partial story
87
+ # back: queued at PHASE (default: the first
88
+ # phase whose worker is not a reviewer or
89
+ # tester), handoffs from PHASE on archived as
90
+ # handoffs/<id>-<phase>.reopened.<n>.json, the
91
+ # attempt count starts over, the note reaches
92
+ # the worker in the envelope. Clears passes:true
93
+ # in the prd. Verified dependents are not
94
+ # reopened; the final summary lists them.
95
+ # gate result --state S fail --story ID --note TEXT [--from-phase PHASE] [--prd P]
96
+ # record a failed gate that names the story that
97
+ # caused it: reopen that story (as above) and set
98
+ # the gate to `reopened`, so routing goes on and
99
+ # the reopened story routes first; the gate is due
100
+ # again once every reopened story verifies
101
+ # Each refuses a story in a state it does not apply to (exit 1) and is
102
+ # idempotent: repeating it prints ALREADY and exits 0.
103
+ #
104
+ # State dir layout:
105
+ # stories/<id>.json phases, current index, state, reroutes, worktree
106
+ # envelopes/<id>-<phase>.json envelopes queued to hq lanes
107
+ # handoffs/<id>-<phase>.json accepted phase handoffs
108
+ # decisions/<id>-<worker>.md approval items for the parent session
109
+ # decisions/<id>-blocked-<phase>.md a worker returned status blocked
110
+ # run.json the prd path the last tick used (for park and report)
111
+ # approvals.json workers released for the whole run / per story
112
+ # gate.json regression gate cadence and state
113
+ # report.md one line per finished story + one final summary
114
+ #
115
+ # Classification (classify, and tick when a story first starts): the story's worker
116
+ # sequence is its full phase sequence, in order: the overlay entry, else
117
+ # worker_preference, else the keyword fallback (classified_by records which).
118
+ # classify refuses (exit 1, every problem listed) when:
119
+ # - a worker id has no worker.yaml under PC_WORKERS_ROOT (the message names the
120
+ # roots and the nearest known ids);
121
+ # - a story that declares code files (anything but docs/, markdown, text, yaml)
122
+ # starts with a verifier or reader (is_implementer below reads worker.role,
123
+ # else worker.type, from worker.yaml); prepend an implementer or set
124
+ # "docs_only": true;
125
+ # - a run with a worker table (--table, recorded by tick in run.json) has a
126
+ # sequence worker with no row in it.
127
+ # It warns (stderr, once per story) on a one-worker sequence, on a model_hint
128
+ # (a table pin wins: "hint ignored, table pins <model>"; a bare alias opus,
129
+ # sonnet, haiku or fable is never used), and on the dependency lint (dep_lint).
130
+ # route prints NO_LANE <id> <phase> <worker> and exits 12 when the current
131
+ # phase's worker has no table row; the story stays queued.
132
+ #
133
+ # Skip list: a skipped story (<state>/skips.json, stories/<id>.json state skipped)
134
+ # is not selected, routed, gated or counted; FINAL lists it with its note.
135
+ # Skipping a story others depend on prints SKIP_STRANDS and needs --force.
136
+ #
137
+ # Repo per story: classify resolves each story's repo, in this order:
138
+ # 1. the story's `repoPath`;
139
+ # 2. when the prd lists more than one repo, the repo that owns the story's first
140
+ # declared file (`files[0]`): an absolute path under the repo, a path from
141
+ # the HQ root, a path that starts with the repo's directory name, or a path
142
+ # that exists inside exactly one repo. The repo list is `metadata.repos[]`
143
+ # (canonical; each entry a path string or an object with `path` or
144
+ # `repoPath`); `metadata.repoPaths[]` (path strings) is read as an alias;
145
+ # 3. `metadata.repoPath`.
146
+ # Relative repo paths are read from the HQ root. stories/<id>.json records
147
+ # the resolved `repo`. `tick --worktree <repo>=<worktree>` (repeatable) maps
148
+ # each repo to its worktree and every phase of a story goes to the worktree of
149
+ # its repo; the bare `--worktree <dir>` form serves a prd with at most one
150
+ # repo. A story whose repo has no worktree is held blocked_needs_owner with a
151
+ # decision item naming the repo; it is never routed to another repo's tree.
152
+ # The envelope carries `repo`, `worktree` and, for a git worktree, `branch`.
153
+ #
154
+ # Branches: `metadata.baseBranch` (default main) is the base. Every cut starts
155
+ # from origin/<baseBranch> when that ref exists after `git fetch origin
156
+ # <baseBranch>`, else from local <baseBranch>, never from the repo's HEAD; the
157
+ # exact ref is printed (CUT <branch> from <ref> <sha>). Neither ref: exit 1.
158
+ # Default mode: one feature branch per repo for the run (`metadata.branchName`,
159
+ # else feature/<project>) in one worktree per repo; every story of that repo
160
+ # commits on it in dependency order, one story in flight per repo branch.
161
+ # `--story-branches` (tick and worktree): one branch and worktree per story
162
+ # (pipeline/<id>), cut when the story first routes from the branch of the last
163
+ # verified story it depends on in the same repo (stacked), else from the base.
164
+ # A story whose dependency branch is missing is held blocked_needs_owner.
165
+ #
166
+ # Concurrency: in the default mode PC_MAX_STORIES is lowered to the number of
167
+ # worktrees in play (one story per repo branch at a time) and tick prints
168
+ # MAX_STORIES <n> with the reason when it lowers it.
169
+ #
170
+ # Story-level go: a story with `approval: "explicit"` (canonical; the alias
171
+ # `needsApproval: true` is read too), or one the release detector flags (its
172
+ # declared files or acceptance criteria mention deploy, release, production,
173
+ # or merging to the base branch; patterns in RELEASE_PATTERNS below), is held
174
+ # awaiting_go before its first implementing phase, whatever the worker's
175
+ # approval_required says, with decisions/<id>-go.md. Only `go` releases it;
176
+ # `release --worker` (whole-run approval) does not. resolve and park apply.
177
+ #
178
+ # Story states: queued, in_flight, held, awaiting_go, awaiting_recheck, verified, failed_report,
179
+ # blocked_needs_owner (a phase handoff said blocked; never retried on its own),
180
+ # accepted_partial (owner accepted a blocked story as a partial draft; counts as
181
+ # done for dependsOn, passes is never set), parked (owner set it aside),
182
+ # parked_dependency (depends, directly or not, on a parked story).
183
+ # A reopened story is queued again with a `reopen` record (note, phase, the
184
+ # verified dependents at the time); report.md drops its earlier line.
185
+ #
186
+ # Back-pressure hint: when a story's `files` include a data store or schema path
187
+ # (contains store, schema, migration or db/), every envelope of that story
188
+ # carries a constraint asking the worker to run the repo's schema-contract
189
+ # tests, if present.
190
+ #
191
+ # A handoff with status `failed` re-queues the phase (the driver allows one
192
+ # route-back). A handoff with status `blocked` holds the story as
193
+ # blocked_needs_owner and writes one decision item: a rerun cannot help.
194
+ # A state dir from the previous version (a queued story whose newest archived
195
+ # handoff, handoffs/<id>-<phase>.failed.<n>.json, says blocked) is read as
196
+ # blocked_needs_owner, so resolve works on it without hand edits.
197
+ #
198
+ # Exit codes: 0 ok; 1 refused or failed; 2 usage; 3 lane capacity/admission (retry next tick);
199
+ # 10 held for a parent decision (approval hold, or a blocked handoff on accept);
200
+ # 11 lane down: `hq lanes enqueue` returned `loop_not_running` or a terminal
201
+ # lane state. The worker mapping is dropped, the story stays queued, and
202
+ # route prints LANE_DOWN <id> <phase> <worker> ...; tick stops there.
203
+ # 12 no lane: the phase's worker has no row in the run's worker table (NO_LANE);
204
+ # tick stops there.
205
+ #
206
+ # Env: PC_HQ (default hq), PC_ENVELOPE (default
207
+ # core/scripts/pipeline-envelope.sh), PC_WORKERS_ROOT (colon-separated worker
208
+ # roots, default below), PC_GATE_EVERY (default 3,
209
+ # the /run-project regression cadence), PC_NOW (epoch seconds, for tests),
210
+ # PC_DEADLINE_MIN (default 120), PC_MAX_STORIES (default 3), PC_HQ_ROOT,
211
+ # PC_PARK_CONFIRM (default 5). PC_WORKERS_ROOT defaults to core/workers, every
212
+ # companies/*/workers and personal/workers.
213
+ #
214
+ # The conductor never writes `passes` into prd.json. A verified story is
215
+ # reported; the parent session decides what to record.
216
+ #
217
+ # bash 3.2 portable; JSON and YAML work is done by node.
218
+
219
+ set -u
220
+
221
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
222
+ PC_HQ_ROOT="${PC_HQ_ROOT:-$(cd "$HERE/../.." && pwd)}"
223
+ export PC_HQ_ROOT
224
+ export PC_HQ="${PC_HQ:-hq}"
225
+ export PC_ENVELOPE="${PC_ENVELOPE:-$HERE/pipeline-envelope.sh}"
226
+ if [ -z "${PC_WORKERS_ROOT:-}" ]; then
227
+ PC_WORKERS_ROOT="$PC_HQ_ROOT/core/workers"
228
+ for d in "$PC_HQ_ROOT"/companies/*/workers; do [ -d "$d" ] && PC_WORKERS_ROOT="$PC_WORKERS_ROOT:$d"; done
229
+ PC_WORKERS_ROOT="$PC_WORKERS_ROOT:$PC_HQ_ROOT/personal/workers"
230
+ fi
231
+ export PC_WORKERS_ROOT
232
+ export PC_GATE_EVERY="${PC_GATE_EVERY:-3}"
233
+ export PC_DEADLINE_MIN="${PC_DEADLINE_MIN:-120}"
234
+ export PC_MAX_STORIES="${PC_MAX_STORIES:-3}"
235
+
236
+ usage() { sed -n '3,/^# bash 3.2 portable/p' "${BASH_SOURCE[0]}" >&2; exit 2; }
237
+ die() { echo "pipeline-conductor: $*" >&2; exit "${2:-1}"; }
238
+
239
+ command -v node >/dev/null 2>&1 || die "node required" 2
240
+ [ $# -ge 1 ] || usage
241
+ CMD="$1"; shift
242
+ SUB=""
243
+ case "$CMD" in
244
+ gate|report|overlay) [ $# -ge 1 ] || usage; SUB="$1"; shift ;;
245
+ -h|--help) usage ;;
246
+ esac
247
+
248
+ PRD=""; STATE=""; STORY=""; HANDOFF=""; REPO=""; SHARED=0; WORKER=""; WT=""; LIMIT=""; NOTE=""; RESULT=""; AS=""; NOTE_SET=0; FROM_PHASE=""
249
+ STORY_BRANCHES=0; OVERLAY=""; TABLE=""; SEQUENCE=""; FORCE=0; ALLOW_REPOS=0
250
+ NL='
251
+ '
252
+ while [ $# -gt 0 ]; do
253
+ case "$1" in
254
+ --prd|--state|--story|--handoff|--repo|--worker|--worktree|--limit|--note|--as|--from-phase|--overlay|--table|--sequence)
255
+ [ $# -ge 2 ] || die "$1 needs a value" 2 ;;
256
+ esac
257
+ case "$1" in
258
+ --prd) PRD="${2:-}"; shift 2 ;;
259
+ --state) STATE="${2:-}"; shift 2 ;;
260
+ --story) STORY="${2:-}"; shift 2 ;;
261
+ --handoff) HANDOFF="${2:-}"; shift 2 ;;
262
+ --repo) REPO="${2:-}"; shift 2 ;;
263
+ --shared) SHARED=1; shift ;;
264
+ --worker) WORKER="${2:-}"; shift 2 ;;
265
+ --worktree) WT="${WT:+$WT$NL}${2:-}"; shift 2 ;;
266
+ --story-branches) STORY_BRANCHES=1; shift ;;
267
+ --limit) LIMIT="${2:-}"; shift 2 ;;
268
+ --note) NOTE="${2:-}"; NOTE_SET=1; shift 2 ;;
269
+ --as) AS="${2:-}"; shift 2 ;;
270
+ --from-phase) FROM_PHASE="${2:-}"; shift 2 ;;
271
+ --overlay) OVERLAY="${2:-}"; shift 2 ;;
272
+ --table) TABLE="${2:-}"; shift 2 ;;
273
+ --sequence) SEQUENCE="${2:-}"; shift 2 ;;
274
+ --force) FORCE=1; shift ;;
275
+ --allow-repos-worktree) ALLOW_REPOS=1; shift ;;
276
+ pass|fail) RESULT="$1"; shift ;;
277
+ *) die "unknown argument: $1" 2 ;;
278
+ esac
279
+ done
280
+ [ -n "$STATE" ] || die "--state <dir> is required" 2
281
+ case "$STORY" in */*|.*) die "unsafe story id: $STORY" 2 ;; esac
282
+ case "$LIMIT" in ""|*[!0-9]*) [ -z "$LIMIT" ] || die "--limit must be a non-negative integer" 2 ;; esac
283
+ mkdir -p "$STATE/stories" "$STATE/envelopes" "$STATE/handoffs" "$STATE/decisions" || die "cannot create state dir $STATE"
284
+
285
+ export PC_SELF_DIR="$HERE" PC_CMD="$CMD" PC_SUB="$SUB" PC_PRD="$PRD" PC_STATE="$STATE" PC_STORY="$STORY" \
286
+ PC_HANDOFF="$HANDOFF" PC_WORKER="$WORKER" PC_WT="$WT" PC_LIMIT="$LIMIT" PC_NOTE="$NOTE" PC_RESULT="$RESULT" \
287
+ PC_AS="$AS" PC_NOTE_SET="$NOTE_SET" PC_FROM_PHASE="$FROM_PHASE" PC_REPO="$REPO" PC_SHARED="$SHARED" \
288
+ PC_STORY_BRANCHES="$STORY_BRANCHES" PC_OVERLAY="$OVERLAY" PC_TABLE="$TABLE" PC_SEQUENCE="$SEQUENCE" PC_FORCE="$FORCE" \
289
+ PC_ALLOW_REPOS="$ALLOW_REPOS"
290
+
291
+ node - <<'JS'
292
+ "use strict";
293
+ const fs = require("fs"), cp = require("child_process"), osm = require("os");
294
+ const pathwin = require("path").win32;
295
+
296
+ const E = process.env;
297
+ const CMD = E.PC_CMD, SUB = E.PC_SUB, STATE = E.PC_STATE;
298
+ const PRD = E.PC_PRD, STORY = E.PC_STORY;
299
+
300
+ // ---------- runtime helpers (stdout is buffered until exit, stderr is written at once) ----------
301
+ const OUTBUF = [];
302
+ function writeAll(fd, s) {
303
+ const b = Buffer.from(s, "utf8"); let off = 0;
304
+ while (off < b.length) {
305
+ try { off += fs.writeSync(fd, b, off, b.length - off); }
306
+ catch (e) { if (e.code !== "EAGAIN") throw e; }
307
+ }
308
+ }
309
+ function flushOut() { if (OUTBUF.length) { const s = OUTBUF.join(""); OUTBUF.length = 0; writeAll(1, s); } }
310
+ function print(s) { OUTBUF.push(s + "\n"); }
311
+ function owrite(s) { OUTBUF.push(s); }
312
+ function eprint(s) { writeAll(2, s + "\n"); }
313
+ function exit(rc) { flushOut(); process.exit(rc); }
314
+ function die(msg, rc) { eprint("pipeline-conductor: " + msg); exit(rc === undefined ? 1 : rc); }
315
+
316
+ function T(x) {
317
+ if (x === null || x === undefined || x === false || x === 0 || x === "") return false;
318
+ if (Array.isArray(x)) return x.length > 0;
319
+ if (typeof x === "object") return Object.keys(x).length > 0;
320
+ return true;
321
+ }
322
+ function or(x, y) { return T(x) ? x : y; }
323
+ function isDict(x) { return x !== null && typeof x === "object" && !Array.isArray(x); }
324
+ function isInt(x) { return typeof x === "boolean" || (typeof x === "number" && Number.isInteger(x)); }
325
+ function isNum(x) { return typeof x === "boolean" || typeof x === "number"; }
326
+ function num(x) { return typeof x === "boolean" ? (x ? 1 : 0) : x; }
327
+ function get(d, k, dflt) { return isDict(d) && Object.prototype.hasOwnProperty.call(d, k) ? d[k] : dflt; }
328
+ function has(d, k) { return isDict(d) && Object.prototype.hasOwnProperty.call(d, k); }
329
+ function pop(d, k, dflt) { if (has(d, k)) { const v = d[k]; delete d[k]; return v; } return dflt; }
330
+ function setdefault(d, k, v) { if (!has(d, k)) d[k] = v; return d[k]; }
331
+ function eq(a, b) { return JSON.stringify(a) === JSON.stringify(b); }
332
+ function pyInt(x) {
333
+ if (typeof x === "boolean") return x ? 1 : 0;
334
+ if (typeof x === "number") { if (!isFinite(x)) throw new Error("cannot convert float to integer"); return Math.trunc(x); }
335
+ if (typeof x === "string" && /^\s*[+-]?\d+(_\d+)*\s*$/.test(x)) return parseInt(x.replace(/_/g, "").trim(), 10);
336
+ throw new Error("invalid literal for int(): " + pyRepr(x));
337
+ }
338
+ function pyNum(x) {
339
+ if (Number.isInteger(x)) return String(x);
340
+ let s = String(x);
341
+ s = s.replace(/e([+-])(\d)$/, "e$10$2");
342
+ return s;
343
+ }
344
+ function pyStrRepr(s) {
345
+ const q = (s.indexOf("'") >= 0 && s.indexOf('"') < 0) ? '"' : "'";
346
+ let out = q;
347
+ for (const ch of s) {
348
+ const c = ch.codePointAt(0);
349
+ if (ch === "\\") out += "\\\\";
350
+ else if (ch === q) out += "\\" + q;
351
+ else if (ch === "\n") out += "\\n";
352
+ else if (ch === "\r") out += "\\r";
353
+ else if (ch === "\t") out += "\\t";
354
+ else if (c < 0x20 || c === 0x7f) out += "\\x" + c.toString(16).padStart(2, "0");
355
+ else out += ch;
356
+ }
357
+ return out + q;
358
+ }
359
+ function pyRepr(x) {
360
+ if (typeof x === "string") return pyStrRepr(x);
361
+ if (Array.isArray(x)) return "[" + x.map(pyRepr).join(", ") + "]";
362
+ if (isDict(x)) return "{" + Object.keys(x).map(k => pyStrRepr(k) + ": " + pyRepr(x[k])).join(", ") + "}";
363
+ return S(x);
364
+ }
365
+ function S(x) {
366
+ if (x === null || x === undefined) return "None";
367
+ if (x === true) return "True";
368
+ if (x === false) return "False";
369
+ if (typeof x === "number") return pyNum(x);
370
+ if (typeof x === "string") return x;
371
+ return pyRepr(x);
372
+ }
373
+ function words(s) { return String(s).split(/\s+/).filter(Boolean); }
374
+ function wsjoin(x) { return words(S(x)).join(" "); }
375
+ function splitlines(s) {
376
+ const parts = s.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
377
+ if (parts.length && parts[parts.length - 1] === "") parts.pop();
378
+ return parts;
379
+ }
380
+ function strip(s) { return s.replace(/^\s+|\s+$/g, ""); }
381
+ function lstripChars(s, chars) { let i = 0; while (i < s.length && chars.indexOf(s[i]) >= 0) i++; return s.slice(i); }
382
+ function stripChars(s, chars) {
383
+ let i = 0, j = s.length;
384
+ while (i < j && chars.indexOf(s[i]) >= 0) i++;
385
+ while (j > i && chars.indexOf(s[j - 1]) >= 0) j--;
386
+ return s.slice(i, j);
387
+ }
388
+ function reEscape(s) { return String(s).replace(/[()[\]{}?*+\-|^$\\.&~# \t\n\r\v\f]/g, "\\$&"); }
389
+ function cmpStr(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
390
+ function sortedStr(a) { return Array.from(a).sort(cmpStr); }
391
+ function uniq(a) { return Array.from(new Set(a)); }
392
+ function pyDumps(v, ascii) {
393
+ // json.dumps default separators (", ", ": "), optional ensure_ascii escaping
394
+ const str = (s) => {
395
+ let o = '"';
396
+ for (let i = 0; i < s.length; i++) {
397
+ const ch = s[i], c = s.charCodeAt(i);
398
+ if (ch === '"') o += '\\"';
399
+ else if (ch === "\\") o += "\\\\";
400
+ else if (ch === "\n") o += "\\n";
401
+ else if (ch === "\r") o += "\\r";
402
+ else if (ch === "\t") o += "\\t";
403
+ else if (ch === "\b") o += "\\b";
404
+ else if (ch === "\f") o += "\\f";
405
+ else if (c < 0x20 || (ascii && c > 0x7e)) o += "\\u" + c.toString(16).padStart(4, "0");
406
+ else o += ch;
407
+ }
408
+ return o + '"';
409
+ };
410
+ const go = (x) => {
411
+ if (x === null || x === undefined) return "null";
412
+ if (x === true) return "true";
413
+ if (x === false) return "false";
414
+ if (typeof x === "number") return Number.isInteger(x) ? String(x) : pyNum(x);
415
+ if (typeof x === "string") return str(x);
416
+ if (Array.isArray(x)) return "[" + x.map(go).join(", ") + "]";
417
+ return "{" + Object.keys(x).map(k => str(k) + ": " + go(x[k])).join(", ") + "}";
418
+ };
419
+ return go(v);
420
+ }
421
+
422
+ // ---------- path helpers (posixpath semantics) ----------
423
+ function isabs(p) { return p.startsWith("/"); }
424
+ function pjoin(a, ...rest) {
425
+ let path = a;
426
+ for (const b of rest) {
427
+ if (b.startsWith("/")) path = b;
428
+ else if (!path || path.endsWith("/")) path += b;
429
+ else path += "/" + b;
430
+ }
431
+ return path;
432
+ }
433
+ function basename(p) { return p.slice(p.lastIndexOf("/") + 1); }
434
+ function dirname(p) {
435
+ let head = p.slice(0, p.lastIndexOf("/") + 1);
436
+ if (head && head !== "/".repeat(head.length)) head = head.replace(/\/+$/, "");
437
+ return head;
438
+ }
439
+ function psplit(p) {
440
+ const i = p.lastIndexOf("/") + 1;
441
+ let head = p.slice(0, i); const tail = p.slice(i);
442
+ if (head && head !== "/".repeat(head.length)) head = head.replace(/\/+$/, "");
443
+ return [head, tail];
444
+ }
445
+ function normpath(p) {
446
+ if (p === "") return ".";
447
+ let initial = p.startsWith("/") ? 1 : 0;
448
+ if (initial && p.startsWith("//") && !p.startsWith("///")) initial = 2;
449
+ const comps = p.split("/"), out = [];
450
+ for (const c of comps) {
451
+ if (c === "" || c === ".") continue;
452
+ if (c !== ".." || (!initial && !out.length) || (out.length && out[out.length - 1] === "..")) out.push(c);
453
+ else if (out.length) out.pop();
454
+ }
455
+ const s = "/".repeat(initial) + out.join("/");
456
+ return s || ".";
457
+ }
458
+ function abspath(p) { p = String(p); if (!isabs(p)) p = pjoin(process.cwd(), p); return normpath(p); }
459
+ function expanduser(p) {
460
+ if (!p.startsWith("~")) return p;
461
+ let i = p.indexOf("/", 1); if (i < 0) i = p.length;
462
+ if (i !== 1) return p;
463
+ const home = E.HOME || osm.homedir();
464
+ let r = home.replace(/\/+$/, "") + p.slice(i);
465
+ return r || "/";
466
+ }
467
+ function lstatOk(p) { try { return fs.lstatSync(p); } catch (e) { return null; } }
468
+ function realpath(filename) {
469
+ const seen = new Map();
470
+ function jr(path, rest) {
471
+ if (isabs(rest)) { rest = rest.slice(1); path = "/"; }
472
+ while (rest) {
473
+ let name; const i = rest.indexOf("/");
474
+ if (i < 0) { name = rest; rest = ""; } else { name = rest.slice(0, i); rest = rest.slice(i + 1); }
475
+ if (!name || name === ".") continue;
476
+ if (name === "..") {
477
+ if (path) {
478
+ let nm; [path, nm] = psplit(path);
479
+ if (nm === "..") path = pjoin(path, "..", "..");
480
+ } else path = "..";
481
+ continue;
482
+ }
483
+ const newpath = pjoin(path, name);
484
+ const st = lstatOk(newpath);
485
+ if (!st || !st.isSymbolicLink()) { path = newpath; continue; }
486
+ if (seen.has(newpath)) {
487
+ path = seen.get(newpath);
488
+ if (path !== null) continue;
489
+ return [pjoin(newpath, rest), false];
490
+ }
491
+ seen.set(newpath, null);
492
+ let ok; [path, ok] = jr(path, fs.readlinkSync(newpath));
493
+ if (!ok) return [pjoin(path, rest), false];
494
+ seen.set(newpath, path);
495
+ }
496
+ return [path, true];
497
+ }
498
+ return abspath(jr("", String(filename))[0]);
499
+ }
500
+ function exists(p) { try { fs.statSync(p); return true; } catch (e) { return false; } }
501
+ function isdir(p) { try { return fs.statSync(p).isDirectory(); } catch (e) { return false; } }
502
+ function islink(p) { const s = lstatOk(p); return !!s && s.isSymbolicLink(); }
503
+ function readText(p) { return fs.readFileSync(p, "utf8"); }
504
+ function writeText(p, s) { fs.writeFileSync(p, s, "utf8"); }
505
+ function listdir(p) { return fs.readdirSync(p); }
506
+ function* walk(top) {
507
+ let ents;
508
+ try { ents = fs.readdirSync(top, { withFileTypes: true }); } catch (e) { return; }
509
+ const dirs = [], files = [];
510
+ for (const e of ents) {
511
+ let d = false;
512
+ try { d = e.isDirectory() || (e.isSymbolicLink() && fs.statSync(pjoin(top, e.name)).isDirectory()); } catch (x) { d = false; }
513
+ (d ? dirs : files).push(e.name);
514
+ }
515
+ const ent = [top, dirs, files];
516
+ yield ent;
517
+ for (const d of ent[1]) {
518
+ const np = pjoin(top, d);
519
+ if (!islink(np)) yield* walk(np);
520
+ }
521
+ }
522
+
523
+ // ---------- subprocess ----------
524
+ function run(cmd, args, opts) {
525
+ opts = opts || {};
526
+ const r = cp.spawnSync(cmd, args, { encoding: "utf8", maxBuffer: 1 << 30, stdio: ["inherit", "pipe", "pipe"],
527
+ timeout: opts.timeout });
528
+ if (r.error) throw r.error;
529
+ let rc = r.status;
530
+ if (rc === null) rc = -((osm.constants.signals || {})[r.signal] || 1);
531
+ return { returncode: rc, stdout: r.stdout || "", stderr: r.stderr || "" };
532
+ }
533
+
534
+ function jload(p, dflt) {
535
+ if (dflt === undefined) dflt = null;
536
+ try { return JSON.parse(readText(p)); } catch (e) { return dflt; }
537
+ }
538
+
539
+ function jsave(p, d) {
540
+ const tmp = p + ".tmp";
541
+ writeText(tmp, JSON.stringify(d, null, 1));
542
+ fs.renameSync(tmp, p);
543
+ }
544
+
545
+ function sp(...parts) { return pjoin(STATE, ...parts); }
546
+
547
+ function now() {
548
+ const v = E.PC_NOW;
549
+ if (v) return pyInt(v);
550
+ return Math.floor(Date.now() / 1000);
551
+ }
552
+
553
+ function iso(t) { return new Date(t * 1000).toISOString().replace(/\.\d+Z$/, "Z"); }
554
+
555
+ function prd() {
556
+ if (!PRD) die("--prd is required", 2);
557
+ const d = jload(PRD);
558
+ if (!isDict(d) || !Array.isArray(d.userStories)) die("not a prd: " + PRD);
559
+ return d;
560
+ }
561
+
562
+ function prd_opt() {
563
+ // The prd from --prd, else the one the last tick recorded in run.json, else null.
564
+ const path = PRD || get(or(jload(sp("run.json"), {}), {}), "prd");
565
+ const d = T(path) ? jload(path) : null;
566
+ return isDict(d) && Array.isArray(d.userStories) ? d : null;
567
+ }
568
+
569
+ function story_of(p, sid) {
570
+ for (const s of p.userStories) if (get(s, "id") === sid) return s;
571
+ die("story not in prd: " + S(sid));
572
+ }
573
+
574
+ function sfile(sid) { return sp("stories", sid + ".json"); }
575
+
576
+ function sload(sid) {
577
+ const d = jload(sfile(sid));
578
+ if (d === null) die("story " + sid + " not classified (no " + sfile(sid) + ")");
579
+ return migrate_legacy(d);
580
+ }
581
+
582
+ // ---------- blocked handoffs ----------
583
+ const DONE_STATES = ["verified", "accepted_partial"];
584
+
585
+ function archives(sid, phase) {
586
+ // Archived handoffs of one phase, oldest first: [[n, path]].
587
+ const pre = sid + "-" + phase + ".failed.";
588
+ const out = [];
589
+ for (const n of listdir(sp("handoffs"))) {
590
+ if (n.startsWith(pre) && n.endsWith(".json") && n.length >= pre.length + 5 && /^[0-9]+$/.test(n.slice(pre.length, -5)))
591
+ out.push([parseInt(n.slice(pre.length, -5), 10), sp("handoffs", n)]);
592
+ }
593
+ return out.sort((a, b) => a[0] - b[0] || cmpStr(a[1], b[1]));
594
+ }
595
+
596
+ function archive_handoff(sid, phase) {
597
+ const h = sp("handoffs", sid + "-" + phase + ".json");
598
+ if (!exists(h)) return null;
599
+ let n = 1;
600
+ while (exists(sp("handoffs", sid + "-" + phase + ".failed." + n + ".json"))) n += 1;
601
+ const dst = sp("handoffs", sid + "-" + phase + ".failed." + n + ".json");
602
+ fs.renameSync(h, dst);
603
+ return dst;
604
+ }
605
+
606
+ function blocker_text(h) {
607
+ const reason = ["context_for_next", "error"].find(k => typeof get(h, k) === "string" && strip(h[k]));
608
+ const parts = ["context_for_next", "error", "summary", "notes"]
609
+ .filter(k => typeof get(h, k) === "string" && strip(h[k]))
610
+ .filter(k => !(reason && k === "summary" && strip(h[k]) === "Phase execution failed."))
611
+ .map(k => strip(h[k]));
612
+ return parts.join("\n\n") || "(the worker gave no reason)";
613
+ }
614
+
615
+ function decision_path(sid, phase) { return sp("decisions", S(sid) + "-blocked-" + S(phase) + ".md"); }
616
+
617
+ function hold_blocked(st, h, hpath) {
618
+ const sid = st.id; const ph = st.phases[st.current];
619
+ const attempts = archives(sid, ph.phase).length + (exists(sp("handoffs", sid + "-" + ph.phase + ".json")) ? 1 : 0);
620
+ const text = blocker_text(h);
621
+ st.state = "blocked_needs_owner";
622
+ st.blocked = { phase: ph.phase, worker: ph.worker, handoff: abspath(hpath), text: text, at: iso(now()), attempts: attempts };
623
+ pop(st, "owner_retry");
624
+ const dp = decision_path(sid, ph.phase);
625
+ writeText(dp,
626
+ "# Decision: " + S(sid) + " is blocked at its " + S(ph.phase) + " phase\n\n" +
627
+ "- story: " + S(sid) + " (" + S(get(st, "title", "")) + ")\n- phase: " + S(ph.phase) + "\n- lane: " + S(ph.worker) +
628
+ "\n- attempts so far: " + attempts + "\n- handoff: " + abspath(hpath) + "\n- status: pending\n\n" +
629
+ "## What the worker said\n\n" + text + "\n\n" +
630
+ "## Resolutions (run one after the owner answers, then restart the driver)\n\n" +
631
+ "- Accept as a partial draft (dependents may start; passes is not set):\n" +
632
+ " pipeline-conductor.sh resolve --state " + STATE + " --story " + S(sid) + " --as accepted-partial --note \"<owner's reason>\"\n" +
633
+ "- Run the phase again with the owner's answer:\n" +
634
+ " pipeline-conductor.sh resolve --state " + STATE + " --story " + S(sid) + " --as retry --note \"<owner's answer>\"\n" +
635
+ "- Set the story and its dependents aside:\n" +
636
+ " pipeline-conductor.sh park --state " + STATE + " --story " + S(sid) + " --note \"<why>\"\n");
637
+ jsave(sfile(sid), st);
638
+ return dp;
639
+ }
640
+
641
+ function migrate_legacy(st) {
642
+ // A previous-version state dir re-queued a blocked phase and archived its handoffs.
643
+ // Read such a story as blocked_needs_owner. blocked_ack counts archives the owner already answered.
644
+ if (get(st, "state") !== "queued" || !T(get(st, "started"))) return st;
645
+ const phases = or(get(st, "phases"), []); const cur = get(st, "current", 0);
646
+ if (!(isInt(cur) && 0 <= num(cur) && num(cur) < phases.length)) return st;
647
+ const phase = phases[num(cur)].phase;
648
+ if (exists(sp("handoffs", st.id + "-" + phase + ".json"))) return st;
649
+ const arch = archives(st.id, phase);
650
+ if (arch.length <= pyInt(get(st, "blocked_ack", 0))) return st;
651
+ const h = or(jload(arch[arch.length - 1][1]), {});
652
+ if (get(h, "status") !== "blocked" || get(h, "phase") !== phase) return st;
653
+ hold_blocked(st, h, arch[arch.length - 1][1]);
654
+ return st;
655
+ }
656
+
657
+ function mark_decision(dp, status) {
658
+ if (exists(dp)) {
659
+ let t = readText(dp);
660
+ t = t.replace(/^- status: .*$/m, () => "- status: " + status);
661
+ writeText(dp, t);
662
+ }
663
+ }
664
+
665
+ function need_story() {
666
+ if (!STORY) die("--story is required", 2);
667
+ return STORY;
668
+ }
669
+
670
+ const PHASE = { "architect": "architect", "backend-dev": "backend", "frontend-dev": "frontend",
671
+ "database-dev": "database", "qa-tester": "qa", "motion-designer": "motion" };
672
+ function phaseOf(w) { return has(PHASE, w) ? PHASE[w] : w; }
673
+
674
+
675
+ // ---------- repos, worktrees, branches ----------
676
+ class CutError extends Error {}
677
+
678
+ function real(path) {
679
+ // Absolute, symlink-free path; a relative path is read from the HQ root.
680
+ path = expanduser(strip(S(path)));
681
+ if (process.platform === "win32") {
682
+ if (!pathwin.isAbsolute(path)) path = pathwin.resolve(E.PC_HQ_ROOT, path);
683
+ return winCanonicalPath(path);
684
+ }
685
+ if (!isabs(path)) path = pjoin(E.PC_HQ_ROOT, path);
686
+ return realpath(path);
687
+ }
688
+
689
+ function listed_repos(p) {
690
+ // metadata.repos[] (canonical: path strings or objects with path/repoPath) plus the alias metadata.repoPaths[].
691
+ const md = or(get(p, "metadata"), {});
692
+ const out = [];
693
+ for (const key of ["repos", "repoPaths"]) {
694
+ for (let r of or(get(md, key), [])) {
695
+ if (isDict(r)) r = or(get(r, "path"), get(r, "repoPath"));
696
+ if (typeof r === "string" && strip(r) && !out.includes(real(r))) out.push(real(r));
697
+ }
698
+ }
699
+ return out;
700
+ }
701
+
702
+ function all_repos(p) {
703
+ const out = listed_repos(p).slice();
704
+ const md = or(get(p, "metadata"), {});
705
+ for (const r of [get(md, "repoPath")].concat(p.userStories.map(s => get(s, "repoPath")))) {
706
+ if (typeof r === "string" && strip(r) && !out.includes(real(r))) out.push(real(r));
707
+ }
708
+ return out;
709
+ }
710
+
711
+ function multi_repo(p) { return all_repos(p).length > 1; }
712
+
713
+ function under(path, root) { return path === root || path.startsWith(root.replace(/\/+$/, "") + "/"); }
714
+ function winAbsolutePath(candidate) { return pathwin.resolve(String(candidate)).replace(/\\/g, "/"); }
715
+ function winCanonicalPath(candidate) {
716
+ const resolved = winAbsolutePath(candidate);
717
+ try { return fs.realpathSync.native(resolved).replace(/\\/g, "/"); }
718
+ catch (error) {
719
+ if (error.code !== "ENOENT") throw error;
720
+ const parent = pathwin.dirname(resolved);
721
+ if (parent === resolved) throw error;
722
+ return pathwin.join(winCanonicalPath(parent), pathwin.basename(resolved)).replace(/\\/g, "/");
723
+ }
724
+ }
725
+ function winUnder(path, root) {
726
+ const candidate = String(path).replace(/\\/g, "/").toLowerCase().replace(/\/$/, "");
727
+ const base = String(root).replace(/\\/g, "/").toLowerCase().replace(/\/$/, "");
728
+ return candidate === base || candidate.startsWith(base + "/");
729
+ }
730
+
731
+ function file_owner(f, repos) {
732
+ // The repo that owns a declared file path, or null.
733
+ f = expanduser(strip(f));
734
+ const tiers = [];
735
+ if (isabs(f)) {
736
+ tiers.push(repos.filter(r => under(realpath(f), r)));
737
+ } else {
738
+ tiers.push(repos.filter(r => under(realpath(pjoin(E.PC_HQ_ROOT, f)), r)));
739
+ const head = lstripChars(f.replace(/\\/g, "/"), "./").split("/")[0];
740
+ tiers.push(repos.filter(r => basename(r) === head));
741
+ tiers.push(repos.filter(r => exists(pjoin(r, f))));
742
+ }
743
+ for (const t of tiers) {
744
+ if (t.length === 1) return t[0];
745
+ if (t.length > 1) { let best = t[0]; for (const x of t) if (x.length > best.length) best = x; return best; }
746
+ }
747
+ return null;
748
+ }
749
+
750
+ function resolve_repo(p, s) {
751
+ if (typeof get(s, "repoPath") === "string" && strip(s.repoPath)) return [real(s.repoPath), "story repoPath"];
752
+ const repos = listed_repos(p);
753
+ const files = or(get(s, "files"), []).filter(f => typeof f === "string" && strip(f));
754
+ if (repos.length > 1 && files.length) {
755
+ const o = file_owner(files[0], repos);
756
+ if (o) return [o, "owner of " + files[0]];
757
+ }
758
+ const md = or(get(p, "metadata"), {});
759
+ if (typeof get(md, "repoPath") === "string" && strip(md.repoPath)) return [real(md.repoPath), "metadata.repoPath"];
760
+ if (repos.length === 1) return [repos[0], "metadata.repos"];
761
+ return [null, "none"];
762
+ }
763
+
764
+ const REPOS_WT_LINE = "This worktree is under repos/, where the core Write/Edit guard blocks the editor tools: " +
765
+ "make every file edit through the shell or apply_patch, never with the Write or Edit tool.";
766
+
767
+ // The one check for a run worktree under <HQ>/repos/.
768
+ function repos_worktree(path, refuse) {
769
+ if (refuse === undefined) refuse = true;
770
+ if (process.platform === "win32") {
771
+ const hq = winCanonicalPath(E.PC_HQ_ROOT);
772
+ const r = winCanonicalPath(path);
773
+ const repos = winCanonicalPath(pathwin.join(hq, "repos"));
774
+ if (!winUnder(r, repos)) return false;
775
+ if (refuse && E.PC_ALLOW_REPOS !== "1") {
776
+ die("refusing worktree " + S(path) + ": it resolves under " + pjoin(hq, "repos") + "/, where the core Write/Edit guard blocks editor-tool " +
777
+ "writes, so a lane that edits with the Write or Edit tool stalls there. Leave --worktree off to use " +
778
+ "the default " + pjoin(hq, "workspace", "worktrees") + "/<project>/<repo-name>/, or pass --allow-repos-worktree to keep this tree; every phase " +
779
+ "envelope then tells the worker to edit through the shell or apply_patch.", 2);
780
+ }
781
+ return true;
782
+ }
783
+ const hq = realpath(E.PC_HQ_ROOT);
784
+ const r = realpath(abspath(expanduser(S(path))));
785
+ if (!under(r, pjoin(hq, "repos"))) return false;
786
+ if (refuse && E.PC_ALLOW_REPOS !== "1") {
787
+ die("refusing worktree " + S(path) + ": it resolves under " + pjoin(hq, "repos") + "/" + ", where the core Write/Edit guard blocks editor-tool " +
788
+ "writes, so a lane that edits with the Write or Edit tool stalls there. Leave --worktree off to use " +
789
+ "the default " + pjoin(hq, "workspace", "worktrees") + "/<project>/<repo-name>/, or pass --allow-repos-worktree to keep this tree; every phase " +
790
+ "envelope then tells the worker to edit through the shell or apply_patch.", 2);
791
+ }
792
+ return true;
793
+ }
794
+
795
+ function wt_mapping(raw) {
796
+ // --worktree values -> {repo: worktree}; a bare --worktree DIR is stored under "*".
797
+ const m = {};
798
+ for (const e of (raw || "").split("\n")) {
799
+ if (!strip(e)) continue;
800
+ const i = e.indexOf("=");
801
+ if (i >= 0) {
802
+ const r = e.slice(0, i), w = e.slice(i + 1);
803
+ m[real(r)] = process.platform === "win32" ? winAbsolutePath(strip(w)) : abspath(strip(w));
804
+ } else {
805
+ m["*"] = process.platform === "win32" ? winAbsolutePath(strip(e)) : abspath(strip(e));
806
+ }
807
+ }
808
+ for (const w of Object.values(m)) repos_worktree(w);
809
+ return m;
810
+ }
811
+
812
+ function pick_worktree(p, repo, m) {
813
+ if (T(repo) && has(m, repo)) return m[repo];
814
+ if (has(m, "*") && !multi_repo(p)) return m["*"];
815
+ return null;
816
+ }
817
+
818
+ function git(repo, ...args) { return run("git", ["-C", repo].concat(args)); }
819
+
820
+ function base_branch(p) {
821
+ const b = get(or(get(p, "metadata"), {}), "baseBranch");
822
+ return typeof b === "string" && strip(b) ? strip(b) : "main";
823
+ }
824
+
825
+ function slug(x) { return stripChars(S(x).replace(/[^A-Za-z0-9._-]/gu, "-"), "-") || "x"; }
826
+
827
+ function feature_branch(p) {
828
+ const b = get(or(get(p, "metadata"), {}), "branchName");
829
+ if (typeof b === "string" && strip(b)) return strip(b);
830
+ return "feature/" + slug(or(or(get(p, "name"), get(p, "project")), "run"));
831
+ }
832
+
833
+ function has_ref(repo, ref) { return git(repo, "rev-parse", "--verify", "--quiet", ref + "^{commit}").returncode === 0; }
834
+
835
+ function base_ref(repo, base) {
836
+ if (git(repo, "remote", "get-url", "origin").returncode === 0) {
837
+ git(repo, "fetch", "--quiet", "origin", "+refs/heads/" + base + ":refs/remotes/origin/" + base);
838
+ if (has_ref(repo, "refs/remotes/origin/" + base))
839
+ return ["origin/" + base, strip(git(repo, "rev-parse", "refs/remotes/origin/" + base).stdout)];
840
+ }
841
+ if (has_ref(repo, "refs/heads/" + base))
842
+ return [base, strip(git(repo, "rev-parse", "refs/heads/" + base).stdout)];
843
+ throw new CutError("cannot cut a branch in " + repo + ": neither origin/" + base + " nor " + base + " exists (base branch from metadata.baseBranch, " +
844
+ "default main)");
845
+ }
846
+
847
+ function check_repo(repo) {
848
+ if (process.platform === "win32") {
849
+ const hq = real(E.PC_HQ_ROOT);
850
+ const canonicalRepo = winCanonicalPath(repo);
851
+ const canonicalHq = winCanonicalPath(hq);
852
+ const reposRoot = pathwin.resolve(canonicalHq, "repos");
853
+ const underWindows = (candidate, root) => {
854
+ const relative = pathwin.relative(root, candidate);
855
+ return relative === "" || (relative !== ".." && !relative.startsWith(".." + pathwin.sep) && !pathwin.isAbsolute(relative));
856
+ };
857
+ if (underWindows(canonicalRepo, canonicalHq) && !underWindows(canonicalRepo, reposRoot))
858
+ throw new CutError("refusing a worktree of a repo inside the HQ root: " + repo);
859
+ } else {
860
+ const hq = realpath(E.PC_HQ_ROOT);
861
+ if (under(repo, hq) && !under(repo, pjoin(hq, "repos")))
862
+ throw new CutError("refusing a worktree of a repo inside the HQ root: " + repo);
863
+ }
864
+ const gitTopRaw = strip(git(repo, "rev-parse", "--show-toplevel").stdout) || "/nonexistent";
865
+ const gitTop = process.platform === "win32" ? fs.realpathSync.native(gitTopRaw) : realpath(gitTopRaw);
866
+ const repoPath = process.platform === "win32" ? fs.realpathSync.native(repo) : repo;
867
+ const sameRepoPath = process.platform === "win32"
868
+ ? (p) => String(p).replace(/\\/g, "/").replace(/^([A-Za-z]):/, (_, drive) => drive.toLowerCase() + ":")
869
+ : (p) => String(p);
870
+ if (sameRepoPath(gitTop) !== sameRepoPath(repoPath))
871
+ throw new CutError("not the top of a repository (git rev-parse --show-toplevel): " + repo);
872
+ }
873
+
874
+ function wt_root(p) {
875
+ if (E.PC_WORKTREE_ROOT) return E.PC_WORKTREE_ROOT;
876
+ const pp = or(p, {});
877
+ const stateName = process.platform === "win32"
878
+ ? pathwin.basename(pathwin.resolve(process.cwd(), STATE))
879
+ : basename(realpath(STATE));
880
+ const proj = or(or(get(pp, "name"), get(pp, "project")), stateName);
881
+ return process.platform === "win32"
882
+ ? pathwin.join(real(E.PC_HQ_ROOT), "workspace", "worktrees", slug(proj))
883
+ : pjoin(realpath(E.PC_HQ_ROOT), "workspace", "worktrees", slug(proj));
884
+ }
885
+
886
+ function worktreePath(root, ...parts) {
887
+ return process.platform === "win32" ? pathwin.join(root, ...parts) : pjoin(root, ...parts);
888
+ }
889
+
890
+ function explicit_target(repo) {
891
+ const m = wt_mapping(E.PC_WT);
892
+ return or(has(m, repo) ? m[repo] : null, has(m, "*") ? m["*"] : null);
893
+ }
894
+
895
+ function add_worktree(repo, wt, branch, start) {
896
+ fs.mkdirSync(dirname(wt) || ".", { recursive: true });
897
+ let r, line;
898
+ if (has_ref(repo, "refs/heads/" + branch)) {
899
+ r = git(repo, "worktree", "add", wt, branch);
900
+ line = "REUSED " + branch + " (branch exists, not cut again) in " + wt;
901
+ } else {
902
+ const [ref, sha] = start;
903
+ r = git(repo, "worktree", "add", "-b", branch, wt, ref);
904
+ line = "CUT " + branch + " from " + ref + " " + sha.slice(0, 12) + " in " + wt;
905
+ }
906
+ if (r.returncode !== 0) throw new CutError("git worktree add failed for " + wt + " (" + branch + "): " + strip(r.stderr));
907
+ return line;
908
+ }
909
+
910
+ function cut_repo_worktree(p, repo) {
911
+ check_repo(repo);
912
+ const wt = explicit_target(repo) || worktreePath(wt_root(p), slug(basename(repo)));
913
+ const branch = feature_branch(p);
914
+ if (isdir(wt))
915
+ return [wt, "EXISTS " + wt + " on " + (strip(git(wt, "rev-parse", "--abbrev-ref", "HEAD").stdout) || "?")];
916
+ const start = has_ref(repo, "refs/heads/" + branch) ? null : base_ref(repo, base_branch(p));
917
+ return [wt, add_worktree(repo, wt, branch, start)];
918
+ }
919
+
920
+ function stacked_on(p, sid, repo, stories) {
921
+ const s = story_of(p, sid); let last = null;
922
+ for (const d of or(get(s, "dependsOn"), [])) {
923
+ const v = or(stories.get(d), {});
924
+ if (DONE_STATES.includes(get(v, "state")) && T(get(v, "branch")) && get(v, "repo") === repo) last = [d, v.branch];
925
+ }
926
+ return last;
927
+ }
928
+
929
+ function cut_story_worktree(p, sid, st, repo) {
930
+ check_repo(repo);
931
+ const wt = explicit_target(repo) || worktreePath(wt_root(p), slug(basename(repo)) + "-" + slug(sid));
932
+ const branch = "pipeline/" + slug(sid);
933
+ if (isdir(wt)) return [wt, "EXISTS " + wt + " on " + branch];
934
+ const dep = stacked_on(p, sid, repo, all_stories());
935
+ let start;
936
+ if (dep) {
937
+ if (!has_ref(repo, "refs/heads/" + dep[1]))
938
+ throw new CutError("dependency branch missing: " + S(dep[1]) + " (story " + S(dep[0]) + ") is not in " + repo + "; story " + sid + " stacks on it");
939
+ start = [dep[1], strip(git(repo, "rev-parse", "refs/heads/" + dep[1]).stdout)];
940
+ } else {
941
+ start = base_ref(repo, base_branch(p));
942
+ }
943
+ const line = add_worktree(repo, wt, branch, has_ref(repo, "refs/heads/" + branch) ? null : start);
944
+ st.branch = branch; st.stacked_on = dep ? dep[0] : null;
945
+ return [wt, line];
946
+ }
947
+
948
+ function hold_owner(st, kind, text) {
949
+ const sid = st.id; const ph = st.phases[st.current];
950
+ st.state = "blocked_needs_owner";
951
+ st.blocked = { phase: ph.phase, worker: ph.worker, handoff: null, kind: kind, text: text, at: iso(now()), attempts: 0 };
952
+ pop(st, "owner_retry");
953
+ const dp = decision_path(sid, ph.phase);
954
+ writeText(dp,
955
+ "# Decision: " + S(sid) + " cannot route its " + S(ph.phase) + " phase\n\n" +
956
+ "- story: " + S(sid) + " (" + S(get(st, "title", "")) + ")\n- phase: " + S(ph.phase) + "\n- lane: " + S(ph.worker) +
957
+ "\n- reason: " + kind + "\n- status: pending\n\n" +
958
+ "## What is wrong\n\n" + text + "\n\n" +
959
+ "## Resolutions (run one after the owner answers, then restart the driver)\n\n" +
960
+ "- Fix it (pass the missing --worktree <repo>=<dir> to the driver, or restore the branch), then route again:\n" +
961
+ " pipeline-conductor.sh resolve --state " + STATE + " --story " + S(sid) + " --as retry --note \"<what changed>\"\n" +
962
+ "- Set the story and its dependents aside:\n" +
963
+ " pipeline-conductor.sh park --state " + STATE + " --story " + S(sid) + " --note \"<why>\"\n");
964
+ jsave(sfile(sid), st);
965
+ print("BLOCKED_OWNER " + S(sid) + " " + S(ph.phase) + " " + kind + " " + dp);
966
+ exit(10);
967
+ }
968
+
969
+ function route_worktree(p, st) {
970
+ const runj = or(jload(sp("run.json"), {}), {});
971
+ const m = or(get(runj, "worktrees"), {});
972
+ const repo = get(st, "repo");
973
+ const wt = T(m) ? pick_worktree(p, repo, m) : get(st, "worktree");
974
+ if (!T(wt)) {
975
+ hold_owner(st, "no_worktree",
976
+ "Story " + S(st.id) + " belongs to repo " + S(or(repo, "(none resolved: set the story's repoPath)")) + ", and no worktree was given for it. Pass --worktree " +
977
+ S(or(repo, "<repo>")) + "=<worktree> to the driver (tick); it is not routed to another repo's worktree.");
978
+ }
979
+ if (T(get(runj, "story_branches"))) {
980
+ if (T(get(st, "story_worktree")) && isdir(st.story_worktree)) return st.story_worktree;
981
+ const src = T(repo) ? repo : realpath(strip(git(wt, "rev-parse", "--show-toplevel").stdout) || wt);
982
+ let swt, line;
983
+ try {
984
+ [swt, line] = cut_story_worktree(p, st.id, st, src);
985
+ } catch (e) {
986
+ if (!(e instanceof CutError)) throw e;
987
+ hold_owner(st, e.message.includes("dependency branch missing") ? "dependency_branch_missing" : "cut_failed", e.message);
988
+ }
989
+ st.story_worktree = swt;
990
+ print(line);
991
+ return swt;
992
+ }
993
+ return wt;
994
+ }
995
+
996
+ // ---------- story-level go ----------
997
+ // The release detector: one place. A story whose declared files or acceptance criteria match
998
+ // any of these needs an explicit go. %(base)s is the prd's base branch.
999
+ const RELEASE_PATTERNS = [
1000
+ "\\bdeploy(s|ed|ing|ment|ments)?\\b",
1001
+ "\\breleas(e|es|ed|ing)\\b",
1002
+ "\\bproduction\\b",
1003
+ "\\bprod\\b",
1004
+ "\\bmerg(e|es|ed|ing)\\b[^.;\\n]{0,40}?\\b(to|into)\\b[^.;\\n]{0,15}?\\b(base branch|main|master|%(base)s)\\b",
1005
+ ];
1006
+
1007
+ function needs_go(p, s) {
1008
+ if (get(s, "approval") === "explicit") return "approval: explicit";
1009
+ if (get(s, "needsApproval") === true) return "needsApproval: true";
1010
+ const base = reEscape(base_branch(p));
1011
+ for (const [src, items] of [["files", get(s, "files")], ["acceptance criteria", get(s, "acceptanceCriteria")]]) {
1012
+ for (const t of or(items, [])) {
1013
+ if (typeof t !== "string") continue;
1014
+ for (const pat of RELEASE_PATTERNS) {
1015
+ const m = new RegExp(pat.split("%(base)s").join(base), "i").exec(t);
1016
+ if (m) return "release detector: " + pyRepr(m[0]) + " in " + src + " (" + t + ")";
1017
+ }
1018
+ }
1019
+ }
1020
+ return null;
1021
+ }
1022
+
1023
+ function go_index(st) {
1024
+ const i = st.phases.findIndex(ph => !is_checker(ph.worker));
1025
+ return i < 0 ? 0 : i;
1026
+ }
1027
+
1028
+ function go_path(sid) { return sp("decisions", sid + "-go.md"); }
1029
+
1030
+ function hold_for_go(st, why) {
1031
+ const sid = st.id; const ph = st.phases[st.current];
1032
+ st.state = "awaiting_go"; st.go_hold = { phase: ph.phase, worker: ph.worker, reason: why, at: iso(now()) };
1033
+ const dp = go_path(sid);
1034
+ writeText(dp,
1035
+ "# Decision: give the go for " + S(sid) + " before its " + S(ph.phase) + " phase\n\n" +
1036
+ "- story: " + S(sid) + " (" + S(get(st, "title", "")) + ")\n- phase: " + S(ph.phase) + "\n- worker: " + S(ph.worker) + "\n- reason: " + why + "\n- status: pending\n\n" +
1037
+ "This story needs an explicit go. Approving the " + S(ph.worker) + " worker for the whole run does not release it.\n\n" +
1038
+ "- Go: pipeline-conductor.sh go --state " + STATE + " --story " + S(sid) + "\n" +
1039
+ "- Accept without running it (passes is not set):\n" +
1040
+ " pipeline-conductor.sh resolve --state " + STATE + " --story " + S(sid) + " --as accepted-partial --note \"<owner's reason>\"\n" +
1041
+ "- Set it and its dependents aside: pipeline-conductor.sh park --state " + STATE + " --story " + S(sid) + " --note \"<why>\"\n");
1042
+ jsave(sfile(sid), st);
1043
+ print("AWAITING_GO " + S(sid) + " " + S(ph.phase) + " " + dp);
1044
+ exit(10);
1045
+ }
1046
+
1047
+ function do_go() {
1048
+ const sid = need_story();
1049
+ const st = jload(sfile(sid));
1050
+ if (st === null) die("story " + sid + " has no state in " + STATE + "; nothing to release");
1051
+ if (st.state !== "awaiting_go") {
1052
+ if (T(get(st, "go"))) { print("ALREADY go " + sid); return; }
1053
+ die("story " + sid + " is " + S(st.state) + "; go applies only to a story awaiting_go");
1054
+ }
1055
+ st.go = { at: iso(now()), note: note() };
1056
+ st.state = "queued";
1057
+ jsave(sfile(sid), st);
1058
+ mark_decision(go_path(sid), "go");
1059
+ print("GO " + sid);
1060
+ }
1061
+
1062
+ function do_worktree() {
1063
+ if (!E.PC_REPO) die("worktree needs --repo", 2);
1064
+ if (!isdir(E.PC_REPO)) die("repo not found: " + E.PC_REPO);
1065
+ const repo = process.platform === "win32"
1066
+ ? winCanonicalPath(pathwin.isAbsolute(E.PC_REPO) ? E.PC_REPO : pathwin.resolve(process.cwd(), E.PC_REPO))
1067
+ : realpath(E.PC_REPO);
1068
+ const st = STORY ? sload(STORY) : null;
1069
+ let wt, line;
1070
+ try {
1071
+ if (E.PC_SHARED !== "1" && !PRD) {
1072
+ // no prd to name a branch: the repo itself is the target, and it is checked like an explicit one
1073
+ check_repo(repo); repos_worktree(explicit_target(repo) || repo); wt = explicit_target(repo) || repo; line = "";
1074
+ } else {
1075
+ const p = prd_opt();
1076
+ if (p === null) die("worktree --shared needs the prd for baseBranch and branchName: pass --prd", 2);
1077
+ if (E.PC_STORY_BRANCHES === "1") {
1078
+ if (st === null) die("worktree --story-branches needs --story", 2);
1079
+ [wt, line] = cut_story_worktree(p, STORY, st, repo);
1080
+ st.story_worktree = wt;
1081
+ } else {
1082
+ [wt, line] = cut_repo_worktree(p, repo);
1083
+ }
1084
+ }
1085
+ } catch (e) {
1086
+ if (!(e instanceof CutError)) throw e;
1087
+ die(e.message);
1088
+ }
1089
+ if (st !== null) { st.worktree = wt; st.repo = repo; jsave(sfile(STORY), st); }
1090
+ if (line) eprint(line);
1091
+ print(wt);
1092
+ }
1093
+
1094
+ // ---------- gate ----------
1095
+ function gate() {
1096
+ return jload(sp("gate.json"), { completed: 0, state: "ok", every: pyInt(E.PC_GATE_EVERY), note: "" });
1097
+ }
1098
+
1099
+ function gate_tick() {
1100
+ const g = gate(); g.completed += 1;
1101
+ const every = pyInt(E.PC_GATE_EVERY) || 3;
1102
+ const due = ((g.completed % every) + every) % every === 0;
1103
+ if (due && !["failed", "reopened"].includes(g.state)) g.state = "due";
1104
+ jsave(sp("gate.json"), g);
1105
+ return due;
1106
+ }
1107
+
1108
+ // ---------- report ----------
1109
+ function report_line(sid, text) {
1110
+ const rp = sp("report.md");
1111
+ let lines = exists(rp) ? splitlines(readText(rp)) : [];
1112
+ if (lines.some(l => l.startsWith("- " + sid + " "))) return false;
1113
+ lines = lines.filter(l => !l.startsWith("FINAL:")).concat(["- " + sid + " " + text]);
1114
+ writeText(rp, lines.join("\n") + "\n");
1115
+ return true;
1116
+ }
1117
+
1118
+ function story_report(sid, st) {
1119
+ const title = S(get(st, "title", ""));
1120
+ if (st.state === "verified") {
1121
+ if (T(get(st, "reopen")))
1122
+ return report_line(sid, "verified after it was reopened (" + wsjoin(get(st.reopen, "note", "")) + "): " + title);
1123
+ return report_line(sid, "verified: " + title);
1124
+ }
1125
+ if (st.state === "failed_report")
1126
+ return report_line(sid, "failed after one reroute: " + title + " (unmet AC: " + get(st, "unmet", []).map(S).join(",") + ")");
1127
+ if (st.state === "accepted_partial") {
1128
+ const r = or(get(st, "resolution"), {});
1129
+ const unmet = get(r, "unmet", []).map(S).join(",") || "none recorded";
1130
+ return report_line(sid, "accepted by the owner as a partial draft, not verified; passes is not set: " + title + " " +
1131
+ "(unmet AC: " + unmet + "; owner note: " + wsjoin(get(r, "note", "")) + ")");
1132
+ }
1133
+ return false;
1134
+ }
1135
+
1136
+ function all_stories() {
1137
+ const out = new Map();
1138
+ const d = sp("stories");
1139
+ for (const n of sortedStr(listdir(d))) {
1140
+ if (n.endsWith(".json")) {
1141
+ const v = jload(pjoin(d, n));
1142
+ if (T(v)) out.set(v.id, migrate_legacy(v));
1143
+ }
1144
+ }
1145
+ return out;
1146
+ }
1147
+
1148
+ // ---------- selection ----------
1149
+ function eligible(p) {
1150
+ const g = gate();
1151
+ if (g.state === "failed") return [];
1152
+ const known = all_stories();
1153
+ const done = new Set(p.userStories.filter(s => get(s, "passes") === true).map(s => s.id));
1154
+ for (const [k, v] of known) if (DONE_STATES.includes(v.state)) done.add(k);
1155
+ const out = [];
1156
+ p.userStories.forEach((s, i) => {
1157
+ const sid = get(s, "id");
1158
+ if (typeof sid !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]*\n?$/.test(sid)) return;
1159
+ if (get(s, "passes") === true || known.has(sid)) return;
1160
+ if (or(get(s, "dependsOn"), []).every(d => done.has(d))) {
1161
+ const pr = get(s, "priority");
1162
+ out.push([isNum(pr) ? num(pr) : 1e9, i, sid]);
1163
+ }
1164
+ });
1165
+ out.sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]));
1166
+ return out.map(x => x[2]);
1167
+ }
1168
+
1169
+ // ---------- classification ----------
1170
+ // A story's worker sequence is the full phase sequence for the story, in order. Sources, first match wins:
1171
+ // 1. the run's overlay (<state>/overlay.json, written by `overlay set` or `classify --overlay`; the prd is
1172
+ // not edited), recorded as classified_by: overlay;
1173
+ // 2. the story's worker_preference (a string or an array), classified_by: worker_preference;
1174
+ // 3. the keyword fallback (architect, the matched implementers, qa-tester), classified_by: keywords.
1175
+ function overlay_load() {
1176
+ const d = or(jload(sp("overlay.json"), {}), {});
1177
+ return isDict(d) ? d : {};
1178
+ }
1179
+
1180
+ function seq_list(v) {
1181
+ if (isDict(v)) v = get(v, "worker_sequence");
1182
+ if (typeof v === "string") v = v.split(",").map(strip);
1183
+ return Array.isArray(v) ? v.filter(w => typeof w === "string" && strip(w)) : [];
1184
+ }
1185
+
1186
+ function overlay_entries(d) {
1187
+ const out = {};
1188
+ if (isDict(d) && Array.isArray(get(d, "ordered_stories"))) {
1189
+ for (const e of d.ordered_stories)
1190
+ if (isDict(e) && typeof get(e, "id") === "string" && seq_list(e).length) out[e.id] = seq_list(e);
1191
+ return out;
1192
+ }
1193
+ if (isDict(d)) {
1194
+ for (const [k, v] of Object.entries(d)) if (seq_list(v).length) out[k] = seq_list(v);
1195
+ }
1196
+ return out;
1197
+ }
1198
+
1199
+ function story_sequence(s) {
1200
+ const ov = seq_list(get(overlay_load(), get(s, "id")));
1201
+ if (ov.length) return [ov, "overlay"];
1202
+ let pref = or(get(s, "worker_preference"), []);
1203
+ if (typeof pref === "string") pref = [pref];
1204
+ const workers = pref.filter(w => typeof w === "string" && w);
1205
+ if (workers.length) return [workers, "worker_preference"];
1206
+ const text = [get(s, "title", ""), get(s, "description", "")].concat(Array.from(or(get(s, "labels"), []))).join(" ").toLowerCase();
1207
+ const hasw = ws => new RegExp("\\b(" + ws.join("|") + ")\\b").test(text);
1208
+ const schema = hasw(["database", "migration", "schema", "prisma", "sql"]);
1209
+ const ui = hasw(["component", "page", "form", "button", "react", "ui"]);
1210
+ const api = hasw(["endpoint", "api", "rest", "graphql", "route", "service"]);
1211
+ if (!(schema || ui || api)) return [null, null];
1212
+ return [["architect"].concat(schema ? ["database-dev"] : [], (api || schema) ? ["backend-dev"] : [],
1213
+ ui ? ["frontend-dev"] : [], ["qa-tester"]), "keywords"];
1214
+ }
1215
+
1216
+ function classify_story(s) {
1217
+ const [workers] = story_sequence(s);
1218
+ return T(workers) ? workers.map(w => ({ phase: phaseOf(w), worker: w })) : null;
1219
+ }
1220
+
1221
+ // Code files: anything that is not docs, markdown, plain text or yaml. A story that declares none is not held
1222
+ // to the implementer-first rule; `"docs_only": true` on the story exempts it explicitly.
1223
+ const DOC_EXT = [".md", ".mdx", ".markdown", ".txt", ".rst", ".yaml", ".yml"];
1224
+
1225
+ function code_files(s) {
1226
+ if (get(s, "docs_only") === true) return [];
1227
+ const out = [];
1228
+ for (const f of or(get(s, "files"), [])) {
1229
+ if (typeof f !== "string" || !strip(f)) continue;
1230
+ const low = strip(f).toLowerCase();
1231
+ if (DOC_EXT.some(x => low.endsWith(x)) || /(^|\/)docs?\//.test(low)) continue;
1232
+ out.push(strip(f));
1233
+ }
1234
+ return out;
1235
+ }
1236
+
1237
+ function sequence_problems(s, workers) {
1238
+ const sid = get(s, "id"); const errs = [], warns = [];
1239
+ for (const w of uniq(workers)) {
1240
+ if (!find_worker_yaml(w)) errs.push("story " + S(sid) + ": " + unknown_worker_msg(w));
1241
+ }
1242
+ const code = code_files(s);
1243
+ if (code.length && workers.length && find_worker_yaml(workers[0]) && !is_implementer(workers[0])) {
1244
+ errs.push("story " + S(sid) + ": its sequence " + workers.join(",") + " starts with " + workers[0] + " (" + (worker_role(workers[0]) || "no role") +
1245
+ "), a verifier or reader, but the story declares code " +
1246
+ "files (" + code.slice(0, 3).join(", ") + "). Prepend an implementer (pipeline-conductor.sh overlay set --state " + STATE + " --story " + S(sid) + " " +
1247
+ "--sequence <implementer>," + workers.join(",") + ") or mark the story docs-only (\"docs_only\": true)");
1248
+ }
1249
+ if (workers.length === 1)
1250
+ warns.push("story " + S(sid) + ": the sequence is one worker (" + workers[0] + "); architect and qa phases will not run");
1251
+ return [errs, warns];
1252
+ }
1253
+
1254
+ // Dependency lint: a story whose acceptance criteria or declared files name a LATER story's id (word match),
1255
+ // or contain a file path that only later stories declare (substring match), and whose dependsOn lacks that
1256
+ // story. A warning only; it prints the dependsOn line that would fix it.
1257
+ function dep_lint(p, s) {
1258
+ const stories = p.userStories.filter(x => typeof get(x, "id") === "string");
1259
+ const ids = stories.map(x => x.id);
1260
+ if (!ids.includes(get(s, "id"))) return [];
1261
+ const i = ids.indexOf(s.id);
1262
+ const deps = Array.from(or(get(s, "dependsOn"), []));
1263
+ const text = Array.from(or(get(s, "acceptanceCriteria"), [])).concat(Array.from(or(get(s, "files"), []))).map(S).join("\n");
1264
+ const own = new Set();
1265
+ for (const x of stories.slice(0, i + 1)) for (const f of or(get(x, "files"), [])) if (typeof f === "string") own.add(f);
1266
+ const missing = [];
1267
+ for (const later of stories.slice(i + 1)) {
1268
+ const lid = later.id;
1269
+ if (deps.includes(lid)) continue;
1270
+ let why = null;
1271
+ if (new RegExp("(?<![A-Za-z0-9_-])" + reEscape(lid) + "(?![A-Za-z0-9_-])").test(text)) why = "names " + lid;
1272
+ else {
1273
+ for (const f of or(get(later, "files"), [])) {
1274
+ if (typeof f === "string" && strip(f) && !own.has(f) && text.includes(f)) {
1275
+ why = "references " + f + ", which only " + lid + " declares"; break;
1276
+ }
1277
+ }
1278
+ }
1279
+ if (why) missing.push([lid, why]);
1280
+ }
1281
+ if (!missing.length) return [];
1282
+ const want = deps.concat(missing.map(x => x[0]).filter(lid => !deps.includes(lid)));
1283
+ return ["story " + s.id + " needs " + missing.map(([lid, w]) => lid + " (" + w + ")").join(" and ") + ", but its dependsOn lacks " +
1284
+ (missing.length > 1 ? "them" : "it") + "; suggested: \"dependsOn\": " + pyDumps(want, true)];
1285
+ }
1286
+
1287
+ // ---------- worker table (lanes) ----------
1288
+ function table_path() {
1289
+ return E.PC_TABLE || or(get(or(jload(sp("run.json"), {}), {}), "table"), "");
1290
+ }
1291
+
1292
+ function table_rows() {
1293
+ // Map{worker: model} from the confirmed table, or null when the run has no table.
1294
+ const t = table_path();
1295
+ if (!T(t)) return null;
1296
+ if (!exists(t)) die("worker table not found: " + S(t));
1297
+ const rows = new Map();
1298
+ for (const ln of splitlines(readText(t))) {
1299
+ const f = words(ln);
1300
+ if (!f.length || f[0].startsWith("#") || f[0] === "worker") continue;
1301
+ rows.set(f[0], f.length > 2 ? f[2] : "");
1302
+ }
1303
+ return rows;
1304
+ }
1305
+
1306
+ function lane_problems(workers) {
1307
+ const rows = table_rows();
1308
+ if (rows === null) return [];
1309
+ const miss = uniq(workers).filter(w => !rows.has(w));
1310
+ return miss.length ? ["no lane in the worker table " + S(table_path()) + " for: " + miss.join(", ") + ". Add a row for each and confirm the table again"] : [];
1311
+ }
1312
+
1313
+ const BARE_ALIASES = ["opus", "sonnet", "haiku", "fable"];
1314
+
1315
+ function hint_notes(s, workers) {
1316
+ let h = get(s, "model_hint");
1317
+ if (typeof h !== "string" || !strip(h)) return [[], null];
1318
+ h = strip(h); const rows = table_rows() || new Map();
1319
+ const pins = sortedStr(new Set(workers.filter(w => T(rows.get(w))).map(w => rows.get(w))));
1320
+ if (pins.length) return [["story " + s.id + ": model_hint " + h + ": hint ignored, table pins " + pins.join(", ")], null];
1321
+ if (BARE_ALIASES.includes(h.toLowerCase()))
1322
+ return [["story " + s.id + ": model_hint " + h + " is a bare alias and is ignored; give a full model id or pin the lane " +
1323
+ "in the worker table"], null];
1324
+ return [[], h];
1325
+ }
1326
+
1327
+ function skipped(sid) { return has(or(jload(sp("skips.json"), {}), {}), sid); }
1328
+
1329
+ function do_classify(p, sid, raw) {
1330
+ if (skipped(sid)) {
1331
+ print("SKIPPED " + sid + " (in skips.json; unskip --story " + sid + " to run it)"); return null;
1332
+ }
1333
+ const s = story_of(p, sid);
1334
+ const [workers, src0] = story_sequence(s);
1335
+ if (!T(workers)) {
1336
+ die("story " + sid + " is unclassified: set worker_preference, add it to the overlay (overlay set --state " + STATE + " " +
1337
+ "--story " + sid + " --sequence <a>,<b>,...), or leave it out (skip --state " + STATE + " --story " + sid + ")");
1338
+ }
1339
+ let [errs, warns] = sequence_problems(s, workers);
1340
+ errs = errs.concat(lane_problems(workers));
1341
+ if (errs.length) die("classify refused:\n " + errs.join("\n "));
1342
+ const [hw, hint] = hint_notes(s, workers);
1343
+ const phases = workers.map(w => ({ phase: phaseOf(w), worker: w }));
1344
+ const st = or(jload(sfile(sid)), {});
1345
+ for (const w of warns.concat(hw, dep_lint(p, s))) {
1346
+ if (!or(get(st, "warned"), []).includes(w)) {
1347
+ eprint("WARN " + w); setdefault(st, "warned", []).push(w);
1348
+ }
1349
+ }
1350
+ if (hint) st.model_hint = hint;
1351
+ else pop(st, "model_hint");
1352
+ Object.assign(st, { id: sid, title: get(s, "title", ""), phases: phases, classified_by: src0, worker_sequence: workers });
1353
+ setdefault(st, "current", 0); setdefault(st, "state", "queued"); setdefault(st, "reroutes", 0);
1354
+ const [repo, src] = resolve_repo(p, s);
1355
+ st.repo = repo; st.repo_source = src;
1356
+ const m = wt_mapping(raw);
1357
+ if (T(m)) {
1358
+ st.worktree = pick_worktree(p, repo, m);
1359
+ } else if (!T(get(st, "worktree")) && !multi_repo(p)) {
1360
+ st.worktree = abspath(repo || process.cwd());
1361
+ }
1362
+ jsave(sfile(sid), st);
1363
+ return st;
1364
+ }
1365
+
1366
+ // ---------- worker.yaml ----------
1367
+ const SKIP_DIRS = ["node_modules", ".git", "dist"];
1368
+
1369
+ function find_worker_yaml(w) {
1370
+ for (const root of E.PC_WORKERS_ROOT.split(":")) {
1371
+ if (!root || !isdir(root)) continue;
1372
+ for (const ent of walk(root)) {
1373
+ ent[1] = ent[1].filter(d => !SKIP_DIRS.includes(d));
1374
+ if (basename(ent[0]) === w && ent[2].includes("worker.yaml")) return pjoin(ent[0], "worker.yaml");
1375
+ }
1376
+ }
1377
+ return null;
1378
+ }
1379
+
1380
+ let _KNOWN = null;
1381
+ function known_workers() {
1382
+ if (_KNOWN === null) {
1383
+ const out = new Set();
1384
+ for (const root of E.PC_WORKERS_ROOT.split(":")) {
1385
+ if (!root || !isdir(root)) continue;
1386
+ for (const ent of walk(root)) {
1387
+ ent[1] = ent[1].filter(d => !SKIP_DIRS.includes(d));
1388
+ if (ent[2].includes("worker.yaml")) out.add(basename(ent[0]));
1389
+ }
1390
+ }
1391
+ _KNOWN = sortedStr(out);
1392
+ }
1393
+ return _KNOWN;
1394
+ }
1395
+
1396
+ function edit_distance(a, b) {
1397
+ a = Array.from(a); b = Array.from(b);
1398
+ let prev = []; for (let j = 0; j <= b.length; j++) prev.push(j);
1399
+ for (let i = 1; i <= a.length; i++) {
1400
+ const cur = [i];
1401
+ for (let j = 1; j <= b.length; j++)
1402
+ cur.push(Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0)));
1403
+ prev = cur;
1404
+ }
1405
+ return prev[prev.length - 1];
1406
+ }
1407
+
1408
+ function unknown_worker_msg(w) {
1409
+ const known = known_workers();
1410
+ const near = known.map(k => [edit_distance(w, k), k]).sort((x, y) => (x[0] - y[0]) || cmpStr(x[1], y[1]))
1411
+ .filter(x => x[0] <= 3).map(x => x[1]).slice(0, 3);
1412
+ const hint = near.length ? "; nearest known: " + near.join(", ") : "; known: " + (known.join(", ") || "none");
1413
+ return "unknown worker " + w + ": no worker.yaml under " + E.PC_WORKERS_ROOT.split(":").filter(Boolean).join(", ") + hint;
1414
+ }
1415
+
1416
+ function yaml_field(y, key) {
1417
+ // worker.<key> (or a top-level <key>) from a worker.yaml, as a string; empty when absent.
1418
+ const text = readText(y);
1419
+ const m = /^worker:\s*\n((?:[ \t]+.*\n?|\s*\n)*)/m.exec(text);
1420
+ for (const block of (m ? [m[1]] : []).concat([text])) {
1421
+ const n = new RegExp("^[ \\t]*" + reEscape(key) + ":[ \\t]*['\"]?([^'\"#\\n]*?)['\"]?[ \\t]*(#.*)?$", "m").exec(block);
1422
+ if (n && strip(n[1])) return strip(n[1]);
1423
+ }
1424
+ return "";
1425
+ }
1426
+
1427
+ // Implementer test, in one place. It reads the worker's worker.yaml: `worker.role` when set, else
1428
+ // `worker.type`. A value naming a reviewer, tester, architect or reader (review, test, qa, architect or read,
1429
+ // case-insensitive) makes the worker a verifier or reader. When worker.yaml sets no role, a worker id that
1430
+ // names one of those (qa-tester, code-reviewer, architect) counts too. Anything else is an implementer.
1431
+ const VERIFIER_RE = /(review|test|qa|architect|read)/i;
1432
+
1433
+ function worker_role(w) {
1434
+ const y = find_worker_yaml(w);
1435
+ return y ? (yaml_field(y, "role") || yaml_field(y, "type")) : "";
1436
+ }
1437
+
1438
+ function is_implementer(w) {
1439
+ const y = find_worker_yaml(w);
1440
+ const role = y ? yaml_field(y, "role") : "";
1441
+ if (role) return !VERIFIER_RE.test(role);
1442
+ const typ = y ? yaml_field(y, "type") : "";
1443
+ if (typ && VERIFIER_RE.test(typ)) return false;
1444
+ return !/(^|[-_])(qa|test|tester|testing|review|reviewer|verifier|architect|reader)([-_]|$)/.test(w || "");
1445
+ }
1446
+
1447
+ // Minimal YAML reading for approval_needs: the top-level mapping, and the verification mapping's
1448
+ // approval_required and human_checkpoints. Returns null when the text has no top-level mapping.
1449
+ const YAML_TRUE = ["y", "Y", "yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON"];
1450
+ const YAML_FALSE = ["n", "N", "no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF"];
1451
+ function yamlUncomment(v) {
1452
+ if (/^["']/.test(v)) return v;
1453
+ const m = /(^|[ \t])#/.exec(v);
1454
+ return m ? v.slice(0, m.index) : v;
1455
+ }
1456
+ function yamlScalar(raw) {
1457
+ let v = strip(yamlUncomment(raw));
1458
+ if (v === "" || v === "~" || v === "null" || v === "Null" || v === "NULL") return null;
1459
+ if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
1460
+ try { return JSON.parse(v); } catch (e) { return v.slice(1, -1); }
1461
+ }
1462
+ if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) return v.slice(1, -1).replace(/''/g, "'");
1463
+ if (YAML_TRUE.indexOf(v) >= 0 && !/^[yYnN]$/.test(v)) return true;
1464
+ if (YAML_FALSE.indexOf(v) >= 0 && !/^[yYnN]$/.test(v)) return false;
1465
+ if (/^[-+]?(0|[1-9][0-9_]*)$/.test(v)) return parseInt(v.replace(/_/g, ""), 10);
1466
+ if (/^[-+]?([0-9][0-9_]*)?\.[0-9_]*([eE][-+][0-9]+)?$/.test(v) && /[0-9]/.test(v)) return parseFloat(v.replace(/_/g, ""));
1467
+ if (v.startsWith("[") && v.endsWith("]")) {
1468
+ const inner = strip(v.slice(1, -1));
1469
+ return inner ? inner.split(",").map(x => yamlScalar(x)) : [];
1470
+ }
1471
+ if (v.startsWith("{") && v.endsWith("}")) {
1472
+ const out = {}; const inner = strip(v.slice(1, -1));
1473
+ if (inner) for (const part of inner.split(",")) {
1474
+ const i = part.indexOf(":"); if (i < 0) continue;
1475
+ out[strip(part.slice(0, i))] = yamlScalar(part.slice(i + 1));
1476
+ }
1477
+ return out;
1478
+ }
1479
+ return v;
1480
+ }
1481
+ function yamlBlock(lines, start, parentIndent) {
1482
+ // The value of a key whose inline value was empty: a block sequence, a block mapping, or null.
1483
+ let i = start;
1484
+ while (i < lines.length && (/^\s*$/.test(lines[i]) || /^\s*#/.test(lines[i]))) i++;
1485
+ if (i >= lines.length) return [null, i];
1486
+ const ind = lines[i].match(/^ */)[0].length;
1487
+ const isSeq = /^ *- |^ *-$/.test(lines[i]);
1488
+ if (ind < parentIndent || (ind === parentIndent && !isSeq)) return [null, start];
1489
+ if (isSeq) {
1490
+ const out = [];
1491
+ while (i < lines.length) {
1492
+ const l = lines[i];
1493
+ if (/^\s*$/.test(l) || /^\s*#/.test(l)) { i++; continue; }
1494
+ const li = l.match(/^ */)[0].length;
1495
+ if (li < ind) break;
1496
+ if (li === ind) {
1497
+ const m = /^ *-(?: (.*))?$/.exec(l);
1498
+ if (!m) break;
1499
+ out.push(yamlScalar(m[1] || ""));
1500
+ }
1501
+ i++;
1502
+ }
1503
+ return [out, i];
1504
+ }
1505
+ const out = {};
1506
+ while (i < lines.length) {
1507
+ const l = lines[i];
1508
+ if (/^\s*$/.test(l) || /^\s*#/.test(l)) { i++; continue; }
1509
+ const li = l.match(/^ */)[0].length;
1510
+ if (li < ind) break;
1511
+ if (li === ind) {
1512
+ const m = /^ *("[^"]*"|'[^']*'|[^:#][^:]*?):(?:[ \t]+(.*)|)$/.exec(l);
1513
+ if (!m) { i++; continue; }
1514
+ const k = m[1].replace(/^["']|["']$/g, "");
1515
+ if (strip(yamlUncomment(m[2] || "")) === "") {
1516
+ const [v, ni] = yamlBlock(lines, i + 1, ind);
1517
+ out[k] = v; i = Math.max(ni, i + 1); continue;
1518
+ }
1519
+ out[k] = yamlScalar(m[2]);
1520
+ }
1521
+ i++;
1522
+ }
1523
+ return [out, i];
1524
+ }
1525
+ function yamlTop(text) {
1526
+ const lines = text.split(/\r?\n/);
1527
+ const [doc] = yamlBlock(lines.filter(l => !/^(---|\.\.\.)\s*$/.test(l)), 0, -1);
1528
+ return isDict(doc) ? doc : null;
1529
+ }
1530
+
1531
+ function approval_needs(w) {
1532
+ const y = find_worker_yaml(w);
1533
+ if (!y) return [];
1534
+ const text = readText(y);
1535
+ let doc = null;
1536
+ try { doc = yamlTop(text); } catch (e) { doc = null; }
1537
+ const reasons = [];
1538
+ if (isDict(doc)) {
1539
+ const ver = isDict(get(doc, "verification")) ? doc.verification : {};
1540
+ if (get(doc, "approval_required") === true || get(ver, "approval_required") === true)
1541
+ reasons.push("approval_required: true");
1542
+ const hc = get(ver, "human_checkpoints");
1543
+ if (T(hc))
1544
+ reasons.push("human_checkpoints: " + (Array.isArray(hc) ? hc : [hc]).map(S).join(", "));
1545
+ } else {
1546
+ if (/^\s*approval_required:\s*true\b/m.test(text)) reasons.push("approval_required: true");
1547
+ const m = /^(\s*)human_checkpoints:\s*(.*)$/m.exec(text);
1548
+ if (m) {
1549
+ const inline = strip(m[2]);
1550
+ let items;
1551
+ if (!inline) {
1552
+ const seg = text.slice(m.index + m[0].length).split("\n\n")[0];
1553
+ items = []; const re = /^\s*-\s*(\S.*)$/gm; let x;
1554
+ while ((x = re.exec(seg)) !== null) { items.push(x[1]); if (x[0] === "") re.lastIndex++; }
1555
+ } else {
1556
+ items = stripChars(inline, "[]").split(",").filter(x => strip(x)).map(strip);
1557
+ }
1558
+ if (items.length) reasons.push("human_checkpoints: " + items.join(", "));
1559
+ }
1560
+ }
1561
+ return reasons;
1562
+ }
1563
+
1564
+ function approved(sid, w) {
1565
+ const a = jload(sp("approvals.json"), { run: [], story: {} });
1566
+ return get(a, "run", []).includes(w) || get(get(a, "story", {}), sid, []).includes(w);
1567
+ }
1568
+
1569
+ // ---------- envelopes ----------
1570
+ function validate(path, kind) {
1571
+ const r = run(E.PC_ENVELOPE, ["validate", "--kind", kind, path]);
1572
+ return [r.returncode === 0, strip(r.stderr || r.stdout)];
1573
+ }
1574
+
1575
+ function do_route(p, sid) {
1576
+ const st = sload(sid);
1577
+ if (gate().state === "failed" && st.state === "queued" && st.current === 0 && !T(get(st, "started")))
1578
+ die("regression gate failed; not starting new stories");
1579
+ if (!["queued", "held"].includes(st.state))
1580
+ die("story " + sid + " is " + S(st.state) + ", nothing to route");
1581
+ const i = st.current; const ph = st.phases[i];
1582
+ const rows = table_rows();
1583
+ if (rows !== null && !rows.has(ph.worker)) {
1584
+ print("NO_LANE " + sid + " " + S(ph.phase) + " " + S(ph.worker) + " not in the worker table " + S(table_path()));
1585
+ exit(12);
1586
+ }
1587
+ let incoming = null;
1588
+ if (i > 0) {
1589
+ const prev = st.phases[i - 1];
1590
+ incoming = sp("handoffs", sid + "-" + prev.phase + ".json");
1591
+ if (!exists(incoming)) die("refusing " + sid + "/" + S(ph.phase) + ": no handoff from " + S(prev.phase));
1592
+ const [ok, why] = validate(incoming, "handoff");
1593
+ if (!ok) die("refusing " + sid + "/" + S(ph.phase) + ": previous handoff invalid: " + why);
1594
+ if (get(or(jload(incoming), {}), "status") !== "passed")
1595
+ die("refusing " + sid + "/" + S(ph.phase) + ": previous phase " + S(prev.phase) + " did not pass");
1596
+ }
1597
+ const s = story_of(p, sid);
1598
+ if (i === go_index(st) && !T(get(st, "go"))) {
1599
+ const why = needs_go(p, s);
1600
+ if (why) hold_for_go(st, why);
1601
+ }
1602
+ const wt = route_worktree(p, st);
1603
+ const reasons = approved(sid, ph.worker) ? [] : approval_needs(ph.worker);
1604
+ if (reasons.length) {
1605
+ const dp = sp("decisions", sid + "-" + ph.worker + ".md");
1606
+ if (!exists(dp)) {
1607
+ writeText(dp,
1608
+ "# Decision: approve " + S(ph.worker) + " for " + S(ph.phase) + " phase of " + sid + "\n\n" +
1609
+ "- story: " + sid + " (" + S(get(st, "title", "")) + ")\n- phase: " + S(ph.phase) + "\n- worker: " + S(ph.worker) + "\n- reason: " + reasons.join("; ") + "\n- status: pending\n\n" +
1610
+ "Approve with: pipeline-conductor.sh release --state " + STATE + " --story " + sid + " --worker " + S(ph.worker) + "\n" +
1611
+ "Approve for the whole run with: pipeline-conductor.sh release --state " + STATE + " --worker " + S(ph.worker) + "\n");
1612
+ }
1613
+ st.state = "held"; st.held_for = ph.worker; jsave(sfile(sid), st);
1614
+ print("HELD " + sid + " " + S(ph.phase) + " " + dp);
1615
+ exit(10);
1616
+ }
1617
+ st.worktree = wt;
1618
+ const env = { schema: "hq-phase-envelope/v1", story_id: sid, phase: ph.phase, worker_id: ph.worker,
1619
+ worktree: wt, incoming_handoff: incoming,
1620
+ acceptance_criteria: Array.from(or(get(s, "acceptanceCriteria"), [])),
1621
+ deadline: iso(now() + pyInt(E.PC_DEADLINE_MIN) * 60),
1622
+ fresh_call: (st.reroutes > 0 && i === get(st, "rerouted_at", -1)) || get(or(get(st, "owner_retry"), {}), "index") === i
1623
+ || get(reopen_pending(st), "index") === i,
1624
+ result_path: abspath(sp("handoffs", sid + "-" + ph.phase + ".json")) };
1625
+ if (T(get(p, "name")) || T(get(p, "project"))) env.project = S(or(get(p, "name"), get(p, "project")));
1626
+ if (T(get(st, "repo"))) env.repo = st.repo;
1627
+ if (isdir(wt)) {
1628
+ const br = git(wt, "rev-parse", "--abbrev-ref", "HEAD");
1629
+ if (br.returncode === 0 && !["", "HEAD"].includes(strip(br.stdout))) {
1630
+ env.branch = strip(br.stdout);
1631
+ setdefault(env, "constraints", []).push(
1632
+ "Commit this phase's work on branch " + env.branch + " in " + wt + "; stories of this repo build on each other there.");
1633
+ }
1634
+ }
1635
+ if (typeof get(s, "title") === "string" && s.title) env.story_title = s.title;
1636
+ if (typeof get(s, "description") === "string" && s.description) env.story_description = s.description;
1637
+ const cpth = sp("constraints.txt");
1638
+ if (exists(cpth)) {
1639
+ const cons = splitlines(readText(cpth)).map(strip).filter(Boolean);
1640
+ if (cons.length) env.constraints = cons;
1641
+ }
1642
+ if (store_paths(s).length) {
1643
+ setdefault(env, "constraints", []).push(
1644
+ "This story adds or changes a data store or schema. Run the repo's schema-contract tests " +
1645
+ "(tests that check queries and columns against the schema) as back-pressure, if present.");
1646
+ }
1647
+ setdefault(env, "constraints", []).push(
1648
+ "Return hq-phase-handoff/v1 with status exactly passed, failed, or blocked; set back_pressure.tests, lint, typecheck, and build to exactly pass, fail, or skip; include one ac_evidence entry per acceptance criterion as {index, criterion, met, evidence}.");
1649
+ const ro = reopen_pending(st);
1650
+ if (get(ro, "index") === i) {
1651
+ env.reopen_note = wsjoin(get(ro, "note", ""));
1652
+ setdefault(env, "constraints", []).push(
1653
+ "This story was " + S(get(ro, "from", "verified")) + " and has been reopened at this phase. Why: " + env.reopen_note);
1654
+ }
1655
+ const orr = or(get(st, "owner_retry"), {});
1656
+ if (get(orr, "index") === i && T(get(orr, "note"))) {
1657
+ setdefault(env, "constraints", []).push(
1658
+ "The owner answered your last blocker on this phase: " + wsjoin(orr.note));
1659
+ }
1660
+ const ip = interrupt_pending(st);
1661
+ if (get(ip, "index") === i) {
1662
+ env.resumed_after_interrupt = true;
1663
+ env.fresh_call = true;
1664
+ if (T(get(ip, "partial_handoff"))) env.prior_handoff = ip.partial_handoff;
1665
+ setdefault(env, "constraints", []).push(
1666
+ "This phase was interrupted at " + S(get(ip, "at", "?")) + " when the run stopped; check the worktree for partial work" +
1667
+ (T(get(ip, "partial_handoff")) ? " and read the partial handoff " + S(ip.partial_handoff) : "") + " before " +
1668
+ "starting over.");
1669
+ }
1670
+ if (repos_worktree(wt, false)) setdefault(env, "constraints", []).push(REPOS_WT_LINE);
1671
+ const ep = sp("envelopes", sid + "-" + ph.phase + ".json");
1672
+ jsave(ep, env);
1673
+ const [ok, why] = validate(ep, "envelope");
1674
+ if (!ok) {
1675
+ fs.unlinkSync(ep); die("built an invalid envelope for " + sid + "/" + S(ph.phase) + ": " + why);
1676
+ }
1677
+ const sessionEnv = process.env.HQ_SESSION_ID ? ["HQ_SESSION_ID=" + process.env.HQ_SESSION_ID] : [];
1678
+ const session = strip(run("env", ["HQ_ROOT=" + E.PC_HQ_ROOT].concat(sessionEnv, ["bash", pjoin(E.PC_HQ_ROOT, "core/scripts/hq-session.sh"), "current"])).stdout);
1679
+ const company = strip(run("env", ["HQ_ROOT=" + E.PC_HQ_ROOT, "bash", pjoin(E.PC_HQ_ROOT, "core/scripts/hq-session.sh"), "--session-id", session, "get", "company_slug"]).stdout);
1680
+ if (!company || company === "personal") die("cannot route pipeline phase: no company is bound in session state");
1681
+ const project = S(or(get(p, "name"), or(get(p, "project"), "")));
1682
+ if (!project) die("cannot route pipeline phase: project name is missing");
1683
+ const runState = or(jload(sp("run.json"), {}), {});
1684
+ if ((T(get(runState, "company_slug")) && runState.company_slug !== company) ||
1685
+ (T(get(runState, "project")) && runState.project !== project)) {
1686
+ die("cannot resume pipeline lane mapping: bound company or project differs from the run owner");
1687
+ }
1688
+ runState.company_slug = company; runState.project = project; jsave(sp("run.json"), runState);
1689
+ const lanesFile = sp("lanes.json");
1690
+ const laneMap = or(jload(lanesFile, {}), {});
1691
+ let lane = laneMap[ph.worker];
1692
+ if (lane) {
1693
+ let rows;
1694
+ try {
1695
+ const listed = JSON.parse(run(E.PC_HQ, ["lanes", "list", "--json"], { timeout: 30000 }).stdout || "{}");
1696
+ rows = Array.isArray(listed) ? listed : get(listed, "lanes", null);
1697
+ } catch (e) { die("cannot verify mapped pipeline lane ownership: hq lanes list returned invalid JSON"); }
1698
+ if (!Array.isArray(rows)) die("cannot verify mapped pipeline lane ownership: hq lanes list omitted lanes");
1699
+ const row = rows.find(x => isDict(x) && get(x, "lane_id") === lane);
1700
+ if (!row) { delete laneMap[ph.worker]; jsave(lanesFile, laneMap); lane = null; }
1701
+ else if (get(get(row, "company", {}), "slug") !== company || get(row, "project_id") !== project || get(row, "worker") !== ph.worker) {
1702
+ die("refusing to reuse mapped pipeline lane owned by a different company, project, or worker");
1703
+ }
1704
+ }
1705
+ if (!lane) {
1706
+ const meta = or(get(p, "metadata"), {});
1707
+ const laneBriefDir = sp("briefs");
1708
+ fs.mkdirSync(laneBriefDir, { recursive: true });
1709
+ const laneBrief = pjoin(laneBriefDir, ph.worker + ".md");
1710
+ writeText(laneBrief, "# Pipeline loop lane: " + project + " / " + ph.worker + "\n\n" +
1711
+ "Pipeline phases arrive as queued envelopes. Follow each envelope; this standing brief has no task of its own.\n");
1712
+ const createArgs = ["lanes", "create", "--loop", "--company", company, "--project", project,
1713
+ "--story", sid, "--worker", ph.worker, "--brief-file", abspath(laneBrief), "--senior", "session:" + session, "--json"];
1714
+ const table = get(or(jload(sp("run.json"), {}), {}), "table", "");
1715
+ if (T(table) && exists(table)) {
1716
+ const row = splitlines(readText(table)).map(x => x.split("\t")).find(x => x[0] === ph.worker);
1717
+ if (row) { if (row[1]) createArgs.push("--provider", row[1]); if (row[2]) createArgs.push("--model", row[2]); if (row[3]) createArgs.push("--effort", row[3]); }
1718
+ }
1719
+ const created = run(E.PC_HQ, createArgs, { timeout: 120000 });
1720
+ let answer; try { answer = JSON.parse(created.stdout); } catch (e) { answer = {}; }
1721
+ if (!isDict(answer) || get(answer, "ok") !== true) {
1722
+ const createError = get(answer, "error", null);
1723
+ const code = S(isDict(createError) ? get(createError, "code", get(answer, "code", "create_failed")) : (createError || get(answer, "code", "create_failed")));
1724
+ if (/admission|capacity|full|limit/i.test(code)) { st.state = "queued"; jsave(sfile(sid), st); print("RETRY " + sid + " " + S(ph.phase) + " lanes-" + code); exit(3); }
1725
+ die("hq lanes create refused pipeline phase: " + code + " " + strip(created.stderr));
1726
+ }
1727
+ lane = get(answer, "lane_id", get(answer, "lane"));
1728
+ if (!T(lane)) die("hq lanes create returned no lane_id");
1729
+ laneMap[ph.worker] = lane;
1730
+ jsave(lanesFile, laneMap);
1731
+ }
1732
+ const envData = jload(ep, {}); const baseEnvelopeId = sid + "-" + ph.phase;
1733
+ const suffixes = or(get(st, "envelope_suffixes"), {});
1734
+ envData.id = baseEnvelopeId + (suffixes[ph.phase] ? ".r" + suffixes[ph.phase] : "");
1735
+ envData.result_path = abspath(sp("handoffs", sid + "-" + ph.phase + ".json")); jsave(ep, envData);
1736
+ let r = run(E.PC_HQ, ["lanes", "enqueue", lane, "--envelope", abspath(ep)], { timeout: 30000 });
1737
+ let ans; try { ans = JSON.parse(r.stdout); } catch (e) { ans = {}; }
1738
+ let enqueueError = get(ans, "error", null);
1739
+ let code = S(isDict(enqueueError) ? get(enqueueError, "code", get(ans, "code", "enqueue_failed")) : (enqueueError || get(ans, "code", "enqueue_failed")));
1740
+ const reuseError = /envelope id .+ was already used in this loop lane/i.test(strip(r.stderr) || strip(r.stdout));
1741
+ if ((!isDict(ans) || get(ans, "ok") !== true) && (/already.*used|envelope_id_reused/i.test(code) || reuseError)) {
1742
+ const n = Number(suffixes[ph.phase] || 0) + 1;
1743
+ suffixes[ph.phase] = n; st.envelope_suffixes = suffixes; envData.id = baseEnvelopeId + ".r" + n;
1744
+ jsave(sfile(sid), st); jsave(ep, envData);
1745
+ r = run(E.PC_HQ, ["lanes", "enqueue", lane, "--envelope", abspath(ep)], { timeout: 30000 });
1746
+ try { ans = JSON.parse(r.stdout); } catch (e) { ans = {}; }
1747
+ enqueueError = get(ans, "error", null);
1748
+ code = S(isDict(enqueueError) ? get(enqueueError, "code", get(ans, "code", "enqueue_failed")) : (enqueueError || get(ans, "code", "enqueue_failed")));
1749
+ }
1750
+ if (!isDict(ans) || get(ans, "ok") !== true) {
1751
+ if (code === "loop_not_running" || ["stopped", "failed", "parked"].includes(get(ans, "loop_state"))) {
1752
+ delete laneMap[ph.worker]; jsave(lanesFile, laneMap); st.state = "queued"; jsave(sfile(sid), st);
1753
+ print("LANE_DOWN " + sid + " " + S(ph.phase) + " " + S(ph.worker) + " lanes-" + code); exit(11);
1754
+ }
1755
+ die("hq lanes enqueue failed for " + sid + "/" + S(ph.phase) + ": " + code + " " + strip(r.stderr));
1756
+ }
1757
+ st.state = "in_flight"; st.started = true; st.routed_at = now(); pop(st, "held_for");
1758
+ pop(st, "owner_retry");
1759
+ if (get(reopen_pending(st), "index") === i) st.reopen.routed = true;
1760
+ if (get(interrupt_pending(st), "index") === i) { st.interrupted.routed = true; st.interrupted.routed_at = iso(now()); }
1761
+ jsave(sfile(sid), st);
1762
+ print("ROUTED " + sid + " " + S(ph.phase) + " " + S(ph.worker) + " " + JSON.stringify(ans));
1763
+ }
1764
+
1765
+ // ---------- accept ----------
1766
+ function do_accept(sid) {
1767
+ const st = sload(sid);
1768
+ const src = E.PC_HANDOFF;
1769
+ if (!src || !exists(src)) die("--handoff file required");
1770
+ const [ok, why] = validate(src, "handoff");
1771
+ if (!ok) die("invalid handoff: " + why);
1772
+ const h = jload(src);
1773
+ if (st.state !== "in_flight")
1774
+ die("story " + sid + " is " + S(st.state) + ", not in_flight; refusing handoff");
1775
+ const ph = st.phases[st.current];
1776
+ if (get(h, "story_id") !== sid && !S(get(h, "story_id", "")).endsWith("/" + sid))
1777
+ die("handoff story_id " + S(get(h, "story_id")) + " does not match " + sid);
1778
+ if (get(h, "phase") !== ph.phase) die("handoff phase " + S(get(h, "phase")) + " is not the current phase " + S(ph.phase));
1779
+ const dst = sp("handoffs", sid + "-" + ph.phase + ".json");
1780
+ if (abspath(src) !== abspath(dst)) jsave(dst, h);
1781
+ if (h.status === "blocked") {
1782
+ const dp = hold_blocked(st, h, dst);
1783
+ print("PHASE_BLOCKED " + sid + " " + S(ph.phase) + " " + dp); exit(10);
1784
+ }
1785
+ if (h.status !== "passed") {
1786
+ st.state = "queued"; jsave(sfile(sid), st);
1787
+ print("PHASE_" + String(h.status).toUpperCase() + " " + sid + " " + S(ph.phase)); exit(1);
1788
+ }
1789
+ if (st.current + 1 < st.phases.length) {
1790
+ st.current += 1; st.state = "queued"; jsave(sfile(sid), st);
1791
+ print("NEXT " + sid + " " + S(st.phases[st.current].phase));
1792
+ } else {
1793
+ st.state = "awaiting_recheck"; jsave(sfile(sid), st);
1794
+ print("RECHECK " + sid);
1795
+ }
1796
+ }
1797
+
1798
+ // ---------- recheck ----------
1799
+ function unmet_acs(sid, st, acs, extra) {
1800
+ const met = new Map();
1801
+ const paths = st.phases.map(ph => sp("handoffs", sid + "-" + ph.phase + ".json")).concat(Array.from(extra || []));
1802
+ for (const path of paths) {
1803
+ const h = or(jload(path), {});
1804
+ for (const ev of or(get(h, "ac_evidence"), [])) {
1805
+ if (!isDict(ev) || !(typeof get(ev, "index") === "number" && Number.isInteger(ev.index))) continue;
1806
+ const k = ev.index;
1807
+ if (has(ev, "criterion") && k < acs.length) {
1808
+ const ak = k < 0 ? acs[acs.length + k] : acs[k];
1809
+ if (k < 0 && acs.length + k < 0) throw new Error("IndexError: list index out of range");
1810
+ if (!eq(ev.criterion, ak)) continue;
1811
+ }
1812
+ if (get(ev, "met") === true && typeof get(ev, "evidence") === "string" && strip(ev.evidence)) met.set(k, true);
1813
+ }
1814
+ }
1815
+ const out = [];
1816
+ for (let i = 0; i < acs.length; i++) if (!met.get(i)) out.push(i);
1817
+ return out;
1818
+ }
1819
+
1820
+ function do_recheck(p, sid) {
1821
+ const st = sload(sid);
1822
+ if (st.state !== "awaiting_recheck") die("story " + sid + " is " + S(st.state) + ", not awaiting_recheck");
1823
+ const acs = Array.from(or(get(story_of(p, sid), "acceptanceCriteria"), []));
1824
+ const unmet = unmet_acs(sid, st, acs);
1825
+ if (!unmet.length) {
1826
+ st.state = "verified";
1827
+ if (T(get(st, "reopen"))) st.reopen.done = true;
1828
+ jsave(sfile(sid), st);
1829
+ story_report(sid, st);
1830
+ print("VERIFIED " + sid);
1831
+ let due = gate_tick();
1832
+ const g = gate();
1833
+ if (or(get(g, "reopened"), []).includes(sid)) {
1834
+ g.reopened.splice(g.reopened.indexOf(sid), 1);
1835
+ if (!g.reopened.length && g.state === "reopened") { g.state = "due"; due = true; }
1836
+ jsave(sp("gate.json"), g);
1837
+ }
1838
+ if (due) print("RUN_GATE");
1839
+ return;
1840
+ }
1841
+ if (st.reroutes < 1) {
1842
+ st.reroutes = 1; st.current = st.phases.length - 1; st.rerouted_at = st.current;
1843
+ st.state = "queued"; st.unmet = unmet; jsave(sfile(sid), st);
1844
+ try { fs.unlinkSync(sp("handoffs", sid + "-" + st.phases[st.phases.length - 1].phase + ".json")); } catch (e) { /* none */ }
1845
+ print("ROUTED_BACK " + sid + " " + S(st.phases[st.phases.length - 1].phase) + " unmet:" + unmet.join(","));
1846
+ exit(1);
1847
+ }
1848
+ st.state = "failed_report"; st.unmet = unmet; jsave(sfile(sid), st);
1849
+ story_report(sid, st);
1850
+ print("FAILED " + sid + " unmet:" + unmet.join(","));
1851
+ exit(1);
1852
+ }
1853
+
1854
+ // ---------- release ----------
1855
+ function do_release() {
1856
+ let w = E.PC_WORKER;
1857
+ const a = jload(sp("approvals.json"), { run: [], story: {} });
1858
+ let targets;
1859
+ if (STORY) {
1860
+ const st = sload(STORY);
1861
+ if (st.state === "awaiting_go")
1862
+ die("story " + STORY + " is awaiting_go; a worker approval does not release it: run go --story " + STORY);
1863
+ w = w || get(st, "held_for");
1864
+ if (!T(w)) die("story " + STORY + " is not held");
1865
+ setdefault(setdefault(a, "story", {}), STORY, []);
1866
+ if (!a.story[STORY].includes(w)) a.story[STORY].push(w);
1867
+ targets = [STORY];
1868
+ } else {
1869
+ if (!w) die("release needs --story or --worker", 2);
1870
+ if (!setdefault(a, "run", []).includes(w)) a.run.push(w);
1871
+ targets = [];
1872
+ for (const [k, v] of all_stories()) if (v.state === "held" && get(v, "held_for") === w) targets.push(k);
1873
+ }
1874
+ jsave(sp("approvals.json"), a);
1875
+ for (const sid of targets) {
1876
+ const st = sload(sid);
1877
+ const dp = sp("decisions", sid + "-" + w + ".md");
1878
+ if (exists(dp)) {
1879
+ const t = readText(dp).split("- status: pending").join("- status: approved");
1880
+ writeText(dp, t);
1881
+ }
1882
+ if (st.state === "held") { st.state = "queued"; pop(st, "held_for"); jsave(sfile(sid), st); }
1883
+ print("RELEASED " + sid + " " + S(w));
1884
+ }
1885
+ }
1886
+
1887
+ // ---------- owner resolutions ----------
1888
+ function note() {
1889
+ return E.PC_NOTE_SET === "1" ? E.PC_NOTE : "";
1890
+ }
1891
+
1892
+ function story_acs(sid, phase) {
1893
+ const p = prd_opt();
1894
+ if (p) {
1895
+ for (const s of p.userStories) if (get(s, "id") === sid) return Array.from(or(get(s, "acceptanceCriteria"), []));
1896
+ }
1897
+ const env = or(jload(sp("envelopes", sid + "-" + phase + ".json")), {});
1898
+ const acs = get(env, "acceptance_criteria");
1899
+ if (Array.isArray(acs)) return acs;
1900
+ die("cannot find the acceptance criteria of " + sid + ": pass --prd");
1901
+ }
1902
+
1903
+ function maxFailed(sid, phase) {
1904
+ const ns = archives(sid, phase).map(x => x[0]);
1905
+ return ns.length ? Math.max.apply(null, ns) : 0;
1906
+ }
1907
+
1908
+ function do_resolve() {
1909
+ const sid = need_story(); const how = E.PC_AS;
1910
+ if (!["accepted-partial", "retry"].includes(how)) die("resolve needs --as accepted-partial|retry", 2);
1911
+ let st = jload(sfile(sid));
1912
+ if (st === null) die("story " + sid + " has no state in " + STATE + "; nothing to resolve");
1913
+ st = migrate_legacy(st);
1914
+ const last = or(get(st, "resolution"), {});
1915
+ if (how === "accepted-partial") {
1916
+ if (st.state === "accepted_partial") { print("ALREADY accepted-partial " + sid); return; }
1917
+ if (!["blocked_needs_owner", "awaiting_go"].includes(st.state))
1918
+ die("story " + sid + " is " + S(st.state) + "; resolve --as accepted-partial applies only to a story blocked for the owner " +
1919
+ "(blocked_needs_owner) or awaiting_go");
1920
+ if (!strip(note())) die("resolve --as accepted-partial needs --note <the owner's reason>", 2);
1921
+ const b = or(get(st, "blocked"), { phase: st.phases[st.current].phase, handoff: null });
1922
+ const acs = story_acs(sid, b.phase);
1923
+ const unmet = unmet_acs(sid, st, acs, T(get(b, "handoff")) ? [b.handoff] : []);
1924
+ st.state = "accepted_partial"; st.unmet = unmet;
1925
+ st.resolution = { as: "accepted-partial", note: note(), at: iso(now()), phase: b.phase,
1926
+ unmet: unmet, unmet_text: unmet.map(i => acs[i]), ac_total: acs.length };
1927
+ jsave(sfile(sid), st);
1928
+ mark_decision(decision_path(sid, b.phase), "resolved: accepted-partial");
1929
+ mark_decision(go_path(sid), "resolved: accepted-partial");
1930
+ story_report(sid, st);
1931
+ print("RESOLVED " + sid + " accepted-partial unmet:" + unmet.join(",") + " passes-not-set");
1932
+ return;
1933
+ }
1934
+ if (!["blocked_needs_owner", "awaiting_go"].includes(st.state)) {
1935
+ if (get(last, "as") === "retry" && ["queued", "in_flight"].includes(st.state)) {
1936
+ print("ALREADY retry " + sid); return;
1937
+ }
1938
+ die("story " + sid + " is " + S(st.state) + "; resolve --as retry applies only to a story blocked for the owner " +
1939
+ "(blocked_needs_owner)");
1940
+ }
1941
+ const p = prd_opt();
1942
+ if (p === null) die("resolve --as retry needs the prd to re-read the story's phases: pass --prd", 2);
1943
+ const phases = classify_story(story_of(p, sid));
1944
+ if (!T(phases)) die("story " + sid + " is unclassified in the prd: set worker_preference");
1945
+ const b = or(pop(st, "blocked", null), { phase: st.phases[st.current].phase });
1946
+ archive_handoff(sid, b.phase);
1947
+ st.blocked_ack = archives(sid, b.phase).length;
1948
+ // The owner may have corrected worker_preference: replace the cached phases
1949
+ // and resume at the first phase that has no passed handoff on disk.
1950
+ st.phases = phases;
1951
+ const ci = phases.findIndex(ph => get(or(jload(sp("handoffs", sid + "-" + ph.phase + ".json")), {}), "status") !== "passed");
1952
+ st.current = ci < 0 ? phases.length - 1 : ci;
1953
+ // A retry starts the attempt count over: earlier failed handoffs stay archived for the record.
1954
+ const fa = {}; for (const ph of phases) fa[ph.phase] = maxFailed(sid, ph.phase);
1955
+ st.fail_ack = fa;
1956
+ st.owner_retry = { index: st.current, note: note() };
1957
+ st.resolution = { as: "retry", note: note(), at: iso(now()), phase: b.phase };
1958
+ st.state = "queued";
1959
+ jsave(sfile(sid), st);
1960
+ mark_decision(decision_path(sid, b.phase), "resolved: retry");
1961
+ print("RESOLVED " + sid + " retry " + S(b.phase) + " phases:" + phases.map(ph => ph.phase).join(",") + " next:" +
1962
+ S(phases[st.current].phase));
1963
+ }
1964
+
1965
+ function do_failcap() {
1966
+ // A phase returned status failed too many times: hold the story for the owner with a decision item.
1967
+ const sid = need_story();
1968
+ const st = sload(sid);
1969
+ if (st.state === "blocked_needs_owner") {
1970
+ print("ALREADY blocked " + sid + " " + decision_path(sid, st.blocked.phase)); exit(10);
1971
+ }
1972
+ if (!["queued", "held", "in_flight"].includes(st.state))
1973
+ die("story " + sid + " is " + S(st.state) + "; failcap applies to a story whose current phase keeps failing");
1974
+ const ph = st.phases[st.current];
1975
+ const ack = pyInt(get(or(get(st, "fail_ack"), {}), ph.phase, 0));
1976
+ const tried = archives(sid, ph.phase).filter(x => x[0] > ack).map(x => x[1]);
1977
+ const cur = sp("handoffs", sid + "-" + ph.phase + ".json");
1978
+ if (exists(cur)) tried.push(cur);
1979
+ const failed = tried.map(path => [path, or(jload(path), {})]).filter(x => get(x[1], "status") === "failed");
1980
+ if (!failed.length) die("story " + sid + " phase " + S(ph.phase) + " has no failed handoff to hold");
1981
+ const last = failed[failed.length - 1][0];
1982
+ const texts = failed.map(([path, h], i) => "### Attempt " + (i + 1) + " (" + path + ")\n\n" + blocker_text(h)).join("\n\n");
1983
+ st.state = "blocked_needs_owner";
1984
+ st.blocked = { phase: ph.phase, worker: ph.worker, handoff: abspath(last), kind: "failed",
1985
+ text: failed.map(x => blocker_text(x[1])).join("\n\n"), at: iso(now()), attempts: failed.length };
1986
+ pop(st, "owner_retry");
1987
+ const dp = decision_path(sid, ph.phase);
1988
+ writeText(dp,
1989
+ "# Decision: " + sid + " failed its " + S(ph.phase) + " phase " + failed.length + " times\n\n" +
1990
+ "- story: " + sid + " (" + S(get(st, "title", "")) + ")\n- phase: " + S(ph.phase) + "\n- lane: " + S(ph.worker) + "\n- failed attempts: " + failed.length +
1991
+ "\n- last handoff: " + abspath(last) + "\n- status: pending\n\n" +
1992
+ "## What the worker said each time\n\n" + texts + "\n\n" +
1993
+ "## Resolutions (run one after the owner answers, then restart the driver)\n\n" +
1994
+ "- Run the story again (re-reads its phases from the prd, so fix worker_preference first if the lane was wrong;\n" +
1995
+ " the attempt count starts over):\n" +
1996
+ " pipeline-conductor.sh resolve --state " + STATE + " --story " + sid + " --as retry --prd <prd.json> --note \"<owner's answer>\"\n" +
1997
+ "- Accept as a partial draft (dependents may start; passes is not set):\n" +
1998
+ " pipeline-conductor.sh resolve --state " + STATE + " --story " + sid + " --as accepted-partial --note \"<owner's reason>\"\n" +
1999
+ "- Set the story and its dependents aside:\n" +
2000
+ " pipeline-conductor.sh park --state " + STATE + " --story " + sid + " --note \"<why>\"\n");
2001
+ jsave(sfile(sid), st);
2002
+ print("PHASE_FAILCAP " + sid + " " + S(ph.phase) + " " + dp);
2003
+ exit(10);
2004
+ }
2005
+
2006
+ function depsMap(p) {
2007
+ const deps = new Map();
2008
+ for (const s of p.userStories) if (typeof get(s, "id") === "string") deps.set(s.id, Array.from(or(get(s, "dependsOn"), [])));
2009
+ return deps;
2010
+ }
2011
+
2012
+ function parked_closure(p, stories) {
2013
+ // Map{story id: [parked stories it depends on, directly or through others]} for unparked stories.
2014
+ const parked = new Set(); for (const [k, v] of stories) if (v.state === "parked") parked.add(k);
2015
+ const deps = depsMap(p);
2016
+ const out = new Map();
2017
+ for (const sid of deps.keys()) {
2018
+ if (parked.has(sid)) continue;
2019
+ const roots = new Set(), seen = new Set([sid]), todo = deps.get(sid).slice();
2020
+ while (todo.length) {
2021
+ const d = todo.pop();
2022
+ if (seen.has(d)) continue;
2023
+ seen.add(d);
2024
+ if (parked.has(d)) roots.add(d);
2025
+ for (const x of (deps.has(d) ? deps.get(d) : [])) todo.push(x);
2026
+ }
2027
+ if (roots.size) out.set(sid, sortedStr(roots));
2028
+ }
2029
+ return out;
2030
+ }
2031
+
2032
+ function sync_parked(p) {
2033
+ // Hold every not-started dependent of a parked story as parked_dependency; release the rest.
2034
+ const stories = all_stories();
2035
+ const held = parked_closure(p, stories);
2036
+ for (const sid of sortedStr(held.keys())) {
2037
+ const roots = held.get(sid);
2038
+ const st = stories.has(sid) ? stories.get(sid) : null;
2039
+ if (st === null) {
2040
+ const s = story_of(p, sid);
2041
+ jsave(sfile(sid), { id: sid, title: get(s, "title", ""), phases: [], current: 0,
2042
+ state: "parked_dependency", reroutes: 0, parked_by: roots });
2043
+ print("PARKED_DEPENDENCY " + sid + " by " + roots.join(","));
2044
+ } else if (st.state === "parked_dependency" && !eq(get(st, "parked_by"), roots)) {
2045
+ st.parked_by = roots; jsave(sfile(sid), st);
2046
+ }
2047
+ }
2048
+ for (const [sid, st] of stories) {
2049
+ if (st.state === "parked_dependency" && !held.has(sid)) {
2050
+ fs.unlinkSync(sfile(sid));
2051
+ print("RELEASED_DEPENDENCY " + sid);
2052
+ }
2053
+ }
2054
+ }
2055
+
2056
+ function log_decision(kind, sid, kw) {
2057
+ // One JSON line per owner action in <state>/decisions.log.
2058
+ const rec = Object.assign({ at: iso(now()), action: kind, story: sid }, kw || {});
2059
+ fs.appendFileSync(sp("decisions.log"), pyDumps(rec, false) + "\n", "utf8");
2060
+ }
2061
+
2062
+ function dependents_closure(p, sid, stories) {
2063
+ // Stories that depend on sid directly or through others and are not done (passes or a done state).
2064
+ const deps = depsMap(p);
2065
+ const done = new Set(p.userStories.filter(s => get(s, "passes") === true).map(s => s.id));
2066
+ for (const [k, v] of stories) if (DONE_STATES.includes(get(v, "state"))) done.add(k);
2067
+ const out = [];
2068
+ for (const k of deps.keys()) {
2069
+ if (k === sid || done.has(k)) continue;
2070
+ const seen = new Set(), todo = deps.get(k).slice();
2071
+ while (todo.length) {
2072
+ const d = todo.pop();
2073
+ if (seen.has(d)) continue;
2074
+ seen.add(d);
2075
+ if (d === sid) { out.push(k); break; }
2076
+ for (const x of (deps.has(d) ? deps.get(d) : [])) todo.push(x);
2077
+ }
2078
+ }
2079
+ return sortedStr(out);
2080
+ }
2081
+
2082
+ // ---------- skip list ----------
2083
+ const SKIPPABLE = ["queued", "held", "awaiting_go", "blocked_needs_owner", "parked_dependency"];
2084
+
2085
+ function do_skip() {
2086
+ const sid = need_story(); const p = prd_opt();
2087
+ const sk = or(jload(sp("skips.json"), {}), {});
2088
+ if (has(sk, sid)) { print("ALREADY skipped " + sid); return; }
2089
+ if (p === null) die("skip needs the prd to find the stories that depend on " + sid + ": pass --prd", 2);
2090
+ const s = story_of(p, sid);
2091
+ let st = jload(sfile(sid));
2092
+ if (st !== null && !SKIPPABLE.includes(get(st, "state")))
2093
+ die("story " + sid + " is " + S(get(st, "state")) + "; skip applies to a story that has not started or is " + SKIPPABLE.join(", "));
2094
+ const strands = dependents_closure(p, sid, all_stories());
2095
+ if (strands.length) {
2096
+ print("SKIP_STRANDS " + sid + " " + strands.length + ": " + strands.join(","));
2097
+ if (E.PC_FORCE !== "1")
2098
+ die("skipping " + sid + " strands " + strands.length + " stories that depend on it (" + strands.join(",") + "); they will not run. Repeat with --force");
2099
+ }
2100
+ sk[sid] = { note: note(), at: iso(now()), from: T(st) ? get(st, "state") : null, strands: strands };
2101
+ jsave(sp("skips.json"), sk);
2102
+ if (st === null) st = { id: sid, title: get(s, "title", ""), phases: [], current: 0, reroutes: 0 };
2103
+ st.skipped_from = sk[sid].from; st.state = "skipped";
2104
+ jsave(sfile(sid), st);
2105
+ log_decision("skip", sid, { note: note(), strands: strands });
2106
+ print("SKIPPED " + sid);
2107
+ }
2108
+
2109
+ function do_unskip() {
2110
+ const sid = need_story();
2111
+ const sk = or(jload(sp("skips.json"), {}), {});
2112
+ if (!has(sk, sid)) { print("ALREADY unskipped " + sid); return; }
2113
+ const frm = get(pop(sk, sid), "from");
2114
+ const st = jload(sfile(sid));
2115
+ if (st !== null && get(st, "state") === "skipped") {
2116
+ if (frm === null || frm === undefined) fs.unlinkSync(sfile(sid));
2117
+ else { st.state = frm; pop(st, "skipped_from"); jsave(sfile(sid), st); }
2118
+ }
2119
+ jsave(sp("skips.json"), sk);
2120
+ log_decision("unskip", sid);
2121
+ print("UNSKIPPED " + sid + " " + S(or(frm, "not-started")));
2122
+ }
2123
+
2124
+ // ---------- overlay ----------
2125
+ function do_overlay() {
2126
+ const ov = overlay_load();
2127
+ if (SUB === "show") { print(JSON.stringify(ov, null, 1)); return; }
2128
+ if (SUB !== "set") die("overlay set|show", 2);
2129
+ const sid = need_story();
2130
+ const seq = seq_list(E.PC_SEQUENCE || "");
2131
+ if (!seq.length) die("overlay set needs --sequence <worker>,<worker>,...", 2);
2132
+ ov[sid] = { worker_sequence: seq, at: iso(now()) };
2133
+ jsave(sp("overlay.json"), ov);
2134
+ const st = or(jload(sfile(sid)), {});
2135
+ if (T(get(st, "started"))) print("NOTE " + sid + " already started; the new sequence applies on resolve --as retry");
2136
+ print("OVERLAY " + sid + " " + seq.join(","));
2137
+ }
2138
+
2139
+ function import_overlay(path) {
2140
+ const d = jload(path);
2141
+ if (d === null) die("overlay file is not JSON: " + path);
2142
+ const ent = overlay_entries(d);
2143
+ if (!T(ent)) die("overlay file has no story worker_sequence entries: " + path);
2144
+ const ov = overlay_load();
2145
+ for (const [k, v] of Object.entries(ent)) ov[k] = { worker_sequence: v, at: iso(now()), from: abspath(path) };
2146
+ jsave(sp("overlay.json"), ov);
2147
+ eprint("OVERLAY_IMPORTED " + Object.keys(ent).length + " from " + path);
2148
+ }
2149
+
2150
+ function classify_all(p) {
2151
+ // Check every story that is not passing or skipped, without writing story state. Exit 1 on any error.
2152
+ let errs = [], warns = []; const need = new Map();
2153
+ for (const s of p.userStories) {
2154
+ const sid = get(s, "id");
2155
+ if (typeof sid !== "string" || get(s, "passes") === true) continue;
2156
+ if (skipped(sid)) { print("SKIPPED " + sid); continue; }
2157
+ const [workers, src] = story_sequence(s);
2158
+ if (!T(workers)) {
2159
+ errs.push("story " + sid + " is unclassified: overlay set --state " + STATE + " --story " + sid + " --sequence <a>,<b>,..., " +
2160
+ "set worker_preference, or skip --state " + STATE + " --story " + sid);
2161
+ continue;
2162
+ }
2163
+ const [e, w] = sequence_problems(s, workers); errs = errs.concat(e); warns = warns.concat(w);
2164
+ warns = warns.concat(hint_notes(s, workers)[0], dep_lint(p, s));
2165
+ const rows = table_rows();
2166
+ if (rows !== null) {
2167
+ for (const wk of workers) if (!rows.has(wk)) { if (!need.has(wk)) need.set(wk, []); need.get(wk).push(sid); }
2168
+ }
2169
+ print("OK " + sid + " " + workers.join(",") + " (" + src + ")");
2170
+ }
2171
+ if (need.size) {
2172
+ errs.push("no lane in the worker table " + S(table_path()) + " for: " +
2173
+ Array.from(need).map(([w, ids]) => w + " (" + ids.join(",") + ")").join("; ") + ". Add a row for each and confirm the table again");
2174
+ }
2175
+ for (const w of warns) print("WARN " + w);
2176
+ for (const e of errs) print("ERROR " + e);
2177
+ if (errs.length) die("classify refused: " + errs.length + " error(s)");
2178
+ }
2179
+
2180
+ const PARKABLE = ["queued", "held", "awaiting_go", "blocked_needs_owner", "failed_report"];
2181
+
2182
+ function do_park() {
2183
+ const sid = need_story(); const p = prd_opt();
2184
+ let st = jload(sfile(sid));
2185
+ st = T(st) ? migrate_legacy(st) : null;
2186
+ if (T(st) && st.state === "parked") { print("ALREADY parked " + sid); return; }
2187
+ let frm;
2188
+ if (st === null) {
2189
+ if (p === null) die("story " + sid + " has no state yet; pass --prd so park can find it", 2);
2190
+ const s = story_of(p, sid);
2191
+ if (get(s, "passes") === true) die("story " + sid + " already passes in the prd; nothing to park");
2192
+ st = { id: sid, title: get(s, "title", ""), phases: [], current: 0, reroutes: 0 };
2193
+ frm = null;
2194
+ } else if (!PARKABLE.includes(st.state)) {
2195
+ die("story " + sid + " is " + S(st.state) + "; park applies to a story that has not started or is " + PARKABLE.join(", "));
2196
+ } else {
2197
+ frm = st.state;
2198
+ }
2199
+ let closure;
2200
+ if (p === null) {
2201
+ print("PARK_CLOSURE " + sid + " unknown: no prd to read dependsOn from (pass --prd)");
2202
+ closure = [];
2203
+ } else {
2204
+ closure = dependents_closure(p, sid, all_stories());
2205
+ print("PARK_CLOSURE " + sid + " " + closure.length + ": " + (closure.join(",") || "none"));
2206
+ const limit = pyInt(E.PC_PARK_CONFIRM || 5);
2207
+ if (closure.length > limit && E.PC_FORCE !== "1")
2208
+ die("park " + sid + " would also park " + closure.length + " dependent stories (" + closure.join(",") + "), more than PC_PARK_CONFIRM=" + limit + "; " +
2209
+ "repeat with --force to park them");
2210
+ }
2211
+ log_decision("park", sid, { note: note(), closure: closure });
2212
+ st.parked = { from: frm, note: note(), at: iso(now()) };
2213
+ st.state = "parked";
2214
+ jsave(sfile(sid), st);
2215
+ const pk = jload(sp("parking.json"), { unparked: [] });
2216
+ if (pk.unparked.includes(sid)) { pk.unparked.splice(pk.unparked.indexOf(sid), 1); jsave(sp("parking.json"), pk); }
2217
+ if (frm === "blocked_needs_owner") mark_decision(decision_path(sid, st.blocked.phase), "parked");
2218
+ if (frm === "awaiting_go") mark_decision(go_path(sid), "parked");
2219
+ print("PARKED " + sid);
2220
+ if (T(p)) sync_parked(p);
2221
+ }
2222
+
2223
+ function do_unpark() {
2224
+ const sid = need_story(); const p = prd_opt();
2225
+ const st = jload(sfile(sid));
2226
+ const pk = jload(sp("parking.json"), { unparked: [] });
2227
+ if (st === null || get(st, "state") !== "parked") {
2228
+ if (pk.unparked.includes(sid)) { print("ALREADY unparked " + sid); return; }
2229
+ die("story " + sid + " is " + (st !== null ? S(st.state) : "not started") + "; unpark applies only to a parked story");
2230
+ }
2231
+ const frm = get(or(get(st, "parked"), {}), "from");
2232
+ const releases = [];
2233
+ for (const [k, v] of all_stories()) if (v.state === "parked_dependency" && eq(or(get(v, "parked_by"), []), [sid])) releases.push(k);
2234
+ releases.sort(cmpStr);
2235
+ print("UNPARK_RELEASES " + sid + " " + releases.length + ": " + (releases.join(",") || "none"));
2236
+ log_decision("unpark", sid, { releases: releases });
2237
+ if (frm === null || frm === undefined) {
2238
+ fs.unlinkSync(sfile(sid));
2239
+ } else {
2240
+ st.state = frm; pop(st, "parked"); jsave(sfile(sid), st);
2241
+ if (frm === "blocked_needs_owner") mark_decision(decision_path(sid, st.blocked.phase), "pending");
2242
+ if (frm === "awaiting_go") mark_decision(go_path(sid), "pending");
2243
+ }
2244
+ pk.unparked.push(sid); jsave(sp("parking.json"), pk);
2245
+ print("UNPARKED " + sid + " " + S(or(frm, "not-started")));
2246
+ if (T(p)) sync_parked(p);
2247
+ }
2248
+
2249
+ // ---------- reopen ----------
2250
+ const STORE_RE = /(store|schema|migration|db\/)/i;
2251
+
2252
+ function store_paths(s) {
2253
+ return or(get(s, "files"), []).filter(f => typeof f === "string" && STORE_RE.test(f));
2254
+ }
2255
+
2256
+ function reopen_pending(st) {
2257
+ const ro = or(get(st, "reopen"), {});
2258
+ return T(ro) && !T(get(ro, "routed")) && !T(get(ro, "done")) ? ro : {};
2259
+ }
2260
+
2261
+ function is_checker(w) {
2262
+ // A reviewer or tester lane: reopen skips it when choosing the default phase.
2263
+ return /(^|[-_])(qa|test|tester|testing|review|reviewer|verifier)([-_]|$)/.test(w || "");
2264
+ }
2265
+
2266
+ function archive_reopened(sid, phase) {
2267
+ const h = sp("handoffs", sid + "-" + phase + ".json");
2268
+ if (!exists(h)) return null;
2269
+ let n = 1;
2270
+ while (exists(sp("handoffs", sid + "-" + phase + ".reopened." + n + ".json"))) n += 1;
2271
+ const dst = sp("handoffs", sid + "-" + phase + ".reopened." + n + ".json");
2272
+ fs.renameSync(h, dst);
2273
+ return dst;
2274
+ }
2275
+
2276
+ function verified_dependents(p, sid, stories) {
2277
+ // Stories that depend on sid, directly or through others, and are verified now.
2278
+ if (p === null) {
2279
+ const out = [];
2280
+ for (const [k, v] of stories) if (v.state === "verified" && or(get(v, "depends_on"), []).includes(sid)) out.push(k);
2281
+ return sortedStr(out);
2282
+ }
2283
+ const deps = depsMap(p);
2284
+ const out = [];
2285
+ for (const k of deps.keys()) {
2286
+ if (k === sid || get(or(stories.get(k), {}), "state") !== "verified") continue;
2287
+ const seen = new Set(), todo = deps.get(k).slice();
2288
+ while (todo.length) {
2289
+ const d = todo.pop();
2290
+ if (seen.has(d)) continue;
2291
+ seen.add(d);
2292
+ if (d === sid) { out.push(k); break; }
2293
+ for (const x of (deps.has(d) ? deps.get(d) : [])) todo.push(x);
2294
+ }
2295
+ }
2296
+ return sortedStr(out);
2297
+ }
2298
+
2299
+ const REOPENABLE = ["verified", "accepted_partial"];
2300
+
2301
+ function do_reopen(by_gate) {
2302
+ by_gate = !!by_gate;
2303
+ const sid = need_story();
2304
+ if (!strip(note())) die("reopen needs --note <why the story goes back>", 2);
2305
+ let st = jload(sfile(sid));
2306
+ if (st === null) die("story " + sid + " has no state in " + STATE + "; nothing to reopen");
2307
+ st = migrate_legacy(st);
2308
+ const ro = or(get(st, "reopen"), {});
2309
+ if (!REOPENABLE.includes(st.state)) {
2310
+ if (T(ro) && !T(get(ro, "done")) && ["queued", "held", "in_flight", "awaiting_recheck"].includes(st.state)) {
2311
+ print("ALREADY reopened " + sid + " at " + S(get(ro, "phase")) + " (" + st.state + ")"); return;
2312
+ }
2313
+ die("story " + sid + " is " + S(st.state) + "; reopen applies only to a verified or accepted_partial story");
2314
+ }
2315
+ const phases = or(get(st, "phases"), []);
2316
+ if (!phases.length) die("story " + sid + " has no phases recorded; nothing to reopen");
2317
+ const fp = E.PC_FROM_PHASE || "";
2318
+ let idx;
2319
+ if (fp) {
2320
+ idx = phases.findIndex(ph => ph.phase === fp);
2321
+ if (idx < 0) die("story " + sid + " has no phase " + fp + " (phases: " + phases.map(ph => S(ph.phase)).join(",") + ")", 2);
2322
+ } else {
2323
+ idx = phases.findIndex(ph => !is_checker(ph.worker));
2324
+ if (idx < 0) idx = 0;
2325
+ }
2326
+ for (const ph of phases.slice(0, idx)) {
2327
+ if (get(or(jload(sp("handoffs", sid + "-" + ph.phase + ".json")), {}), "status") !== "passed")
2328
+ die("story " + sid + ": phase " + S(ph.phase) + " before " + S(phases[idx].phase) + " has no passed handoff; reopen from " + S(ph.phase) + " instead");
2329
+ }
2330
+ const p = prd_opt();
2331
+ const deps = verified_dependents(p, sid, all_stories());
2332
+ const archived = phases.slice(idx).map(ph => archive_reopened(sid, ph.phase)).filter(T);
2333
+ const frm = st.state;
2334
+ st.current = idx; st.state = "queued"; st.reroutes = 0;
2335
+ const prev = pop(st, "resolution", null);
2336
+ for (const k of ["rerouted_at", "unmet", "owner_retry", "blocked", "routed_at"]) pop(st, k);
2337
+ // The attempt count starts over: earlier failed handoffs stay archived for the record.
2338
+ const fa = {}; for (const ph of phases) fa[ph.phase] = maxFailed(sid, ph.phase);
2339
+ st.fail_ack = fa;
2340
+ st.blocked_ack = archives(sid, phases[idx].phase).length;
2341
+ st.reopen = { index: idx, phase: phases[idx].phase, note: note(), at: iso(now()), from: frm,
2342
+ by_gate: by_gate, verified_dependents: deps,
2343
+ archived: archived.map(abspath), count: pyInt(get(ro, "count", 0)) + 1 };
2344
+ if (T(prev)) st.reopen.previous_resolution = prev;
2345
+ jsave(sfile(sid), st);
2346
+ const rp = sp("report.md");
2347
+ if (exists(rp)) {
2348
+ const lines = splitlines(readText(rp)).filter(l => !l.startsWith("- " + sid + " "));
2349
+ writeText(rp, lines.join("\n") + (lines.length ? "\n" : ""));
2350
+ }
2351
+ print("REOPENED " + sid + " from:" + frm + " phase:" + S(phases[idx].phase) + " archived:" + archived.length + " verified-dependents:" + (deps.join(",") || "none"));
2352
+ const path = PRD || get(or(jload(sp("run.json"), {}), {}), "prd");
2353
+ if (p !== null && T(path)) {
2354
+ for (const s of p.userStories) {
2355
+ if (get(s, "id") === sid && get(s, "passes") === true) {
2356
+ s.passes = false;
2357
+ jsave(path, p);
2358
+ print("PASSES_CLEARED " + sid + " in " + abspath(path));
2359
+ }
2360
+ }
2361
+ }
2362
+ }
2363
+
2364
+ function final_line() {
2365
+ const ss = all_stories(); const c = new Map();
2366
+ for (const v of ss.values()) c.set(v.state, (c.get(v.state) || 0) + 1);
2367
+ const n = k => c.get(k) || 0;
2368
+ const line = "FINAL: verified " + n("verified") + ", failed " + n("failed_report") + ", partial " + n("accepted_partial") +
2369
+ ", blocked " + n("blocked_needs_owner") + ", parked " + n("parked") + ", held " + n("held") + ", awaiting go " + n("awaiting_go") +
2370
+ ", interrupted " + n("interrupted") + ", unfinished " + (n("queued") + n("in_flight") + n("awaiting_recheck")) + ", gate " + S(gate().state);
2371
+ const extra = [];
2372
+ const il = interrupted_list(ss);
2373
+ if (il) extra.push("interrupted, routed first on the next driver start: " + il);
2374
+ const part = []; for (const [k, v] of ss) if (v.state === "accepted_partial") part.push(k);
2375
+ if (part.length) extra.push("partial drafts, passes not set: " + part.map(k =>
2376
+ k + " (unmet AC " + (get(or(get(ss.get(k), "resolution"), {}), "unmet", []).map(S).join(",") || "none") + ")").join(", "));
2377
+ for (const [k, v] of ss) {
2378
+ if (v.state === "parked") {
2379
+ const why = wsjoin(or(get(or(get(v, "parked"), {}), "note"), "no note"));
2380
+ const deps = [];
2381
+ for (const [d, w] of ss) if (w.state === "parked_dependency" && or(get(w, "parked_by"), []).includes(k)) deps.push(d);
2382
+ deps.sort(cmpStr);
2383
+ extra.push("parked " + k + " (" + why + "; holds " + (deps.join(", ") || "no dependents") + ")");
2384
+ }
2385
+ }
2386
+ for (const k of sortedStr(ss.keys())) {
2387
+ const v = ss.get(k);
2388
+ const ro = or(get(v, "reopen"), {});
2389
+ if (!T(ro)) continue;
2390
+ extra.push("reopened " + k + " at " + S(get(ro, "phase")) + " (" + wsjoin(get(ro, "note", "")) + ")");
2391
+ if (T(get(ro, "verified_dependents")))
2392
+ extra.push("verified before " + k + " was reopened: " + ro.verified_dependents.join(", "));
2393
+ }
2394
+ const sk = or(jload(sp("skips.json"), {}), {});
2395
+ for (const k of sortedStr(Object.keys(sk))) {
2396
+ const why = wsjoin(or(get(sk[k], "note"), "no note"));
2397
+ const st_ = or(get(sk[k], "strands"), []);
2398
+ extra.push("skipped " + k + " (" + why + (T(st_) ? "; strands " + st_.join(", ") : "") + ")");
2399
+ }
2400
+ const blk = []; for (const [k, v] of ss) if (v.state === "blocked_needs_owner") blk.push(k);
2401
+ if (blk.length) extra.push("blocked for the owner: " + blk.join(", "));
2402
+ const ag = []; for (const [k, v] of ss) if (v.state === "awaiting_go") ag.push(k);
2403
+ ag.sort(cmpStr);
2404
+ if (ag.length) extra.push("awaiting go: " + ag.map(k =>
2405
+ k + " (" + S(get(or(get(ss.get(k), "go_hold"), {}), "reason", "")) + "; run go --story " + k + ")").join(", "));
2406
+ return line + (extra.length ? "; " + extra.join("; ") : "");
2407
+ }
2408
+
2409
+ // ---------- interrupt / stop ----------
2410
+ function interrupt_pending(st) {
2411
+ // The interrupted record of a story whose interrupted phase has not been routed again, else {}.
2412
+ const it = or(get(st, "interrupted"), {});
2413
+ return isDict(it) && T(get(it, "phase")) && !T(get(it, "routed")) ? it : {};
2414
+ }
2415
+
2416
+ function interrupted_list(ss) {
2417
+ ss = ss === undefined ? all_stories() : ss;
2418
+ return sortedStr(ss.keys()).filter(k => T(interrupt_pending(ss.get(k))))
2419
+ .map(k => k + " at " + S(interrupt_pending(ss.get(k)).phase) + " (" + S(get(interrupt_pending(ss.get(k)), "at", "?")) + ")").join(", ");
2420
+ }
2421
+
2422
+ function lane_runs() {
2423
+ return new Map(Object.entries(or(jload(sp("lanes.json"), {}), {})));
2424
+ }
2425
+
2426
+ function withdraw(lane, sid, ph) {
2427
+ if (!T(lane)) return false;
2428
+ const r = run(E.PC_HQ, ["lanes", "interrupt", lane, "--story", sid, "--phase", ph, "--json"], { timeout: 30000 });
2429
+ let ans; try { ans = JSON.parse(r.stdout); } catch (e) { return false; }
2430
+ const id = sid + "-" + ph;
2431
+ if ((get(ans, "withdrawn", []) || []).includes(id)) return true;
2432
+ return (get(ans, "already_picked_up", []) || []).includes(id) ? false : false;
2433
+ }
2434
+
2435
+ function interrupted_aside(sid, phase) {
2436
+ const h = sp("handoffs", sid + "-" + phase + ".json");
2437
+ if (!exists(h)) return null;
2438
+ let n = 1;
2439
+ while (exists(sp("handoffs", sid + "-" + phase + ".interrupted." + n + ".json"))) n += 1;
2440
+ const dst = abspath(sp("handoffs", sid + "-" + phase + ".interrupted." + n + ".json"));
2441
+ fs.renameSync(h, dst);
2442
+ return dst;
2443
+ }
2444
+
2445
+ function do_interrupt(reason) {
2446
+ let lanes = null;
2447
+ const ss = all_stories();
2448
+ for (const sid of sortedStr(ss.keys())) {
2449
+ const st = ss.get(sid);
2450
+ if (st.state !== "in_flight" || (STORY && sid !== STORY)) continue;
2451
+ const i = st.current; const ph = st.phases[i];
2452
+ const h = sp("handoffs", sid + "-" + ph.phase + ".json");
2453
+ const hd = jload(h);
2454
+ if (isDict(hd) && get(hd, "phase") === ph.phase && ["passed", "failed", "blocked"].includes(get(hd, "status"))) {
2455
+ // the phase finished; the next driver accepts it
2456
+ print("KEPT " + sid + " " + S(ph.phase) + " handoff-present"); continue;
2457
+ }
2458
+ const partial = interrupted_aside(sid, ph.phase);
2459
+ if (lanes === null) lanes = lane_runs();
2460
+ const gone = withdraw(lanes.has(ph.worker) ? lanes.get(ph.worker) : null, sid, ph.phase);
2461
+ st.state = "interrupted";
2462
+ st.interrupted = { phase: ph.phase, index: i, worker: ph.worker, at: iso(now()), reason: reason, withdrawn: gone };
2463
+ if (partial) st.interrupted.partial_handoff = partial;
2464
+ jsave(sfile(sid), st);
2465
+ print("INTERRUPTED " + sid + " " + S(ph.phase) + " " + (gone ? "withdrawn" : "picked_up"));
2466
+ }
2467
+ }
2468
+
2469
+ function driver_alive() {
2470
+ let pid;
2471
+ try {
2472
+ const t = strip(readText(sp("driver", "driver.pid")));
2473
+ pid = pyInt(t);
2474
+ } catch (e) { return false; }
2475
+ try { process.kill(pid, 0); return true; } catch (e) { return false; }
2476
+ }
2477
+
2478
+ function do_stop() {
2479
+ fs.mkdirSync(sp("driver"), { recursive: true });
2480
+ const rec = { kind: "stop", at: iso(now()), note: note() };
2481
+ if (driver_alive()) {
2482
+ jsave(sp("driver", "stop.json"), rec);
2483
+ print("STOP_REQUESTED " + sp("driver", "stop.json")); return;
2484
+ }
2485
+ do_interrupt("stop: " + (note() || "parent stop command"));
2486
+ const lanes = Array.from(lane_runs().values());
2487
+ for (const lane of lanes) run(E.PC_HQ, ["lanes", "stop", lane], { timeout: 30000 });
2488
+ let listed = null;
2489
+ try {
2490
+ const parsed = JSON.parse(run(E.PC_HQ, ["lanes", "list", "--json"], { timeout: 30000 }).stdout || "{}");
2491
+ listed = Array.isArray(parsed) ? parsed : get(parsed, "lanes", null);
2492
+ } catch (e) { listed = null; }
2493
+ const stopped = Array.isArray(listed) && lanes.every(id => {
2494
+ const row = listed.find(item => isDict(item) && get(item, "lane_id") === id);
2495
+ return !row || get(get(row, "loop", {}), "state") === "stopped";
2496
+ });
2497
+ if (stopped) jsave(sp("lanes.json"), {});
2498
+ print(stopped ? "STOPPED no live driver" : "STOP_REQUESTED no live driver; waiting for every loop lane to stop");
2499
+ }
2500
+
2501
+ // ---------- tick ----------
2502
+ const ACTIVE_STATES = ["queued", "in_flight", "held", "awaiting_recheck"];
2503
+
2504
+ function do_tick(p) {
2505
+ const m = wt_mapping(E.PC_WT); const sb = E.PC_STORY_BRANCHES === "1";
2506
+ const prev = or(jload(sp("run.json"), {}), {});
2507
+ const runj = { ...prev, prd: abspath(PRD), worktrees: m, story_branches: sb,
2508
+ allow_repos_worktree: E.PC_ALLOW_REPOS === "1",
2509
+ table: E.PC_TABLE ? abspath(E.PC_TABLE) : get(prev, "table", "") };
2510
+ let cap = pyInt(E.PC_MAX_STORIES); let capnote = "";
2511
+ if (!sb) {
2512
+ const n = new Set(Object.values(m)).size || 1;
2513
+ if (cap > n) {
2514
+ capnote = "MAX_STORIES " + n + " PC_MAX_STORIES=" + cap + " lowered: " + n + " worktree(s) in play and stories of a repo share one " +
2515
+ "feature branch, so one story is in flight per repo branch at a time";
2516
+ cap = n;
2517
+ }
2518
+ }
2519
+ runj.max_stories_note = capnote;
2520
+ jsave(sp("run.json"), runj);
2521
+ if (capnote && get(prev, "max_stories_note") !== capnote) print(capnote);
2522
+ sync_parked(p);
2523
+ for (const [sid, v] of all_stories()) {
2524
+ if (v.state === "interrupted") {
2525
+ // a lane that was mid-call when the run stopped may have written its handoff since: keep it as the
2526
+ // prior partial handoff, so the resumed phase is not accepted on the stale one
2527
+ const late = interrupted_aside(sid, v.interrupted.phase);
2528
+ if (late) v.interrupted.partial_handoff = late;
2529
+ v.state = "queued"; v.interrupted.resuming = true; jsave(sfile(sid), v);
2530
+ }
2531
+ }
2532
+ const stories = all_stories();
2533
+ const rank = k => T(interrupt_pending(stories.get(k))) ? 0 : T(reopen_pending(stories.get(k))) ? 1 : 2;
2534
+ const order = Array.from(stories.keys()).sort((a, b) => (rank(a) - rank(b)) || cmpStr(a, b));
2535
+ for (const sid of order) {
2536
+ const st = stories.get(sid);
2537
+ if (st.state === "queued") {
2538
+ if ([11, 12].includes(run_sub(["route", "--prd", PRD, "--state", STATE, "--story", sid]))) return;
2539
+ }
2540
+ }
2541
+ const active = Array.from(all_stories().values()).filter(v => ACTIVE_STATES.includes(v.state));
2542
+ const busy = new Set(active.map(v => get(v, "worktree")));
2543
+ let room = Math.max(0, cap - active.length);
2544
+ for (const sid of eligible(p)) {
2545
+ if (room <= 0) break;
2546
+ if (!sb) {
2547
+ const known = or(jload(sfile(sid)), {});
2548
+ const want = T(m) ? pick_worktree(p, resolve_repo(p, story_of(p, sid))[0], m) : get(known, "worktree");
2549
+ if (T(want) && busy.has(want)) continue;
2550
+ }
2551
+ const [ws] = story_sequence(story_of(p, sid));
2552
+ const rows = table_rows();
2553
+ const miss = (ws || []).filter(w => rows !== null && !rows.has(w));
2554
+ if (miss.length) {
2555
+ print("NO_LANE " + sid + " " + phaseOf(miss[0]) + " " + miss[0] + " not in the worker table " + S(table_path()));
2556
+ return;
2557
+ }
2558
+ if (do_classify(p, sid, E.PC_WT) === null) continue;
2559
+ if ([11, 12].includes(run_sub(["route", "--prd", PRD, "--state", STATE, "--story", sid]))) return;
2560
+ const st = or(jload(sfile(sid)), {});
2561
+ if (ACTIVE_STATES.includes(get(st, "state"))) {
2562
+ room -= 1; busy.add(get(st, "worktree"));
2563
+ }
2564
+ }
2565
+ if (gate().state === "due") print("RUN_GATE");
2566
+ if (gate().state === "failed") print("GATE_FAILED");
2567
+ }
2568
+
2569
+ function run_sub(args) {
2570
+ const r = run(pjoin(E.PC_SELF_DIR, "pipeline-conductor.sh"), args);
2571
+ owrite(r.stdout);
2572
+ if (![0, 3, 10, 11, 12].includes(r.returncode) && r.stderr) owrite("ERROR " + r.stderr);
2573
+ return r.returncode;
2574
+ }
2575
+
2576
+ // ---------- dispatch ----------
2577
+ function main() {
2578
+ if (CMD === "next") {
2579
+ const ids = eligible(prd());
2580
+ const lim = E.PC_LIMIT;
2581
+ for (const sid of (lim ? ids.slice(0, pyInt(lim)) : ids)) print(sid);
2582
+ } else if (CMD === "classify") {
2583
+ if (E.PC_OVERLAY) import_overlay(E.PC_OVERLAY);
2584
+ if (!STORY) {
2585
+ classify_all(prd());
2586
+ } else {
2587
+ const st = do_classify(prd(), STORY, E.PC_WT);
2588
+ for (const ph of or(get(or(st, {}), "phases"), [])) print(S(ph.phase) + "\t" + S(ph.worker));
2589
+ }
2590
+ } else if (CMD === "overlay") {
2591
+ do_overlay();
2592
+ } else if (CMD === "skip") {
2593
+ do_skip();
2594
+ } else if (CMD === "unskip") {
2595
+ do_unskip();
2596
+ } else if (CMD === "route") {
2597
+ do_route(prd(), need_story());
2598
+ } else if (CMD === "accept") {
2599
+ do_accept(need_story());
2600
+ } else if (CMD === "recheck") {
2601
+ do_recheck(prd(), need_story());
2602
+ } else if (CMD === "gate") {
2603
+ if (SUB === "tick") {
2604
+ if (gate_tick()) print("RUN_GATE");
2605
+ } else if (SUB === "status") {
2606
+ const g = gate(); print(S(g.state) + " completed=" + pyInt(g.completed) + " every=" + E.PC_GATE_EVERY);
2607
+ } else if (SUB === "result") {
2608
+ const res = E.PC_RESULT;
2609
+ if (!["pass", "fail"].includes(res)) die("gate result needs pass|fail", 2);
2610
+ if (res === "fail" && STORY) {
2611
+ do_reopen(true);
2612
+ const g = gate(); g.state = "reopened"; g.note = E.PC_NOTE;
2613
+ const ro = or(get(g, "reopened"), []);
2614
+ g.reopened = ro.concat(!ro.includes(STORY) ? [STORY] : []);
2615
+ jsave(sp("gate.json"), g);
2616
+ print("GATE reopened " + STORY);
2617
+ } else {
2618
+ const g = gate(); g.state = res === "pass" ? "ok" : "failed"; g.note = E.PC_NOTE;
2619
+ if (res === "pass") pop(g, "reopened");
2620
+ jsave(sp("gate.json"), g);
2621
+ print("GATE " + g.state);
2622
+ }
2623
+ } else die("gate tick|status|result", 2);
2624
+ } else if (CMD === "release") {
2625
+ do_release();
2626
+ } else if (CMD === "report") {
2627
+ if (SUB === "story") {
2628
+ const st = sload(need_story());
2629
+ if (!story_report(STORY, st) && !["verified", "failed_report", "accepted_partial"].includes(st.state))
2630
+ die("story " + STORY + " is not finished (" + S(st.state) + ")");
2631
+ } else if (SUB === "final") {
2632
+ const rp = sp("report.md");
2633
+ let lines = exists(rp) ? splitlines(readText(rp)) : [];
2634
+ lines = lines.filter(l => !l.startsWith("FINAL:"));
2635
+ lines.push(final_line());
2636
+ writeText(rp, lines.join("\n") + "\n");
2637
+ print(lines[lines.length - 1]);
2638
+ } else die("report story|final", 2);
2639
+ } else if (CMD === "tick") {
2640
+ do_tick(prd());
2641
+ } else if (CMD === "resolve") {
2642
+ do_resolve();
2643
+ } else if (CMD === "failcap") {
2644
+ do_failcap();
2645
+ } else if (CMD === "park") {
2646
+ do_park();
2647
+ } else if (CMD === "unpark") {
2648
+ do_unpark();
2649
+ } else if (CMD === "reopen") {
2650
+ do_reopen();
2651
+ } else if (CMD === "go") {
2652
+ do_go();
2653
+ } else if (CMD === "worktree") {
2654
+ do_worktree();
2655
+ } else if (CMD === "interrupt") {
2656
+ do_interrupt(note() || "interrupt");
2657
+ } else if (CMD === "stop") {
2658
+ do_stop();
2659
+ } else {
2660
+ die("unknown subcommand: " + CMD, 2);
2661
+ }
2662
+ }
2663
+
2664
+ try {
2665
+ main();
2666
+ } catch (e) {
2667
+ eprint("Traceback (most recent call last):\n" + (e && e.stack ? e.stack : String(e)));
2668
+ exit(1);
2669
+ }
2670
+ exit(0);
2671
+ JS