@gajae-code/coding-agent 0.12.4 → 0.12.6

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 (139) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +9 -1
  4. package/dist/types/eval/backend.d.ts +2 -2
  5. package/dist/types/exec/bash-executor.d.ts +10 -8
  6. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  7. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  8. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  9. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  10. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  11. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  12. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  15. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  16. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  17. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  19. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  20. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  21. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  23. package/dist/types/sdk/broker/broker.d.ts +18 -0
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  25. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  26. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -2
  27. package/dist/types/sdk/bus/index.d.ts +4 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -3
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  30. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  31. package/dist/types/sdk/host/host.d.ts +23 -2
  32. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  33. package/dist/types/session/agent-session.d.ts +5 -4
  34. package/dist/types/session/artifacts.d.ts +8 -5
  35. package/dist/types/session/attempt-record-store.d.ts +19 -0
  36. package/dist/types/session/client-bridge.d.ts +14 -0
  37. package/dist/types/session/internal/managed-session-storage.d.ts +7 -23
  38. package/dist/types/session/messages.d.ts +4 -1
  39. package/dist/types/session/session-manager.d.ts +7 -5
  40. package/dist/types/session/session-storage.d.ts +8 -0
  41. package/dist/types/session/streaming-output.d.ts +32 -0
  42. package/dist/types/task/executor.d.ts +1 -1
  43. package/dist/types/task/index.d.ts +2 -1
  44. package/dist/types/task/receipt.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +194 -0
  46. package/dist/types/task/worktree.d.ts +13 -6
  47. package/dist/types/tools/bash.d.ts +1 -1
  48. package/dist/types/tools/fetch.d.ts +3 -0
  49. package/dist/types/tools/output-meta.d.ts +37 -5
  50. package/dist/types/tools/subagent.d.ts +2 -0
  51. package/dist/types/tools/tool-result.d.ts +1 -0
  52. package/dist/types/utils/artifact-id.d.ts +2 -0
  53. package/dist/types/utils/git.d.ts +1 -0
  54. package/package.json +8 -8
  55. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  56. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  57. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  58. package/src/async/job-manager.ts +13 -5
  59. package/src/config/model-profiles.ts +8 -0
  60. package/src/config/settings-schema.ts +17 -3
  61. package/src/cursor.ts +20 -13
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  63. package/src/eval/backend.ts +2 -2
  64. package/src/exec/bash-executor.ts +425 -151
  65. package/src/extensibility/extensions/runner.ts +80 -6
  66. package/src/extensibility/extensions/wrapper.ts +22 -15
  67. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  68. package/src/gjc-runtime/launch-tmux.ts +309 -293
  69. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  70. package/src/gjc-runtime/psmux-detect.ts +52 -6
  71. package/src/gjc-runtime/team-launch.ts +50 -2
  72. package/src/gjc-runtime/team-runtime.ts +716 -251
  73. package/src/gjc-runtime/team-store.ts +25 -7
  74. package/src/gjc-runtime/team-workers.ts +161 -1
  75. package/src/gjc-runtime/tmux-common.ts +16 -4
  76. package/src/gjc-runtime/tmux-gc.ts +37 -5
  77. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  78. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  79. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  80. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  82. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  83. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  84. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  85. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  86. package/src/internal-urls/agent-protocol.ts +98 -38
  87. package/src/internal-urls/artifact-protocol.ts +28 -17
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/internal-urls/local-protocol.ts +39 -18
  90. package/src/internal-urls/registry-helpers.ts +62 -0
  91. package/src/modes/acp/acp-agent.ts +51 -13
  92. package/src/modes/acp/acp-client-bridge.ts +154 -11
  93. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  94. package/src/modes/components/plugin-settings.ts +5 -0
  95. package/src/modes/components/settings-selector.ts +4 -0
  96. package/src/modes/controllers/selector-controller.ts +1 -0
  97. package/src/prompts/tools/bash.md +2 -2
  98. package/src/prompts/tools/monitor.md +4 -4
  99. package/src/sdk/acp/adapter.ts +200 -9
  100. package/src/sdk/broker/broker.ts +93 -10
  101. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  102. package/src/sdk/broker/lifecycle.ts +692 -98
  103. package/src/sdk/bus/chat-daemon-control.ts +33 -5
  104. package/src/sdk/bus/index.ts +347 -14
  105. package/src/sdk/bus/telegram-daemon-contract.ts +7 -3
  106. package/src/sdk/bus/telegram-daemon.ts +283 -44
  107. package/src/sdk/bus/topic-registry.ts +133 -10
  108. package/src/sdk/host/host.ts +187 -9
  109. package/src/sdk/host/reverse-leases.ts +249 -42
  110. package/src/sdk/session.ts +8 -6
  111. package/src/session/agent-session.ts +930 -354
  112. package/src/session/artifacts.ts +211 -74
  113. package/src/session/attempt-record-store.ts +106 -0
  114. package/src/session/client-bridge.ts +15 -0
  115. package/src/session/internal/managed-session-scope.ts +332 -198
  116. package/src/session/internal/managed-session-storage.ts +162 -450
  117. package/src/session/messages.ts +32 -7
  118. package/src/session/session-manager.ts +31 -28
  119. package/src/session/session-storage.ts +190 -53
  120. package/src/session/streaming-output.ts +198 -40
  121. package/src/task/executor.ts +5 -2
  122. package/src/task/index.ts +277 -64
  123. package/src/task/provider-retry-status.ts +4 -1
  124. package/src/task/receipt.ts +26 -2
  125. package/src/task/render.ts +42 -1
  126. package/src/task/types.ts +52 -0
  127. package/src/task/worktree.ts +122 -43
  128. package/src/tools/bash.ts +818 -239
  129. package/src/tools/eval.ts +99 -4
  130. package/src/tools/fetch.ts +77 -15
  131. package/src/tools/find.ts +7 -3
  132. package/src/tools/monitor.ts +7 -1
  133. package/src/tools/output-meta.ts +706 -116
  134. package/src/tools/search.ts +9 -4
  135. package/src/tools/subagent-render.ts +5 -2
  136. package/src/tools/subagent.ts +8 -0
  137. package/src/tools/tool-result.ts +1 -0
  138. package/src/utils/artifact-id.ts +6 -0
  139. package/src/utils/git.ts +2 -0
@@ -77,6 +77,17 @@ export {
77
77
  validateRecoveryAdmission,
78
78
  validateRecoveryPath,
79
79
  } from "./ultragoal-owner-loss-recovery";
80
+
81
+ import {
82
+ captureReviewSourceSnapshot,
83
+ createReviewSourceCohort,
84
+ createReviewSourceDispatch,
85
+ normalizeReviewSourceCohorts,
86
+ type ReviewDeliveryDisposition,
87
+ type ReviewSourceCohort,
88
+ type ReviewSourceLane,
89
+ } from "./ultragoal-review-source";
90
+
80
91
  export type UltragoalGjcGoalMode = "aggregate" | "per-story";
81
92
  export type UltragoalGoalStatus =
82
93
  | "pending"
