@gajae-code/coding-agent 0.12.5 → 0.12.7

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 (91) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +36 -1
  4. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  5. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  6. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  7. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  8. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  9. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  10. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  11. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  12. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  13. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  14. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  15. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
  16. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  17. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  18. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  19. package/dist/types/sdk/broker/broker.d.ts +18 -0
  20. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  21. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  22. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -3
  23. package/dist/types/sdk/bus/index.d.ts +4 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  27. package/dist/types/session/agent-session.d.ts +5 -4
  28. package/dist/types/session/attempt-record-store.d.ts +19 -0
  29. package/dist/types/session/session-manager.d.ts +1 -0
  30. package/dist/types/session/session-storage.d.ts +8 -0
  31. package/dist/types/task/receipt.d.ts +1 -0
  32. package/dist/types/task/types.d.ts +43 -0
  33. package/dist/types/tools/subagent.d.ts +15 -0
  34. package/dist/types/tools/todo-write.d.ts +3 -0
  35. package/package.json +8 -8
  36. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  37. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  38. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  39. package/src/async/job-manager.ts +430 -46
  40. package/src/config/model-profiles.ts +8 -0
  41. package/src/config/settings-schema.ts +17 -3
  42. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  43. package/src/extensibility/extensions/runner.ts +80 -6
  44. package/src/extensibility/extensions/wrapper.ts +22 -15
  45. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  46. package/src/gjc-runtime/launch-tmux.ts +309 -293
  47. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  48. package/src/gjc-runtime/psmux-detect.ts +52 -6
  49. package/src/gjc-runtime/team-launch.ts +50 -2
  50. package/src/gjc-runtime/team-runtime.ts +716 -251
  51. package/src/gjc-runtime/team-store.ts +25 -7
  52. package/src/gjc-runtime/team-workers.ts +161 -1
  53. package/src/gjc-runtime/tmux-common.ts +16 -4
  54. package/src/gjc-runtime/tmux-gc.ts +37 -5
  55. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  56. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  57. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  58. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  59. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  60. package/src/gjc-runtime/ultragoal-runtime.ts +360 -156
  61. package/src/internal-urls/docs-index.generated.ts +4 -4
  62. package/src/internal-urls/local-protocol.ts +39 -18
  63. package/src/modes/acp/acp-agent.ts +27 -13
  64. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  65. package/src/modes/components/plugin-settings.ts +5 -0
  66. package/src/modes/components/settings-selector.ts +4 -0
  67. package/src/modes/controllers/selector-controller.ts +1 -0
  68. package/src/prompts/tools/monitor.md +4 -4
  69. package/src/sdk/broker/broker.ts +93 -10
  70. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  71. package/src/sdk/broker/lifecycle.ts +691 -92
  72. package/src/sdk/bus/chat-daemon-control.ts +30 -6
  73. package/src/sdk/bus/index.ts +23 -6
  74. package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
  75. package/src/sdk/bus/telegram-daemon.ts +283 -44
  76. package/src/sdk/bus/topic-registry.ts +133 -10
  77. package/src/sdk/session.ts +7 -6
  78. package/src/session/agent-session.ts +752 -339
  79. package/src/session/attempt-record-store.ts +106 -0
  80. package/src/session/internal/managed-session-scope.ts +252 -67
  81. package/src/session/internal/managed-session-storage.ts +1 -0
  82. package/src/session/session-manager.ts +7 -0
  83. package/src/session/session-storage.ts +146 -24
  84. package/src/task/index.ts +230 -19
  85. package/src/task/provider-retry-status.ts +4 -1
  86. package/src/task/receipt.ts +2 -0
  87. package/src/task/types.ts +10 -0
  88. package/src/tools/monitor.ts +7 -1
  89. package/src/tools/output-meta.ts +10 -2
  90. package/src/tools/subagent.ts +97 -75
  91. package/src/tools/todo-write.ts +47 -0
