opencode-ship 1.1.2-rc.2 → 1.1.2

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 (76) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +2 -2
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/assets/agents/delivery-reviewer.md +1 -0
  5. package/assets/agents/delivery-verifier.md +1 -0
  6. package/assets/agents/ship-controller.md +20 -7
  7. package/assets/agents/ship-final-spec-reviewer.md +5 -3
  8. package/assets/agents/ship-final-standards-reviewer.md +5 -3
  9. package/assets/agents/ship-planner.md +1 -0
  10. package/assets/agents/ship-task-builder.md +1 -0
  11. package/assets/agents/ship-task-reviewer.md +1 -0
  12. package/dist/cli.js +2274 -362
  13. package/dist/core.js +794 -10
  14. package/dist/plugin.js +3487 -1795
  15. package/package.json +9 -4
  16. package/tests/plugin/expected-tools.mjs +2 -2
  17. package/tests/plugin/plugin-load.test.mjs +8 -3
  18. package/assets/_archive/issue-tracker-gitlab.md +0 -46
  19. package/assets/_archive/issue-tracker-local.md +0 -30
  20. package/docs/adr/0001-opencode-ship-redesign.md +0 -168
  21. package/docs/release/1.0.0-execution-plan.md +0 -1063
  22. package/docs/release/1.1.1-stabilization-plan.md +0 -655
  23. package/vendor/upstreams/mattpocock/skills/engineering/code-review/SKILL.md +0 -89
  24. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DEEPENING.md +0 -37
  25. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DESIGN-IT-TWICE.md +0 -44
  26. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/SKILL.md +0 -114
  27. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/ADR-FORMAT.md +0 -47
  28. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/CONTEXT-FORMAT.md +0 -60
  29. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/SKILL.md +0 -74
  30. package/vendor/upstreams/mattpocock/skills/engineering/grill-with-docs/SKILL.md +0 -7
  31. package/vendor/upstreams/mattpocock/skills/engineering/prototype/LOGIC.md +0 -79
  32. package/vendor/upstreams/mattpocock/skills/engineering/prototype/SKILL.md +0 -26
  33. package/vendor/upstreams/mattpocock/skills/engineering/prototype/UI.md +0 -112
  34. package/vendor/upstreams/mattpocock/skills/engineering/research/SKILL.md +0 -12
  35. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/SKILL.md +0 -116
  36. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/domain.md +0 -51
  37. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-github.md +0 -45
  38. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-gitlab.md +0 -46
  39. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-local.md +0 -30
  40. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/triage-labels.md +0 -15
  41. package/vendor/upstreams/mattpocock/skills/engineering/to-spec/SKILL.md +0 -75
  42. package/vendor/upstreams/mattpocock/skills/engineering/to-tickets/SKILL.md +0 -105
  43. package/vendor/upstreams/mattpocock/skills/engineering/triage/AGENT-BRIEF.md +0 -207
  44. package/vendor/upstreams/mattpocock/skills/engineering/triage/OUT-OF-SCOPE.md +0 -105
  45. package/vendor/upstreams/mattpocock/skills/engineering/triage/SKILL.md +0 -112
  46. package/vendor/upstreams/mattpocock/skills/engineering/wayfinder/SKILL.md +0 -128
  47. package/vendor/upstreams/mattpocock/skills/productivity/grilling/SKILL.md +0 -12
  48. package/vendor/upstreams/mattpocock/skills/productivity/handoff/SKILL.md +0 -16
  49. package/vendor/upstreams/obra/skills/brainstorming/SKILL.md +0 -151
  50. package/vendor/upstreams/obra/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  51. package/vendor/upstreams/obra/skills/brainstorming/visual-companion.md +0 -298
  52. package/vendor/upstreams/obra/skills/dispatching-parallel-agents/SKILL.md +0 -167
  53. package/vendor/upstreams/obra/skills/executing-plans/SKILL.md +0 -64
  54. package/vendor/upstreams/obra/skills/receiving-code-review/SKILL.md +0 -205
  55. package/vendor/upstreams/obra/skills/requesting-code-review/SKILL.md +0 -95
  56. package/vendor/upstreams/obra/skills/requesting-code-review/code-reviewer.md +0 -172
  57. package/vendor/upstreams/obra/skills/subagent-driven-development/SKILL.md +0 -503
  58. package/vendor/upstreams/obra/skills/subagent-driven-development/implementer-prompt.md +0 -142
  59. package/vendor/upstreams/obra/skills/subagent-driven-development/re-review-prompt.md +0 -106
  60. package/vendor/upstreams/obra/skills/subagent-driven-development/task-reviewer-prompt.md +0 -185
  61. package/vendor/upstreams/obra/skills/systematic-debugging/CREATION-LOG.md +0 -119
  62. package/vendor/upstreams/obra/skills/systematic-debugging/SKILL.md +0 -283
  63. package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  64. package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting.md +0 -115
  65. package/vendor/upstreams/obra/skills/systematic-debugging/defense-in-depth.md +0 -122
  66. package/vendor/upstreams/obra/skills/systematic-debugging/find-polluter.sh +0 -72
  67. package/vendor/upstreams/obra/skills/systematic-debugging/root-cause-tracing.md +0 -169
  68. package/vendor/upstreams/obra/skills/systematic-debugging/test-academic.md +0 -14
  69. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-1.md +0 -58
  70. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-2.md +0 -68
  71. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-3.md +0 -69
  72. package/vendor/upstreams/obra/skills/test-driven-development/SKILL.md +0 -320
  73. package/vendor/upstreams/obra/skills/test-driven-development/writing-good-tests.md +0 -198
  74. package/vendor/upstreams/obra/skills/verification-before-completion/SKILL.md +0 -120
  75. package/vendor/upstreams/obra/skills/writing-plans/SKILL.md +0 -168
  76. package/vendor/upstreams/obra/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
package/dist/core.js CHANGED
@@ -1,4 +1,4 @@
1
- // opencode-ship/core v1.1.2-rc.2
1
+ // opencode-ship/core v1.1.2
2
2
 
3
3
  // src/adapter.js
4
4
  import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
@@ -195,8 +195,8 @@ async function loadAdapter(repoRoot) {
195
195
  }
196
196
  const v = validateAdapter(parsed);
197
197
  if (!v.ok) return { ok: false, error: { kind: "contract", path, issues: v.issues } };
198
- const sha256 = createHash("sha256").update(raw).digest("hex");
199
- return { ok: true, adapter: v.adapter, path, sha256 };
198
+ const sha2563 = createHash("sha256").update(raw).digest("hex");
199
+ return { ok: true, adapter: v.adapter, path, sha256: sha2563 };
200
200
  }
