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