@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.
- package/CHANGELOG.md +34 -0
- package/README.md +4 -1
- package/dist/types/async/job-manager.d.ts +36 -1
- package/dist/types/extensibility/extensions/runner.d.ts +9 -7
- package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
- package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
- package/dist/types/gjc-runtime/team-store.d.ts +0 -4
- package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
- package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +21 -4
- package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
- package/dist/types/modes/components/plugin-settings.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +18 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -3
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
- package/dist/types/session/agent-session.d.ts +5 -4
- package/dist/types/session/attempt-record-store.d.ts +19 -0
- package/dist/types/session/session-manager.d.ts +1 -0
- package/dist/types/session/session-storage.d.ts +8 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +43 -0
- package/dist/types/tools/subagent.d.ts +15 -0
- package/dist/types/tools/todo-write.d.ts +3 -0
- package/package.json +8 -8
- package/scripts/capture-sticky-viewport-showcase.ts +131 -5
- package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
- package/scripts/verify-sticky-viewport-showcase.ts +620 -93
- package/src/async/job-manager.ts +430 -46
- package/src/config/model-profiles.ts +8 -0
- package/src/config/settings-schema.ts +17 -3
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/runner.ts +80 -6
- package/src/extensibility/extensions/wrapper.ts +22 -15
- package/src/extensibility/runtime-skill-discovery.ts +4 -6
- package/src/gjc-runtime/launch-tmux.ts +309 -293
- package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
- package/src/gjc-runtime/psmux-detect.ts +52 -6
- package/src/gjc-runtime/team-launch.ts +50 -2
- package/src/gjc-runtime/team-runtime.ts +716 -251
- package/src/gjc-runtime/team-store.ts +25 -7
- package/src/gjc-runtime/team-workers.ts +161 -1
- package/src/gjc-runtime/tmux-common.ts +16 -4
- package/src/gjc-runtime/tmux-gc.ts +37 -5
- package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
- package/src/gjc-runtime/tmux-provider-context.ts +451 -0
- package/src/gjc-runtime/tmux-sessions.ts +409 -156
- package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +360 -156
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/local-protocol.ts +39 -18
- package/src/modes/acp/acp-agent.ts +27 -13
- package/src/modes/components/gjc-bundle-settings.ts +2 -0
- package/src/modes/components/plugin-settings.ts +5 -0
- package/src/modes/components/settings-selector.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/prompts/tools/monitor.md +4 -4
- package/src/sdk/broker/broker.ts +93 -10
- package/src/sdk/broker/lifecycle-ledger.ts +227 -11
- package/src/sdk/broker/lifecycle.ts +691 -92
- package/src/sdk/bus/chat-daemon-control.ts +30 -6
- package/src/sdk/bus/index.ts +23 -6
- package/src/sdk/bus/telegram-daemon-contract.ts +2 -2
- package/src/sdk/bus/telegram-daemon.ts +283 -44
- package/src/sdk/bus/topic-registry.ts +133 -10
- package/src/sdk/session.ts +7 -6
- package/src/session/agent-session.ts +752 -339
- package/src/session/attempt-record-store.ts +106 -0
- package/src/session/internal/managed-session-scope.ts +252 -67
- package/src/session/internal/managed-session-storage.ts +1 -0
- package/src/session/session-manager.ts +7 -0
- package/src/session/session-storage.ts +146 -24
- package/src/task/index.ts +230 -19
- package/src/task/provider-retry-status.ts +4 -1
- package/src/task/receipt.ts +2 -0
- package/src/task/types.ts +10 -0
- package/src/tools/monitor.ts +7 -1
- package/src/tools/output-meta.ts +10 -2
- package/src/tools/subagent.ts +97 -75
- 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(
|
|
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
|
|
1564
|
-
return
|
|
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
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
//
|
|
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
|
-
|
|
1673
|
-
|
|
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
|
-
|
|
2087
|
-
|
|
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
|
-
|
|
2039
|
+
const successfulAdversarialRows = successfulLinkedRows(
|
|
2094
2040
|
adversarialIds,
|
|
2095
2041
|
adversarialCases,
|
|
2096
2042
|
`${fieldName}.adversarialCaseRefs`,
|
|
2097
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
2144
|
+
const oldPath = exactNonEmptyString(record.oldPath);
|
|
2160
2145
|
return {
|
|
2161
|
-
path:
|
|
2146
|
+
path: normalizeChangeSetPath(pathValue),
|
|
2162
2147
|
status: status as UltragoalChangeStatus,
|
|
2163
|
-
...(oldPath ? { 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)
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
if (!
|
|
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 =
|
|
2310
|
+
const pathValue = exactNonEmptyString(record.path);
|
|
2303
2311
|
if (!pathValue || nonEmptyString(record.status)) return record;
|
|
2304
|
-
const computed = computedByPath.get(
|
|
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
|
|
2405
|
+
const derivedMemberChangeSetHashes: Record<string, unknown> = {
|
|
2398
2406
|
...derivedChangeSetHashes,
|
|
2399
2407
|
[metadata.finalGoalId]: changeSetHashForPaths(unionRows),
|
|
2400
|
-
...suppliedChangeSetHashes,
|
|
2401
2408
|
};
|
|
2402
|
-
if (union.memberChangeSetHashes === undefined
|
|
2403
|
-
union.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:
|
|
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 => ({
|
|
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 =
|
|
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 {
|
|
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")
|
|
4129
|
-
|
|
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
|
|
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)
|
|
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:
|
|
4143
|
-
|
|
4144
|
-
|
|
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:
|
|
4154
|
-
rawDiff:
|
|
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
|
-
|
|
4175
|
-
|
|
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: {
|
|
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
|
|
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:
|
|
5223
|
+
goal_id: blockerGoalId,
|
|
5020
5224
|
goals_path: getUltragoalPaths(cwd, currentUltragoalSessionId(cwd)).goalsPath,
|
|
5021
5225
|
})
|
|
5022
5226
|
: "Recorded review blockers.\n",
|