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