@@ -663,6 +663,10 @@ function buildCompletionReceipt(input: {
663
663
  export function nonEmptyString(value: unknown): string | null {
664
664
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
665
665
  }
666
+
667
+ function exactNonEmptyString(value: unknown): string | null {
668
+ return typeof value === "string" && value.length > 0 ? value : null;
669
+ }
666
670
  export function stringArray(value: unknown): string[] | null {
667
671
  return Array.isArray(value) && value.every(item => typeof item === "string") ? value.map(item => item.trim()) : null;
668
672
  }
@@ -1490,12 +1494,20 @@ export function requireResolvedLinks(ids: string[], map: Map<string, JsonObject>
1490
1494
  if (!map.has(id)) throw new Error(`qualityGate ${fieldName} references unknown id ${id}`);
1491
1495
  }
1492
1496
  }
1493
- function successfulLinkedRows(ids: string[], map: Map<string, JsonObject>, fieldName: string): JsonObject[] {
1497
+ function successfulLinkedRows(
1498
+ ids: string[],
1499
+ map: Map<string, JsonObject>,
1500
+ fieldName: string,
1501
+ expectedContractRef: string,
1502
+ ): JsonObject[] {
1494
1503
  const rows: JsonObject[] = [];
1495
1504
  for (const id of ids) {
1496
1505
  const row = map.get(id);
1497
1506
  if (!row) throw new Error(`qualityGate ${fieldName} references unknown id ${id}`);
1498
1507
  requireSuccessfulRowOutcome(row, `${fieldName}.${id}`);
1508
+ if (requiredStringField(row, "contractRef", `${fieldName}.${id}`) !== expectedContractRef) {
1509
+ throw new Error(`qualityGate ${fieldName}.${id}.contractRef must match ${expectedContractRef}`);
1510
+ }
1499
1511
  rows.push(row);
1500
1512
  }
1501
1513
  return rows;
@@ -1542,6 +1554,7 @@ export interface UltragoalChangeSet extends JsonObject {
1542
1554
  paths: UltragoalChangeSetPath[];
1543
1555
  rawDiffStat?: string;
1544
1556
  rawDiff?: string;
1557
+ captureIncomplete?: boolean;
1545
1558
  trusted: true;
1546
1559
  }
1547
1560
 
@@ -1560,8 +1573,8 @@ export function normalizeRepoPath(value: string): string {
1560
1573
  return value.replaceAll("\\\\", "/").replace(/^\.\//, "");
1561
1574
  }
1562
1575
 
1563
- function isToolsIndexPath(value: string): boolean {
1564
- return normalizeRepoPath(value) === TOOLS_INDEX_PATH;
1576
+ export function normalizeChangeSetPath(value: string): string {
1577
+ return value.replace(/^\.\//, "");
1565
1578
  }
1566
1579
 
1567
1580
  export function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
@@ -1574,6 +1587,7 @@ export function categorizeComputerChangePath(value: string): UltragoalChangeCate
1574
1587
  )
1575
1588
  return "tool";
1576
1589
  if (
1590
+ normalized === TOOLS_INDEX_PATH ||
1577
1591
  normalized === "packages/coding-agent/src/tools/renderers.ts" ||
1578
1592
  normalized === "packages/coding-agent/src/config/settings-schema.ts"
1579
1593
  )
@@ -1589,78 +1603,13 @@ export function categorizeComputerChangePath(value: string): UltragoalChangeCate
1589
1603
  }
1590
1604
 
1591
1605
  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.
1606
+ // Shared behavior registries are intentionally conservative: a path-only or
1607
+ // uninspectable change cannot prove that computer controls were untouched.
1608
+ // Generated bindings remain excluded because their behavior-bearing Rust
1609
+ // source is captured separately.
1603
1610
  return category === "code" || category === "tool" || category === "settings-registry";
1604
1611
  }
1605
1612
 
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
1613
  function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolean {
1665
1614
  const category = row.category ?? categorizeComputerChangePath(row.path);
1666
1615
  const oldCategory = row.oldPath ? categorizeComputerChangePath(row.oldPath) : category;
@@ -1669,26 +1618,8 @@ function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolea
1669
1618
 
1670
1619
  function trustedChangeSetRequiresComputerSuite(changeSet: UltragoalChangeSet | undefined): boolean {
1671
1620
  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
- });
1621
+ if (changeSet.captureIncomplete) return true;
1622
+ return changeSet.paths.some(isComputerControlSurfaceChangePath);
1692
1623
  }
1693
1624
 
1694
1625
  function requiresComputerRedTeamSuite(executorQa: JsonObject, changeSet: UltragoalChangeSet | undefined): boolean {
@@ -1901,6 +1832,16 @@ async function validateSurfaceEvidence(
1901
1832
  }
1902
1833
  const artifactIds = requireStringLinks(row.artifactRefs, `${fieldName}.artifactRefs`);
1903
1834
  requireResolvedLinks(artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
1835
+ if (!isLiveSurfaceFamily(family)) {
1836
+ for (const artifactId of artifactIds) {
1837
+ const artifact = artifactRefs.get(artifactId)!;
1838
+ if (!(await hasExistingNonEmptyArtifact(cwd, artifact.path))) {
1839
+ throw new Error(
1840
+ `qualityGate executorQa.artifactRefs.${artifactId} non-live surface evidence requires an existing non-empty file`,
1841
+ );
1842
+ }
1843
+ }
1844
+ }
1904
1845
  await validateLiveSurfaceProofPresence(cwd, family, artifactIds, artifactRefs);
1905
1846
  validateSurfaceArtifactCompatibility(surface, artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
1906
1847
  await validateSurfaceStructuralRequirement(cwd, family, artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
@@ -2053,18 +1994,19 @@ async function validateMandatoryComputerAdversarialCases(
2053
1994
  }
2054
1995
  }
2055
1996
 
2056
- function validateContractCoverage(
1997
+ async function validateContractCoverage(
1998
+ cwd: string,
2057
1999
  executorQa: JsonObject,
2058
2000
  surfaceEvidence: Map<string, JsonObject>,
2059
2001
  adversarialCases: Map<string, JsonObject>,
2060
2002
  artifactRefs: Map<string, JsonObject>,
2061
- ): JsonObject[] {
2003
+ ): Promise<JsonObject[]> {
2062
2004
  const rows = requireObjectArray(executorQa.contractCoverage, "executorQa.contractCoverage");
2063
2005
  buildRowIdMap(rows, "executorQa.contractCoverage");
2064
2006
  let hasSuccessfulContractCoverage = false;
2065
2007
  for (const [index, row] of rows.entries()) {
2066
2008
  const fieldName = `executorQa.contractCoverage[${index}]`;
2067
- requiredStringField(row, "contractRef", fieldName);
2009
+ const contractRef = requiredStringField(row, "contractRef", fieldName);
2068
2010
  const status = optionalStatusField(row, fieldName);
2069
2011
  if (status === NOT_APPLICABLE_STATUS) {
2070
2012
  requiredStringField(row, "reason", fieldName);
@@ -2083,21 +2025,57 @@ function validateContractCoverage(
2083
2025
  );
2084
2026
  }
2085
2027
  let successfulProofLinks = 0;
2086
- if (surfaceIds)
2087
- successfulProofLinks += successfulLinkedRows(
2028
+ let successfulSurfaceProofLinks = 0;
2029
+ if (surfaceIds) {
2030
+ successfulSurfaceProofLinks = successfulLinkedRows(
2088
2031
  surfaceIds,
2089
2032
  surfaceEvidence,
2090
2033
  `${fieldName}.surfaceEvidenceRefs`,
2034
+ contractRef,
2091
2035
  ).length;
2036
+ successfulProofLinks += successfulSurfaceProofLinks;
2037
+ }
2092
2038
  if (adversarialIds) {
2093
- successfulProofLinks += successfulLinkedRows(
2039
+ const successfulAdversarialRows = successfulLinkedRows(
2094
2040
  adversarialIds,
2095
2041
  adversarialCases,
2096
2042
  `${fieldName}.adversarialCaseRefs`,
2097
- ).length;
2043
+ contractRef,
2044
+ );
2045
+ for (const adversarialRow of successfulAdversarialRows) {
2046
+ const caseArtifactIds = requireStringLinks(
2047
+ adversarialRow.artifactRefs,
2048
+ `${fieldName}.adversarialCaseRefs.artifactRefs`,
2049
+ );
2050
+ for (const artifactId of caseArtifactIds) {
2051
+ const artifact = artifactRefs.get(artifactId)!;
2052
+ if (!(await hasExistingNonEmptyArtifact(cwd, artifact.path))) {
2053
+ throw new Error(
2054
+ `qualityGate executorQa.artifactRefs.${artifactId} adversarial coverage requires an existing non-empty file`,
2055
+ );
2056
+ }
2057
+ await validateArtifactProof(cwd, artifact, `executorQa.artifactRefs.${artifactId}`, {
2058
+ surfaceFamily: "native",
2059
+ live: false,
2060
+ });
2061
+ }
2062
+ }
2063
+ successfulProofLinks += successfulAdversarialRows.length;
2098
2064
  }
2099
2065
  if (artifactIds) {
2100
2066
  requireResolvedLinks(artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
2067
+ for (const artifactId of artifactIds) {
2068
+ const artifact = artifactRefs.get(artifactId)!;
2069
+ if (!(await hasExistingNonEmptyArtifact(cwd, artifact.path))) {
2070
+ throw new Error(
2071
+ `qualityGate executorQa.artifactRefs.${artifactId} artifact-only coverage requires an existing non-empty file`,
2072
+ );
2073
+ }
2074
+ await validateArtifactProof(cwd, artifact, `executorQa.artifactRefs.${artifactId}`, {
2075
+ surfaceFamily: "native",
2076
+ live: false,
2077
+ });
2078
+ }
2101
2079
  successfulProofLinks += artifactIds.length;
2102
2080
  }
2103
2081
  if (successfulProofLinks === 0) {
@@ -2120,7 +2098,13 @@ async function validateExecutorQaRedTeamEvidenceInternal(
2120
2098
  const artifactRefs = await validateArtifactRefs(cwd, executorQa);
2121
2099
  const surfaceEvidence = await validateSurfaceEvidence(cwd, executorQa, artifactRefs);
2122
2100
  const adversarialCases = validateAdversarialCases(executorQa, artifactRefs);
2123
- const contractCoverage = validateContractCoverage(executorQa, surfaceEvidence, adversarialCases, artifactRefs);
2101
+ const contractCoverage = await validateContractCoverage(
2102
+ cwd,
2103
+ executorQa,
2104
+ surfaceEvidence,
2105
+ adversarialCases,
2106
+ artifactRefs,
2107
+ );
2124
2108
  if (requiresComputerRedTeamSuite(executorQa, options.changeSet)) {
2125
2109
  await validateMandatoryComputerAdversarialCases(cwd, contractCoverage, adversarialCases, artifactRefs);
2126
2110
  }
@@ -2151,16 +2135,17 @@ function canonicalChangeSetRows(value: unknown, fieldName: string): UltragoalCha
2151
2135
  if (typeof row !== "object" || row === null || Array.isArray(row))
2152
2136
  throw new Error(`${fieldName}[${index}] must be an object`);
2153
2137
  const record = row as JsonObject;
2154
- const pathValue = nonEmptyString(record.path);
2138
+ requireAllowedRecordKeys(record, ["path", "status", "oldPath"], `${fieldName}[${index}]`);
2139
+ const pathValue = exactNonEmptyString(record.path);
2155
2140
  if (!pathValue) throw new Error(`${fieldName}[${index}].path is required`);
2156
2141
  if ("goalId" in record) throw new Error(`${fieldName}[${index}] must not contain goalId attribution`);
2157
2142
  const status = nonEmptyString(record.status);
2158
2143
  if (!status) throw new Error(`${fieldName}[${index}].status is required`);
2159
- const oldPath = nonEmptyString(record.oldPath);
2144
+ const oldPath = exactNonEmptyString(record.oldPath);
2160
2145
  return {
2161
- path: normalizeRepoPath(pathValue),
2146
+ path: normalizeChangeSetPath(pathValue),
2162
2147
  status: status as UltragoalChangeStatus,
2163
- ...(oldPath ? { oldPath: normalizeRepoPath(oldPath) } : {}),
2148
+ ...(oldPath ? { oldPath: normalizeChangeSetPath(oldPath) } : {}),
2164
2149
  };
2165
2150
  });
2166
2151
  }
@@ -2174,17 +2159,39 @@ function requireChangeSetCoverage(
2174
2159
  declared: readonly UltragoalChangeSetPath[],
2175
2160
  fieldName: string,
2176
2161
  ): 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}`);
2162
+ if (!expected) throw new Error(`${fieldName} requires an authoritative computed checkpoint change set`);
2163
+ if (expected.captureIncomplete)
2164
+ throw new Error(`${fieldName} requires a complete authoritative checkpoint change set`);
2165
+ const expectedExactRows = expected.paths.map(row => `${row.oldPath ?? ""}\u0000${row.path}\u0000${row.status}`);
2166
+ const declaredExactRows = declared.map(row => `${row.oldPath ?? ""}\u0000${row.path}\u0000${row.status}`);
2167
+ const declaredExactKeys = new Set(declaredExactRows);
2168
+ for (const [index, row] of expected.paths.entries()) {
2169
+ if (!declaredExactKeys.has(expectedExactRows[index]!)) {
2170
+ throw new Error(`${fieldName} does not cover computed checkpoint change-set path ${row.path}`);
2171
+ }
2172
+ }
2173
+ if (
2174
+ declaredExactRows.length !== expectedExactRows.length ||
2175
+ declaredExactRows.some((key, index) => key !== expectedExactRows[index])
2176
+ ) {
2177
+ throw new Error(`${fieldName} must exactly match the computed checkpoint change set`);
2178
+ }
2179
+ }
2180
+
2181
+ function requireExactRecordKeys(record: JsonObject, expectedKeys: readonly string[], fieldName: string): void {
2182
+ const actual = Object.keys(record).sort();
2183
+ const expected = [...expectedKeys].sort();
2184
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
2185
+ throw new Error(`${fieldName} keys must exactly match durable validationBatch memberIds`);
2185
2186
  }
2186
2187
  }
2187
2188
 
2189
+ function requireAllowedRecordKeys(record: JsonObject, allowedKeys: readonly string[], fieldName: string): void {
2190
+ const allowed = new Set(allowedKeys);
2191
+ const unsupported = Object.keys(record).filter(key => !allowed.has(key));
2192
+ if (unsupported.length > 0) throw new Error(`${fieldName} contains unsupported keys: ${unsupported.join(", ")}`);
2193
+ }
2194
+
2188
2195
  function requireValidationBatchTuple(
2189
2196
  metadata: UltragoalValidationBatchMetadata,
2190
2197
  record: JsonObject,
@@ -2288,6 +2295,7 @@ function hydrateDeferredGateDefaults(
2288
2295
  }));
2289
2296
  const computedByPath = new Map(computedRows.map(row => [row.path, row]));
2290
2297
  const declared = qualityGateObject(hydrated.changeSet);
2298
+ if (hydrated.changeSet !== undefined && !declared) return { ...gate, deferredToBatch: hydrated };
2291
2299
  const changeSetRecord: JsonObject = declared ? { ...declared } : {};
2292
2300
  if (changeSetRecord.memberGoalId === undefined) changeSetRecord.memberGoalId = goal.id;
2293
2301
  if (changeSetRecord.cumulativeFromBase === undefined) changeSetRecord.cumulativeFromBase = true;
@@ -2299,9 +2307,9 @@ function hydrateDeferredGateDefaults(
2299
2307
  changeSetRecord.paths = changeSetRecord.paths.map(row => {
2300
2308
  const record = typeof row === "string" ? { path: row } : qualityGateObject(row);
2301
2309
  if (!record) return row;
2302
- const pathValue = nonEmptyString(record.path);
2310
+ const pathValue = exactNonEmptyString(record.path);
2303
2311
  if (!pathValue || nonEmptyString(record.status)) return record;
2304
- const computed = computedByPath.get(normalizeRepoPath(pathValue));
2312
+ const computed = computedByPath.get(normalizeChangeSetPath(pathValue));
2305
2313
  return {
2306
2314
  ...record,
2307
2315
  status: computed?.status ?? "unknown",
@@ -2376,12 +2384,12 @@ function hydrateBatchCloseDefaults(input: {
2376
2384
  // Member not fresh/complete: omit it so validation reports the real defect.
2377
2385
  }
2378
2386
  }
2379
- const suppliedMetadataHashes = qualityGateObject(close.memberMetadataHashes);
2380
2387
  if (close.memberMetadataHashes === undefined) close.memberMetadataHashes = derivedMetadataHashes;
2381
- else if (suppliedMetadataHashes)
2382
- close.memberMetadataHashes = { ...derivedMetadataHashes, ...suppliedMetadataHashes };
2383
2388
  if (close.memberReceipts === undefined) close.memberReceipts = derivedReceipts;
2384
2389
  const requestedUnion = qualityGateObject(close.unionChangeSet);
2390
+ if (close.unionChangeSet !== undefined && !requestedUnion) {
2391
+ return { ...input.gate, validationBatchClose: close };
2392
+ }
2385
2393
  const union: JsonObject = requestedUnion ? { ...requestedUnion } : {};
2386
2394
  if (union.source === undefined) union.source = "validation-batch";
2387
2395
  if (union.paths === undefined) {
@@ -2394,17 +2402,16 @@ function hydrateBatchCloseDefaults(input: {
2394
2402
  try {
2395
2403
  const unionRows = canonicalChangeSetRows(union.paths, "validationBatchClose.unionChangeSet.paths");
2396
2404
  const suppliedChangeSetHashes = qualityGateObject(union.memberChangeSetHashes);
2397
- const memberChangeSetHashes: Record<string, unknown> = {
2405
+ const derivedMemberChangeSetHashes: Record<string, unknown> = {
2398
2406
  ...derivedChangeSetHashes,
2399
2407
  [metadata.finalGoalId]: changeSetHashForPaths(unionRows),
2400
- ...suppliedChangeSetHashes,
2401
2408
  };
2402
- if (union.memberChangeSetHashes === undefined || suppliedChangeSetHashes) {
2403
- union.memberChangeSetHashes = memberChangeSetHashes;
2409
+ if (union.memberChangeSetHashes === undefined) {
2410
+ union.memberChangeSetHashes = derivedMemberChangeSetHashes;
2404
2411
  }
2405
2412
  if (union.unionHash === undefined) {
2406
2413
  union.unionHash = hashStructuredValue({
2407
- memberChangeSetHashes: qualityGateObject(union.memberChangeSetHashes) ?? memberChangeSetHashes,
2414
+ memberChangeSetHashes: suppliedChangeSetHashes ?? derivedMemberChangeSetHashes,
2408
2415
  paths: unionRows.map(row => ({ path: row.path, status: row.status, oldPath: row.oldPath })),
2409
2416
  });
2410
2417
  }
@@ -2427,6 +2434,24 @@ function validateDeferredCompletionQualityGate(
2427
2434
  throw new Error(`deferred qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`);
2428
2435
  const deferred = qualityGateObject(gate.deferredToBatch);
2429
2436
  if (!deferred) throw new Error("deferred qualityGate requires deferredToBatch object");
2437
+ requireAllowedRecordKeys(
2438
+ deferred,
2439
+ [
2440
+ "schemaVersion",
2441
+ "kind",
2442
+ "batchId",
2443
+ "memberIds",
2444
+ "finalGoalId",
2445
+ "metadataHash",
2446
+ "deferredLanes",
2447
+ "ranLanes",
2448
+ "targetedVerification",
2449
+ "aiSlopCleaner",
2450
+ "iteration",
2451
+ "changeSet",
2452
+ ],
2453
+ "deferredToBatch",
2454
+ );
2430
2455
  if (deferred.kind !== "validation-batch-deferred")
2431
2456
  throw new Error("deferredToBatch.kind must be validation-batch-deferred");
2432
2457
  if (metadata) {
@@ -2462,6 +2487,11 @@ function validateDeferredCompletionQualityGate(
2462
2487
  validateDeferredLaneDeclaration(deferred, "deferredToBatch");
2463
2488
  const declaredChangeSet = qualityGateObject(deferred.changeSet);
2464
2489
  if (!declaredChangeSet) throw new Error("deferredToBatch.changeSet is required");
2490
+ requireAllowedRecordKeys(
2491
+ declaredChangeSet,
2492
+ ["memberGoalId", "cumulativeFromBase", "paths", "changeSetHash"],
2493
+ "deferredToBatch.changeSet",
2494
+ );
2465
2495
  if (declaredChangeSet.memberGoalId !== goal.id)
2466
2496
  throw new Error(
2467
2497
  `deferredToBatch.changeSet.memberGoalId must label the checkpointed goal ${goal.id} (or be omitted; the runtime fills it)`,
@@ -2756,6 +2786,21 @@ function validateBatchCloseQualityGate(
2756
2786
  ): void {
2757
2787
  const close = qualityGateObject(gate.validationBatchClose);
2758
2788
  if (!close) throw new Error("validationBatchClose is required");
2789
+ requireAllowedRecordKeys(
2790
+ close,
2791
+ [
2792
+ "schemaVersion",
2793
+ "kind",
2794
+ "batchId",
2795
+ "finalGoalId",
2796
+ "memberIds",
2797
+ "memberMetadataHashes",
2798
+ "memberReceipts",
2799
+ "unionChangeSet",
2800
+ "coverageEvidence",
2801
+ ],
2802
+ "validationBatchClose",
2803
+ );
2759
2804
  if (close.schemaVersion !== 1 || close.kind !== "validation-batch-close")
2760
2805
  throw new Error("validationBatchClose.kind must be validation-batch-close");
2761
2806
  if (close.batchId !== metadata.batchId || close.finalGoalId !== metadata.finalGoalId)
@@ -2782,6 +2827,12 @@ function validateBatchCloseQualityGate(
2782
2827
  if (memberId !== metadata.finalGoalId && member.status !== "complete")
2783
2828
  throw new Error(`validationBatchClose cannot close before ${memberId} is complete`);
2784
2829
  }
2830
+ requireExactRecordKeys(memberMetadataHashes, metadata.memberIds, "validationBatchClose.memberMetadataHashes");
2831
+ requireExactRecordKeys(
2832
+ memberChangeSetHashes,
2833
+ metadata.memberIds,
2834
+ "validationBatchClose.unionChangeSet.memberChangeSetHashes",
2835
+ );
2785
2836
  if (receiptRows.length !== nonFinalIds.length)
2786
2837
  throw new Error("validationBatchClose.memberReceipts must list every non-final member exactly once");
2787
2838
  for (const row of receiptRows) {
@@ -2791,6 +2842,11 @@ function validateBatchCloseQualityGate(
2791
2842
  const memberId = nonEmptyString(record.goalId);
2792
2843
  if (!memberId || !nonFinalIds.includes(memberId))
2793
2844
  throw new Error("validationBatchClose.memberReceipts contains invalid member goalId");
2845
+ requireAllowedRecordKeys(
2846
+ record,
2847
+ ["goalId", "receiptId", "checkpointLedgerEventId", "qualityGateHash", "changeSetHash", "role"],
2848
+ `validationBatchClose.memberReceipts.${memberId}`,
2849
+ );
2794
2850
  if (seenReceipts.has(memberId))
2795
2851
  throw new Error(`validationBatchClose.memberReceipts contains duplicate member ${memberId}`);
2796
2852
  seenReceipts.add(memberId);
@@ -2815,6 +2871,11 @@ function validateBatchCloseQualityGate(
2815
2871
  const union = qualityGateObject(close.unionChangeSet);
2816
2872
  if (union?.source !== "validation-batch")
2817
2873
  throw new Error("validationBatchClose.unionChangeSet.source must be validation-batch");
2874
+ requireAllowedRecordKeys(
2875
+ union,
2876
+ ["source", "memberChangeSetHashes", "paths", "unionHash"],
2877
+ "validationBatchClose.unionChangeSet",
2878
+ );
2818
2879
  const unionPaths = canonicalChangeSetRows(union.paths, "validationBatchClose.unionChangeSet.paths");
2819
2880
  requireChangeSetCoverage(changeSet, unionPaths, "validationBatchClose.unionChangeSet.paths");
2820
2881
  const finalHash = changeSetHashForPaths(unionPaths);
@@ -2900,8 +2961,11 @@ function hydrateReviewedBatchReplacementClose(input: {
2900
2961
  const aggregateGoal = aggregateGoals.find(goal => {
2901
2962
  const receipt = goal.completionVerification!;
2902
2963
  const event = findLedgerReceiptEvent(input.ledger, receipt);
2964
+ const eventReceipt = event?.completionVerification as UltragoalCompletionVerification | undefined;
2903
2965
  return (
2904
2966
  event !== null &&
2967
+ eventReceipt !== undefined &&
2968
+ hashStructuredValue(eventReceipt) === hashStructuredValue(receipt) &&
2905
2969
  hashStructuredValue(event.qualityGateJson) === receipt.qualityGateHash &&
2906
2970
  goal.updatedAt === receipt.verifiedAt &&
2907
2971
  receipt.basis.relevantGoalIdsBeforeCheckpoint.length === historicalRequiredGoalIds.length &&
@@ -2940,7 +3004,11 @@ function hydrateReviewedBatchReplacementClose(input: {
2940
3004
  role: "deferred-member",
2941
3005
  });
2942
3006
  }
2943
- const paths = input.changeSet.paths.map(row => ({ ...row }));
3007
+ const paths = input.changeSet.paths.map(row => ({
3008
+ path: row.path,
3009
+ status: row.status,
3010
+ ...(row.oldPath ? { oldPath: row.oldPath } : {}),
3011
+ }));
2944
3012
  memberChangeSetHashes[input.goal.id] = changeSetHashForPaths(paths);
2945
3013
  const unionHash = hashStructuredValue({
2946
3014
  memberChangeSetHashes,
@@ -3174,11 +3242,23 @@ export async function validateUltragoalQualityGateReadOnly(input: {
3174
3242
  }
3175
3243
  const ledger = plan ? await readUltragoalLedger(input.cwd, sessionId) : undefined;
3176
3244
  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
3245
  try {
3246
+ const validationBatch = goal ? requireFreshValidationBatchMetadata(goal) : undefined;
3247
+ let hydratedGate =
3248
+ validationBatch && plan && goal && ledger
3249
+ ? hydrateReviewedBatchReplacementClose({
3250
+ gate,
3251
+ plan,
3252
+ goal,
3253
+ metadata: validationBatch,
3254
+ ledger,
3255
+ changeSet,
3256
+ })
3257
+ : gate;
3258
+ if (goal) hydratedGate = hydrateDeferredGateDefaults(hydratedGate, goal, changeSet);
3259
+ if (goal && plan && ledger) {
3260
+ hydratedGate = hydrateBatchCloseDefaults({ gate: hydratedGate, plan, goal, ledger, changeSet });
3261
+ }
3182
3262
  await validateCompletionQualityGate(input.cwd, hydratedGate, {
3183
3263
  changeSet,
3184
3264
  plan: plan ?? undefined,
@@ -3886,9 +3966,33 @@ export async function recordUltragoalReviewBlockers(input: {
3886
3966
  title: string;
3887
3967
  objective: string;
3888
3968
  evidence: string;
3889
- }): Promise<UltragoalPlan> {
3969
+ }): Promise<{ plan: UltragoalPlan; blockerGoalId: string }> {
3890
3970
  const objective = input.objective.trim();
3891
3971
  if (!objective) throw new Error("record-review-blockers --objective is required");
3972
+ // Pre-check on the persisted plan BEFORE any mutation (#3613): dedup and cap are
3973
+ // evaluated against the durable state so a dedup-hit is a pure idempotent return
3974
+ // (no checkpoint, no writePlan, no appendLedger) and a cap-hit throws before any
3975
+ // partial write corrupts goals.json/ledger. Read-check-then-write on this snapshot.
3976
+ const prePlan = await readUltragoalPlan(input.cwd);
3977
+ if (!prePlan) throw new Error("No ultragoal plan found. Run `gjc ultragoal create-goals --brief ...` first.");
3978
+ // Dedup BEFORE the budget check: an identical-objective open review_blocker already
3979
+ // descending from this blocked goal is returned idempotently — mirroring
3980
+ // recordReviewFindingGoals' findOpenReviewBlockerGoal path and the checkpoint #645
3981
+ // dedup discipline. Identity = review_blocker kind + trimmed objective +
3982
+ // same blockedGoalId + non-resolved status.
3983
+ const existing = findOpenReviewBlockerGoal(prePlan, objective);
3984
+ if (existing && existing.steering?.kind === "review_blocker" && existing.steering.blockedGoalId === input.goalId) {
3985
+ return { plan: prePlan, blockerGoalId: existing.id };
3986
+ }
3987
+ // Bounded cap: count unresolved descents off this blocked goal BEFORE any mutation.
3988
+ // Resolved (complete/superseded) ancestors never count, so legitimate multi-generation
3989
+ // review is not falsely capped. Descents 1..3 may exist; creating the 4th triggers the
3990
+ // deterministic terminal human handoff. Durable across replay/restart/concurrency:
3991
+ // recomputed from the persisted plan snapshot each call.
3992
+ const unresolvedDescents = countUnresolvedReviewBlockerDescents(prePlan, input.goalId);
3993
+ if (unresolvedDescents >= MAX_REVIEW_BLOCKER_DESCENTS)
3994
+ throw new UltragoalReviewBlockerRecursionCapError(input.goalId, unresolvedDescents);
3995
+ // Only now transition the blocked goal to review_blocked and record the new descent.
3892
3996
  const plan = await checkpointUltragoalGoal({
3893
3997
  cwd: input.cwd,
3894
3998
  goalId: input.goalId,
@@ -3898,7 +4002,7 @@ export async function recordUltragoalReviewBlockers(input: {
3898
4002
  const persistedPlan = await readUltragoalPlan(input.cwd);
3899
4003
  if (persistedPlan?.state_revision !== undefined) plan.state_revision = persistedPlan.state_revision;
3900
4004
  const now = new Date().toISOString();
3901
- const nextId = `G${String(plan.goals.length + 1).padStart(3, "0")}`;
4005
+ const nextId = nextUltragoalGoalId(plan);
3902
4006
  plan.goals.push({
3903
4007
  id: nextId,
3904
4008
  title: input.title.trim() || "Resolve final code-review blockers",
@@ -3911,7 +4015,7 @@ export async function recordUltragoalReviewBlockers(input: {
3911
4015
  plan.updatedAt = now;
3912
4016
  await writePlan(input.cwd, plan);
3913
4017
  await appendLedger(input.cwd, { event: "review_blockers_recorded", goalId: input.goalId, blockerGoalId: nextId });
3914
- return plan;
4018
+ return { plan, blockerGoalId: nextId };
3915
4019
  }
3916
4020
 
3917
4021
  export type UltragoalBlockerClassification = "human_blocked" | "resolvable";
@@ -4114,34 +4218,60 @@ async function readOptionalExecutorQa(cwd: string, value: string | undefined): P
4114
4218
  }
4115
4219
 
4116
4220
  import {
4221
+ ciDevChangedPathRows,
4117
4222
  computeCheckpointChangeSet,
4223
+ mergeChangeSetPaths,
4118
4224
  parseGitNameStatus,
4225
+ parseGitUntrackedPaths,
4119
4226
  parseUnifiedDiffPaths,
4120
4227
  resolveGitBase,
4121
4228
  spawnText,
4122
4229
  } from "./ultragoal-change-set";
4123
4230
 
4124
- export { computeCheckpointChangeSet, parseGitNameStatus, parseUnifiedDiffPaths, resolveGitBase, spawnText };
4231
+ export {
4232
+ ciDevChangedPathRows,
4233
+ computeCheckpointChangeSet,
4234
+ mergeChangeSetPaths,
4235
+ parseGitNameStatus,
4236
+ parseGitUntrackedPaths,
4237
+ parseUnifiedDiffPaths,
4238
+ resolveGitBase,
4239
+ spawnText,
4240
+ };
4125
4241
 
4126
4242
  function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | undefined {
4127
4243
  const kind = nonEmptyString(source.kind);
4128
- if (kind === "spec") return { source: "review-spec", paths: [], trusted: true };
4129
- if (kind === "pr" && typeof source.diff === "string")
4244
+ if (kind === "spec") {
4245
+ const codeSource = qualityGateObject(source.codeSource);
4246
+ return codeSource ? changeSetFromReviewSource(codeSource) : undefined;
4247
+ }
4248
+ if (kind === "pr" && typeof source.diff === "string") {
4249
+ const paths = parseUnifiedDiffPaths(source.diff);
4130
4250
  return {
4131
4251
  source: "review-pr",
4132
- paths: parseUnifiedDiffPaths(source.diff),
4252
+ paths,
4133
4253
  rawDiffStat: source.diff,
4134
4254
  rawDiff: source.diff,
4255
+ captureIncomplete: true,
4135
4256
  trusted: true,
4136
4257
  };
4258
+ }
4137
4259
  const local = qualityGateObject(source.local);
4138
- if (kind === "pr" && local) return changeSetFromReviewSource(local);
4260
+ if (kind === "pr" && local) {
4261
+ const localChangeSet = changeSetFromReviewSource(local);
4262
+ return localChangeSet ? { ...localChangeSet, captureIncomplete: true } : undefined;
4263
+ }
4139
4264
  if (kind === "worktree")
4140
4265
  return {
4141
4266
  source: "review-worktree",
4142
- paths: parseGitNameStatus(String(source.nameStatus ?? source.status ?? "")),
4143
- rawDiffStat: String(source.diffStat ?? ""),
4144
- rawDiff: String(source.diff ?? ""),
4267
+ paths: mergeChangeSetPaths([
4268
+ parseGitNameStatus(String(source.nameStatus ?? source.status ?? "")),
4269
+ parseGitUntrackedPaths(String(source.untracked ?? "")),
4270
+ ciDevChangedPathRows(),
4271
+ ]),
4272
+ rawDiffStat: typeof source.diffStat === "string" ? source.diffStat : undefined,
4273
+ rawDiff: typeof source.diff === "string" ? source.diff : undefined,
4274
+ captureIncomplete: source.captureIncomplete === true,
4145
4275
  trusted: true,
4146
4276
  };
4147
4277
  if (kind === "branch" || kind === "pr-fallback")
@@ -4149,9 +4279,10 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
4149
4279
  source: "review-branch",
4150
4280
  baseRef: nonEmptyString(source.base) ?? undefined,
4151
4281
  headRef: "HEAD",
4152
- paths: parseGitNameStatus(String(source.nameStatus ?? "")),
4153
- rawDiffStat: String(source.diffStat ?? ""),
4154
- rawDiff: String(source.diff ?? ""),
4282
+ paths: mergeChangeSetPaths([parseGitNameStatus(String(source.nameStatus ?? "")), ciDevChangedPathRows()]),
4283
+ rawDiffStat: typeof source.diffStat === "string" ? source.diffStat : undefined,
4284
+ rawDiff: typeof source.diff === "string" ? source.diff : undefined,
4285
+ captureIncomplete: source.captureIncomplete === true,
4155
4286
  trusted: true,
4156
4287
  };
4157
4288
  return undefined;
@@ -4159,35 +4290,52 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
4159
4290
 
4160
4291
  async function localDiffSource(cwd: string, sourceKind: string, branch?: string): Promise<JsonObject> {
4161
4292
  if (sourceKind === "worktree") {
4162
- const [status, diffStat, unstaged, staged, unstagedDiff, stagedDiff] = await Promise.all([
4293
+ const [status, diffStat, unstaged, staged, untracked, unstagedDiff, stagedDiff] = await Promise.all([
4163
4294
  spawnText(["git", "status", "--short"], { cwd, timeoutMs: 5000 }),
4164
4295
  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 }),
4296
+ spawnText(["git", "diff", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
4297
+ spawnText(["git", "diff", "--cached", "--name-status", "-z"], { cwd, timeoutMs: 5000 }),
4298
+ spawnText(["git", "ls-files", "--others", "--exclude-standard", "-z"], { cwd, timeoutMs: 5000 }),
4167
4299
  spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
4168
4300
  spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
4169
4301
  ]);
4170
4302
  return {
4171
4303
  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}`,
4304
+ ...(status.ok ? { status: status.stdout } : {}),
4305
+ ...(diffStat.ok ? { diffStat: diffStat.stdout } : {}),
4306
+ ...(unstagedDiff.ok && stagedDiff.ok
4307
+ ? { diff: [unstagedDiff.stdout, stagedDiff.stdout].filter(Boolean).join("\n") }
4308
+ : {}),
4309
+ nameStatus: [unstaged.ok ? unstaged.stdout : "", staged.ok ? staged.stdout : ""].join(""),
4310
+ ...(untracked.ok ? { untracked: untracked.stdout } : {}),
4311
+ captureIncomplete:
4312
+ !status.ok ||
4313
+ !diffStat.ok ||
4314
+ !unstaged.ok ||
4315
+ !staged.ok ||
4316
+ !untracked.ok ||
4317
+ !unstagedDiff.ok ||
4318
+ !stagedDiff.ok,
4176
4319
  };
4177
4320
  }
4321
+ if (branch) {
4322
+ const branchExists = await spawnText(["git", "rev-parse", "--verify", branch], { cwd, timeoutMs: 3000 });
4323
+ if (!branchExists.ok) throw new Error(`review branch ${branch} does not resolve`);
4324
+ }
4178
4325
  const base = await resolveGitBase(cwd, branch);
4179
4326
  const [diffStat, nameStatus, diff] = await Promise.all([
4180
4327
  spawnText(["git", "diff", "--stat", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4181
- spawnText(["git", "diff", "--name-status", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4328
+ spawnText(["git", "diff", "--name-status", "-z", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4182
4329
  spawnText(["git", "diff", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4183
4330
  ]);
4184
4331
  return {
4185
4332
  kind: sourceKind,
4186
4333
  base,
4187
4334
  branch,
4188
- diffStat: diffStat.stdout,
4189
- diff: diff.stdout,
4190
- nameStatus: nameStatus.stdout,
4335
+ ...(diffStat.ok ? { diffStat: diffStat.stdout } : {}),
4336
+ ...(diff.ok ? { diff: diff.stdout } : {}),
4337
+ nameStatus: nameStatus.ok ? nameStatus.stdout : "",
4338
+ captureIncomplete: !diffStat.ok || !nameStatus.ok || !diff.ok,
4191
4339
  };
4192
4340
  }
4193
4341
 
@@ -4198,9 +4346,15 @@ async function resolveReviewSource(
4198
4346
  ): Promise<{ contractStrength: UltragoalReviewContractStrength; source: JsonObject }> {
4199
4347
  if (specPath) {
4200
4348
  const absolute = path.resolve(cwd, specPath);
4349
+ const codeReviewSource = await resolveReviewSource(cwd, args, undefined);
4201
4350
  return {
4202
4351
  contractStrength: "strong",
4203
- source: { kind: "spec", path: specPath, contract: await Bun.file(absolute).text() },
4352
+ source: {
4353
+ kind: "spec",
4354
+ path: specPath,
4355
+ contract: await Bun.file(absolute).text(),
4356
+ codeSource: codeReviewSource.source,
4357
+ },
4204
4358
  };
4205
4359
  }
4206
4360
  const pr = flagValue(args, "--pr");
@@ -4251,6 +4405,57 @@ function findOpenReviewBlockerGoal(plan: UltragoalPlan, message: string): Ultrag
4251
4405
  );
4252
4406
  }
4253
4407
 
4408
+ /**
4409
+ * Maximum unresolved review_blocker descents chained off a single blocked goal.
4410
+ * Descents 1..3 may exist; an attempt to create the 4th triggers the deterministic
4411
+ * terminal {@link UltragoalReviewBlockerRecursionCapError} handoff (#3613).
4412
+ */
4413
+ const MAX_REVIEW_BLOCKER_DESCENTS = 3;
4414
+
4415
+ /**
4416
+ * Typed terminal handoff thrown when {@link recordUltragoalReviewBlockers} would
4417
+ * exceed {@link MAX_REVIEW_BLOCKER_DESCENTS} unresolved review_blocker descents
4418
+ * off a single blocked goal (#3613). Never silently marks unresolved technical
4419
+ * findings complete; the operator/leader must pause and escalate.
4420
+ */
4421
+ export class UltragoalReviewBlockerRecursionCapError extends Error {
4422
+ readonly code = "review_blocker_recursion_cap" as const;
4423
+ readonly blockedGoalId: string;
4424
+ readonly unresolvedDescents: number;
4425
+ readonly cap: number;
4426
+ constructor(blockedGoalId: string, unresolvedDescents: number, cap = MAX_REVIEW_BLOCKER_DESCENTS) {
4427
+ super(
4428
+ `review_blocker_recursion_cap: goal ${blockedGoalId} already has ${unresolvedDescents} unresolved review_blocker descents (cap=${cap}). ` +
4429
+ "Record a human pause/escalation or resolve existing blockers before recording more. " +
4430
+ "Unresolved technical findings are never auto-completed.",
4431
+ );
4432
+ this.name = "UltragoalReviewBlockerRecursionCapError";
4433
+ this.blockedGoalId = blockedGoalId;
4434
+ this.unresolvedDescents = unresolvedDescents;
4435
+ this.cap = cap;
4436
+ }
4437
+ }
4438
+
4439
+ /**
4440
+ * Count unresolved review_blocker descents off a single blocked goal. A descent
4441
+ * counts iff `steering.kind === "review_blocker"` AND
4442
+ * `steering.blockedGoalId === goalId` AND status is not resolved
4443
+ * (complete/superseded). Resolved ancestors never count, so legitimate
4444
+ * multi-generation review is not falsely capped (#3613). Durable across
4445
+ * replay/restart/concurrency: computed from the persisted plan snapshot each call.
4446
+ */
4447
+ function countUnresolvedReviewBlockerDescents(plan: UltragoalPlan, goalId: string): number {
4448
+ return plan.goals.reduce((count, goal) => {
4449
+ if (
4450
+ goal.steering?.kind === "review_blocker" &&
4451
+ goal.steering.blockedGoalId === goalId &&
4452
+ !RESOLVED_REVIEW_BLOCKER_STATUSES.has(goal.status)
4453
+ )
4454
+ return count + 1;
4455
+ return count;
4456
+ }, 0);
4457
+ }
4458
+
4254
4459
  async function recordReviewFindingGoals(cwd: string, findings: readonly UltragoalReviewFinding[]): Promise<string[]> {
4255
4460
  let plan = await readUltragoalPlan(cwd);
4256
4461
  const now = new Date().toISOString();
@@ -5003,20 +5208,19 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
5003
5208
  };
5004
5209
  }
5005
5210
  case "record-review-blockers": {
5006
- const plan = await recordUltragoalReviewBlockers({
5211
+ const { blockerGoalId } = await recordUltragoalReviewBlockers({
5007
5212
  cwd,
5008
5213
  goalId: flagValue(args, "--goal-id") ?? "",
5009
5214
  title: flagValue(args, "--title") ?? "Resolve final code-review blockers",
5010
5215
  objective: flagValue(args, "--objective") ?? "",
5011
5216
  evidence: flagValue(args, "--evidence") ?? "",
5012
5217
  });
5013
- const goal = plan.goals.at(-1);
5014
5218
  return {
5015
5219
  status: 0,
5016
5220
  stdout: json
5017
5221
  ? renderCliWriteReceipt({
5018
5222
  ok: true,
5019
- goal_id: goal?.id,
5223
+ goal_id: blockerGoalId,
5020
5224
  goals_path: getUltragoalPaths(cwd, currentUltragoalSessionId(cwd)).goalsPath,
5021
5225
  })
5022
5226
  : "Recorded review blockers.\n",