201
201
  async function writeLock(repoRoot, adapterSha256) {
202
202
  const lockPath = resolve(repoRoot, ".opencode", LOCK_FILENAME);
@@ -292,7 +292,7 @@ function transition(m, to, opts) {
292
292
  function createManifest(input) {
293
293
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
294
294
  return {
295
- schemaVersion: 1,
295
+ schemaVersion: 2,
296
296
  taskId: input.taskId,
297
297
  repoIdentity: input.repoIdentity,
298
298
  issueNumber: input.issueNumber,
@@ -304,6 +304,7 @@ function createManifest(input) {
304
304
  lastPrHeadSha: input.lastPrHeadSha ?? null,
305
305
  lastReviewerSha: input.lastReviewerSha ?? null,
306
306
  lastVerifierSha: input.lastVerifierSha ?? null,
307
+ workflowId: input.workflowId ?? null,
307
308
  owner: input.owner,
308
309
  state: "issue-linked",
309
310
  transitionLog: [],
@@ -331,6 +332,7 @@ import { join as join4, resolve as resolve4 } from "node:path";
331
332
  // src/state/git-common-dir.js
332
333
  import { spawn } from "node:child_process";
333
334
  import { resolve as resolve2, join as join2 } from "node:path";
335
+ var STATE_DIRNAME = "opencode-ship";
334
336
  async function resolveGitCommonDir(repoRoot) {
335
337
  if (typeof repoRoot !== "string" || repoRoot.length === 0) {
336
338
  throw new Error("resolveGitCommonDir: repoRoot must be a non-empty string");
@@ -366,6 +368,12 @@ async function resolveGitCommonDir(repoRoot) {
366
368
  });
367
369
  });
368
370
  }
371
+ function opencodeShipStateDir(commonDir, ...segments) {
372
+ if (typeof commonDir !== "string" || commonDir.length === 0) {
373
+ throw new Error("opencodeShipStateDir: commonDir must be a non-empty string");
374
+ }
375
+ return join2(commonDir, STATE_DIRNAME, ...segments);
376
+ }
369
377
 
370
378
  // src/state/durable-store.js
371
379
  import {
@@ -379,8 +387,10 @@ import {
379
387
  unlink,
380
388
  stat
381
389
  } from "node:fs/promises";
390
+ import { existsSync as existsSync2 } from "node:fs";
382
391
  import { dirname as dirname2, join as join3, resolve as resolve3 } from "node:path";
383
392
  import { createHash as createHash2, randomBytes } from "node:crypto";
393
+ import { hostname as osHostname } from "node:os";
384
394
  var STALE_LOCK_MS = 120 * 1e3;
385
395
  function randomToken() {
386
396
  return randomBytes(8).toString("hex");
@@ -417,6 +427,159 @@ async function atomicReplaceJson(path, value) {
417
427
  await rename2(tmp, target);
418
428
  await fsyncDir(parent);
419
429
  }
430
+ async function publishImmutableJson(path, value) {
431
+ if (typeof path !== "string" || path.length === 0) {
432
+ throw new Error("publishImmutableJson: path must be a non-empty string");
433
+ }
434
+ const target = resolve3(path);
435
+ const parent = dirname2(target);
436
+ await mkdir2(parent, { recursive: true });
437
+ if (existsSync2(target)) {
438
+ throw new Error(`publishImmutableJson: target already exists: ${target}`);
439
+ }
440
+ const tmp = `${target}.${randomToken()}.immutable.tmp`;
441
+ const handle = await fsOpen(tmp, "w", 384);
442
+ try {
443
+ await handle.writeFile(ensureString(value), "utf8");
444
+ await handle.sync();
445
+ } finally {
446
+ await handle.close();
447
+ }
448
+ const attempt = await tryHardLink(tmp, target);
449
+ if (attempt === "exists") {
450
+ await unlink(tmp).catch(() => null);
451
+ throw new Error(`publishImmutableJson: target already exists: ${target}`);
452
+ }
453
+ if (attempt === "linked") {
454
+ await unlink(tmp).catch(() => null);
455
+ } else {
456
+ try {
457
+ await rename2(tmp, target);
458
+ } catch (err) {
459
+ await unlink(tmp).catch(() => null);
460
+ if (err && err.code === "EEXIST") {
461
+ throw new Error(`publishImmutableJson: target already exists: ${target}`);
462
+ }
463
+ throw err;
464
+ }
465
+ }
466
+ await fsyncDir(parent);
467
+ }
468
+ async function tryHardLink(src, dst) {
469
+ try {
470
+ await link(src, dst);
471
+ return "linked";
472
+ } catch (err) {
473
+ if (err && err.code === "EEXIST") return "exists";
474
+ if (err && (err.code === "ENOTSUP" || err.code === "EPERM" || err.code === "ENOSYS" || err.code === "EOPNOTSUPP" || err.code === "EXDEV")) {
475
+ return "fallback";
476
+ }
477
+ throw err;
478
+ }
479
+ }
480
+ async function withResourceLock(stateDir, resourceKey, input) {
481
+ if (typeof stateDir !== "string" || stateDir.length === 0) {
482
+ throw new Error("withResourceLock: stateDir must be a non-empty string");
483
+ }
484
+ if (typeof resourceKey !== "string" || resourceKey.length === 0) {
485
+ throw new Error("withResourceLock: resourceKey must be a non-empty string");
486
+ }
487
+ const callback = typeof input === "function" ? input : input?.callback;
488
+ const options = typeof input === "function" ? {} : input?.options ?? {};
489
+ if (typeof callback !== "function") {
490
+ throw new Error("withResourceLock: callback must be a function");
491
+ }
492
+ const acquireMode = options.acquire ?? "wait";
493
+ const waitMs = Number.isInteger(options.waitMs) && options.waitMs >= 0 ? options.waitMs : 30 * 1e3;
494
+ const pollMs = Number.isInteger(options.pollMs) && options.pollMs > 0 ? options.pollMs : 25;
495
+ const keyHash = createHash2("sha256").update(resourceKey).digest("hex");
496
+ const lockDir = join3(stateDir, "locks", keyHash);
497
+ const ownerPath = join3(lockDir, "owner.json");
498
+ await mkdir2(stateDir, { recursive: true });
499
+ const deadline = Date.now() + waitMs;
500
+ let acquired = false;
501
+ let quarantinedThisAcquire = false;
502
+ while (!acquired) {
503
+ try {
504
+ await mkdir2(lockDir, { recursive: true });
505
+ const handle = await fsOpen(ownerPath, "wx", 384);
506
+ try {
507
+ const owner = {
508
+ pid: process.pid,
509
+ hostname: osHostname(),
510
+ resource: resourceKey,
511
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
512
+ };
513
+ await handle.writeFile(JSON.stringify(owner, null, 2) + "\n", "utf8");
514
+ await handle.sync();
515
+ } finally {
516
+ await handle.close();
517
+ }
518
+ acquired = true;
519
+ } catch (err) {
520
+ if (err && err.code === "EEXIST") {
521
+ const claimed = await maybeQuarantineStaleLock(lockDir, ownerPath, resourceKey);
522
+ if (claimed) {
523
+ quarantinedThisAcquire = true;
524
+ continue;
525
+ }
526
+ if (acquireMode === "try") {
527
+ throw new Error(`withResourceLock: resource is busy: ${resourceKey}`);
528
+ }
529
+ if (Date.now() >= deadline) {
530
+ throw new Error(`withResourceLock: timed out waiting for ${resourceKey}`);
531
+ }
532
+ await new Promise((r) => setTimeout(r, pollMs));
533
+ continue;
534
+ }
535
+ throw err;
536
+ }
537
+ }
538
+ try {
539
+ return await callback();
540
+ } finally {
541
+ await unlink(ownerPath).catch(() => null);
542
+ if (!quarantinedThisAcquire) {
543
+ await unlink(lockDir).catch(() => null);
544
+ }
545
+ }
546
+ }
547
+ async function maybeQuarantineStaleLock(lockDir, ownerPath, resourceKey) {
548
+ let raw;
549
+ try {
550
+ raw = await readFile2(ownerPath, "utf8");
551
+ } catch {
552
+ return false;
553
+ }
554
+ let owner;
555
+ try {
556
+ owner = JSON.parse(raw);
557
+ } catch {
558
+ return false;
559
+ }
560
+ if (!owner || typeof owner !== "object") return false;
561
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
562
+ if (typeof owner.startedAt !== "string") return false;
563
+ const start = Date.parse(owner.startedAt);
564
+ if (!Number.isFinite(start)) return false;
565
+ if (Date.now() - start < STALE_LOCK_MS) return false;
566
+ if (owner.hostname !== osHostname()) return false;
567
+ let alive = true;
568
+ try {
569
+ process.kill(owner.pid, 0);
570
+ } catch (e) {
571
+ alive = e?.code === "EPERM";
572
+ }
573
+ if (alive) return false;
574
+ const stamp = new Date(start).toISOString().replace(/[:.]/g, "-");
575
+ const quarantinePath = join3(lockDir, `stale-${stamp}-${resourceKey.replace(/[^A-Za-z0-9._-]+/g, "_")}.owner.json`);
576
+ try {
577
+ await rename2(ownerPath, quarantinePath);
578
+ } catch {
579
+ return false;
580
+ }
581
+ return "quarantined";
582
+ }
420
583
 
421
584
  // src/state/manifest-store.js
422
585
  var SHIP_DIRNAME = "opencode-ship";
@@ -488,7 +651,7 @@ async function deleteManifest(repoRoot, taskId) {
488
651
  // src/drivers/git.js
489
652
  import { spawnSync } from "node:child_process";
490
653
  import { resolve as resolve5 } from "node:path";
491
- import { existsSync as existsSync2 } from "node:fs";
654
+ import { existsSync as existsSync3 } from "node:fs";
492
655
  var WorktreeRecord = {
493
656
  path: "",
494
657
  branch: "",
@@ -548,7 +711,7 @@ function isRebaseInProgress(cwd) {
548
711
  }
549
712
  function safeExists(p) {
550
713
  try {
551
- return existsSync2(p);
714
+ return existsSync3(p);
552
715
  } catch {
553
716
  return false;
554
717
  }
@@ -1012,6 +1175,7 @@ function recoverManifestAfterCrash(manifest) {
1012
1175
  }
1013
1176
 
1014
1177
  // src/doctor.js
1178
+ import { access } from "node:fs/promises";
1015
1179
  import { spawnSync as spawnSync2 } from "node:child_process";
1016
1180
  import { resolve as resolve6 } from "node:path";
1017
1181
  function runVersion(argv) {
@@ -1454,6 +1618,77 @@ function createWorktreeTool(deps) {
1454
1618
 
1455
1619
  // src/tools/delivery-verify.js
1456
1620
  import { spawn as spawn4 } from "node:child_process";
1621
+
1622
+ // src/workflow/gate-receipts.js
1623
+ import { createHash as createHash3 } from "node:crypto";
1624
+ import { mkdir as mkdir3, readFile as readFile4 } from "node:fs/promises";
1625
+ import { existsSync as existsSync4 } from "node:fs";
1626
+ import { join as join5 } from "node:path";
1627
+
1628
+ // src/installer/json-pointer.js
1629
+ function isObject(v) {
1630
+ return v !== null && typeof v === "object" && !Array.isArray(v);
1631
+ }
1632
+ function stableStringify(value) {
1633
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1634
+ if (isObject(value)) {
1635
+ const keys = Object.keys(value).sort();
1636
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
1637
+ }
1638
+ return JSON.stringify(value);
1639
+ }
1640
+ function canonicalJson(value) {
1641
+ return stableStringify(value);
1642
+ }
1643
+
1644
+ // src/workflow/gate-receipts.js
1645
+ var SAFE_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
1646
+ var HASH_RE = /^[0-9a-f]{64}$/;
1647
+ var HEAD_RE = /^[0-9a-f]{40}$/;
1648
+ function validateIdentity(taskId, kind, headSha, receiptHash = null) {
1649
+ if (!SAFE_ID_RE.test(taskId) || !SAFE_ID_RE.test(kind) || !HEAD_RE.test(headSha)) {
1650
+ throw new Error("invalid gate receipt identity");
1651
+ }
1652
+ if (receiptHash !== null && !HASH_RE.test(receiptHash)) throw new Error("invalid gate receipt hash");
1653
+ }
1654
+ function hashGateReceipt(receipt) {
1655
+ const { receiptHash: _receiptHash, ...payload } = receipt;
1656
+ return createHash3("sha256").update(canonicalJson(payload), "utf8").digest("hex");
1657
+ }
1658
+ async function publishGateReceipt(repoRoot, taskId, kind, input) {
1659
+ validateIdentity(taskId, kind, input?.headSha);
1660
+ const receipt = { ...input, kind, taskId };
1661
+ receipt.receiptHash = hashGateReceipt(receipt);
1662
+ const commonDir = await resolveGitCommonDir(repoRoot);
1663
+ const dir = join5(opencodeShipStateDir(commonDir), "gate-receipts", taskId, kind, receipt.headSha);
1664
+ await mkdir3(dir, { recursive: true });
1665
+ const path = join5(dir, `${receipt.receiptHash}.json`);
1666
+ if (!existsSync4(path)) await publishImmutableJson(path, receipt);
1667
+ return { receipt, path };
1668
+ }
1669
+ async function readGateReceipt(repoRoot, taskId, kind, receiptHash) {
1670
+ if (!SAFE_ID_RE.test(taskId) || !SAFE_ID_RE.test(kind) || !HASH_RE.test(receiptHash)) {
1671
+ throw new Error("invalid gate receipt lookup");
1672
+ }
1673
+ const commonDir = await resolveGitCommonDir(repoRoot);
1674
+ const root = join5(opencodeShipStateDir(commonDir), "gate-receipts", taskId, kind);
1675
+ if (!existsSync4(root)) return null;
1676
+ const { readdir: readdir4 } = await import("node:fs/promises");
1677
+ for (const head of await readdir4(root)) {
1678
+ validateIdentity(taskId, kind, head, receiptHash);
1679
+ const path = join5(root, head, `${receiptHash}.json`);
1680
+ if (!existsSync4(path)) continue;
1681
+ const receipt = JSON.parse(await readFile4(path, "utf8"));
1682
+ if (receipt.kind !== kind || receipt.taskId !== taskId || hashGateReceipt(receipt) !== receipt.receiptHash) {
1683
+ throw new Error(`invalid ${kind} gate receipt`);
1684
+ }
1685
+ return receipt;
1686
+ }
1687
+ return null;
1688
+ }
1689
+
1690
+ // src/tools/delivery-verify.js
1691
+ import { createHash as createHash4 } from "node:crypto";
1457
1692
  function runCommand(argv, cwd, timeoutMs) {
1458
1693
  return new Promise((resolveP, rejectP) => {
1459
1694
  const proc = spawn4(argv[0], argv.slice(1), {
@@ -1520,8 +1755,16 @@ function createVerifyTool(deps) {
1520
1755
  headSha: head
1521
1756
  };
1522
1757
  }
1758
+ const { receipt } = await publishGateReceipt(deps.repoRoot, m.taskId, "verification", {
1759
+ headSha: head,
1760
+ commandId: cmd.id,
1761
+ argv: cmd.argv,
1762
+ exitCode: 0,
1763
+ stdoutSha256: createHash4("sha256").update(result.stdout, "utf8").digest("hex"),
1764
+ stderrSha256: createHash4("sha256").update(result.stderr, "utf8").digest("hex")
1765
+ });
1523
1766
  const t = transition(
1524
- { ...m, lastVerifierSha: head },
1767
+ { ...m, lastVerifierSha: head, lastVerificationHash: receipt.receiptHash },
1525
1768
  "validating",
1526
1769
  { reason: `verify ok (${cmd.id})` }
1527
1770
  );
@@ -1529,6 +1772,7 @@ function createVerifyTool(deps) {
1529
1772
  const next = {
1530
1773
  ...m,
1531
1774
  lastVerifierSha: head,
1775
+ lastVerificationHash: receipt.receiptHash,
1532
1776
  state: t.to,
1533
1777
  transitionLog: [
1534
1778
  ...m.transitionLog,
@@ -1544,6 +1788,7 @@ function createVerifyTool(deps) {
1544
1788
  stdoutTail,
1545
1789
  stderrTail,
1546
1790
  headSha: head,
1791
+ verificationHash: receipt.receiptHash,
1547
1792
  manifestPath
1548
1793
  };
1549
1794
  };
@@ -1699,6 +1944,476 @@ function createPrTool(deps) {
1699
1944
  };
1700
1945
  }
1701
1946
 
1947
+ // src/workflow/run-controller.js
1948
+ import { createHash as createHash5 } from "node:crypto";
1949
+ import { readFile as readFile5, writeFile as writeFile3, mkdir as mkdir4, readdir as readdir3 } from "node:fs/promises";
1950
+ import { existsSync as existsSync5 } from "node:fs";
1951
+ import { join as join6 } from "node:path";
1952
+ async function readSnapshotFromDisk(repoRoot, workflowId) {
1953
+ const common = await resolveGitCommonDir(repoRoot);
1954
+ const runPath = join6(opencodeShipStateDir(common), "runs", workflowId, "run.json");
1955
+ if (!existsSync5(runPath)) return null;
1956
+ try {
1957
+ const raw = await readFile5(runPath, "utf8");
1958
+ return JSON.parse(raw);
1959
+ } catch {
1960
+ return null;
1961
+ }
1962
+ }
1963
+ async function readEventsFromDisk(repoRoot, workflowId) {
1964
+ const common = await resolveGitCommonDir(repoRoot);
1965
+ const dir = join6(opencodeShipStateDir(common), "runs", workflowId, "events");
1966
+ if (!existsSync5(dir)) return [];
1967
+ const entries = await readdir3(dir);
1968
+ const sorted = entries.filter((n) => n.endsWith(".json")).sort();
1969
+ const out = [];
1970
+ for (const name of sorted) {
1971
+ const raw = await readFile5(join6(dir, name), "utf8");
1972
+ out.push(JSON.parse(raw));
1973
+ }
1974
+ return out;
1975
+ }
1976
+ var STATES2 = Object.freeze({
1977
+ CREATED: "created",
1978
+ RUNNING: "running",
1979
+ COMMIT_PENDING: "commit-pending",
1980
+ COMMITTED: "committed",
1981
+ FIX_PENDING: "fix-pending",
1982
+ REVISION_REQUIRED: "revision-required",
1983
+ BLOCKED: "blocked",
1984
+ ALL_TASKS_DONE: "all-tasks-done",
1985
+ READY_PENDING: "ready-pending",
1986
+ READY: "ready",
1987
+ MERGED: "merged",
1988
+ DONE: "done"
1989
+ });
1990
+ var EVENT_KINDS = Object.freeze({
1991
+ RUN_START: "run-start",
1992
+ TASK_DISPATCH: "task-dispatch",
1993
+ TASK_REPORT: "task-report",
1994
+ TASK_REVIEW: "task-review",
1995
+ COMMIT: "commit",
1996
+ TASK_COMPLETE: "task-complete",
1997
+ ALL_TASKS_DONE: "all-tasks-done",
1998
+ FINAL_REVIEW: "final-review",
1999
+ READY_PENDING: "ready-pending",
2000
+ READY: "ready",
2001
+ MERGE: "merge",
2002
+ DONE: "done",
2003
+ BLOCKED: "blocked"
2004
+ });
2005
+ var MAX_FIX_ROUNDS = 3;
2006
+ function sha256(value) {
2007
+ return createHash5("sha256").update(value, "utf8").digest("hex");
2008
+ }
2009
+ function canonicalize(value) {
2010
+ const seen = /* @__PURE__ */ new WeakSet();
2011
+ const sort = (v) => {
2012
+ if (v === null || typeof v !== "object") return v;
2013
+ if (seen.has(v)) return null;
2014
+ seen.add(v);
2015
+ if (Array.isArray(v)) return v.map(sort);
2016
+ const out = {};
2017
+ for (const k of Object.keys(v).sort()) out[k] = sort(v[k]);
2018
+ return out;
2019
+ };
2020
+ return JSON.stringify(sort(value));
2021
+ }
2022
+ function nextRound(failures) {
2023
+ return (failures ?? 0) + 1;
2024
+ }
2025
+ function nextSequence(events) {
2026
+ return events.length + 1;
2027
+ }
2028
+ function appendEvent(state, recorded) {
2029
+ const prev = state.events[state.events.length - 1];
2030
+ const priorHash = prev?.hash ?? "0".repeat(64);
2031
+ const hash = sha256(canonicalize({ kind: recorded.kind, data: recorded.data, at: recorded.at, sequence: recorded.sequence, priorHash }));
2032
+ const withHash = { ...recorded, priorHash, hash };
2033
+ return [...state.events, withHash];
2034
+ }
2035
+ function normalizeState(state) {
2036
+ return {
2037
+ ...state,
2038
+ completedTasks: Array.isArray(state.completedTasks) ? state.completedTasks : [],
2039
+ taskReady: state.taskReady ?? null,
2040
+ events: Array.isArray(state.events) ? state.events : [],
2041
+ round: Number.isInteger(state.round) ? state.round : 0,
2042
+ failures: Number.isInteger(state.failures) ? state.failures : 0
2043
+ };
2044
+ }
2045
+ function ensureActiveTask(state, taskId) {
2046
+ if (state.activeTask === null) return;
2047
+ if (state.activeTask !== taskId) {
2048
+ throw new Error(`run reducer: another task is active (${state.activeTask}), refusing ${taskId}`);
2049
+ }
2050
+ }
2051
+ function reduce(state, event) {
2052
+ const ev = { ...event, at: event.at ?? (/* @__PURE__ */ new Date()).toISOString() };
2053
+ const sequence = nextSequence(state.events);
2054
+ const recorded = (kind, data) => ({ sequence, kind, at: ev.at, data });
2055
+ const nextState = (extra) => ({ ...state, ...extra, events: appendEvent(state, recorded(event.kind, event.data)) });
2056
+ switch (event.kind) {
2057
+ case EVENT_KINDS.RUN_START: {
2058
+ if (state.state !== STATES2.CREATED) {
2059
+ throw new Error(`run reducer: RUN_START requires state=created, got ${state.state}`);
2060
+ }
2061
+ return {
2062
+ state: nextState({ state: STATES2.RUNNING, activeTask: null, round: 0, completedTasks: [] }),
2063
+ event: recorded(EVENT_KINDS.RUN_START, { revision: event.data.revision, sha256: event.data.sha256 })
2064
+ };
2065
+ }
2066
+ case EVENT_KINDS.TASK_DISPATCH: {
2067
+ if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING) {
2068
+ throw new Error(`run reducer: TASK_DISPATCH requires state=running|fix-pending, got ${state.state}`);
2069
+ }
2070
+ if (state.activeTask !== null) {
2071
+ throw new Error(`run reducer: TASK_DISPATCH while another task is active (${state.activeTask})`);
2072
+ }
2073
+ const round = state.round > 0 ? state.round : 1;
2074
+ return {
2075
+ state: nextState({ state: STATES2.RUNNING, activeTask: event.data.taskId, round }),
2076
+ event: recorded(EVENT_KINDS.TASK_DISPATCH, { taskId: event.data.taskId, briefHash: event.data.briefHash, round })
2077
+ };
2078
+ }
2079
+ case EVENT_KINDS.TASK_REPORT: {
2080
+ if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING || state.activeTask === null) {
2081
+ throw new Error(`run reducer: TASK_REPORT requires running with active task`);
2082
+ }
2083
+ ensureActiveTask(state, event.data.taskId);
2084
+ return {
2085
+ state: nextState({ state: STATES2.RUNNING, taskReady: { taskId: event.data.taskId, reportHash: event.data.reportHash } }),
2086
+ event: recorded(EVENT_KINDS.TASK_REPORT, { taskId: event.data.taskId, reportHash: event.data.reportHash })
2087
+ };
2088
+ }
2089
+ case EVENT_KINDS.TASK_REVIEW: {
2090
+ if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING || state.activeTask === null) {
2091
+ throw new Error(`run reducer: TASK_REVIEW requires running with active task`);
2092
+ }
2093
+ ensureActiveTask(state, event.data.taskId);
2094
+ const failures = event.data.verdict === "pass" ? 0 : nextRound(state.failures);
2095
+ if (event.data.verdict === "pass") {
2096
+ return {
2097
+ state: nextState({ state: STATES2.COMMIT_PENDING, round: state.round, taskReady: { ...state.taskReady ?? {}, taskId: event.data.taskId, reviewHash: event.data.reviewHash } }),
2098
+ event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "pass", reviewHash: event.data.reviewHash })
2099
+ };
2100
+ }
2101
+ const next = nextRound(state.round);
2102
+ if (failures >= MAX_FIX_ROUNDS) {
2103
+ return {
2104
+ state: nextState({ state: STATES2.REVISION_REQUIRED, failures, round: next, activeTask: null }),
2105
+ event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "fail", failures })
2106
+ };
2107
+ }
2108
+ return {
2109
+ state: nextState({ state: STATES2.FIX_PENDING, failures, round: next, activeTask: null, taskReady: null }),
2110
+ event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "fail", round: next, failures })
2111
+ };
2112
+ }
2113
+ case EVENT_KINDS.COMMIT: {
2114
+ if (state.state !== STATES2.COMMIT_PENDING) {
2115
+ throw new Error(`run reducer: COMMIT requires state=commit-pending, got ${state.state}`);
2116
+ }
2117
+ const taskId = state.taskReady?.taskId;
2118
+ const completedTasks = taskId ? state.completedTasks.includes(taskId) ? state.completedTasks : [...state.completedTasks, taskId] : state.completedTasks;
2119
+ return {
2120
+ state: nextState({ state: STATES2.COMMITTED, activeTask: null, round: 0, failures: 0, completedTasks }),
2121
+ event: recorded(EVENT_KINDS.COMMIT, { taskId, commitSha: event.data.commitSha })
2122
+ };
2123
+ }
2124
+ case EVENT_KINDS.TASK_COMPLETE: {
2125
+ if (state.state !== STATES2.COMMITTED) {
2126
+ throw new Error(`run reducer: TASK_COMPLETE requires state=committed, got ${state.state}`);
2127
+ }
2128
+ const moreTasks = event.data.moreTasks === false ? false : true;
2129
+ const nextStateObj = moreTasks ? { state: STATES2.RUNNING, activeTask: null, taskReady: null, round: 0, failures: 0 } : { state: STATES2.ALL_TASKS_DONE, activeTask: null, taskReady: null };
2130
+ return {
2131
+ state: nextState(nextStateObj),
2132
+ event: recorded(EVENT_KINDS.TASK_COMPLETE, { taskId: event.data.taskId, moreTasks, nextTaskId: event.data.nextTaskId ?? null })
2133
+ };
2134
+ }
2135
+ case EVENT_KINDS.FINAL_REVIEW: {
2136
+ if (state.state !== STATES2.ALL_TASKS_DONE && state.state !== STATES2.READY_PENDING) {
2137
+ throw new Error(`run reducer: FINAL_REVIEW requires state=all-tasks-done|ready-pending, got ${state.state}`);
2138
+ }
2139
+ const incomingHash = event.data.packageHash;
2140
+ const incomingHead = event.data.headSha;
2141
+ const incomingMergeBase = event.data.mergeBaseSha;
2142
+ const finalReview = { ...state.finalReview ?? {} };
2143
+ if (state.state === STATES2.ALL_TASKS_DONE) {
2144
+ finalReview.packageHash = incomingHash;
2145
+ finalReview.headSha = incomingHead;
2146
+ finalReview.mergeBaseSha = incomingMergeBase;
2147
+ } else {
2148
+ if (finalReview.packageHash !== incomingHash) {
2149
+ throw new Error(`run reducer: FINAL_REVIEW axis ${event.data.axis} disagrees with package hash`);
2150
+ }
2151
+ if (finalReview.headSha !== incomingHead) {
2152
+ throw new Error(`run reducer: FINAL_REVIEW axis ${event.data.axis} disagrees with HEAD`);
2153
+ }
2154
+ if (finalReview.mergeBaseSha !== incomingMergeBase) {
2155
+ throw new Error(`run reducer: FINAL_REVIEW axis ${event.data.axis} disagrees with merge-base`);
2156
+ }
2157
+ }
2158
+ finalReview[event.data.axis] = event.data.review;
2159
+ return {
2160
+ state: nextState({
2161
+ state: STATES2.READY_PENDING,
2162
+ finalReview
2163
+ }),
2164
+ event: recorded(EVENT_KINDS.FINAL_REVIEW, {
2165
+ axis: event.data.axis,
2166
+ verdict: event.data.verdict,
2167
+ headSha: incomingHead,
2168
+ mergeBaseSha: incomingMergeBase,
2169
+ packageHash: incomingHash,
2170
+ sessionID: event.data.sessionID,
2171
+ review: event.data.review
2172
+ })
2173
+ };
2174
+ }
2175
+ case EVENT_KINDS.READY: {
2176
+ if (state.state !== STATES2.COMMITTED && state.state !== STATES2.READY_PENDING) {
2177
+ throw new Error(`run reducer: READY requires state=committed|ready-pending, got ${state.state}`);
2178
+ }
2179
+ if (state.state === STATES2.READY_PENDING && (!state.finalReview?.standards || !state.finalReview?.spec)) {
2180
+ throw new Error(`run reducer: READY requires both Standards and Spec final reviews`);
2181
+ }
2182
+ if (state.state === STATES2.READY_PENDING && state.finalReview.headSha && event.data.headSha && state.finalReview.headSha !== event.data.headSha) {
2183
+ throw new Error(`run reducer: READY head drift (finalReview=${state.finalReview.headSha.slice(0, 8)}, ready=${event.data.headSha.slice(0, 8)})`);
2184
+ }
2185
+ return {
2186
+ state: nextState({ state: STATES2.READY, activeTask: null, completedTasks: state.completedTasks }),
2187
+ event: recorded(EVENT_KINDS.READY, { headSha: event.data.headSha })
2188
+ };
2189
+ }
2190
+ case EVENT_KINDS.MERGE: {
2191
+ if (state.state !== STATES2.READY) {
2192
+ throw new Error(`run reducer: MERGE requires state=ready, got ${state.state}`);
2193
+ }
2194
+ return {
2195
+ state: nextState({ state: STATES2.MERGED, mergedAt: ev.at, mergeSha: event.data.mergeSha }),
2196
+ event: recorded(EVENT_KINDS.MERGE, { mergeSha: event.data.mergeSha })
2197
+ };
2198
+ }
2199
+ case EVENT_KINDS.DONE: {
2200
+ if (state.state !== STATES2.MERGED) {
2201
+ throw new Error(`run reducer: DONE requires state=merged, got ${state.state}`);
2202
+ }
2203
+ return {
2204
+ state: nextState({ state: STATES2.DONE, activeTask: null }),
2205
+ event: recorded(EVENT_KINDS.DONE, { taskId: event.data.taskId })
2206
+ };
2207
+ }
2208
+ case EVENT_KINDS.BLOCKED: {
2209
+ return {
2210
+ state: nextState({ state: STATES2.BLOCKED, blockedReason: event.data.reason }),
2211
+ event: recorded(EVENT_KINDS.BLOCKED, { reason: event.data.reason })
2212
+ };
2213
+ }
2214
+ default:
2215
+ throw new Error(`run reducer: unknown event kind ${event.kind}`);
2216
+ }
2217
+ }
2218
+ function createInitialState(workflowId, revision, sha2563) {
2219
+ return {
2220
+ workflowId,
2221
+ revision,
2222
+ sha256: sha2563,
2223
+ state: STATES2.CREATED,
2224
+ activeTask: null,
2225
+ taskReady: null,
2226
+ round: 0,
2227
+ failures: 0,
2228
+ completedTasks: [],
2229
+ events: []
2230
+ };
2231
+ }
2232
+ var RUN_EVENT_KINDS = EVENT_KINDS;
2233
+ function snapshotFields(state) {
2234
+ return {
2235
+ workflowId: state.workflowId,
2236
+ revision: state.revision,
2237
+ sha256: state.sha256,
2238
+ state: state.state,
2239
+ activeTask: state.activeTask,
2240
+ taskReady: state.taskReady ?? null,
2241
+ failures: state.failures,
2242
+ round: state.round,
2243
+ completedTasks: state.completedTasks,
2244
+ finalReview: state.finalReview ?? null
2245
+ };
2246
+ }
2247
+ function replayPersistedRun(workflowId, snapshot, events) {
2248
+ if (!snapshot || events.length === 0) {
2249
+ throw new Error("run state has no immutable event ledger");
2250
+ }
2251
+ const first = events[0];
2252
+ if (first.kind !== EVENT_KINDS.RUN_START) {
2253
+ throw new Error("run event ledger must begin with run-start");
2254
+ }
2255
+ let state = createInitialState(workflowId, first.data?.revision, first.data?.sha256);
2256
+ let priorHash = "0".repeat(64);
2257
+ for (let index = 0; index < events.length; index += 1) {
2258
+ const persisted = events[index];
2259
+ if (persisted.sequence !== index + 1 || persisted.priorHash !== priorHash) {
2260
+ throw new Error(`run event ledger chain mismatch at sequence ${index + 1}`);
2261
+ }
2262
+ const expectedHash = sha256(canonicalize({
2263
+ kind: persisted.kind,
2264
+ data: persisted.data,
2265
+ at: persisted.at,
2266
+ sequence: persisted.sequence,
2267
+ priorHash
2268
+ }));
2269
+ if (persisted.hash !== expectedHash) {
2270
+ throw new Error(`run event ledger hash mismatch at sequence ${index + 1}`);
2271
+ }
2272
+ const reduced = reduce(state, { kind: persisted.kind, data: persisted.data, at: persisted.at });
2273
+ const replayedEvent = reduced.state.events.at(-1);
2274
+ if (replayedEvent.hash !== persisted.hash) {
2275
+ throw new Error(`run event ledger replay mismatch at sequence ${index + 1}`);
2276
+ }
2277
+ state = reduced.state;
2278
+ priorHash = persisted.hash;
2279
+ }
2280
+ if (canonicalize(snapshotFields(state)) !== canonicalize(snapshotFields(snapshot))) {
2281
+ throw new Error("run snapshot does not match immutable event ledger");
2282
+ }
2283
+ return state;
2284
+ }
2285
+ async function runDir(repoRoot, workflowId) {
2286
+ const common = await resolveGitCommonDir(repoRoot);
2287
+ return join6(opencodeShipStateDir(common), "runs", workflowId);
2288
+ }
2289
+ async function appendRunEvent(repoRoot, workflowId, state, event) {
2290
+ const common = await resolveGitCommonDir(repoRoot);
2291
+ const dir = join6(opencodeShipStateDir(common), "runs", workflowId, "events");
2292
+ await mkdir4(dir, { recursive: true });
2293
+ const lockKey = `run:${workflowId}`;
2294
+ return withResourceLock(opencodeShipStateDir(common), lockKey, async () => {
2295
+ const persistedEvents = await readEventsFromDisk(repoRoot, workflowId);
2296
+ const persistedSnapshot = await readSnapshotFromDisk(repoRoot, workflowId);
2297
+ const liveState = persistedEvents.length > 0 ? replayPersistedRun(workflowId, persistedSnapshot, persistedEvents) : normalizeState(state);
2298
+ const { state: next, event: recorded } = reduce(liveState, event);
2299
+ const chainedEvent = next.events.at(-1);
2300
+ const sequence = String(recorded.sequence).padStart(8, "0");
2301
+ const path = join6(dir, `${sequence}.json`);
2302
+ await publishImmutableJson(path, chainedEvent);
2303
+ const runPath = join6(opencodeShipStateDir(common), "runs", workflowId, "run.json");
2304
+ const snapshot = {
2305
+ ...snapshotFields(next),
2306
+ lastEvent: chainedEvent,
2307
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2308
+ };
2309
+ await writeFile3(runPath, JSON.stringify(snapshot, null, 2), "utf8");
2310
+ return { state: next, event: chainedEvent };
2311
+ });
2312
+ }
2313
+ async function readRunState(repoRoot, workflowId) {
2314
+ const dir = await runDir(repoRoot, workflowId);
2315
+ const runPath = join6(dir, "run.json");
2316
+ if (!existsSync5(runPath)) return null;
2317
+ const snapshot = JSON.parse(await readFile5(runPath, "utf8"));
2318
+ const eventsDir = join6(dir, "events");
2319
+ const events = existsSync5(eventsDir) ? await Promise.all(
2320
+ (await readdir3(eventsDir)).filter((n) => n.endsWith(".json")).sort().map(async (n) => JSON.parse(await readFile5(join6(eventsDir, n), "utf8")))
2321
+ ) : [];
2322
+ return replayPersistedRun(workflowId, snapshot, events);
2323
+ }
2324
+
2325
+ // src/workflow/final-review-store.js
2326
+ import { readFile as readFile6 } from "node:fs/promises";
2327
+ import { existsSync as existsSync6 } from "node:fs";
2328
+ import { join as join7 } from "node:path";
2329
+
2330
+ // src/workflow/final-review.js
2331
+ import { createHash as createHash6 } from "node:crypto";
2332
+ function hashFinalReviewPackage(pkg) {
2333
+ const { packageHash: _packageHash, ...payload } = pkg;
2334
+ return sha2562(canonicalJson(payload));
2335
+ }
2336
+ function hashAxisRecord(record) {
2337
+ const { reviewHash: _reviewHash, ...payload } = record;
2338
+ return sha2562(canonicalJson(payload));
2339
+ }
2340
+ function bindFinalReview(standards, spec) {
2341
+ if (standards.headSha !== spec.headSha) {
2342
+ return { ok: false, reason: `head-mismatch: standards=${standards.headSha} spec=${spec.headSha}` };
2343
+ }
2344
+ if (standards.mergeBaseSha !== spec.mergeBaseSha) {
2345
+ return { ok: false, reason: `merge-base-mismatch: standards=${standards.mergeBaseSha} spec=${spec.mergeBaseSha}` };
2346
+ }
2347
+ if (standards.packageHash !== spec.packageHash) {
2348
+ return { ok: false, reason: `package-mismatch: standards=${standards.packageHash} spec=${spec.packageHash}` };
2349
+ }
2350
+ if (standards.verdict !== "pass" || spec.verdict !== "pass") {
2351
+ return { ok: false, reason: `verdict: standards=${standards.verdict} spec=${spec.verdict}` };
2352
+ }
2353
+ const blocking = [...standards.findings ?? [], ...spec.findings ?? []].filter((f) => f.severity === "blocking");
2354
+ if (blocking.length > 0) {
2355
+ return { ok: false, reason: `blocking-findings: ${blocking.length}` };
2356
+ }
2357
+ return { ok: true, headSha: standards.headSha };
2358
+ }
2359
+ function sha2562(text) {
2360
+ return createHash6("sha256").update(text, "utf8").digest("hex");
2361
+ }
2362
+
2363
+ // src/workflow/final-review-store.js
2364
+ async function readFinalReviewEvidence(repoRoot, workflowId) {
2365
+ const commonDir = await resolveGitCommonDir(repoRoot);
2366
+ const root = join7(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review");
2367
+ const paths = {
2368
+ package: join7(root, "package.json"),
2369
+ standards: join7(root, "standards", "review.json"),
2370
+ spec: join7(root, "spec", "review.json")
2371
+ };
2372
+ for (const [kind, path] of Object.entries(paths)) {
2373
+ if (!existsSync6(path)) throw new Error(`canonical final review ${kind} record is missing`);
2374
+ }
2375
+ const [pkg, standards, spec, runState] = await Promise.all([
2376
+ readJson(paths.package),
2377
+ readJson(paths.standards),
2378
+ readJson(paths.spec),
2379
+ readRunState(repoRoot, workflowId)
2380
+ ]);
2381
+ if (hashFinalReviewPackage(pkg) !== pkg.packageHash) {
2382
+ throw new Error("canonical final review package hash is invalid");
2383
+ }
2384
+ const [verification, ci] = await Promise.all([
2385
+ readGateReceipt(repoRoot, pkg.gateTaskId, "verification", pkg.verificationHash),
2386
+ readGateReceipt(repoRoot, pkg.gateTaskId, "ci", pkg.ciHash)
2387
+ ]);
2388
+ if (!verification || verification.headSha !== pkg.headSha || verification.exitCode !== 0) {
2389
+ throw new Error("canonical verification receipt does not match the final review package");
2390
+ }
2391
+ if (!ci || ci.headSha !== pkg.headSha || ci.checks?.some((check) => check.bucket !== "pass")) {
2392
+ throw new Error("canonical CI receipt does not match the final review package");
2393
+ }
2394
+ for (const [axis, record] of [["standards", standards], ["spec", spec]]) {
2395
+ if (record.axis !== axis || hashAxisRecord(record) !== record.reviewHash) {
2396
+ throw new Error(`canonical ${axis} review record hash is invalid`);
2397
+ }
2398
+ if (record.packageHash !== pkg.packageHash || record.headSha !== pkg.headSha || record.mergeBaseSha !== pkg.mergeBaseSha) {
2399
+ throw new Error(`canonical ${axis} review does not match the final review package`);
2400
+ }
2401
+ }
2402
+ const binding = bindFinalReview(standards, spec);
2403
+ if (!binding.ok) throw new Error(`canonical final review is not passing: ${binding.reason}`);
2404
+ if (!runState || !["ready-pending", "ready", "merged", "done"].includes(runState.state)) {
2405
+ throw new Error(`workflow is not final-review complete (state=${runState?.state ?? "missing"})`);
2406
+ }
2407
+ const summary = runState.finalReview;
2408
+ if (summary?.packageHash !== pkg.packageHash || summary?.headSha !== pkg.headSha || summary?.mergeBaseSha !== pkg.mergeBaseSha || summary?.standards?.reviewHash !== standards.reviewHash || summary?.spec?.reviewHash !== spec.reviewHash) {
2409
+ throw new Error("workflow final-review summary does not match immutable review records");
2410
+ }
2411
+ return { package: pkg, standards, spec, verification, ci, runState };
2412
+ }
2413
+ async function readJson(path) {
2414
+ return JSON.parse(await readFile6(path, "utf8"));
2415
+ }
2416
+
1702
2417
  // src/tools/delivery-ready.js
1703
2418
  function createReadyTool(deps) {
1704
2419
  return async function ready(input) {
@@ -1721,8 +2436,32 @@ function createReadyTool(deps) {
1721
2436
  branch: m.branch,
1722
2437
  required: deps.adapter?.ci?.requiredChecks ?? []
1723
2438
  }) : [];
2439
+ let runState = null;
2440
+ let gateManifest = m;
2441
+ const suppliedWorkflowId = input.workflowId ? String(input.workflowId) : null;
2442
+ if (m.workflowId && suppliedWorkflowId && m.workflowId !== suppliedWorkflowId) {
2443
+ return { kind: "workflow-mismatch", expected: m.workflowId, received: suppliedWorkflowId };
2444
+ }
2445
+ const workflowId = m.workflowId ?? suppliedWorkflowId;
2446
+ if (m.schemaVersion >= 2 && !workflowId) {
2447
+ return { kind: "missing-workflow-link", taskId: m.taskId };
2448
+ }
2449
+ let finalEvidence = null;
2450
+ if (workflowId) {
2451
+ try {
2452
+ finalEvidence = await readFinalReviewEvidence(deps.repoRoot, workflowId);
2453
+ } catch (err) {
2454
+ return { kind: "invalid-final-review-evidence", workflowId, reason: String(err?.message ?? err) };
2455
+ }
2456
+ runState = finalEvidence.runState;
2457
+ gateManifest = {
2458
+ ...m,
2459
+ finalStandardsReview: finalEvidence.standards,
2460
+ finalSpecReview: finalEvidence.spec
2461
+ };
2462
+ }
1724
2463
  const result = checkGates({
1725
- manifest: { ...m, adapter: deps.adapter },
2464
+ manifest: { ...gateManifest, adapter: deps.adapter },
1726
2465
  prHead,
1727
2466
  checks,
1728
2467
  requires: required
@@ -1738,6 +2477,10 @@ function createReadyTool(deps) {
1738
2477
  if (!t.ok) return { kind: "lifecycle", reason: t.reason };
1739
2478
  const next = {
1740
2479
  ...m,
2480
+ workflowId: workflowId ?? null,
2481
+ finalReviewPackageHash: finalEvidence?.package.packageHash ?? m.finalReviewPackageHash ?? null,
2482
+ finalStandardsReview: finalEvidence?.standards ?? m.finalStandardsReview ?? null,
2483
+ finalSpecReview: finalEvidence?.spec ?? m.finalSpecReview ?? null,
1741
2484
  lastPrHeadSha: prHead,
1742
2485
  state: t.to,
1743
2486
  transitionLog: [
@@ -1747,7 +2490,13 @@ function createReadyTool(deps) {
1747
2490
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1748
2491
  };
1749
2492
  const path = await writeManifest(deps.repoRoot, next);
1750
- return { contractVersion: 1, manifestPath: path, pr: m.prNumber };
2493
+ if (runState && workflowId) {
2494
+ await appendRunEvent(deps.repoRoot, workflowId, runState, {
2495
+ kind: RUN_EVENT_KINDS.READY,
2496
+ data: { headSha: prHead, taskId: input.taskId }
2497
+ });
2498
+ }
2499
+ return { contractVersion: 1, manifestPath: path, pr: m.prNumber, workflowId };
1751
2500
  };
1752
2501
  }
1753
2502
 
@@ -1768,6 +2517,16 @@ function createMergeTool(deps) {
1768
2517
  return { kind: "wrong-base", base: pr.baseRefName };
1769
2518
  }
1770
2519
  const freshGates = deps.adapter?.merge?.requireFreshGates !== false;
2520
+ let finalEvidence = null;
2521
+ if (m.workflowId) {
2522
+ try {
2523
+ finalEvidence = await readFinalReviewEvidence(deps.repoRoot, m.workflowId);
2524
+ } catch (err) {
2525
+ return { kind: "invalid-final-review-evidence", workflowId: m.workflowId, reason: String(err?.message ?? err) };
2526
+ }
2527
+ } else if (m.schemaVersion >= 2) {
2528
+ return { kind: "missing-workflow-link", taskId: m.taskId };
2529
+ }
1771
2530
  if (freshGates) {
1772
2531
  const required = deps.adapter?.ready?.requires ?? [
1773
2532
  "review",
@@ -1782,7 +2541,12 @@ function createMergeTool(deps) {
1782
2541
  required: deps.adapter?.ci?.requiredChecks ?? []
1783
2542
  }) : [];
1784
2543
  const result = checkGates({
1785
- manifest: { ...m, adapter: deps.adapter },
2544
+ manifest: {
2545
+ ...m,
2546
+ finalStandardsReview: finalEvidence?.standards ?? m.finalStandardsReview,
2547
+ finalSpecReview: finalEvidence?.spec ?? m.finalSpecReview,
2548
+ adapter: deps.adapter
2549
+ },
1786
2550
  prHead: pr.headSha,
1787
2551
  checks,
1788
2552
  requires: required
@@ -1822,6 +2586,12 @@ function createMergeTool(deps) {
1822
2586
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1823
2587
  };
1824
2588
  const path = await writeManifest(deps.repoRoot, next);
2589
+ if (finalEvidence && m.workflowId) {
2590
+ await appendRunEvent(deps.repoRoot, m.workflowId, finalEvidence.runState, {
2591
+ kind: RUN_EVENT_KINDS.MERGE,
2592
+ data: { mergeSha: merged.mergeSha ?? merged.mergeCommitSha ?? merged.headSha }
2593
+ });
2594
+ }
1825
2595
  return { kind: "merge", contractVersion: 1, manifestPath: path, pr: m.prNumber, taskId: m.taskId };
1826
2596
  };
1827
2597
  }
@@ -1902,6 +2672,14 @@ function createCleanupTool(deps) {
1902
2672
  };
1903
2673
  }
1904
2674
  const isBootstrapRecovery = m.state === "cleanup-pending" && m.prNumber === null;
2675
+ let workflowRun = null;
2676
+ if (!isBootstrapRecovery && m.schemaVersion >= 2) {
2677
+ if (!m.workflowId) return { kind: "missing-workflow-link", taskId: m.taskId };
2678
+ workflowRun = await readRunState(deps.repoRoot, m.workflowId);
2679
+ if (!workflowRun || workflowRun.state !== "merged") {
2680
+ return { kind: "workflow-not-merged", workflowId: m.workflowId, state: workflowRun?.state ?? null };
2681
+ }
2682
+ }
1905
2683
  if (!isBootstrapRecovery && m.prNumber === null) {
1906
2684
  return { kind: "missing-pr" };
1907
2685
  }
@@ -1952,6 +2730,12 @@ function createCleanupTool(deps) {
1952
2730
  if (branchResult.status !== 0 && branchStillExists(deps.repoRoot, m.branch)) {
1953
2731
  return { kind: "branch-delete-failed", stderr: branchResult.stderr };
1954
2732
  }
2733
+ if (workflowRun && m.workflowId) {
2734
+ await appendRunEvent(deps.repoRoot, m.workflowId, workflowRun, {
2735
+ kind: RUN_EVENT_KINDS.DONE,
2736
+ data: { taskId: m.taskId }
2737
+ });
2738
+ }
1955
2739
  const tCleanup = transition(m, "cleanup-pending", { reason: "worktree removed" });
1956
2740
  const candidate = tCleanup.ok ? {
1957
2741
  ...m,