@@ -127,6 +138,8 @@ export interface UltragoalPlan {
127
138
  goals: UltragoalGoal[];
128
139
  /** Authoritative repository identity for multi-repo fail-closed spawn (#2901). */
129
140
  repositoryBinding?: RepositoryBinding;
141
+ reviewCohorts?: ReviewSourceCohort[];
142
+
130
143
  createdAt: string;
131
144
  updatedAt: string;
132
145
  [key: string]: unknown;
@@ -663,6 +676,10 @@ function buildCompletionReceipt(input: {
663
676
  export function nonEmptyString(value: unknown): string | null {
664
677
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
665
678
  }
679
+
680
+ function exactNonEmptyString(value: unknown): string | null {
681
+ return typeof value === "string" && value.length > 0 ? value : null;
682
+ }
666
683
  export function stringArray(value: unknown): string[] | null {
667
684
  return Array.isArray(value) && value.every(item => typeof item === "string") ? value.map(item => item.trim()) : null;
668
685
  }
@@ -980,6 +997,8 @@ function normalizePlan(raw: unknown): UltragoalPlan {
980
997
  if (record.repositoryBinding !== undefined) {
981
998
  repositoryBinding = parseRepositoryBinding(record.repositoryBinding);
982
999
  }
1000
+ const reviewCohorts = normalizeReviewSourceCohorts(record.reviewCohorts);
1001
+
983
1002
  return {
984
1003
  version: 1,
985
1004
  brief,
@@ -990,6 +1009,7 @@ function normalizePlan(raw: unknown): UltragoalPlan {
990
1009
  createdAt,
991
1010
  updatedAt,
992
1011
  ...(repositoryBinding ? { repositoryBinding } : {}),
1012
+ reviewCohorts,
993
1013
  ...(typeof record.state_revision === "number" && Number.isFinite(record.state_revision)
994
1014
  ? { state_revision: record.state_revision }
995
1015
  : {}),
@@ -1490,12 +1510,20 @@ export function requireResolvedLinks(ids: string[], map: Map<string, JsonObject>
1490
1510
  if (!map.has(id)) throw new Error(`qualityGate ${fieldName} references unknown id ${id}`);
1491
1511
  }
1492
1512
  }
1493
- function successfulLinkedRows(ids: string[], map: Map<string, JsonObject>, fieldName: string): JsonObject[] {
1513
+ function successfulLinkedRows(
1514
+ ids: string[],
1515
+ map: Map<string, JsonObject>,
1516
+ fieldName: string,
1517
+ expectedContractRef: string,
1518
+ ): JsonObject[] {
1494
1519
  const rows: JsonObject[] = [];
1495
1520
  for (const id of ids) {
1496
1521
  const row = map.get(id);
1497
1522
  if (!row) throw new Error(`qualityGate ${fieldName} references unknown id ${id}`);
1498
1523
  requireSuccessfulRowOutcome(row, `${fieldName}.${id}`);
1524
+ if (requiredStringField(row, "contractRef", `${fieldName}.${id}`) !== expectedContractRef) {
1525
+ throw new Error(`qualityGate ${fieldName}.${id}.contractRef must match ${expectedContractRef}`);
1526
+ }
1499
1527
  rows.push(row);
1500
1528
  }
1501
1529
  return rows;
@@ -1542,6 +1570,7 @@ export interface UltragoalChangeSet extends JsonObject {
1542
1570
  paths: UltragoalChangeSetPath[];
1543
1571
  rawDiffStat?: string;
1544
1572
  rawDiff?: string;
1573
+ captureIncomplete?: boolean;
1545
1574
  trusted: true;
1546
1575
  }
1547
1576
 
@@ -1560,8 +1589,8 @@ export function normalizeRepoPath(value: string): string {
1560
1589
  return value.replaceAll("\\\\", "/").replace(/^\.\//, "");
1561
1590
  }
1562
1591
 
1563
- function isToolsIndexPath(value: string): boolean {
1564
- return normalizeRepoPath(value) === TOOLS_INDEX_PATH;
1592
+ export function normalizeChangeSetPath(value: string): string {
1593
+ return value.replace(/^\.\//, "");
1565
1594
  }
1566
1595
 
1567
1596
  export function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
@@ -1574,6 +1603,7 @@ export function categorizeComputerChangePath(value: string): UltragoalChangeCate
1574
1603
  )
1575
1604
  return "tool";
1576
1605
  if (
1606
+ normalized === TOOLS_INDEX_PATH ||
1577
1607
  normalized === "packages/coding-agent/src/tools/renderers.ts" ||
1578
1608
  normalized === "packages/coding-agent/src/config/settings-schema.ts"
1579
1609
  )
@@ -1589,78 +1619,13 @@ export function categorizeComputerChangePath(value: string): UltragoalChangeCate
1589
1619
  }
1590
1620
 
1591
1621
  function isComputerControlSurfaceCategory(category: UltragoalChangeCategory): boolean {
1592
- // The computer-use red-team suite is conditional, not universal (see the
1593
- // ultragoal SKILL): require it only when the change actually touches
1594
- // computer-control source — the computer tool (`tool`), its behavior-bearing
1595
- // settings/renderer wiring (`settings-registry`), or computer Rust (`code`).
1596
- // A bare regeneration of the SHARED native binding (`generated-binding`:
1597
- // packages/natives/native/index.{d.ts,js}) is NOT by itself a computer-use
1598
- // change: that file is generated from Rust, so any real computer-use behavior
1599
- // change must also touch one of the categories above and will still trigger
1600
- // the suite. Treating aggregate binding or registration files as a computer
1601
- // surface forced the suite on unrelated changes, which the SKILL explicitly
1602
- // warns against, so they are excluded here.
1622
+ // Shared behavior registries are intentionally conservative: a path-only or
1623
+ // uninspectable change cannot prove that computer controls were untouched.
1624
+ // Generated bindings remain excluded because their behavior-bearing Rust
1625
+ // source is captured separately.
1603
1626
  return category === "code" || category === "tool" || category === "settings-registry";
1604
1627
  }
1605
1628
 
1606
- function isComputerSpecificToolsIndexDiff(diff: string | undefined, targetPath: string): boolean {
1607
- if (!diff || !isToolsIndexPath(targetPath)) return false;
1608
- let inTargetFile = false;
1609
- for (const line of diff.split("\n")) {
1610
- if (line.startsWith("diff --git ")) {
1611
- const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
1612
- inTargetFile = !!match && (isToolsIndexPath(match[1]!) || isToolsIndexPath(match[2]!));
1613
- continue;
1614
- }
1615
- if (!inTargetFile || line.startsWith("+++") || line.startsWith("---")) continue;
1616
- if (!line.startsWith("+") && !line.startsWith("-")) continue;
1617
- const changedLine = line.slice(1);
1618
- if (
1619
- /\bComputerTool\b/.test(changedLine) ||
1620
- /\bisComputerCallable\b/.test(changedLine) ||
1621
- /\bisComputerLoadablePlatform\b/.test(changedLine) ||
1622
- /["']computer["']/.test(changedLine) ||
1623
- /["']\.\/computer["']/.test(changedLine) ||
1624
- /\bcomputer\s*:/.test(changedLine)
1625
- ) {
1626
- return true;
1627
- }
1628
- }
1629
- return false;
1630
- }
1631
-
1632
- /** Settings registry file that holds ALL settings, most of them unrelated to computer control. */
1633
- const SETTINGS_SCHEMA_PATH = "packages/coding-agent/src/config/settings-schema.ts";
1634
-
1635
- export function isSettingsSchemaPath(value: string): boolean {
1636
- return normalizeRepoPath(value) === SETTINGS_SCHEMA_PATH;
1637
- }
1638
-
1639
- /**
1640
- * The settings registry holds every setting (themes, tool output sizes, retry
1641
- * knobs, …), so a bare `settings-schema.ts` edit is NOT by itself a computer
1642
- * change. Mirror {@link isComputerSpecificToolsIndexDiff}: only treat it as a
1643
- * computer-control surface when the diff actually adds/removes a `computer.*`
1644
- * setting key. When no diff is available, callers fall back to the conservative
1645
- * (fail-closed) categorization instead of this narrowing.
1646
- */
1647
- function isComputerSpecificSettingsDiff(diff: string | undefined, targetPath: string): boolean {
1648
- if (!diff || !isSettingsSchemaPath(targetPath)) return false;
1649
- let inTargetFile = false;
1650
- for (const line of diff.split("\n")) {
1651
- if (line.startsWith("diff --git ")) {
1652
- const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
1653
- inTargetFile = !!match && (isSettingsSchemaPath(match[1]!) || isSettingsSchemaPath(match[2]!));
1654
- continue;
1655
- }
1656
- if (!inTargetFile || line.startsWith("+++") || line.startsWith("---")) continue;
1657
- if (!line.startsWith("+") && !line.startsWith("-")) continue;
1658
- const changedLine = line.slice(1);
1659
- if (/["']computer\./.test(changedLine)) return true;
1660
- }
1661
- return false;
1662
- }
1663
-
1664
1629
  function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolean {
1665
1630
  const category = row.category ?? categorizeComputerChangePath(row.path);
1666
1631
  const oldCategory = row.oldPath ? categorizeComputerChangePath(row.oldPath) : category;
@@ -1669,26 +1634,8 @@ function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolea
1669
1634
 
1670
1635
  function trustedChangeSetRequiresComputerSuite(changeSet: UltragoalChangeSet | undefined): boolean {
1671
1636
  if (!changeSet?.trusted) return false;
1672
- return changeSet.paths.some(row => {
1673
- if (isComputerControlSurfaceChangePath(row)) {
1674
- // The settings registry mixes computer and non-computer settings. Narrow it
1675
- // with the diff so unrelated settings edits do not force the computer suite;
1676
- // fall back to the conservative categorization when no diff is available.
1677
- const touchesSettingsSchema =
1678
- isSettingsSchemaPath(row.path) || (row.oldPath ? isSettingsSchemaPath(row.oldPath) : false);
1679
- if (touchesSettingsSchema && changeSet.rawDiff !== undefined) {
1680
- return (
1681
- isComputerSpecificSettingsDiff(changeSet.rawDiff, row.path) ||
1682
- (row.oldPath ? isComputerSpecificSettingsDiff(changeSet.rawDiff, row.oldPath) : false)
1683
- );
1684
- }
1685
- return true;
1686
- }
1687
- return (
1688
- isComputerSpecificToolsIndexDiff(changeSet.rawDiff, row.path) ||
1689
- (row.oldPath ? isComputerSpecificToolsIndexDiff(changeSet.rawDiff, row.oldPath) : false)
1690
- );
1691
- });
1637
+ if (changeSet.captureIncomplete) return true;
1638
+ return changeSet.paths.some(isComputerControlSurfaceChangePath);
1692
1639
  }
1693
1640
 
1694
1641
  function requiresComputerRedTeamSuite(executorQa: JsonObject, changeSet: UltragoalChangeSet | undefined): boolean {
@@ -1901,6 +1848,16 @@ async function validateSurfaceEvidence(
1901
1848
  }
1902
1849
  const artifactIds = requireStringLinks(row.artifactRefs, `${fieldName}.artifactRefs`);
1903
1850
  requireResolvedLinks(artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
1851
+ if (!isLiveSurfaceFamily(family)) {
1852
+ for (const artifactId of artifactIds) {
1853
+ const artifact = artifactRefs.get(artifactId)!;
1854
+ if (!(await hasExistingNonEmptyArtifact(cwd, artifact.path))) {
1855
+ throw new Error(
1856
+ `qualityGate executorQa.artifactRefs.${artifactId} non-live surface evidence requires an existing non-empty file`,
1857
+ );
1858
+ }
1859
+ }
1860
+ }
1904
1861
  await validateLiveSurfaceProofPresence(cwd, family, artifactIds, artifactRefs);
1905
1862
  validateSurfaceArtifactCompatibility(surface, artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
1906
1863
  await validateSurfaceStructuralRequirement(cwd, family, artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
@@ -2053,18 +2010,19 @@ async function validateMandatoryComputerAdversarialCases(
2053
2010
  }
2054
2011
  }
2055
2012
 
2056
- function validateContractCoverage(
2013
+ async function validateContractCoverage(
2014
+ cwd: string,
2057
2015
  executorQa: JsonObject,
2058
2016
  surfaceEvidence: Map<string, JsonObject>,
2059
2017
  adversarialCases: Map<string, JsonObject>,
2060
2018
  artifactRefs: Map<string, JsonObject>,
2061
- ): JsonObject[] {
2019
+ ): Promise<JsonObject[]> {
2062
2020
  const rows = requireObjectArray(executorQa.contractCoverage, "executorQa.contractCoverage");
2063
2021
  buildRowIdMap(rows, "executorQa.contractCoverage");
2064
2022
  let hasSuccessfulContractCoverage = false;
2065
2023
  for (const [index, row] of rows.entries()) {
2066
2024
  const fieldName = `executorQa.contractCoverage[${index}]`;
2067
- requiredStringField(row, "contractRef", fieldName);
2025
+ const contractRef = requiredStringField(row, "contractRef", fieldName);
2068
2026
  const status = optionalStatusField(row, fieldName);
2069
2027
  if (status === NOT_APPLICABLE_STATUS) {
2070
2028
  requiredStringField(row, "reason", fieldName);
@@ -2083,21 +2041,57 @@ function validateContractCoverage(
2083
2041
  );
2084
2042
  }
2085
2043
  let successfulProofLinks = 0;
2086
- if (surfaceIds)
2087
- successfulProofLinks += successfulLinkedRows(
2044
+ let successfulSurfaceProofLinks = 0;
2045
+ if (surfaceIds) {
2046
+ successfulSurfaceProofLinks = successfulLinkedRows(
2088
2047
  surfaceIds,
2089
2048
  surfaceEvidence,
2090
2049
  `${fieldName}.surfaceEvidenceRefs`,
2050
+ contractRef,
2091
2051
  ).length;
2052
+ successfulProofLinks += successfulSurfaceProofLinks;
2053
+ }
2092
2054
  if (adversarialIds) {
2093
- successfulProofLinks += successfulLinkedRows(
2055
+ const successfulAdversarialRows = successfulLinkedRows(
2094
2056
  adversarialIds,
2095
2057
  adversarialCases,
2096
2058
  `${fieldName}.adversarialCaseRefs`,
2097
- ).length;
2059
+ contractRef,
2060
+ );
2061
+ for (const adversarialRow of successfulAdversarialRows) {
2062
+ const caseArtifactIds = requireStringLinks(
2063
+ adversarialRow.artifactRefs,
2064
+ `${fieldName}.adversarialCaseRefs.artifactRefs`,
2065
+ );
2066
+ for (const artifactId of caseArtifactIds) {
2067
+ const artifact = artifactRefs.get(artifactId)!;
2068
+ if (!(await hasExistingNonEmptyArtifact(cwd, artifact.path))) {
2069
+ throw new Error(
2070
+ `qualityGate executorQa.artifactRefs.${artifactId} adversarial coverage requires an existing non-empty file`,
2071
+ );
2072
+ }
2073
+ await validateArtifactProof(cwd, artifact, `executorQa.artifactRefs.${artifactId}`, {
2074
+ surfaceFamily: "native",
2075
+ live: false,
2076
+ });
2077
+ }
2078
+ }
2079
+ successfulProofLinks += successfulAdversarialRows.length;
2098
2080
  }
2099
2081
  if (artifactIds) {
2100
2082
  requireResolvedLinks(artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
2083
+ for (const artifactId of artifactIds) {
2084
+ const artifact = artifactRefs.get(artifactId)!;
2085
+ if (!(await hasExistingNonEmptyArtifact(cwd, artifact.path))) {
2086
+ throw new Error(
2087
+ `qualityGate executorQa.artifactRefs.${artifactId} artifact-only coverage requires an existing non-empty file`,
2088
+ );
2089
+ }
2090
+ await validateArtifactProof(cwd, artifact, `executorQa.artifactRefs.${artifactId}`, {
2091
+ surfaceFamily: "native",
2092
+ live: false,
2093
+ });
2094
+ }
2101
2095
  successfulProofLinks += artifactIds.length;
2102
2096
  }
2103
2097
  if (successfulProofLinks === 0) {
@@ -2120,7 +2114,13 @@ async function validateExecutorQaRedTeamEvidenceInternal(
2120
2114
  const artifactRefs = await validateArtifactRefs(cwd, executorQa);
2121
2115
  const surfaceEvidence = await validateSurfaceEvidence(cwd, executorQa, artifactRefs);
2122
2116
  const adversarialCases = validateAdversarialCases(executorQa, artifactRefs);
2123
- const contractCoverage = validateContractCoverage(executorQa, surfaceEvidence, adversarialCases, artifactRefs);
2117
+ const contractCoverage = await validateContractCoverage(
2118
+ cwd,
2119
+ executorQa,
2120
+ surfaceEvidence,
2121
+ adversarialCases,
2122
+ artifactRefs,
2123
+ );
2124
2124
  if (requiresComputerRedTeamSuite(executorQa, options.changeSet)) {
2125
2125
  await validateMandatoryComputerAdversarialCases(cwd, contractCoverage, adversarialCases, artifactRefs);
2126
2126
  }
@@ -2151,16 +2151,17 @@ function canonicalChangeSetRows(value: unknown, fieldName: string): UltragoalCha
2151
2151
  if (typeof row !== "object" || row === null || Array.isArray(row))
2152
2152
  throw new Error(`${fieldName}[${index}] must be an object`);
2153
2153
  const record = row as JsonObject;
2154
- const pathValue = nonEmptyString(record.path);
2154
+ requireAllowedRecordKeys(record, ["path", "status", "oldPath"], `${fieldName}[${index}]`);
2155
+ const pathValue = exactNonEmptyString(record.path);
2155
2156
  if (!pathValue) throw new Error(`${fieldName}[${index}].path is required`);
2156
2157
  if ("goalId" in record) throw new Error(`${fieldName}[${index}] must not contain goalId attribution`);
2157
2158
  const status = nonEmptyString(record.status);
2158
2159
  if (!status) throw new Error(`${fieldName}[${index}].status is required`);
2159
- const oldPath = nonEmptyString(record.oldPath);
2160
+ const oldPath = exactNonEmptyString(record.oldPath);
2160
2161
  return {
2161
- path: normalizeRepoPath(pathValue),
2162
+ path: normalizeChangeSetPath(pathValue),
2162
2163
  status: status as UltragoalChangeStatus,
2163
- ...(oldPath ? { oldPath: normalizeRepoPath(oldPath) } : {}),
2164
+ ...(oldPath ? { oldPath: normalizeChangeSetPath(oldPath) } : {}),
2164
2165
  };
2165
2166
  });
2166
2167
  }
@@ -2174,17 +2175,39 @@ function requireChangeSetCoverage(
2174
2175
  declared: readonly UltragoalChangeSetPath[],
2175
2176
  fieldName: string,
2176
2177
  ): void {
2177
- if (!expected) return;
2178
- const declaredExactKeys = new Set(declared.map(row => `${row.oldPath ?? ""}\u0000${row.path}\u0000${row.status}`));
2179
- const declaredPathKeys = new Set(declared.map(row => `${row.oldPath ?? ""}\u0000${row.path}`));
2180
- for (const row of expected.paths) {
2181
- const pathKey = `${row.oldPath ?? ""}\u0000${row.path}`;
2182
- const exactKey = `${pathKey}\u0000${row.status}`;
2183
- const covered = row.status === "unknown" ? declaredPathKeys.has(pathKey) : declaredExactKeys.has(exactKey);
2184
- if (!covered) throw new Error(`${fieldName} does not cover computed checkpoint change-set path ${row.path}`);
2178
+ if (!expected) throw new Error(`${fieldName} requires an authoritative computed checkpoint change set`);
2179
+ if (expected.captureIncomplete)
2180
+ throw new Error(`${fieldName} requires a complete authoritative checkpoint change set`);
2181
+ const expectedExactRows = expected.paths.map(row => `${row.oldPath ?? ""}\u0000${row.path}\u0000${row.status}`);
2182
+ const declaredExactRows = declared.map(row => `${row.oldPath ?? ""}\u0000${row.path}\u0000${row.status}`);
2183
+ const declaredExactKeys = new Set(declaredExactRows);
2184
+ for (const [index, row] of expected.paths.entries()) {
2185
+ if (!declaredExactKeys.has(expectedExactRows[index]!)) {
2186
+ throw new Error(`${fieldName} does not cover computed checkpoint change-set path ${row.path}`);
2187
+ }
2188
+ }
2189
+ if (
2190
+ declaredExactRows.length !== expectedExactRows.length ||
2191
+ declaredExactRows.some((key, index) => key !== expectedExactRows[index])
2192
+ ) {
2193
+ throw new Error(`${fieldName} must exactly match the computed checkpoint change set`);
2185
2194
  }
2186
2195
  }
2187
2196
 
2197
+ function requireExactRecordKeys(record: JsonObject, expectedKeys: readonly string[], fieldName: string): void {
2198
+ const actual = Object.keys(record).sort();
2199
+ const expected = [...expectedKeys].sort();
2200
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
2201
+ throw new Error(`${fieldName} keys must exactly match durable validationBatch memberIds`);
2202
+ }
2203
+ }
2204
+
2205
+ function requireAllowedRecordKeys(record: JsonObject, allowedKeys: readonly string[], fieldName: string): void {
2206
+ const allowed = new Set(allowedKeys);
2207
+ const unsupported = Object.keys(record).filter(key => !allowed.has(key));
2208
+ if (unsupported.length > 0) throw new Error(`${fieldName} contains unsupported keys: ${unsupported.join(", ")}`);
2209
+ }
2210
+
2188
2211
  function requireValidationBatchTuple(
2189
2212
  metadata: UltragoalValidationBatchMetadata,
2190
2213
  record: JsonObject,
@@ -2288,6 +2311,7 @@ function hydrateDeferredGateDefaults(
2288
2311
  }));
2289
2312
  const computedByPath = new Map(computedRows.map(row => [row.path, row]));
2290
2313
  const declared = qualityGateObject(hydrated.changeSet);
2314
+ if (hydrated.changeSet !== undefined && !declared) return { ...gate, deferredToBatch: hydrated };
2291
2315
  const changeSetRecord: JsonObject = declared ? { ...declared } : {};
2292
2316
  if (changeSetRecord.memberGoalId === undefined) changeSetRecord.memberGoalId = goal.id;
2293
2317
  if (changeSetRecord.cumulativeFromBase === undefined) changeSetRecord.cumulativeFromBase = true;
@@ -2299,9 +2323,9 @@ function hydrateDeferredGateDefaults(
2299
2323
  changeSetRecord.paths = changeSetRecord.paths.map(row => {
2300
2324
  const record = typeof row === "string" ? { path: row } : qualityGateObject(row);
2301
2325
  if (!record) return row;
2302
- const pathValue = nonEmptyString(record.path);
2326
+ const pathValue = exactNonEmptyString(record.path);
2303
2327
  if (!pathValue || nonEmptyString(record.status)) return record;
2304
- const computed = computedByPath.get(normalizeRepoPath(pathValue));
2328
+ const computed = computedByPath.get(normalizeChangeSetPath(pathValue));
2305
2329
  return {
2306
2330
  ...record,
2307
2331
  status: computed?.status ?? "unknown",
@@ -2376,12 +2400,12 @@ function hydrateBatchCloseDefaults(input: {
2376
2400
  // Member not fresh/complete: omit it so validation reports the real defect.
2377
2401
  }
2378
2402
  }
2379
- const suppliedMetadataHashes = qualityGateObject(close.memberMetadataHashes);
2380
2403
  if (close.memberMetadataHashes === undefined) close.memberMetadataHashes = derivedMetadataHashes;
2381
- else if (suppliedMetadataHashes)
2382
- close.memberMetadataHashes = { ...derivedMetadataHashes, ...suppliedMetadataHashes };
2383
2404
  if (close.memberReceipts === undefined) close.memberReceipts = derivedReceipts;
2384
2405
  const requestedUnion = qualityGateObject(close.unionChangeSet);
2406
+ if (close.unionChangeSet !== undefined && !requestedUnion) {
2407
+ return { ...input.gate, validationBatchClose: close };
2408
+ }
2385
2409
  const union: JsonObject = requestedUnion ? { ...requestedUnion } : {};
2386
2410
  if (union.source === undefined) union.source = "validation-batch";
2387
2411
  if (union.paths === undefined) {
@@ -2394,17 +2418,16 @@ function hydrateBatchCloseDefaults(input: {
2394
2418
  try {
2395
2419
  const unionRows = canonicalChangeSetRows(union.paths, "validationBatchClose.unionChangeSet.paths");
2396
2420
  const suppliedChangeSetHashes = qualityGateObject(union.memberChangeSetHashes);
2397
- const memberChangeSetHashes: Record<string, unknown> = {
2421
+ const derivedMemberChangeSetHashes: Record<string, unknown> = {
2398
2422
  ...derivedChangeSetHashes,
2399
2423
  [metadata.finalGoalId]: changeSetHashForPaths(unionRows),
2400
- ...suppliedChangeSetHashes,
2401
2424
  };
2402
- if (union.memberChangeSetHashes === undefined || suppliedChangeSetHashes) {
2403
- union.memberChangeSetHashes = memberChangeSetHashes;
2425
+ if (union.memberChangeSetHashes === undefined) {
2426
+ union.memberChangeSetHashes = derivedMemberChangeSetHashes;
2404
2427
  }
2405
2428
  if (union.unionHash === undefined) {
2406
2429
  union.unionHash = hashStructuredValue({
2407
- memberChangeSetHashes: qualityGateObject(union.memberChangeSetHashes) ?? memberChangeSetHashes,
2430
+ memberChangeSetHashes: suppliedChangeSetHashes ?? derivedMemberChangeSetHashes,
2408
2431
  paths: unionRows.map(row => ({ path: row.path, status: row.status, oldPath: row.oldPath })),
2409
2432
  });
2410
2433
  }
@@ -2427,6 +2450,24 @@ function validateDeferredCompletionQualityGate(
2427
2450
  throw new Error(`deferred qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`);
2428
2451
  const deferred = qualityGateObject(gate.deferredToBatch);
2429
2452
  if (!deferred) throw new Error("deferred qualityGate requires deferredToBatch object");
2453
+ requireAllowedRecordKeys(
2454
+ deferred,
2455
+ [
2456
+ "schemaVersion",
2457
+ "kind",
2458
+ "batchId",
2459
+ "memberIds",
2460
+ "finalGoalId",
2461
+ "metadataHash",
2462
+ "deferredLanes",
2463
+ "ranLanes",
2464
+ "targetedVerification",
2465
+ "aiSlopCleaner",
2466
+ "iteration",
2467
+ "changeSet",
2468
+ ],
2469
+ "deferredToBatch",
2470
+ );
2430
2471
  if (deferred.kind !== "validation-batch-deferred")
2431
2472
  throw new Error("deferredToBatch.kind must be validation-batch-deferred");
2432
2473
  if (metadata) {
@@ -2462,6 +2503,11 @@ function validateDeferredCompletionQualityGate(
2462
2503
  validateDeferredLaneDeclaration(deferred, "deferredToBatch");
2463
2504
  const declaredChangeSet = qualityGateObject(deferred.changeSet);
2464
2505
  if (!declaredChangeSet) throw new Error("deferredToBatch.changeSet is required");
2506
+ requireAllowedRecordKeys(
2507
+ declaredChangeSet,
2508
+ ["memberGoalId", "cumulativeFromBase", "paths", "changeSetHash"],
2509
+ "deferredToBatch.changeSet",
2510
+ );
2465
2511
  if (declaredChangeSet.memberGoalId !== goal.id)
2466
2512
  throw new Error(
2467
2513
  `deferredToBatch.changeSet.memberGoalId must label the checkpointed goal ${goal.id} (or be omitted; the runtime fills it)`,
@@ -2484,7 +2530,12 @@ const COHORT_LANE_KEYS = ["cleaner", "architect", "qa"] as const;
2484
2530
  * generations are delta-only. Cohort state rides the existing `iteration` gate key so
2485
2531
  * no new top-level quality-gate key is introduced.
2486
2532
  */
2487
- function validateReviewCohort(gate: JsonObject, iteration: JsonObject): void {
2533
+ async function validateReviewCohort(
2534
+ cwd: string,
2535
+ gate: JsonObject,
2536
+ iteration: JsonObject,
2537
+ plan?: UltragoalPlan,
2538
+ ): Promise<void> {
2488
2539
  const cohort = qualityGateObject(iteration.reviewCohort);
2489
2540
  if (!cohort) throw new Error("qualityGate iteration.reviewCohort is required at the review boundary");
2490
2541
  const generation = cohort.reviewGeneration;
@@ -2492,6 +2543,40 @@ function validateReviewCohort(gate: JsonObject, iteration: JsonObject): void {
2492
2543
  throw new Error("iteration.reviewCohort.reviewGeneration must be an integer >= 1");
2493
2544
  const sourceHash = nonEmptyString(cohort.sourceHash);
2494
2545
  if (!sourceHash) throw new Error("iteration.reviewCohort.sourceHash is required");
2546
+ const cohortId = nonEmptyString(cohort.cohortId);
2547
+ const lanesForAuthority = qualityGateObject(cohort.lanes);
2548
+ const deliveryBearing = COHORT_LANE_KEYS.some(lane =>
2549
+ nonEmptyString(qualityGateObject(lanesForAuthority?.[lane])?.deliveryId),
2550
+ );
2551
+ const authoritativeReview = Boolean(cohortId || deliveryBearing || plan?.reviewCohorts?.length);
2552
+ if (authoritativeReview && !cohortId) {
2553
+ throw new Error(
2554
+ "iteration.reviewCohort.cohortId is required; reviewer-declared source hashes are not gate authority",
2555
+ );
2556
+ }
2557
+ const authoritative = cohortId ? plan?.reviewCohorts?.find(record => record.cohortId === cohortId) : undefined;
2558
+ if (cohortId && !authoritative) {
2559
+ throw new Error("iteration.reviewCohort.cohortId must resolve to a runtime-owned persisted review cohort");
2560
+ }
2561
+ if (authoritative?.status !== undefined && authoritative.status !== "active") {
2562
+ throw new Error(
2563
+ "iteration.reviewCohort references a superseded review cohort; rerun reviews on the active source snapshot",
2564
+ );
2565
+ }
2566
+ if (authoritative && (authoritative.generation !== generation || authoritative.snapshotId !== sourceHash)) {
2567
+ throw new Error(
2568
+ "iteration.reviewCohort generation/sourceHash must match the runtime-owned persisted review cohort",
2569
+ );
2570
+ }
2571
+ if (authoritative && plan?.repositoryBinding) {
2572
+ const current = await captureReviewSourceSnapshot(cwd, plan.repositoryBinding);
2573
+ if (current.snapshotId !== authoritative.snapshotId) {
2574
+ throw new Error(
2575
+ "iteration.reviewCohort source advanced after review delivery; rerun the cohort on the current source snapshot",
2576
+ );
2577
+ }
2578
+ }
2579
+
2495
2580
  if (cohort.joined !== true)
2496
2581
  throw new Error("iteration.reviewCohort.joined must be true: all lane findings must join before checkpoint");
2497
2582
  const lanes = qualityGateObject(cohort.lanes);
@@ -2504,6 +2589,22 @@ function validateReviewCohort(gate: JsonObject, iteration: JsonObject): void {
2504
2589
  throw new Error(`iteration.reviewCohort.lanes.${lane} must be one lane per generation, not a list`);
2505
2590
  const record = qualityGateObject(lanes[lane]);
2506
2591
  if (!record) throw new Error(`iteration.reviewCohort.lanes.${lane} is required`);
2592
+ if (authoritative) {
2593
+ const deliveryId = nonEmptyString(record.deliveryId);
2594
+ if (!deliveryId) throw new Error(`iteration.reviewCohort.lanes.${lane}.deliveryId is required`);
2595
+ const delivery = authoritative.deliveries.find(item => item.deliveryId === deliveryId && item.lane === lane);
2596
+ if (!delivery) {
2597
+ throw new Error(
2598
+ `iteration.reviewCohort.lanes.${lane}.deliveryId must resolve to its runtime-owned lane delivery`,
2599
+ );
2600
+ }
2601
+ if (delivery.disposition !== "current" || delivery.snapshotId !== sourceHash) {
2602
+ throw new Error(
2603
+ `iteration.reviewCohort.lanes.${lane} is ${delivery.disposition}; stale or invalid review delivery cannot satisfy the current gate. Rerun the ${lane} lane on cohort ${cohortId}`,
2604
+ );
2605
+ }
2606
+ }
2607
+
2507
2608
  const laneHash = nonEmptyString(record.sourceHash);
2508
2609
  if (!laneHash) throw new Error(`iteration.reviewCohort.lanes.${lane}.sourceHash is required`);
2509
2610
  if (laneHash !== sourceHash)
@@ -2541,6 +2642,19 @@ function validateReviewCohort(gate: JsonObject, iteration: JsonObject): void {
2541
2642
  // per-lane or per-generation vote.
2542
2643
  const critic = qualityGateObject(gate.criticReview);
2543
2644
  if (critic) {
2645
+ if (authoritative) {
2646
+ const criticDeliveryId = nonEmptyString(critic.deliveryId);
2647
+ if (!criticDeliveryId) throw new Error("criticReview.deliveryId is required for the terminal review gate");
2648
+ const criticDelivery = authoritative.deliveries.find(
2649
+ item => item.deliveryId === criticDeliveryId && item.lane === "critic",
2650
+ );
2651
+ if (criticDelivery?.disposition !== "current" || criticDelivery.snapshotId !== sourceHash) {
2652
+ throw new Error(
2653
+ "criticReview.deliveryId must resolve to a current terminal critic delivery on the joined cohort",
2654
+ );
2655
+ }
2656
+ }
2657
+
2544
2658
  const criticHash = nonEmptyString(critic.sourceHash);
2545
2659
  if (criticHash && criticHash !== sourceHash)
2546
2660
  throw new Error(
@@ -2738,7 +2852,9 @@ async function validateCompletionQualityGate(
2738
2852
  found.check("iteration.blockers", "non_empty_blockers", () =>
2739
2853
  requireEmptyBlockers(iteration.blockers, "iteration.blockers"),
2740
2854
  );
2741
- found.check("iteration.reviewCohort", "review_cohort_invalid", () => validateReviewCohort(gate, iteration));
2855
+ await found.checkAsync("iteration.reviewCohort", "review_cohort_invalid", () =>
2856
+ validateReviewCohort(cwd, gate, iteration, options.plan),
2857
+ );
2742
2858
  if (batchMode && options.goal && options.plan && options.ledger) {
2743
2859
  found.check("validationBatchClose", "batch_close_invalid", () =>
2744
2860
  validateBatchCloseQualityGate(gate, options.plan!, batchMode, options.ledger!, options.changeSet),
@@ -2756,6 +2872,21 @@ function validateBatchCloseQualityGate(
2756
2872
  ): void {
2757
2873
  const close = qualityGateObject(gate.validationBatchClose);
2758
2874
  if (!close) throw new Error("validationBatchClose is required");
2875
+ requireAllowedRecordKeys(
2876
+ close,
2877
+ [
2878
+ "schemaVersion",
2879
+ "kind",
2880
+ "batchId",
2881
+ "finalGoalId",
2882
+ "memberIds",
2883
+ "memberMetadataHashes",
2884
+ "memberReceipts",
2885
+ "unionChangeSet",
2886
+ "coverageEvidence",
2887
+ ],
2888
+ "validationBatchClose",
2889
+ );
2759
2890
  if (close.schemaVersion !== 1 || close.kind !== "validation-batch-close")
2760
2891
  throw new Error("validationBatchClose.kind must be validation-batch-close");
2761
2892
  if (close.batchId !== metadata.batchId || close.finalGoalId !== metadata.finalGoalId)
@@ -2782,6 +2913,12 @@ function validateBatchCloseQualityGate(
2782
2913
  if (memberId !== metadata.finalGoalId && member.status !== "complete")
2783
2914
  throw new Error(`validationBatchClose cannot close before ${memberId} is complete`);
2784
2915
  }
2916
+ requireExactRecordKeys(memberMetadataHashes, metadata.memberIds, "validationBatchClose.memberMetadataHashes");
2917
+ requireExactRecordKeys(
2918
+ memberChangeSetHashes,
2919
+ metadata.memberIds,
2920
+ "validationBatchClose.unionChangeSet.memberChangeSetHashes",
2921
+ );
2785
2922
  if (receiptRows.length !== nonFinalIds.length)
2786
2923
  throw new Error("validationBatchClose.memberReceipts must list every non-final member exactly once");
2787
2924
  for (const row of receiptRows) {
@@ -2791,6 +2928,11 @@ function validateBatchCloseQualityGate(
2791
2928
  const memberId = nonEmptyString(record.goalId);
2792
2929
  if (!memberId || !nonFinalIds.includes(memberId))
2793
2930
  throw new Error("validationBatchClose.memberReceipts contains invalid member goalId");
2931
+ requireAllowedRecordKeys(
2932
+ record,
2933
+ ["goalId", "receiptId", "checkpointLedgerEventId", "qualityGateHash", "changeSetHash", "role"],
2934
+ `validationBatchClose.memberReceipts.${memberId}`,
2935
+ );
2794
2936
  if (seenReceipts.has(memberId))
2795
2937
  throw new Error(`validationBatchClose.memberReceipts contains duplicate member ${memberId}`);
2796
2938
  seenReceipts.add(memberId);
@@ -2815,6 +2957,11 @@ function validateBatchCloseQualityGate(
2815
2957
  const union = qualityGateObject(close.unionChangeSet);
2816
2958
  if (union?.source !== "validation-batch")
2817
2959
  throw new Error("validationBatchClose.unionChangeSet.source must be validation-batch");
2960
+ requireAllowedRecordKeys(
2961
+ union,
2962
+ ["source", "memberChangeSetHashes", "paths", "unionHash"],
2963
+ "validationBatchClose.unionChangeSet",
2964
+ );
2818
2965
  const unionPaths = canonicalChangeSetRows(union.paths, "validationBatchClose.unionChangeSet.paths");
2819
2966
  requireChangeSetCoverage(changeSet, unionPaths, "validationBatchClose.unionChangeSet.paths");
2820
2967
  const finalHash = changeSetHashForPaths(unionPaths);
@@ -2900,8 +3047,11 @@ function hydrateReviewedBatchReplacementClose(input: {
2900
3047
  const aggregateGoal = aggregateGoals.find(goal => {
2901
3048
  const receipt = goal.completionVerification!;
2902
3049
  const event = findLedgerReceiptEvent(input.ledger, receipt);
3050
+ const eventReceipt = event?.completionVerification as UltragoalCompletionVerification | undefined;
2903
3051
  return (
2904
3052
  event !== null &&
3053
+ eventReceipt !== undefined &&
3054
+ hashStructuredValue(eventReceipt) === hashStructuredValue(receipt) &&
2905
3055
  hashStructuredValue(event.qualityGateJson) === receipt.qualityGateHash &&
2906
3056
  goal.updatedAt === receipt.verifiedAt &&
2907
3057
  receipt.basis.relevantGoalIdsBeforeCheckpoint.length === historicalRequiredGoalIds.length &&
@@ -2940,7 +3090,11 @@ function hydrateReviewedBatchReplacementClose(input: {
2940
3090
  role: "deferred-member",
2941
3091
  });
2942
3092
  }
2943
- const paths = input.changeSet.paths.map(row => ({ ...row }));
3093
+ const paths = input.changeSet.paths.map(row => ({
3094
+ path: row.path,
3095
+ status: row.status,
3096
+ ...(row.oldPath ? { oldPath: row.oldPath } : {}),
3097
+ }));
2944
3098
  memberChangeSetHashes[input.goal.id] = changeSetHashForPaths(paths);
2945
3099
  const unionHash = hashStructuredValue({
2946
3100
  memberChangeSetHashes,
@@ -3174,11 +3328,23 @@ export async function validateUltragoalQualityGateReadOnly(input: {
3174
3328
  }
3175
3329
  const ledger = plan ? await readUltragoalLedger(input.cwd, sessionId) : undefined;
3176
3330
  const changeSet = await computeCheckpointChangeSet(input.cwd);
3177
- let hydratedGate = goal ? hydrateDeferredGateDefaults(gate, goal, changeSet) : gate;
3178
- if (goal && plan && ledger) {
3179
- hydratedGate = hydrateBatchCloseDefaults({ gate: hydratedGate, plan, goal, ledger, changeSet });
3180
- }
3181
3331
  try {
3332
+ const validationBatch = goal ? requireFreshValidationBatchMetadata(goal) : undefined;
3333
+ let hydratedGate =
3334
+ validationBatch && plan && goal && ledger
3335
+ ? hydrateReviewedBatchReplacementClose({
3336
+ gate,
3337
+ plan,
3338
+ goal,
3339
+ metadata: validationBatch,
3340
+ ledger,
3341
+ changeSet,
3342
+ })
3343
+ : gate;
3344
+ if (goal) hydratedGate = hydrateDeferredGateDefaults(hydratedGate, goal, changeSet);
3345
+ if (goal && plan && ledger) {
3346
+ hydratedGate = hydrateBatchCloseDefaults({ gate: hydratedGate, plan, goal, ledger, changeSet });
3347
+ }
3182
3348
  await validateCompletionQualityGate(input.cwd, hydratedGate, {
3183
3349
  changeSet,
3184
3350
  plan: plan ?? undefined,
@@ -3837,9 +4003,150 @@ async function annotateUltragoalLedger(input: {
3837
4003
  rationale: input.rationale,
3838
4004
  });
3839
4005
  await appendLedger(input.cwd, { event: "steering_accepted", kind, evidence, rationale });
4006
+
3840
4007
  return { plan: input.plan };
3841
4008
  }
3842
4009
 
4010
+ async function mutateReviewCohorts<T>(cwd: string, mutate: (plan: UltragoalPlan) => T): Promise<T> {
4011
+ for (let attempt = 0; attempt < 5; attempt++) {
4012
+ const plan = await readUltragoalPlan(cwd);
4013
+ if (!plan) throw new Error("No ultragoal plan found");
4014
+ const result = mutate(plan);
4015
+ plan.updatedAt = new Date().toISOString();
4016
+ try {
4017
+ await writePlan(cwd, plan);
4018
+ return result;
4019
+ } catch (error) {
4020
+ if (!(error instanceof Error) || !error.message.includes("revision")) throw error;
4021
+ }
4022
+ }
4023
+ throw new Error("review cohort state changed concurrently; rerun the operation");
4024
+ }
4025
+
4026
+ export async function validateUltragoalReviewDispatch(input: {
4027
+ cwd: string;
4028
+ dispatchId: string;
4029
+ cohortId: string;
4030
+ taskId: string;
4031
+ lane: ReviewSourceLane;
4032
+ snapshotId: string;
4033
+ generation: number;
4034
+ repositoryBindingDigest: string;
4035
+ stateRevision: number;
4036
+ rerunCommand: string;
4037
+ taskSourceTaskId: string;
4038
+ createdAt: string;
4039
+ }): Promise<void> {
4040
+ const plan = await readUltragoalPlan(input.cwd);
4041
+ const cohort = plan?.reviewCohorts?.find(item => item.cohortId === input.cohortId && item.status === "active");
4042
+ const dispatch = cohort?.dispatches.find(item => item.dispatchId === input.dispatchId);
4043
+ if (
4044
+ !dispatch ||
4045
+ dispatch.taskId !== input.taskId ||
4046
+ dispatch.lane !== input.lane ||
4047
+ dispatch.snapshotId !== input.snapshotId ||
4048
+ dispatch.generation !== input.generation ||
4049
+ dispatch.repositoryBindingDigest !== input.repositoryBindingDigest ||
4050
+ dispatch.stateRevision !== input.stateRevision ||
4051
+ dispatch.rerunCommand !== input.rerunCommand ||
4052
+ dispatch.taskId !== input.taskSourceTaskId ||
4053
+ dispatch.createdAt !== input.createdAt
4054
+ ) {
4055
+ throw new Error("reviewSource must resolve to an active runtime-owned dispatch before task launch");
4056
+ }
4057
+ }
4058
+
4059
+ export async function freezeUltragoalReviewCohort(input: {
4060
+ cwd: string;
4061
+ workflow?: "ultragoal" | "ralplan";
4062
+ }): Promise<ReviewSourceCohort> {
4063
+ const plan = await readUltragoalPlan(input.cwd);
4064
+ if (!plan?.repositoryBinding) throw new Error("review cohort freeze requires an authoritative repository binding");
4065
+ const captured = await captureReviewSourceSnapshot(input.cwd, plan.repositoryBinding);
4066
+ const prior = plan.reviewCohorts?.find(cohort => cohort.status === "active");
4067
+ if (
4068
+ prior?.snapshotId === captured.snapshotId &&
4069
+ prior.repositoryBindingDigest === captured.repositoryBindingDigest
4070
+ ) {
4071
+ return prior;
4072
+ }
4073
+ const cohort = createReviewSourceCohort({
4074
+ workflow: input.workflow ?? "ultragoal",
4075
+ generation: (prior?.generation ?? 0) + 1,
4076
+ snapshotId: captured.snapshotId,
4077
+ repositoryBindingDigest: captured.repositoryBindingDigest,
4078
+ stateRevision: persistedStateRevision(plan),
4079
+ });
4080
+ if (prior) {
4081
+ prior.status = "superseded";
4082
+ prior.supersededBy = cohort.cohortId;
4083
+ }
4084
+ plan.reviewCohorts = [...(plan.reviewCohorts ?? []), cohort];
4085
+ plan.updatedAt = new Date().toISOString();
4086
+ await writePlan(input.cwd, plan);
4087
+ return cohort;
4088
+ }
4089
+
4090
+ export async function dispatchUltragoalReviewLane(input: {
4091
+ cwd: string;
4092
+ cohortId: string;
4093
+ taskId: string;
4094
+ lane: ReviewSourceLane;
4095
+ rerunCommand: string;
4096
+ }): Promise<ReturnType<typeof createReviewSourceDispatch>> {
4097
+ return mutateReviewCohorts(input.cwd, plan => {
4098
+ const cohort = plan.reviewCohorts?.find(item => item.cohortId === input.cohortId);
4099
+ if (!cohort) throw new Error(`Unknown review cohort ${input.cohortId}`);
4100
+ const dispatch = createReviewSourceDispatch({
4101
+ cohort,
4102
+ taskId: input.taskId,
4103
+ lane: input.lane,
4104
+ rerunCommand: input.rerunCommand,
4105
+ });
4106
+ cohort.dispatches.push(dispatch);
4107
+ return dispatch;
4108
+ });
4109
+ }
4110
+
4111
+ export async function classifyUltragoalReviewDelivery(input: {
4112
+ cwd: string;
4113
+ cohortId: string;
4114
+ dispatchId: string;
4115
+ observedDisposition: ReviewDeliveryDisposition;
4116
+ }): Promise<{ disposition: ReviewDeliveryDisposition; deliveryId: string }> {
4117
+ return mutateReviewCohorts(input.cwd, plan => {
4118
+ const cohort = plan.reviewCohorts?.find(item => item.cohortId === input.cohortId);
4119
+ const active = plan.reviewCohorts?.find(item => item.status === "active");
4120
+ if (!cohort) throw new Error(`Unknown review cohort ${input.cohortId}`);
4121
+ const dispatch = cohort.dispatches.find(item => item.dispatchId === input.dispatchId);
4122
+ if (!dispatch) throw new Error(`Unknown review dispatch ${input.dispatchId}`);
4123
+ const disposition =
4124
+ input.observedDisposition === "invalid_provenance"
4125
+ ? "invalid_provenance"
4126
+ : active?.cohortId === cohort.cohortId && cohort.status === "active"
4127
+ ? input.observedDisposition
4128
+ : "stale_review_delivery";
4129
+ const existing = cohort.deliveries.find(item => item.dispatchId === dispatch.dispatchId);
4130
+ if (existing) {
4131
+ if (existing.disposition !== disposition) throw new Error("conflicting_review_delivery");
4132
+ return { disposition, deliveryId: existing.deliveryId };
4133
+ }
4134
+ const deliveryId = crypto.randomUUID();
4135
+ cohort.deliveries.push({
4136
+ deliveryId,
4137
+ cohortId: cohort.cohortId,
4138
+ dispatchId: dispatch.dispatchId,
4139
+ taskId: dispatch.taskId,
4140
+ lane: dispatch.lane,
4141
+ snapshotId: dispatch.snapshotId,
4142
+ disposition,
4143
+ receivedAt: new Date().toISOString(),
4144
+ rerunCommand: dispatch.rerunCommand,
4145
+ });
4146
+ return { disposition, deliveryId };
4147
+ });
4148
+ }
4149
+
3843
4150
  async function markBlockedUltragoalSuperseded(input: {
3844
4151
  cwd: string;
3845
4152
  plan: UltragoalPlan;
@@ -3886,9 +4193,33 @@ export async function recordUltragoalReviewBlockers(input: {
3886
4193
  title: string;
3887
4194
  objective: string;
3888
4195
  evidence: string;
3889
- }): Promise<UltragoalPlan> {
4196
+ }): Promise<{ plan: UltragoalPlan; blockerGoalId: string }> {
3890
4197
  const objective = input.objective.trim();
3891
4198
  if (!objective) throw new Error("record-review-blockers --objective is required");
4199
+ // Pre-check on the persisted plan BEFORE any mutation (#3613): dedup and cap are
4200
+ // evaluated against the durable state so a dedup-hit is a pure idempotent return
4201
+ // (no checkpoint, no writePlan, no appendLedger) and a cap-hit throws before any
4202
+ // partial write corrupts goals.json/ledger. Read-check-then-write on this snapshot.
4203
+ const prePlan = await readUltragoalPlan(input.cwd);
4204
+ if (!prePlan) throw new Error("No ultragoal plan found. Run `gjc ultragoal create-goals --brief ...` first.");
4205
+ // Dedup BEFORE the budget check: an identical-objective open review_blocker already
4206
+ // descending from this blocked goal is returned idempotently — mirroring
4207
+ // recordReviewFindingGoals' findOpenReviewBlockerGoal path and the checkpoint #645
4208
+ // dedup discipline. Identity = review_blocker kind + trimmed objective +
4209
+ // same blockedGoalId + non-resolved status.
4210
+ const existing = findOpenReviewBlockerGoal(prePlan, objective);
4211
+ if (existing && existing.steering?.kind === "review_blocker" && existing.steering.blockedGoalId === input.goalId) {
4212
+ return { plan: prePlan, blockerGoalId: existing.id };
4213
+ }
4214
+ // Bounded cap: count unresolved descents off this blocked goal BEFORE any mutation.
4215
+ // Resolved (complete/superseded) ancestors never count, so legitimate multi-generation
4216
+ // review is not falsely capped. Descents 1..3 may exist; creating the 4th triggers the
4217
+ // deterministic terminal human handoff. Durable across replay/restart/concurrency:
4218
+ // recomputed from the persisted plan snapshot each call.
4219
+ const unresolvedDescents = countUnresolvedReviewBlockerDescents(prePlan, input.goalId);
4220
+ if (unresolvedDescents >= MAX_REVIEW_BLOCKER_DESCENTS)
4221
+ throw new UltragoalReviewBlockerRecursionCapError(input.goalId, unresolvedDescents);
4222
+ // Only now transition the blocked goal to review_blocked and record the new descent.
3892
4223
  const plan = await checkpointUltragoalGoal({
3893
4224
  cwd: input.cwd,
3894
4225
  goalId: input.goalId,
@@ -3898,7 +4229,7 @@ export async function recordUltragoalReviewBlockers(input: {
3898
4229
  const persistedPlan = await readUltragoalPlan(input.cwd);
3899
4230
  if (persistedPlan?.state_revision !== undefined) plan.state_revision = persistedPlan.state_revision;
3900
4231
  const now = new Date().toISOString();
3901
- const nextId = `G${String(plan.goals.length + 1).padStart(3, "0")}`;
4232
+ const nextId = nextUltragoalGoalId(plan);
3902
4233
  plan.goals.push({
3903
4234
  id: nextId,
3904
4235
  title: input.title.trim() || "Resolve final code-review blockers",
@@ -3911,7 +4242,7 @@ export async function recordUltragoalReviewBlockers(input: {
3911
4242
  plan.updatedAt = now;
3912
4243
  await writePlan(input.cwd, plan);
3913
4244
  await appendLedger(input.cwd, { event: "review_blockers_recorded", goalId: input.goalId, blockerGoalId: nextId });
3914
- return plan;
4245
+ return { plan, blockerGoalId: nextId };
3915
4246
  }
3916
4247
 
3917
4248
  export type UltragoalBlockerClassification = "human_blocked" | "resolvable";
@@ -4114,34 +4445,60 @@ async function readOptionalExecutorQa(cwd: string, value: string | undefined): P
4114
4445
  }
4115
4446
 
4116
4447
  import {
4448
+ ciDevChangedPathRows,
4117
4449
  computeCheckpointChangeSet,
4450
+ mergeChangeSetPaths,
4118
4451
  parseGitNameStatus,
4452
+ parseGitUntrackedPaths,
4119
4453
  parseUnifiedDiffPaths,
4120
4454
  resolveGitBase,
4121
4455
  spawnText,
4122
4456
  } from "./ultragoal-change-set";
4123
4457
 
4124
- export { computeCheckpointChangeSet, parseGitNameStatus, parseUnifiedDiffPaths, resolveGitBase, spawnText };
4458
+ export {
4459
+ ciDevChangedPathRows,
4460
+ computeCheckpointChangeSet,
4461
+ mergeChangeSetPaths,
4462
+ parseGitNameStatus,
4463
+ parseGitUntrackedPaths,
4464
+ parseUnifiedDiffPaths,
4465
+ resolveGitBase,
4466
+ spawnText,
4467
+ };
4125
4468
 
4126
4469
  function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | undefined {
4127
4470
  const kind = nonEmptyString(source.kind);
4128
- if (kind === "spec") return { source: "review-spec", paths: [], trusted: true };
4129
- if (kind === "pr" && typeof source.diff === "string")
4471
+ if (kind === "spec") {
4472
+ const codeSource = qualityGateObject(source.codeSource);
4473
+ return codeSource ? changeSetFromReviewSource(codeSource) : undefined;
4474
+ }
4475
+ if (kind === "pr" && typeof source.diff === "string") {
4476
+ const paths = parseUnifiedDiffPaths(source.diff);
4130
4477
  return {
4131
4478
  source: "review-pr",
4132
- paths: parseUnifiedDiffPaths(source.diff),
4479
+ paths,
4133
4480
  rawDiffStat: source.diff,
4134
4481
  rawDiff: source.diff,
4482
+ captureIncomplete: true,
4135
4483
  trusted: true,
4136
4484
  };
4485
+ }
4137
4486
  const local = qualityGateObject(source.local);
4138
- if (kind === "pr" && local) return changeSetFromReviewSource(local);
4487
+ if (kind === "pr" && local) {
4488
+ const localChangeSet = changeSetFromReviewSource(local);
4489
+ return localChangeSet ? { ...localChangeSet, captureIncomplete: true } : undefined;
4490
+ }
4139
4491
  if (kind === "worktree")
4140
4492
  return {
4141
4493
  source: "review-worktree",
4142
- paths: parseGitNameStatus(String(source.nameStatus ?? source.status ?? "")),
4143
- rawDiffStat: String(source.diffStat ?? ""),
4144
- rawDiff: String(source.diff ?? ""),
4494
+ paths: mergeChangeSetPaths([
4495
+ parseGitNameStatus(String(source.nameStatus ?? source.status ?? "")),
4496
+ parseGitUntrackedPaths(String(source.untracked ?? "")),
4497
+ ciDevChangedPathRows(),
4498
+ ]),
4499
+ rawDiffStat: typeof source.diffStat === "string" ? source.diffStat : undefined,
4500
+ rawDiff: typeof source.diff === "string" ? source.diff : undefined,
4501
+ captureIncomplete: source.captureIncomplete === true,
4145
4502
  trusted: true,
4146
4503
  };
4147
4504
  if (kind === "branch" || kind === "pr-fallback")
@@ -4149,9 +4506,10 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
4149
4506
  source: "review-branch",
4150
4507
  baseRef: nonEmptyString(source.base) ?? undefined,
4151
4508
  headRef: "HEAD",
4152
- paths: parseGitNameStatus(String(source.nameStatus ?? "")),
4153
- rawDiffStat: String(source.diffStat ?? ""),
4154
- rawDiff: String(source.diff ?? ""),
4509
+ paths: mergeChangeSetPaths([parseGitNameStatus(String(source.nameStatus ?? "")), ciDevChangedPathRows()]),
4510
+ rawDiffStat: typeof source.diffStat === "string" ? source.diffStat : undefined,
4511
+ rawDiff: typeof source.diff === "string" ? source.diff : undefined,
4512
+ captureIncomplete: source.captureIncomplete === true,
4155
4513
  trusted: true,
4156
4514
  };
4157
4515
  return undefined;
@@ -4159,35 +4517,52 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
4159
4517
 
4160
4518
  async function localDiffSource(cwd: string, sourceKind: string, branch?: string): Promise<JsonObject> {
4161
4519
  if (sourceKind === "worktree") {
4162
- const [status, diffStat, unstaged, staged, unstagedDiff, stagedDiff] = await Promise.all([
4520
+ const [status, diffStat, unstaged, staged, untracked, unstagedDiff, stagedDiff] = await Promise.all([
4163
4521
  spawnText(["git", "status", "--short"], { cwd, timeoutMs: 5000 }),
4164
4522
  spawnText(["git", "diff", "--stat"], { cwd, timeoutMs: 5000 }),
4165
- spawnText(["git", "diff", "--name-status"], { cwd, timeoutMs: 5000 }),
4166
- spawnText(["git", "diff", "--cached", "--name-status"], { cwd, timeoutMs: 5000 }),
4523
+ spawnText(["git", "diff", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
4524
+ spawnText(["git", "diff", "--cached", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
4525
+ spawnText(["git", "ls-files", "--others", "--exclude-standard", "-z"], { cwd, timeoutMs: 5000 }),
4167
4526
  spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
4168
4527
  spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
4169
4528
  ]);
4170
4529
  return {
4171
4530
  kind: "worktree",
4172
- status: status.stdout,
4173
- diffStat: diffStat.stdout,
4174
- diff: [unstagedDiff.stdout, stagedDiff.stdout].filter(Boolean).join("\n"),
4175
- nameStatus: `${unstaged.stdout}\n${staged.stdout}`,
4531
+ ...(status.ok ? { status: status.stdout } : {}),
4532
+ ...(diffStat.ok ? { diffStat: diffStat.stdout } : {}),
4533
+ ...(unstagedDiff.ok && stagedDiff.ok
4534
+ ? { diff: [unstagedDiff.stdout, stagedDiff.stdout].filter(Boolean).join("\n") }
4535
+ : {}),
4536
+ nameStatus: [unstaged.ok ? unstaged.stdout : "", staged.ok ? staged.stdout : ""].join(""),
4537
+ ...(untracked.ok ? { untracked: untracked.stdout } : {}),
4538
+ captureIncomplete:
4539
+ !status.ok ||
4540
+ !diffStat.ok ||
4541
+ !unstaged.ok ||
4542
+ !staged.ok ||
4543
+ !untracked.ok ||
4544
+ !unstagedDiff.ok ||
4545
+ !stagedDiff.ok,
4176
4546
  };
4177
4547
  }
4548
+ if (branch) {
4549
+ const branchExists = await spawnText(["git", "rev-parse", "--verify", branch], { cwd, timeoutMs: 3000 });
4550
+ if (!branchExists.ok) throw new Error(`review branch ${branch} does not resolve`);
4551
+ }
4178
4552
  const base = await resolveGitBase(cwd, branch);
4179
4553
  const [diffStat, nameStatus, diff] = await Promise.all([
4180
4554
  spawnText(["git", "diff", "--stat", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4181
- spawnText(["git", "diff", "--name-status", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4555
+ spawnText(["git", "diff", "--name-status", "-z", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4182
4556
  spawnText(["git", "diff", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4183
4557
  ]);
4184
4558
  return {
4185
4559
  kind: sourceKind,
4186
4560
  base,
4187
4561
  branch,
4188
- diffStat: diffStat.stdout,
4189
- diff: diff.stdout,
4190
- nameStatus: nameStatus.stdout,
4562
+ ...(diffStat.ok ? { diffStat: diffStat.stdout } : {}),
4563
+ ...(diff.ok ? { diff: diff.stdout } : {}),
4564
+ nameStatus: nameStatus.ok ? nameStatus.stdout : "",
4565
+ captureIncomplete: !diffStat.ok || !nameStatus.ok || !diff.ok,
4191
4566
  };
4192
4567
  }
4193
4568
 
@@ -4198,9 +4573,15 @@ async function resolveReviewSource(
4198
4573
  ): Promise<{ contractStrength: UltragoalReviewContractStrength; source: JsonObject }> {
4199
4574
  if (specPath) {
4200
4575
  const absolute = path.resolve(cwd, specPath);
4576
+ const codeReviewSource = await resolveReviewSource(cwd, args, undefined);
4201
4577
  return {
4202
4578
  contractStrength: "strong",
4203
- source: { kind: "spec", path: specPath, contract: await Bun.file(absolute).text() },
4579
+ source: {
4580
+ kind: "spec",
4581
+ path: specPath,
4582
+ contract: await Bun.file(absolute).text(),
4583
+ codeSource: codeReviewSource.source,
4584
+ },
4204
4585
  };
4205
4586
  }
4206
4587
  const pr = flagValue(args, "--pr");
@@ -4251,6 +4632,57 @@ function findOpenReviewBlockerGoal(plan: UltragoalPlan, message: string): Ultrag
4251
4632
  );
4252
4633
  }
4253
4634
 
4635
+ /**
4636
+ * Maximum unresolved review_blocker descents chained off a single blocked goal.
4637
+ * Descents 1..3 may exist; an attempt to create the 4th triggers the deterministic
4638
+ * terminal {@link UltragoalReviewBlockerRecursionCapError} handoff (#3613).
4639
+ */
4640
+ const MAX_REVIEW_BLOCKER_DESCENTS = 3;
4641
+
4642
+ /**
4643
+ * Typed terminal handoff thrown when {@link recordUltragoalReviewBlockers} would
4644
+ * exceed {@link MAX_REVIEW_BLOCKER_DESCENTS} unresolved review_blocker descents
4645
+ * off a single blocked goal (#3613). Never silently marks unresolved technical
4646
+ * findings complete; the operator/leader must pause and escalate.
4647
+ */
4648
+ export class UltragoalReviewBlockerRecursionCapError extends Error {
4649
+ readonly code = "review_blocker_recursion_cap" as const;
4650
+ readonly blockedGoalId: string;
4651
+ readonly unresolvedDescents: number;
4652
+ readonly cap: number;
4653
+ constructor(blockedGoalId: string, unresolvedDescents: number, cap = MAX_REVIEW_BLOCKER_DESCENTS) {
4654
+ super(
4655
+ `review_blocker_recursion_cap: goal ${blockedGoalId} already has ${unresolvedDescents} unresolved review_blocker descents (cap=${cap}). ` +
4656
+ "Record a human pause/escalation or resolve existing blockers before recording more. " +
4657
+ "Unresolved technical findings are never auto-completed.",
4658
+ );
4659
+ this.name = "UltragoalReviewBlockerRecursionCapError";
4660
+ this.blockedGoalId = blockedGoalId;
4661
+ this.unresolvedDescents = unresolvedDescents;
4662
+ this.cap = cap;
4663
+ }
4664
+ }
4665
+
4666
+ /**
4667
+ * Count unresolved review_blocker descents off a single blocked goal. A descent
4668
+ * counts iff `steering.kind === "review_blocker"` AND
4669
+ * `steering.blockedGoalId === goalId` AND status is not resolved
4670
+ * (complete/superseded). Resolved ancestors never count, so legitimate
4671
+ * multi-generation review is not falsely capped (#3613). Durable across
4672
+ * replay/restart/concurrency: computed from the persisted plan snapshot each call.
4673
+ */
4674
+ function countUnresolvedReviewBlockerDescents(plan: UltragoalPlan, goalId: string): number {
4675
+ return plan.goals.reduce((count, goal) => {
4676
+ if (
4677
+ goal.steering?.kind === "review_blocker" &&
4678
+ goal.steering.blockedGoalId === goalId &&
4679
+ !RESOLVED_REVIEW_BLOCKER_STATUSES.has(goal.status)
4680
+ )
4681
+ return count + 1;
4682
+ return count;
4683
+ }, 0);
4684
+ }
4685
+
4254
4686
  async function recordReviewFindingGoals(cwd: string, findings: readonly UltragoalReviewFinding[]): Promise<string[]> {
4255
4687
  let plan = await readUltragoalPlan(cwd);
4256
4688
  const now = new Date().toISOString();
@@ -4516,6 +4948,21 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
4516
4948
  ].join("\n");
4517
4949
  }
4518
4950
 
4951
+ if (subject === "review-source") {
4952
+ return [
4953
+ "Run native GJC Ultragoal workflow commands",
4954
+ "",
4955
+ "USAGE",
4956
+ " $ gjc ultragoal review-source freeze [--json]",
4957
+ " $ gjc ultragoal review-source dispatch --cohort-id <id> --task-id <id> --lane <cleaner|architect|qa|critic> --rerun-command <command> [--json]",
4958
+ "",
4959
+ "DESCRIPTION",
4960
+ " Freeze derives and persists a runtime-owned source snapshot before review dispatch.",
4961
+ " Dispatch returns the leader-issued reviewSource object required by source-aware task lanes.",
4962
+ "",
4963
+ ].join("\n");
4964
+ }
4965
+
4519
4966
  if (subject === "quality-gate") {
4520
4967
  return [
4521
4968
  "Run native GJC Ultragoal workflow commands",
@@ -4557,9 +5004,11 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
4557
5004
  " record-critic-gate-override",
4558
5005
  " quality-gate init",
4559
5006
  " quality-gate validate",
5007
+ " review-source freeze",
5008
+ " review-source dispatch",
4560
5009
 
4561
5010
  "",
4562
- "Run `gjc ultragoal checkpoint --help`, `gjc ultragoal review --help`, `gjc ultragoal classify-blocker --help`, `gjc ultragoal record-critic-verdict --help`, or `gjc ultragoal record-critic-gate-override --help`, or `gjc ultragoal quality-gate --help` for command-specific requirements.",
5011
+ "Run `gjc ultragoal checkpoint --help`, `gjc ultragoal review --help`, `gjc ultragoal review-source --help`, `gjc ultragoal classify-blocker --help`, `gjc ultragoal record-critic-verdict --help`, `gjc ultragoal record-critic-gate-override --help`, or `gjc ultragoal quality-gate --help` for command-specific requirements.",
4563
5012
  "",
4564
5013
  ].join("\n");
4565
5014
  }
@@ -4986,6 +5435,39 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
4986
5435
  .join("\n")}\n`,
4987
5436
  };
4988
5437
  }
5438
+ case "review-source": {
5439
+ const positional = args.filter(arg => !arg.startsWith("-"));
5440
+ const subcommand = positional[1];
5441
+ if (subcommand === "freeze") {
5442
+ const cohort = await freezeUltragoalReviewCohort({ cwd });
5443
+ return { status: 0, stdout: `${JSON.stringify(cohort, null, 2)}\n` };
5444
+ }
5445
+ if (subcommand === "dispatch") {
5446
+ const cohortId = flagValue(args, "--cohort-id") ?? "";
5447
+ const taskId = flagValue(args, "--task-id") ?? "";
5448
+ const lane = flagValue(args, "--lane") as ReviewSourceLane | undefined;
5449
+ const rerunCommand = flagValue(args, "--rerun-command") ?? "";
5450
+ if (
5451
+ !cohortId ||
5452
+ !taskId ||
5453
+ !lane ||
5454
+ !["cleaner", "architect", "qa", "critic"].includes(lane) ||
5455
+ !rerunCommand
5456
+ ) {
5457
+ return {
5458
+ status: 1,
5459
+ stderr:
5460
+ "review-source dispatch requires --cohort-id, --task-id, --lane cleaner|architect|qa|critic, and --rerun-command\n",
5461
+ };
5462
+ }
5463
+ const dispatch = await dispatchUltragoalReviewLane({ cwd, cohortId, taskId, lane, rerunCommand });
5464
+ return { status: 0, stdout: `${JSON.stringify(dispatch, null, 2)}\n` };
5465
+ }
5466
+ return {
5467
+ status: 1,
5468
+ stderr: `Unknown gjc ultragoal review-source subcommand: ${subcommand ?? "(missing)"}\n`,
5469
+ };
5470
+ }
4989
5471
  case "review": {
4990
5472
  const result = await runUltragoalReview(cwd, args);
4991
5473
  return {
@@ -5003,20 +5485,19 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
5003
5485
  };
5004
5486
  }
5005
5487
  case "record-review-blockers": {
5006
- const plan = await recordUltragoalReviewBlockers({
5488
+ const { blockerGoalId } = await recordUltragoalReviewBlockers({
5007
5489
  cwd,
5008
5490
  goalId: flagValue(args, "--goal-id") ?? "",
5009
5491
  title: flagValue(args, "--title") ?? "Resolve final code-review blockers",
5010
5492
  objective: flagValue(args, "--objective") ?? "",
5011
5493
  evidence: flagValue(args, "--evidence") ?? "",
5012
5494
  });
5013
- const goal = plan.goals.at(-1);
5014
5495
  return {
5015
5496
  status: 0,
5016
5497
  stdout: json
5017
5498
  ? renderCliWriteReceipt({
5018
5499
  ok: true,
5019
- goal_id: goal?.id,
5500
+ goal_id: blockerGoalId,
5020
5501
  goals_path: getUltragoalPaths(cwd, currentUltragoalSessionId(cwd)).goalsPath,
5021
5502
  })
5022
5503
  : "Recorded review blockers.\n",