@gajae-code/coding-agent 0.5.1 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/dist/types/cli/setup-cli.d.ts +8 -1
- package/dist/types/commands/setup.d.ts +7 -0
- package/dist/types/config/file-lock.d.ts +24 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +62 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +64 -2
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +10 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +29 -0
- package/dist/types/modes/components/provider-onboarding-selector.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +56 -1
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +10 -0
- package/dist/types/modes/theme/defaults/index.d.ts +302 -0
- package/dist/types/modes/theme/theme.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -1
- package/dist/types/session/history-storage.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +10 -1
- package/dist/types/setup/credential-import.d.ts +79 -0
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/render.d.ts +1 -1
- package/dist/types/tools/subagent-render.d.ts +7 -1
- package/dist/types/tools/subagent.d.ts +21 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +5 -0
- package/dist/types/web/search/index.d.ts +4 -4
- package/dist/types/web/search/provider.d.ts +16 -20
- package/dist/types/web/search/providers/base.d.ts +2 -1
- package/dist/types/web/search/providers/openai-compatible.d.ts +9 -0
- package/dist/types/web/search/types.d.ts +14 -2
- package/package.json +7 -7
- package/scripts/build-binary.ts +7 -0
- package/src/cli/args.ts +2 -0
- package/src/cli/fast-help.ts +2 -0
- package/src/cli/setup-cli.ts +138 -3
- package/src/commands/setup.ts +5 -1
- package/src/commands/ultragoal.ts +3 -1
- package/src/config/file-lock-gc.ts +14 -2
- package/src/config/file-lock.ts +54 -12
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +15 -15
- package/src/config/model-registry.ts +21 -1
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +62 -0
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +30 -8
- package/src/gjc-runtime/deep-interview-recorder.ts +40 -0
- package/src/gjc-runtime/launch-tmux.ts +3 -4
- package/src/gjc-runtime/ralplan-runtime.ts +174 -12
- package/src/gjc-runtime/state-runtime.ts +2 -1
- package/src/gjc-runtime/state-writer.ts +254 -7
- package/src/gjc-runtime/tmux-gc.ts +2 -1
- package/src/gjc-runtime/ultragoal-guard.ts +155 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1227 -31
- package/src/gjc-runtime/workflow-manifest.generated.json +44 -0
- package/src/gjc-runtime/workflow-manifest.ts +12 -0
- package/src/harness-control-plane/owner.ts +3 -2
- package/src/harness-control-plane/rpc-adapter.ts +1 -1
- package/src/hooks/skill-state.ts +121 -2
- package/src/internal-urls/docs-index.generated.ts +13 -9
- package/src/lsp/defaults.json +1 -0
- package/src/main.ts +14 -4
- package/src/modes/acp/acp-agent.ts +4 -2
- package/src/modes/bridge/bridge-mode.ts +2 -1
- package/src/modes/components/history-search.ts +5 -2
- package/src/modes/components/model-selector.ts +26 -0
- package/src/modes/components/provider-onboarding-selector.ts +6 -1
- package/src/modes/controllers/selector-controller.ts +80 -1
- package/src/modes/interactive-mode.ts +11 -1
- package/src/modes/rpc/rpc-mode.ts +132 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +5 -2
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +3 -0
- package/src/modes/shared/agent-wire/unattended-session.ts +16 -1
- package/src/modes/theme/defaults/claude-code.json +100 -0
- package/src/modes/theme/defaults/codex.json +100 -0
- package/src/modes/theme/defaults/index.ts +6 -0
- package/src/modes/theme/defaults/opencode.json +102 -0
- package/src/modes/theme/theme.ts +2 -2
- package/src/modes/types.ts +1 -1
- package/src/prompts/agents/executor.md +5 -2
- package/src/sdk.ts +12 -1
- package/src/session/agent-session.ts +22 -11
- package/src/session/history-storage.ts +32 -11
- package/src/session/session-manager.ts +70 -18
- package/src/setup/credential-import.ts +429 -0
- package/src/skill-state/deep-interview-mutation-guard.ts +2 -1
- package/src/task/executor.ts +7 -1
- package/src/task/render.ts +18 -7
- package/src/tools/ask.ts +4 -2
- package/src/tools/cron.ts +1 -1
- package/src/tools/subagent-render.ts +119 -29
- package/src/tools/subagent.ts +147 -7
- package/src/tools/ultragoal-ask-guard.ts +39 -0
- package/src/web/search/index.ts +25 -25
- package/src/web/search/provider.ts +178 -87
- package/src/web/search/providers/base.ts +2 -1
- package/src/web/search/providers/openai-compatible.ts +151 -0
- package/src/web/search/types.ts +47 -22
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as crypto from "node:crypto";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { inflateSync } from "node:zlib";
|
|
4
|
+
|
|
3
5
|
import type { WorkflowHudSummary } from "../skill-state/active-state";
|
|
4
6
|
import { buildUltragoalHudSummary as buildWorkflowUltragoalHudSummary } from "../skill-state/workflow-hud";
|
|
5
7
|
import { renderCliWriteReceipt } from "./cli-write-receipt";
|
|
@@ -93,6 +95,8 @@ export interface UltragoalStatusSummary {
|
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
export interface UltragoalCommandResult {
|
|
98
|
+
reviewBlockerGoalIds?: string[];
|
|
99
|
+
createdReviewPlan?: boolean;
|
|
96
100
|
status: number;
|
|
97
101
|
stdout?: string;
|
|
98
102
|
stderr?: string;
|
|
@@ -795,16 +799,37 @@ function evidenceKindMatches(kind: string, words: string[]): boolean {
|
|
|
795
799
|
return words.some(word => kind.includes(word));
|
|
796
800
|
}
|
|
797
801
|
|
|
802
|
+
type SurfaceFamily = "web" | "cli" | "native" | "api-package" | "algorithm-math" | "unknown";
|
|
803
|
+
|
|
804
|
+
export function normalizeSurfaceToken(value: string): string {
|
|
805
|
+
return value.toLowerCase().replaceAll("_", "-").trim();
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export function surfaceFamily(value: string): SurfaceFamily {
|
|
809
|
+
const normalized = normalizeSurfaceToken(value);
|
|
810
|
+
if (["native", "desktop", "tui"].some(word => normalized.includes(word))) return "native";
|
|
811
|
+
if (["gui", "web", "browser", "ui", "visual"].some(word => normalized.includes(word))) return "web";
|
|
812
|
+
if (["cli", "terminal", "command"].some(word => normalized.includes(word))) return "cli";
|
|
813
|
+
if (["api", "package", "library", "sdk"].some(word => normalized.includes(word))) return "api-package";
|
|
814
|
+
if (["algorithm", "math", "mathematical", "equation"].some(word => normalized.includes(word))) {
|
|
815
|
+
return "algorithm-math";
|
|
816
|
+
}
|
|
817
|
+
return "unknown";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function isLiveSurfaceFamily(family: SurfaceFamily): boolean {
|
|
821
|
+
return family === "web" || family === "cli" || family === "native";
|
|
822
|
+
}
|
|
823
|
+
|
|
798
824
|
function validateSurfaceArtifactCompatibility(
|
|
799
825
|
surface: string,
|
|
800
826
|
artifactIds: string[],
|
|
801
827
|
artifactRefs: Map<string, JsonObject>,
|
|
802
828
|
fieldName: string,
|
|
803
829
|
): void {
|
|
804
|
-
const
|
|
830
|
+
const family = surfaceFamily(surface);
|
|
805
831
|
const kinds = artifactIds.map(id => normalizedEvidenceKind(artifactRefs.get(id)!));
|
|
806
|
-
|
|
807
|
-
if (isGuiOrWeb) {
|
|
832
|
+
if (family === "web") {
|
|
808
833
|
const hasBrowser = kinds.some(kind =>
|
|
809
834
|
evidenceKindMatches(kind, ["browser", "playwright", "pandawright", "automation"]),
|
|
810
835
|
);
|
|
@@ -816,31 +841,30 @@ function validateSurfaceArtifactCompatibility(
|
|
|
816
841
|
}
|
|
817
842
|
return;
|
|
818
843
|
}
|
|
819
|
-
const surfaceFamilies:
|
|
820
|
-
{
|
|
821
|
-
surface: ["cli", "terminal", "command"],
|
|
844
|
+
const surfaceFamilies: Record<Exclude<SurfaceFamily, "web" | "unknown">, { evidence: string[]; label: string }> = {
|
|
845
|
+
cli: {
|
|
822
846
|
evidence: ["cli", "log", "transcript", "terminal", "command", "test-report"],
|
|
823
847
|
label: "CLI",
|
|
824
848
|
},
|
|
825
|
-
{
|
|
826
|
-
|
|
849
|
+
native: {
|
|
850
|
+
evidence: ["native", "desktop", "tui", "terminal", "pty", "transcript", "screenshot", "image", "automation"],
|
|
851
|
+
label: "native",
|
|
852
|
+
},
|
|
853
|
+
"api-package": {
|
|
827
854
|
evidence: ["api", "package", "consumer", "black-box", "test-report"],
|
|
828
855
|
label: "API/package",
|
|
829
856
|
},
|
|
830
|
-
{
|
|
831
|
-
surface: ["algorithm", "math", "mathematical", "equation"],
|
|
857
|
+
"algorithm-math": {
|
|
832
858
|
evidence: ["property", "boundary", "edge", "adversarial", "failure", "math", "algorithm", "test-report"],
|
|
833
859
|
label: "algorithm/math",
|
|
834
860
|
},
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
-
return;
|
|
861
|
+
};
|
|
862
|
+
if (family !== "unknown") {
|
|
863
|
+
const expected = surfaceFamilies[family];
|
|
864
|
+
if (!kinds.some(kind => evidenceKindMatches(kind, expected.evidence))) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
`qualityGate ${fieldName} for ${expected.label} surfaces must reference compatible artifact kinds`,
|
|
867
|
+
);
|
|
844
868
|
}
|
|
845
869
|
}
|
|
846
870
|
}
|
|
@@ -877,31 +901,850 @@ async function hasExistingNonEmptyArtifact(cwd: string, value: unknown): Promise
|
|
|
877
901
|
}
|
|
878
902
|
}
|
|
879
903
|
|
|
880
|
-
async function
|
|
881
|
-
|
|
882
|
-
if (
|
|
883
|
-
|
|
904
|
+
async function readArtifactBytes(cwd: string, row: JsonObject, fieldName: string): Promise<Buffer | null> {
|
|
905
|
+
const artifactPath = nonEmptyString(row.path);
|
|
906
|
+
if (!artifactPath) return null;
|
|
907
|
+
const resolved = path.resolve(cwd, artifactPath);
|
|
908
|
+
try {
|
|
909
|
+
const file = Bun.file(resolved);
|
|
910
|
+
if (!(await file.exists())) return null;
|
|
911
|
+
return Buffer.from(await file.arrayBuffer());
|
|
912
|
+
} catch (error) {
|
|
913
|
+
if (isEnoent(error)) return null;
|
|
914
|
+
throw new Error(`qualityGate ${fieldName} artifact could not be read: ${String(error)}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
919
|
+
const JPEG_START_OF_IMAGE = 0xd8;
|
|
920
|
+
const JPEG_END_OF_IMAGE = 0xd9;
|
|
921
|
+
const JPEG_START_OF_SCAN = 0xda;
|
|
922
|
+
const JPEG_STANDALONE_MARKERS = new Set([0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7]);
|
|
923
|
+
const PNG_CRC_TABLE = new Uint32Array(256).map((_, index) => {
|
|
924
|
+
let crc = index;
|
|
925
|
+
for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
926
|
+
return crc >>> 0;
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
function pngCrc32(bytes: Buffer): number {
|
|
930
|
+
let crc = 0xffffffff;
|
|
931
|
+
for (const byte of bytes) crc = PNG_CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
|
932
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function parsePngDimensions(
|
|
936
|
+
bytes: Buffer,
|
|
937
|
+
): { width: number; height: number; headerBytes: number; sampleBytes?: Buffer } | null {
|
|
938
|
+
if (bytes.length < 45) return null;
|
|
939
|
+
if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
|
940
|
+
let offset = 8;
|
|
941
|
+
let width = 0;
|
|
942
|
+
let height = 0;
|
|
943
|
+
let sawIhdr = false;
|
|
944
|
+
let sawIdat = false;
|
|
945
|
+
const idatChunks: Buffer[] = [];
|
|
946
|
+
while (offset + 12 <= bytes.length) {
|
|
947
|
+
const chunkStart = offset;
|
|
948
|
+
const length = bytes.readUInt32BE(offset);
|
|
949
|
+
offset += 4;
|
|
950
|
+
const type = bytes.toString("ascii", offset, offset + 4);
|
|
951
|
+
offset += 4;
|
|
952
|
+
if (offset + length + 4 > bytes.length) return null;
|
|
953
|
+
const data = bytes.subarray(offset, offset + length);
|
|
954
|
+
offset += length;
|
|
955
|
+
const expectedCrc = bytes.readUInt32BE(offset);
|
|
956
|
+
offset += 4;
|
|
957
|
+
if (pngCrc32(bytes.subarray(chunkStart + 4, offset - 4)) !== expectedCrc) return null;
|
|
958
|
+
if (!sawIhdr) {
|
|
959
|
+
if (type !== "IHDR" || length !== 13) return null;
|
|
960
|
+
width = data.readUInt32BE(0);
|
|
961
|
+
height = data.readUInt32BE(4);
|
|
962
|
+
if (
|
|
963
|
+
width === 0 ||
|
|
964
|
+
height === 0 ||
|
|
965
|
+
data[8] !== 8 ||
|
|
966
|
+
![2, 6].includes(data[9]!) ||
|
|
967
|
+
data[10] !== 0 ||
|
|
968
|
+
data[11] !== 0 ||
|
|
969
|
+
data[12] !== 0
|
|
970
|
+
)
|
|
971
|
+
return null;
|
|
972
|
+
sawIhdr = true;
|
|
973
|
+
} else if (type === "IHDR") return null;
|
|
974
|
+
if (type === "IDAT") {
|
|
975
|
+
if (!sawIhdr || length === 0) return null;
|
|
976
|
+
sawIdat = true;
|
|
977
|
+
idatChunks.push(data);
|
|
978
|
+
}
|
|
979
|
+
if (type === "IEND") {
|
|
980
|
+
if (length !== 0 || !sawIhdr || !sawIdat || offset !== bytes.length) return null;
|
|
981
|
+
try {
|
|
982
|
+
return { width, height, headerBytes: 8, sampleBytes: inflateSync(Buffer.concat(idatChunks)) };
|
|
983
|
+
} catch {
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return null;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function parseJpegDimensions(
|
|
992
|
+
bytes: Buffer,
|
|
993
|
+
): { width: number; height: number; headerBytes: number; sampleBytes?: Buffer } | null {
|
|
994
|
+
if (bytes.length < 8 || bytes[0] !== 0xff || bytes[1] !== JPEG_START_OF_IMAGE) return null;
|
|
995
|
+
let offset = 2;
|
|
996
|
+
let dimensions: { width: number; height: number; headerBytes: number } | null = null;
|
|
997
|
+
let sawStartOfScan = false;
|
|
998
|
+
let scanStart = -1;
|
|
999
|
+
while (offset < bytes.length) {
|
|
1000
|
+
if (bytes[offset] !== 0xff) return null;
|
|
1001
|
+
while (offset < bytes.length && bytes[offset] === 0xff) offset++;
|
|
1002
|
+
if (offset >= bytes.length) return null;
|
|
1003
|
+
const marker = bytes[offset++];
|
|
1004
|
+
if (marker === 0x00) return null;
|
|
1005
|
+
if (marker === JPEG_END_OF_IMAGE) return null;
|
|
1006
|
+
if (JPEG_STANDALONE_MARKERS.has(marker)) continue;
|
|
1007
|
+
if (offset + 2 > bytes.length) return null;
|
|
1008
|
+
const segmentLength = bytes.readUInt16BE(offset);
|
|
1009
|
+
if (segmentLength < 2 || offset + segmentLength > bytes.length) return null;
|
|
1010
|
+
const segmentDataEnd = offset + segmentLength;
|
|
1011
|
+
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
|
|
1012
|
+
if (segmentLength < 8) return null;
|
|
1013
|
+
dimensions = {
|
|
1014
|
+
width: bytes.readUInt16BE(offset + 5),
|
|
1015
|
+
height: bytes.readUInt16BE(offset + 3),
|
|
1016
|
+
headerBytes: offset + segmentLength,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
if (marker === JPEG_START_OF_SCAN) {
|
|
1020
|
+
if (!dimensions || segmentDataEnd >= bytes.length) return null;
|
|
1021
|
+
sawStartOfScan = true;
|
|
1022
|
+
scanStart = segmentDataEnd;
|
|
1023
|
+
break;
|
|
1024
|
+
}
|
|
1025
|
+
offset += segmentLength;
|
|
1026
|
+
}
|
|
1027
|
+
if (!dimensions || !sawStartOfScan || scanStart < 0) return null;
|
|
1028
|
+
let scanOffset = scanStart;
|
|
1029
|
+
let entropyBytes = 0;
|
|
1030
|
+
while (scanOffset < bytes.length) {
|
|
1031
|
+
const byte = bytes[scanOffset++]!;
|
|
1032
|
+
if (byte !== 0xff) {
|
|
1033
|
+
entropyBytes++;
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (scanOffset >= bytes.length) return null;
|
|
1037
|
+
const marker = bytes[scanOffset++]!;
|
|
1038
|
+
if (marker === 0x00) {
|
|
1039
|
+
entropyBytes++;
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (JPEG_STANDALONE_MARKERS.has(marker)) continue;
|
|
1043
|
+
if (marker === JPEG_END_OF_IMAGE) {
|
|
1044
|
+
if (scanOffset !== bytes.length || entropyBytes < 32) return null;
|
|
1045
|
+
return { ...dimensions, sampleBytes: bytes.subarray(scanStart, scanOffset - 2) };
|
|
1046
|
+
}
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function unsupportedScreenshotFormat(bytes: Buffer): string | null {
|
|
1053
|
+
if (bytes.toString("ascii", 0, 6) === "GIF87a" || bytes.toString("ascii", 0, 6) === "GIF89a") return "GIF";
|
|
1054
|
+
if (bytes.toString("ascii", 0, 2) === "BM") return "BMP";
|
|
1055
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP")
|
|
1056
|
+
return "WebP";
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function parseImageDimensions(
|
|
1061
|
+
bytes: Buffer,
|
|
1062
|
+
): { width: number; height: number; headerBytes: number; sampleBytes?: Buffer } | null {
|
|
1063
|
+
return parsePngDimensions(bytes) ?? parseJpegDimensions(bytes);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function hasNonUniformImageBytes(bytes: Buffer, headerBytes: number, sampleBytes?: Buffer): boolean {
|
|
1067
|
+
const source = sampleBytes ?? bytes;
|
|
1068
|
+
const sampleStart = sampleBytes ? 0 : Math.min(Math.max(headerBytes, 0), source.length);
|
|
1069
|
+
const sampleLength = source.length - sampleStart;
|
|
1070
|
+
if (sampleLength < 32) return false;
|
|
1071
|
+
const windows: Buffer[] = [];
|
|
1072
|
+
for (let index = 0; index < 64; index++) {
|
|
1073
|
+
const offset = sampleStart + Math.floor(((sampleLength - 32) * index) / 63);
|
|
1074
|
+
windows.push(source.subarray(offset, offset + 32));
|
|
1075
|
+
}
|
|
1076
|
+
const byteCounts = new Map<number, number>();
|
|
1077
|
+
let total = 0;
|
|
1078
|
+
for (const window of windows) {
|
|
1079
|
+
for (const byte of window) {
|
|
1080
|
+
byteCounts.set(byte, (byteCounts.get(byte) ?? 0) + 1);
|
|
1081
|
+
total++;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
const first = windows[0]!;
|
|
1085
|
+
const differingWindows = windows.slice(1).filter(window => !window.equals(first)).length;
|
|
1086
|
+
const maxCount = Math.max(...byteCounts.values());
|
|
1087
|
+
return byteCounts.size >= 16 && differingWindows >= 8 && maxCount / total <= 0.95;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function validateScreenshotArtifact(cwd: string, row: JsonObject, fieldName: string): Promise<boolean> {
|
|
1091
|
+
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
1092
|
+
if (!bytes) throw new Error(`qualityGate ${fieldName} screenshot artifact path must resolve to an existing file`);
|
|
1093
|
+
if (bytes.length < 4096) throw new Error(`qualityGate ${fieldName} screenshot artifact must be at least 4096 bytes`);
|
|
1094
|
+
const unsupportedFormat = unsupportedScreenshotFormat(bytes);
|
|
1095
|
+
if (unsupportedFormat) {
|
|
1096
|
+
throw new Error(
|
|
1097
|
+
`qualityGate ${fieldName} unsupported/undecodable screenshot format ${unsupportedFormat}; use PNG or fully marker-validated JPEG`,
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const dimensions = parseImageDimensions(bytes);
|
|
1101
|
+
if (!dimensions)
|
|
1102
|
+
throw new Error(`qualityGate ${fieldName} screenshot artifact must be a decodable PNG or JPEG image`);
|
|
1103
|
+
if (dimensions.width < 320 || dimensions.height < 180) {
|
|
1104
|
+
throw new Error(`qualityGate ${fieldName} screenshot artifact must be at least 320x180 pixels`);
|
|
1105
|
+
}
|
|
1106
|
+
if (!hasNonUniformImageBytes(bytes, dimensions.headerBytes, dimensions.sampleBytes)) {
|
|
1107
|
+
throw new Error(
|
|
1108
|
+
`qualityGate ${fieldName} screenshot artifact must be non-uniform, not blank, solid, tiny, or placeholder imagery`,
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
return true;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function normalizeTranscriptTimestamp(value: unknown): number | null {
|
|
1115
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
1116
|
+
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
1117
|
+
const numeric = Number(value);
|
|
1118
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
1119
|
+
const parsed = Date.parse(value);
|
|
1120
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function transcriptSurfaceCompatible(value: unknown, family: SurfaceFamily): boolean {
|
|
1124
|
+
const surface = nonEmptyString(value);
|
|
1125
|
+
return !surface || family === "unknown" || surfaceFamily(surface) === family;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function actionSelectorRequired(type: string): boolean {
|
|
1129
|
+
return ["click", "fill", "press", "assert", "screenshot", "observe"].includes(type);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
async function validateAutomationTranscriptArtifact(
|
|
1133
|
+
cwd: string,
|
|
1134
|
+
row: JsonObject,
|
|
1135
|
+
fieldName: string,
|
|
1136
|
+
options: { surfaceFamily: SurfaceFamily },
|
|
1137
|
+
): Promise<boolean> {
|
|
1138
|
+
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
1139
|
+
if (!bytes) throw new Error(`qualityGate ${fieldName} automation transcript path must resolve to an existing file`);
|
|
1140
|
+
let transcript: JsonObject;
|
|
1141
|
+
try {
|
|
1142
|
+
const parsed = JSON.parse(bytes.toString("utf8"));
|
|
1143
|
+
transcript = requireQualityGateObject(parsed, `${fieldName}.transcript`);
|
|
1144
|
+
} catch (error) {
|
|
1145
|
+
throw new Error(`qualityGate ${fieldName} automation transcript must be valid JSON: ${String(error)}`);
|
|
1146
|
+
}
|
|
1147
|
+
if (transcript.schemaVersion !== 1)
|
|
1148
|
+
throw new Error(`qualityGate ${fieldName} automation transcript schemaVersion must be 1`);
|
|
1149
|
+
if (!transcriptSurfaceCompatible(transcript.surface, options.surfaceFamily)) {
|
|
1150
|
+
throw new Error(
|
|
1151
|
+
`qualityGate ${fieldName} automation transcript surface is not compatible with ${options.surfaceFamily}`,
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
if (!nonEmptyString(transcript.tool))
|
|
1155
|
+
throw new Error(`qualityGate ${fieldName} automation transcript tool must be non-empty`);
|
|
1156
|
+
const actions = requireObjectArray(transcript.actions, `${fieldName}.actions`);
|
|
1157
|
+
if (actions.length < 1) throw new Error(`qualityGate ${fieldName} automation transcript actions must be non-empty`);
|
|
1158
|
+
const assertionsValue = transcript.assertions;
|
|
1159
|
+
const assertions =
|
|
1160
|
+
assertionsValue === undefined ? [] : requireObjectArray(assertionsValue, `${fieldName}.assertions`);
|
|
1161
|
+
const timestamps: number[] = [];
|
|
1162
|
+
let hasSelectorBearingEntry = false;
|
|
1163
|
+
for (const [index, action] of actions.entries()) {
|
|
1164
|
+
const actionField = `${fieldName}.actions[${index}]`;
|
|
1165
|
+
const type = requiredStringField(action, "type", actionField).toLowerCase();
|
|
1166
|
+
const timestamp = normalizeTranscriptTimestamp(action.timestamp);
|
|
1167
|
+
if (timestamp === null) throw new Error(`qualityGate ${actionField}.timestamp must be present and parseable`);
|
|
1168
|
+
timestamps.push(timestamp);
|
|
1169
|
+
const selector = nonEmptyString(action.selector);
|
|
1170
|
+
if (actionSelectorRequired(type) && !selector)
|
|
1171
|
+
throw new Error(`qualityGate ${actionField}.selector must be non-empty`);
|
|
1172
|
+
if (type === "goto" && !nonEmptyString(action.url))
|
|
1173
|
+
throw new Error(`qualityGate ${actionField}.url must be non-empty`);
|
|
1174
|
+
if (type === "custom" && !selector && !nonEmptyString(action.target)) {
|
|
1175
|
+
throw new Error(`qualityGate ${actionField}.selector or target must be non-empty`);
|
|
1176
|
+
}
|
|
1177
|
+
if (selector) hasSelectorBearingEntry = true;
|
|
1178
|
+
}
|
|
1179
|
+
for (const [index, assertion] of assertions.entries()) {
|
|
1180
|
+
const assertionField = `${fieldName}.assertions[${index}]`;
|
|
1181
|
+
const timestamp = normalizeTranscriptTimestamp(assertion.timestamp);
|
|
1182
|
+
if (timestamp === null) throw new Error(`qualityGate ${assertionField}.timestamp must be present and parseable`);
|
|
1183
|
+
timestamps.push(timestamp);
|
|
1184
|
+
if (nonEmptyString(assertion.status)?.toLowerCase() !== PASSED_STATUS) {
|
|
1185
|
+
throw new Error(`qualityGate ${assertionField}.status must be passed`);
|
|
1186
|
+
}
|
|
1187
|
+
if (nonEmptyString(assertion.selector)) hasSelectorBearingEntry = true;
|
|
1188
|
+
}
|
|
1189
|
+
for (let index = 1; index < timestamps.length; index++) {
|
|
1190
|
+
if (timestamps[index]! < timestamps[index - 1]!) {
|
|
1191
|
+
throw new Error(`qualityGate ${fieldName} automation transcript timestamps must be monotonic non-decreasing`);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (!hasSelectorBearingEntry) {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
`qualityGate ${fieldName} automation transcript must include at least one selector-bearing action or assertion`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
return true;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function validatePtyCaptureArtifact(cwd: string, row: JsonObject, fieldName: string): Promise<boolean> {
|
|
1203
|
+
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
1204
|
+
if (!bytes) throw new Error(`qualityGate ${fieldName} PTY capture path must resolve to an existing file`);
|
|
1205
|
+
if (bytes.length < 512) throw new Error(`qualityGate ${fieldName} PTY capture must be at least 512 bytes`);
|
|
1206
|
+
const text = bytes.toString("utf8");
|
|
1207
|
+
const hasCsi = /\x1b\[[0-?]*[ -/]*[@-~]/.test(text);
|
|
1208
|
+
const hasOsc = /\x1b\][^\x07]*(?:\x07|\x1b\\)/.test(text);
|
|
1209
|
+
const hasAltOrCursor = /\x1b\[\?1049[hl]|\x1b\[H|\x1b\[2J/.test(text);
|
|
1210
|
+
const hasRedraw = /[\r\b]/.test(text) && hasCsi;
|
|
1211
|
+
if (!hasCsi && !hasOsc && !hasAltOrCursor && !hasRedraw) {
|
|
1212
|
+
throw new Error(`qualityGate ${fieldName} PTY capture must contain terminal control sequences`);
|
|
1213
|
+
}
|
|
1214
|
+
if (!/[\x20-\x7e]{10,}/.test(text)) {
|
|
1215
|
+
throw new Error(
|
|
1216
|
+
`qualityGate ${fieldName} PTY capture must contain a printable text run of at least 10 characters`,
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
return true;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function structuralArtifactKind(row: JsonObject): "screenshot" | "automation" | "pty" | null {
|
|
1223
|
+
const kind = normalizedEvidenceKind(row);
|
|
1224
|
+
if (evidenceKindMatches(kind, ["screenshot", "image", "visual"])) return "screenshot";
|
|
1225
|
+
if (evidenceKindMatches(kind, ["browser", "playwright", "pandawright", "automation", "app-automation"]))
|
|
1226
|
+
return "automation";
|
|
1227
|
+
if (evidenceKindMatches(kind, ["pty", "tui", "terminal-capture"])) return "pty";
|
|
1228
|
+
return null;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async function validateStructuralArtifact(
|
|
1232
|
+
cwd: string,
|
|
1233
|
+
row: JsonObject,
|
|
1234
|
+
fieldName: string,
|
|
1235
|
+
options: { surfaceFamily: SurfaceFamily; live: boolean },
|
|
1236
|
+
): Promise<boolean> {
|
|
1237
|
+
void options.live;
|
|
1238
|
+
const kind = structuralArtifactKind(row);
|
|
1239
|
+
if (!kind) return false;
|
|
1240
|
+
if (kind === "screenshot") return validateScreenshotArtifact(cwd, row, fieldName);
|
|
1241
|
+
if (kind === "automation") return validateAutomationTranscriptArtifact(cwd, row, fieldName, options);
|
|
1242
|
+
if (kind === "pty") return validatePtyCaptureArtifact(cwd, row, fieldName);
|
|
1243
|
+
return false;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
const CLI_REPLAY_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
1247
|
+
const CLI_REPLAY_DEFAULT_TIMEOUT_MS = 10_000;
|
|
1248
|
+
const CLI_REPLAY_MIN_TIMEOUT_MS = 1_000;
|
|
1249
|
+
const CLI_REPLAY_MAX_TIMEOUT_MS = 30_000;
|
|
1250
|
+
const CLI_REPLAY_EXEMPT_REASON_CODES = new Set([
|
|
1251
|
+
"unsafe_side_effect",
|
|
1252
|
+
"requires_credentials",
|
|
1253
|
+
"requires_network",
|
|
1254
|
+
"non_deterministic_external",
|
|
1255
|
+
"destructive",
|
|
1256
|
+
"interactive_only",
|
|
1257
|
+
"platform_unavailable",
|
|
1258
|
+
]);
|
|
1259
|
+
const CLI_REPLAY_ENV_BASE: Record<string, string> = { CI: "1", NO_COLOR: "1", GJC_ULTRAGOAL_REPLAY: "1" };
|
|
1260
|
+
const CLI_REPLAY_SAFE_ENV_NAMES = new Set(["LANG", "LC_ALL", "LC_CTYPE", "TZ"]);
|
|
1261
|
+
const CLI_REPLAY_DANGEROUS_ENV_NAME_PATTERN =
|
|
1262
|
+
/^(?:NODE_OPTIONS|GIT_EXTERNAL_DIFF|GIT_SSH|GIT_SSH_COMMAND|GIT_PAGER|PATH|LD_PRELOAD|LD_LIBRARY_PATH)$|^(?:GIT_CONFIG|DYLD_|BUN_|NPM_CONFIG_)|(?:^|_)OPTIONS$|PRELOAD$/;
|
|
1263
|
+
const ANSI_ESCAPE_PATTERN = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|[@-Z\\-_])/g;
|
|
1264
|
+
|
|
1265
|
+
function clampCliReplayTimeout(value: unknown): number {
|
|
1266
|
+
if (value === undefined) return CLI_REPLAY_DEFAULT_TIMEOUT_MS;
|
|
1267
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
1268
|
+
throw new Error("qualityGate CLI replay timeoutMs must be a finite number");
|
|
1269
|
+
}
|
|
1270
|
+
return Math.min(CLI_REPLAY_MAX_TIMEOUT_MS, Math.max(CLI_REPLAY_MIN_TIMEOUT_MS, Math.trunc(value)));
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function basenameCommand(value: string): string {
|
|
1274
|
+
return path.basename(value).toLowerCase();
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function isDeterministicConsoleLogReplay(code: string): boolean {
|
|
1278
|
+
let remaining = code.trim();
|
|
1279
|
+
if (remaining.length === 0) return false;
|
|
1280
|
+
let matched = false;
|
|
1281
|
+
while (remaining.length > 0) {
|
|
1282
|
+
const match =
|
|
1283
|
+
/^console\.log\(\s*("(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\$])*`)\s*\)\s*;?\s*/.exec(
|
|
1284
|
+
remaining,
|
|
1285
|
+
);
|
|
1286
|
+
if (!match) return false;
|
|
1287
|
+
const statement = match[0]!;
|
|
1288
|
+
const literal = match[1]!;
|
|
1289
|
+
if (literal.startsWith("`") && literal.includes("${")) return false;
|
|
1290
|
+
matched = true;
|
|
1291
|
+
remaining = remaining.slice(statement.length);
|
|
1292
|
+
}
|
|
1293
|
+
return matched;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function hasShellRedirectionToken(value: string): boolean {
|
|
1297
|
+
return /^(?:[<>]|\d?[<>]|\d?>&\d|\|\|?|&&|;)$/.test(value) || /(?:^|[^\w])-?>/.test(value);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function isSafeRefOrPathspec(value: string): boolean {
|
|
1301
|
+
return value.length > 0 && !value.startsWith("-") && !/[\0\n\r]/.test(value) && !hasShellRedirectionToken(value);
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function isAllowedGitReplayCommand(args: readonly string[]): boolean {
|
|
1305
|
+
const subcommand = args[0];
|
|
1306
|
+
const rest = args.slice(1);
|
|
1307
|
+
if (subcommand === "status") return rest.every(arg => ["--short", "--porcelain", "--branch"].includes(arg));
|
|
1308
|
+
if (subcommand === "rev-parse" || subcommand === "merge-base")
|
|
1309
|
+
return rest.length > 0 && rest.every(isSafeRefOrPathspec);
|
|
1310
|
+
if (subcommand !== "diff" && subcommand !== "show" && subcommand !== "log") return false;
|
|
1311
|
+
let pathspecMode = false;
|
|
1312
|
+
for (const arg of rest) {
|
|
1313
|
+
if (arg === "--") {
|
|
1314
|
+
pathspecMode = true;
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
if (pathspecMode) {
|
|
1318
|
+
if (!isSafeRefOrPathspec(arg)) return false;
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
if (["--stat", "--name-only", "--oneline", "--no-ext-diff"].includes(arg)) continue;
|
|
1322
|
+
if (!isSafeRefOrPathspec(arg)) return false;
|
|
1323
|
+
}
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function isBareExecutableName(value: string): boolean {
|
|
1328
|
+
// The allowlist is keyed on the basename, but the raw command[0] is what gets spawned.
|
|
1329
|
+
// Reject path-qualified or case-spoofed executables (e.g. ./git, /tmp/npm, scripts/node, GIT)
|
|
1330
|
+
// so an attacker-controlled binary cannot impersonate a trusted tool.
|
|
1331
|
+
return (
|
|
1332
|
+
value.length > 0 &&
|
|
1333
|
+
!value.includes("/") &&
|
|
1334
|
+
!value.includes("\\") &&
|
|
1335
|
+
value === path.basename(value) &&
|
|
1336
|
+
value === value.toLowerCase()
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function isAllowedCliReplayCommand(command: readonly string[]): boolean {
|
|
1341
|
+
if (
|
|
1342
|
+
command.length === 0 ||
|
|
1343
|
+
command.some(arg => arg.trim() !== arg || arg.length === 0 || hasShellRedirectionToken(arg))
|
|
1344
|
+
)
|
|
1345
|
+
return false;
|
|
1346
|
+
if (!isBareExecutableName(command[0]!)) return false;
|
|
1347
|
+
const executable = basenameCommand(command[0]!);
|
|
1348
|
+
const args = command.slice(1);
|
|
1349
|
+
if (executable === "bun" || executable === "node") {
|
|
1350
|
+
if (args.length === 1 && args[0] === "--version") return true;
|
|
1351
|
+
return args.length === 2 && args[0] === "-e" && isDeterministicConsoleLogReplay(args[1]!);
|
|
1352
|
+
}
|
|
1353
|
+
if (executable === "npm" || executable === "pnpm" || executable === "yarn") {
|
|
1354
|
+
return (args.length === 1 && args[0] === "--version") || (args.length === 1 && args[0] === "list");
|
|
1355
|
+
}
|
|
1356
|
+
if (executable === "git") return isAllowedGitReplayCommand(args);
|
|
1357
|
+
if (executable === "gjc") return args.length === 1 && ["read", "status"].includes(args[0] ?? "");
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
function resolveCliReplayCommand(command: string[]): string[] {
|
|
1362
|
+
if (basenameCommand(command[0]!) === "bun") return [process.execPath, ...command.slice(1)];
|
|
1363
|
+
return command;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function resolveUnderCwd(cwd: string, replayCwd: unknown, fieldName: string): string {
|
|
1367
|
+
const relative = replayCwd === undefined ? "." : nonEmptyString(replayCwd);
|
|
1368
|
+
if (!relative) throw new Error(`qualityGate ${fieldName}.cwd must be a non-empty string when provided`);
|
|
1369
|
+
const root = path.resolve(cwd);
|
|
1370
|
+
const resolved = path.resolve(root, relative);
|
|
1371
|
+
const relativeToRoot = path.relative(root, resolved);
|
|
1372
|
+
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeToRoot)) {
|
|
1373
|
+
throw new Error(`qualityGate ${fieldName}.cwd must resolve under the repository cwd`);
|
|
1374
|
+
}
|
|
1375
|
+
return resolved;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
function buildCliReplayEnv(value: unknown, fieldName: string): Record<string, string> {
|
|
1379
|
+
const env: Record<string, string> = { ...CLI_REPLAY_ENV_BASE };
|
|
1380
|
+
if (value === undefined) return env;
|
|
1381
|
+
const object = requireQualityGateObject(value, `${fieldName}.env`);
|
|
1382
|
+
for (const [key, envValue] of Object.entries(object)) {
|
|
1383
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(key))
|
|
1384
|
+
throw new Error(`qualityGate ${fieldName}.env.${key} must be an uppercase environment key`);
|
|
1385
|
+
if (CLI_REPLAY_DANGEROUS_ENV_NAME_PATTERN.test(key) || !CLI_REPLAY_SAFE_ENV_NAMES.has(key)) {
|
|
1386
|
+
throw new Error(`qualityGate ${fieldName}.env.${key} is not in the CLI replay safe environment allowlist`);
|
|
1387
|
+
}
|
|
1388
|
+
if (typeof envValue !== "string") throw new Error(`qualityGate ${fieldName}.env.${key} must be a string`);
|
|
1389
|
+
env[key] = envValue;
|
|
1390
|
+
}
|
|
1391
|
+
return env;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function normalizeCliReplayOutput(value: string, cwd: string): string {
|
|
1395
|
+
let normalized = value.replace(ANSI_ESCAPE_PATTERN, "").replace(/\r\n?/g, "\n");
|
|
1396
|
+
const home = process.env.HOME;
|
|
1397
|
+
const replacements: Array<[RegExp, string]> = [
|
|
1398
|
+
[/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, "<TIMESTAMP>"],
|
|
1399
|
+
[/\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi, "<UUID>"],
|
|
1400
|
+
[/\b[0-9a-f]{7,}\b/gi, "<HASH>"],
|
|
1401
|
+
[/(?:\/private)?\/var\/folders\/[^\s"']+|\/tmp\/[^\s"']+|\/var\/tmp\/[^\s"']+/g, "<TMP>"],
|
|
1402
|
+
];
|
|
1403
|
+
for (const candidate of [path.resolve(cwd), home]) {
|
|
1404
|
+
if (!candidate) continue;
|
|
1405
|
+
const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1406
|
+
normalized = normalized.replace(new RegExp(escaped, "g"), candidate === home ? "<HOME>" : "<CWD>");
|
|
1407
|
+
}
|
|
1408
|
+
for (const [pattern, replacement] of replacements) normalized = normalized.replace(pattern, replacement);
|
|
1409
|
+
const lines = normalized.split("\n").map(line => line.replace(/[ \t]+$/g, ""));
|
|
1410
|
+
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
1411
|
+
return lines.join("\n");
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async function readCliReplayRecord(cwd: string, row: JsonObject, fieldName: string): Promise<JsonObject | null> {
|
|
1415
|
+
const inline = qualityGateObject(row.replay) ?? (row.kind === "cli-replay" ? row : null);
|
|
1416
|
+
if (inline) return inline;
|
|
1417
|
+
if (!evidenceKindMatches(normalizedEvidenceKind(row), ["cli-replay", "command-replay"])) return null;
|
|
1418
|
+
const bytes = await readArtifactBytes(cwd, row, fieldName);
|
|
1419
|
+
if (!bytes) return null;
|
|
1420
|
+
try {
|
|
1421
|
+
return requireQualityGateObject(JSON.parse(bytes.toString("utf8")), `${fieldName}.replay`);
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
throw new Error(`qualityGate ${fieldName} CLI replay artifact must be valid JSON: ${String(error)}`);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function parseCliReplayRecord(
|
|
1428
|
+
record: JsonObject,
|
|
1429
|
+
fieldName: string,
|
|
1430
|
+
): {
|
|
1431
|
+
command: string[];
|
|
1432
|
+
replayCwd: unknown;
|
|
1433
|
+
env: Record<string, string>;
|
|
1434
|
+
timeoutMs: number;
|
|
1435
|
+
expectedExitCode: number;
|
|
1436
|
+
recordedStdout: string;
|
|
1437
|
+
invariants: JsonObject[];
|
|
1438
|
+
} {
|
|
1439
|
+
if (record.schemaVersion !== 1) throw new Error(`qualityGate ${fieldName}.schemaVersion must be 1`);
|
|
1440
|
+
if (record.kind !== "cli-replay") throw new Error(`qualityGate ${fieldName}.kind must be cli-replay`);
|
|
1441
|
+
if (record.command !== undefined && typeof record.command === "string") {
|
|
1442
|
+
throw new Error(`qualityGate ${fieldName}.command must be an argv string array, not a shell string`);
|
|
1443
|
+
}
|
|
1444
|
+
const command = nonEmptyStringArray(record.command);
|
|
1445
|
+
if (!command) throw new Error(`qualityGate ${fieldName}.command must be a non-empty string array`);
|
|
1446
|
+
if (record.replaySafe !== true)
|
|
1447
|
+
throw new Error(`qualityGate ${fieldName}.replaySafe must be true before CLI replay executes`);
|
|
1448
|
+
if (!isAllowedCliReplayCommand(command))
|
|
1449
|
+
throw new Error(`qualityGate ${fieldName}.command is not in the conservative CLI replay allowlist`);
|
|
1450
|
+
if (record.normalization !== undefined && record.normalization !== "default") {
|
|
1451
|
+
throw new Error(`qualityGate ${fieldName}.normalization must be default when provided`);
|
|
1452
|
+
}
|
|
1453
|
+
if (typeof record.recordedStdout !== "string")
|
|
1454
|
+
throw new Error(`qualityGate ${fieldName}.recordedStdout must be a string`);
|
|
1455
|
+
if (record.recordedStderr !== undefined && typeof record.recordedStderr !== "string") {
|
|
1456
|
+
throw new Error(`qualityGate ${fieldName}.recordedStderr must be a string when provided`);
|
|
1457
|
+
}
|
|
1458
|
+
const expectedExitCode = record.expectedExitCode === undefined ? 0 : record.expectedExitCode;
|
|
1459
|
+
if (typeof expectedExitCode !== "number" || !Number.isInteger(expectedExitCode)) {
|
|
1460
|
+
throw new Error(`qualityGate ${fieldName}.expectedExitCode must be an integer`);
|
|
1461
|
+
}
|
|
1462
|
+
const invariants =
|
|
1463
|
+
record.invariants === undefined ? [] : requireObjectArray(record.invariants, `${fieldName}.invariants`);
|
|
1464
|
+
return {
|
|
1465
|
+
command: command.map(item => item.trim()),
|
|
1466
|
+
replayCwd: record.cwd,
|
|
1467
|
+
env: buildCliReplayEnv(record.env, fieldName),
|
|
1468
|
+
timeoutMs: clampCliReplayTimeout(record.timeoutMs),
|
|
1469
|
+
expectedExitCode,
|
|
1470
|
+
recordedStdout: record.recordedStdout,
|
|
1471
|
+
invariants,
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function validateCliReplayInvariants(invariants: JsonObject[], stdout: string, fieldName: string): void {
|
|
1476
|
+
for (const [index, invariant] of invariants.entries()) {
|
|
1477
|
+
const invariantField = `${fieldName}.invariants[${index}]`;
|
|
1478
|
+
const type = requiredStringField(invariant, "type", invariantField);
|
|
1479
|
+
const value = requiredStringField(invariant, "value", invariantField);
|
|
1480
|
+
if (type === "substring" && !stdout.includes(value))
|
|
1481
|
+
throw new Error(`qualityGate ${invariantField} substring invariant did not match stdout`);
|
|
1482
|
+
else if (type === "not_substring" && stdout.includes(value))
|
|
1483
|
+
throw new Error(`qualityGate ${invariantField} not_substring invariant matched stdout`);
|
|
1484
|
+
else if (type === "regex") {
|
|
1485
|
+
const flags = invariant.flags === undefined ? "" : requiredStringField(invariant, "flags", invariantField);
|
|
1486
|
+
if (!/^[im]*$/.test(flags)) throw new Error(`qualityGate ${invariantField}.flags may only contain i and m`);
|
|
1487
|
+
if (!new RegExp(value, flags).test(stdout))
|
|
1488
|
+
throw new Error(`qualityGate ${invariantField} regex invariant did not match stdout`);
|
|
1489
|
+
} else if (type !== "substring" && type !== "not_substring") {
|
|
1490
|
+
throw new Error(`qualityGate ${invariantField}.type must be substring, regex, or not_substring`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
async function collectCliReplayOutput(
|
|
1496
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
1497
|
+
): Promise<{ text: string; truncated: boolean }> {
|
|
1498
|
+
if (!stream) return { text: "", truncated: false };
|
|
1499
|
+
const reader = stream.getReader();
|
|
1500
|
+
const chunks: Buffer[] = [];
|
|
1501
|
+
let size = 0;
|
|
1502
|
+
let truncated = false;
|
|
1503
|
+
try {
|
|
1504
|
+
while (true) {
|
|
1505
|
+
const { done, value } = await reader.read();
|
|
1506
|
+
if (done) break;
|
|
1507
|
+
if (size < CLI_REPLAY_MAX_OUTPUT_BYTES) {
|
|
1508
|
+
const remaining = CLI_REPLAY_MAX_OUTPUT_BYTES - size;
|
|
1509
|
+
const chunk = Buffer.from(value.subarray(0, remaining));
|
|
1510
|
+
chunks.push(chunk);
|
|
1511
|
+
size += chunk.length;
|
|
1512
|
+
}
|
|
1513
|
+
if (value.length > 0 && size >= CLI_REPLAY_MAX_OUTPUT_BYTES) {
|
|
1514
|
+
truncated = true;
|
|
1515
|
+
await reader.cancel().catch(() => undefined);
|
|
1516
|
+
break;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
} finally {
|
|
1520
|
+
reader.releaseLock();
|
|
1521
|
+
}
|
|
1522
|
+
return { text: Buffer.concat(chunks).toString("utf8"), truncated };
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
export interface ReplayProcessHandle {
|
|
1526
|
+
readonly exited: Promise<number>;
|
|
1527
|
+
kill(signal?: number | NodeJS.Signals): void;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
export async function waitForReplayProcessWithTimeout(
|
|
1531
|
+
process: ReplayProcessHandle,
|
|
1532
|
+
timeoutMs: number,
|
|
1533
|
+
graceMs = 2000,
|
|
1534
|
+
): Promise<number> {
|
|
1535
|
+
let timeoutTimer: NodeJS.Timeout | undefined;
|
|
1536
|
+
let graceTimer: NodeJS.Timeout | undefined;
|
|
1537
|
+
const timedOut = Symbol("timedOut");
|
|
1538
|
+
const timeout = new Promise<typeof timedOut>(resolve => {
|
|
1539
|
+
timeoutTimer = setTimeout(() => resolve(timedOut), timeoutMs);
|
|
1540
|
+
});
|
|
1541
|
+
const first = await Promise.race([process.exited, timeout]);
|
|
1542
|
+
if (first !== timedOut) {
|
|
1543
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
1544
|
+
return first;
|
|
1545
|
+
}
|
|
1546
|
+
process.kill("SIGTERM");
|
|
1547
|
+
const killed = Symbol("killed");
|
|
1548
|
+
const grace = new Promise<typeof killed>(resolve => {
|
|
1549
|
+
graceTimer = setTimeout(() => {
|
|
1550
|
+
process.kill("SIGKILL");
|
|
1551
|
+
resolve(killed);
|
|
1552
|
+
}, graceMs);
|
|
1553
|
+
});
|
|
1554
|
+
await Promise.race([process.exited, grace]);
|
|
1555
|
+
await process.exited.catch(() => undefined);
|
|
1556
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
1557
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
1558
|
+
throw new Error("timeout");
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
async function validateReplayExemptFallback(
|
|
1562
|
+
cwd: string,
|
|
1563
|
+
record: JsonObject,
|
|
1564
|
+
fieldName: string,
|
|
1565
|
+
artifactRefs: Map<string, JsonObject>,
|
|
1566
|
+
options: { surfaceFamily: SurfaceFamily; live: boolean },
|
|
1567
|
+
): Promise<boolean> {
|
|
1568
|
+
const exempt = qualityGateObject(record.replayExempt);
|
|
1569
|
+
if (!exempt) return false;
|
|
1570
|
+
const reasonCode = requiredStringField(exempt, "reasonCode", `${fieldName}.replayExempt`);
|
|
1571
|
+
if (!CLI_REPLAY_EXEMPT_REASON_CODES.has(reasonCode))
|
|
1572
|
+
throw new Error(`qualityGate ${fieldName}.replayExempt.reasonCode is not recognized`);
|
|
1573
|
+
const reason = requiredStringField(exempt, "reason", `${fieldName}.replayExempt`);
|
|
1574
|
+
if (!isSubstantiveEvidence(reason) || reason.length < 30)
|
|
1575
|
+
throw new Error(`qualityGate ${fieldName}.replayExempt.reason must be audited and substantive`);
|
|
1576
|
+
requiredStringField(exempt, "approvedBy", `${fieldName}.replayExempt`);
|
|
1577
|
+
const fallbackRefs = requireStringLinks(
|
|
1578
|
+
exempt.fallbackArtifactRefs,
|
|
1579
|
+
`${fieldName}.replayExempt.fallbackArtifactRefs`,
|
|
1580
|
+
);
|
|
1581
|
+
requireResolvedLinks(fallbackRefs, artifactRefs, `${fieldName}.replayExempt.fallbackArtifactRefs`);
|
|
1582
|
+
let validFallback = false;
|
|
1583
|
+
for (const fallbackRef of fallbackRefs) {
|
|
1584
|
+
if (fallbackRef === requiredStringField(record, "id", fieldName)) {
|
|
1585
|
+
throw new Error(`qualityGate ${fieldName}.replayExempt fallback must not reference the replay record itself`);
|
|
1586
|
+
}
|
|
1587
|
+
const fallback = artifactRefs.get(fallbackRef)!;
|
|
1588
|
+
if (await validateStructuralArtifact(cwd, fallback, `executorQa.artifactRefs.${fallbackRef}`, options))
|
|
1589
|
+
validFallback = true;
|
|
1590
|
+
}
|
|
1591
|
+
if (!validFallback)
|
|
1592
|
+
throw new Error(
|
|
1593
|
+
`qualityGate ${fieldName}.replayExempt requires at least one structurally-valid fallback artifact`,
|
|
1594
|
+
);
|
|
1595
|
+
return true;
|
|
1596
|
+
}
|
|
1597
|
+
async function validateCliReplay(
|
|
1598
|
+
cwd: string,
|
|
1599
|
+
row: JsonObject,
|
|
1600
|
+
fieldName: string,
|
|
1601
|
+
options: { live: boolean },
|
|
1602
|
+
): Promise<boolean> {
|
|
1603
|
+
const record = await readCliReplayRecord(cwd, row, fieldName);
|
|
1604
|
+
if (!record) return false;
|
|
1605
|
+
if (record.replayExempt !== undefined) {
|
|
1606
|
+
throw new Error(
|
|
1607
|
+
`qualityGate ${fieldName}.replayExempt can only be validated from surfaceEvidence with fallback context`,
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
void options.live;
|
|
1611
|
+
const replay = parseCliReplayRecord(record, fieldName);
|
|
1612
|
+
const replayCwd = resolveUnderCwd(cwd, replay.replayCwd, fieldName);
|
|
1613
|
+
const process = Bun.spawn(resolveCliReplayCommand(replay.command), {
|
|
1614
|
+
cwd: replayCwd,
|
|
1615
|
+
env: replay.env,
|
|
1616
|
+
stdout: "pipe",
|
|
1617
|
+
stderr: "pipe",
|
|
1618
|
+
});
|
|
1619
|
+
try {
|
|
1620
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
1621
|
+
collectCliReplayOutput(process.stdout),
|
|
1622
|
+
collectCliReplayOutput(process.stderr),
|
|
1623
|
+
waitForReplayProcessWithTimeout(process, replay.timeoutMs),
|
|
1624
|
+
]);
|
|
1625
|
+
if (stdout.truncated || stderr.truncated)
|
|
1626
|
+
throw new Error(`qualityGate ${fieldName} CLI replay output exceeded 1 MiB buffer cap`);
|
|
1627
|
+
if (exitCode !== replay.expectedExitCode) {
|
|
1628
|
+
throw new Error(
|
|
1629
|
+
`qualityGate ${fieldName} CLI replay exit code ${exitCode} did not match expected ${replay.expectedExitCode}`,
|
|
1630
|
+
);
|
|
1631
|
+
}
|
|
1632
|
+
const actualStdout = normalizeCliReplayOutput(stdout.text, cwd);
|
|
1633
|
+
const recordedStdout = normalizeCliReplayOutput(replay.recordedStdout, cwd);
|
|
1634
|
+
if (replay.invariants.length > 0) {
|
|
1635
|
+
validateCliReplayInvariants(replay.invariants, actualStdout, fieldName);
|
|
1636
|
+
} else if (actualStdout !== recordedStdout) {
|
|
1637
|
+
throw new Error(`qualityGate ${fieldName} CLI replay stdout did not match recordedStdout after normalization`);
|
|
1638
|
+
}
|
|
1639
|
+
return true;
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
if (error instanceof Error && error.message === "timeout") {
|
|
1642
|
+
throw new Error(`qualityGate ${fieldName} CLI replay timed out after ${replay.timeoutMs}ms`);
|
|
1643
|
+
}
|
|
1644
|
+
throw error;
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
async function hasLiveProofPresence(
|
|
1649
|
+
cwd: string,
|
|
1650
|
+
row: JsonObject,
|
|
1651
|
+
fieldName: string,
|
|
1652
|
+
family: SurfaceFamily,
|
|
1653
|
+
): Promise<boolean> {
|
|
1654
|
+
if (await hasExistingNonEmptyArtifact(cwd, row.path)) return true;
|
|
1655
|
+
if (family === "cli") {
|
|
1656
|
+
const record = await readCliReplayRecord(cwd, row, fieldName);
|
|
1657
|
+
if (record) return true;
|
|
1658
|
+
}
|
|
1659
|
+
return false;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
async function validateLiveSurfaceProofPresence(
|
|
1663
|
+
cwd: string,
|
|
1664
|
+
family: SurfaceFamily,
|
|
1665
|
+
artifactIds: string[],
|
|
1666
|
+
artifactRefs: Map<string, JsonObject>,
|
|
1667
|
+
): Promise<void> {
|
|
1668
|
+
if (!isLiveSurfaceFamily(family)) return;
|
|
1669
|
+
for (const artifactId of artifactIds) {
|
|
1670
|
+
if (
|
|
1671
|
+
await hasLiveProofPresence(cwd, artifactRefs.get(artifactId)!, `executorQa.artifactRefs.${artifactId}`, family)
|
|
1672
|
+
)
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
884
1675
|
throw new Error(
|
|
885
|
-
`qualityGate ${
|
|
1676
|
+
`qualityGate ${artifactIds.map(id => `executorQa.artifactRefs.${id}`).join(", ")} must reference a live proof artifact, structural capture, or CLI replay; inlineEvidence and typed verifiedReceipt do not prove live surfaces`,
|
|
886
1677
|
);
|
|
887
1678
|
}
|
|
1679
|
+
async function validateSurfaceStructuralRequirement(
|
|
1680
|
+
cwd: string,
|
|
1681
|
+
family: SurfaceFamily,
|
|
1682
|
+
artifactIds: string[],
|
|
1683
|
+
artifactRefs: Map<string, JsonObject>,
|
|
1684
|
+
fieldName: string,
|
|
1685
|
+
): Promise<void> {
|
|
1686
|
+
if (family !== "web" && family !== "native") return;
|
|
1687
|
+
let hasScreenshot = false;
|
|
1688
|
+
let hasAutomation = false;
|
|
1689
|
+
let hasPty = false;
|
|
1690
|
+
for (const artifactId of artifactIds) {
|
|
1691
|
+
const artifact = artifactRefs.get(artifactId)!;
|
|
1692
|
+
const kind = structuralArtifactKind(artifact);
|
|
1693
|
+
if (!kind) continue;
|
|
1694
|
+
const valid = await validateStructuralArtifact(cwd, artifact, `executorQa.artifactRefs.${artifactId}`, {
|
|
1695
|
+
surfaceFamily: family,
|
|
1696
|
+
live: true,
|
|
1697
|
+
});
|
|
1698
|
+
if (kind === "screenshot" && valid) hasScreenshot = true;
|
|
1699
|
+
if (kind === "automation" && valid) hasAutomation = true;
|
|
1700
|
+
if (kind === "pty" && valid) hasPty = true;
|
|
1701
|
+
}
|
|
1702
|
+
if (family === "web" && (!hasScreenshot || !hasAutomation)) {
|
|
1703
|
+
throw new Error(
|
|
1704
|
+
`qualityGate ${fieldName} for GUI/web surfaces must include a valid automation transcript and non-uniform screenshot`,
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
if (family === "native" && !hasScreenshot && !hasAutomation && !hasPty) {
|
|
1708
|
+
throw new Error(
|
|
1709
|
+
`qualityGate ${fieldName} for native surfaces must include a valid screenshot, PTY capture, or app-automation transcript`,
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
async function validateArtifactProof(
|
|
1715
|
+
cwd: string,
|
|
1716
|
+
row: JsonObject,
|
|
1717
|
+
fieldName: string,
|
|
1718
|
+
options: { surfaceFamily: SurfaceFamily; live: boolean },
|
|
1719
|
+
): Promise<void> {
|
|
1720
|
+
if (await hasExistingNonEmptyArtifact(cwd, row.path)) return;
|
|
1721
|
+
if (await validateStructuralArtifact(cwd, row, fieldName, options)) return;
|
|
1722
|
+
if (options.surfaceFamily === "cli" && (await validateCliReplay(cwd, row, fieldName, { live: options.live })))
|
|
1723
|
+
return;
|
|
1724
|
+
if (!options.live && (hasTypedVerifiedReceipt(row.verifiedReceipt) || hasTypedVerifiedReceipt(row.receipt))) return;
|
|
1725
|
+
const proofLabel = options.live
|
|
1726
|
+
? "a live proof artifact, structural capture, or CLI replay; inlineEvidence and typed verifiedReceipt do not prove live surfaces"
|
|
1727
|
+
: "an existing non-empty artifact path or a typed verifiedReceipt; inlineEvidence alone is not sufficient";
|
|
1728
|
+
throw new Error(`qualityGate ${fieldName} must reference ${proofLabel}`);
|
|
1729
|
+
}
|
|
888
1730
|
|
|
889
1731
|
async function validateArtifactRefs(cwd: string, executorQa: JsonObject): Promise<Map<string, JsonObject>> {
|
|
1732
|
+
void cwd;
|
|
890
1733
|
const rows = requireObjectArray(executorQa.artifactRefs, "executorQa.artifactRefs");
|
|
891
1734
|
const idMap = buildRowIdMap(rows, "executorQa.artifactRefs");
|
|
892
1735
|
for (const [index, row] of rows.entries()) {
|
|
893
1736
|
const fieldName = `executorQa.artifactRefs[${index}]`;
|
|
894
1737
|
requiredStringField(row, "kind", fieldName);
|
|
895
1738
|
requiredStringField(row, "description", fieldName);
|
|
896
|
-
await requireSubstantiveArtifactEvidence(cwd, row, fieldName);
|
|
897
1739
|
}
|
|
898
1740
|
return idMap;
|
|
899
1741
|
}
|
|
900
1742
|
|
|
901
|
-
function validateSurfaceEvidence(
|
|
1743
|
+
async function validateSurfaceEvidence(
|
|
1744
|
+
cwd: string,
|
|
902
1745
|
executorQa: JsonObject,
|
|
903
1746
|
artifactRefs: Map<string, JsonObject>,
|
|
904
|
-
): Map<string, JsonObject
|
|
1747
|
+
): Promise<Map<string, JsonObject>> {
|
|
905
1748
|
const rows = requireObjectArray(executorQa.surfaceEvidence, "executorQa.surfaceEvidence");
|
|
906
1749
|
const idMap = buildRowIdMap(rows, "executorQa.surfaceEvidence");
|
|
907
1750
|
for (const [index, row] of rows.entries()) {
|
|
@@ -913,6 +1756,7 @@ function validateSurfaceEvidence(
|
|
|
913
1756
|
continue;
|
|
914
1757
|
}
|
|
915
1758
|
const surface = requiredStringField(row, "surface", fieldName);
|
|
1759
|
+
const family = surfaceFamily(surface);
|
|
916
1760
|
requireSuccessfulRowOutcome(row, fieldName);
|
|
917
1761
|
requiredStringField(row, "invocation", fieldName);
|
|
918
1762
|
if (typeof row.verdict !== "string" || row.verdict.trim().length === 0) {
|
|
@@ -920,7 +1764,49 @@ function validateSurfaceEvidence(
|
|
|
920
1764
|
}
|
|
921
1765
|
const artifactIds = requireStringLinks(row.artifactRefs, `${fieldName}.artifactRefs`);
|
|
922
1766
|
requireResolvedLinks(artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
|
|
1767
|
+
await validateLiveSurfaceProofPresence(cwd, family, artifactIds, artifactRefs);
|
|
923
1768
|
validateSurfaceArtifactCompatibility(surface, artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
|
|
1769
|
+
await validateSurfaceStructuralRequirement(cwd, family, artifactIds, artifactRefs, `${fieldName}.artifactRefs`);
|
|
1770
|
+
if (family === "cli") {
|
|
1771
|
+
let hasPassingReplay = false;
|
|
1772
|
+
for (const artifactId of artifactIds) {
|
|
1773
|
+
const artifact = artifactRefs.get(artifactId)!;
|
|
1774
|
+
const artifactField = `executorQa.artifactRefs.${artifactId}`;
|
|
1775
|
+
const record = await readCliReplayRecord(cwd, artifact, artifactField);
|
|
1776
|
+
if (!record) continue;
|
|
1777
|
+
if (record.replayExempt !== undefined) {
|
|
1778
|
+
if (
|
|
1779
|
+
await validateReplayExemptFallback(cwd, { ...record, id: artifactId }, artifactField, artifactRefs, {
|
|
1780
|
+
surfaceFamily: family,
|
|
1781
|
+
live: true,
|
|
1782
|
+
})
|
|
1783
|
+
) {
|
|
1784
|
+
hasPassingReplay = true;
|
|
1785
|
+
}
|
|
1786
|
+
} else if (await validateCliReplay(cwd, artifact, artifactField, { live: true })) {
|
|
1787
|
+
hasPassingReplay = true;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (!hasPassingReplay) {
|
|
1791
|
+
throw new Error(
|
|
1792
|
+
`qualityGate ${fieldName} for CLI surfaces must include a passing argv CLI replay or valid replayExempt fallback`,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
for (const artifactId of artifactIds) {
|
|
1797
|
+
if (family === "cli") {
|
|
1798
|
+
const record = await readCliReplayRecord(
|
|
1799
|
+
cwd,
|
|
1800
|
+
artifactRefs.get(artifactId)!,
|
|
1801
|
+
`executorQa.artifactRefs.${artifactId}`,
|
|
1802
|
+
);
|
|
1803
|
+
if (record?.replayExempt !== undefined) continue;
|
|
1804
|
+
}
|
|
1805
|
+
await validateArtifactProof(cwd, artifactRefs.get(artifactId)!, `executorQa.artifactRefs.${artifactId}`, {
|
|
1806
|
+
surfaceFamily: family,
|
|
1807
|
+
live: isLiveSurfaceFamily(family),
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
924
1810
|
}
|
|
925
1811
|
return idMap;
|
|
926
1812
|
}
|
|
@@ -1008,13 +1894,29 @@ function validateContractCoverage(
|
|
|
1008
1894
|
}
|
|
1009
1895
|
}
|
|
1010
1896
|
|
|
1011
|
-
async function
|
|
1897
|
+
async function validateExecutorQaRedTeamEvidenceInternal(
|
|
1898
|
+
cwd: string,
|
|
1899
|
+
executorQa: JsonObject,
|
|
1900
|
+
_options: { mode?: "checkpoint" | "review" } = {},
|
|
1901
|
+
): Promise<void> {
|
|
1012
1902
|
const artifactRefs = await validateArtifactRefs(cwd, executorQa);
|
|
1013
|
-
const surfaceEvidence = validateSurfaceEvidence(executorQa, artifactRefs);
|
|
1903
|
+
const surfaceEvidence = await validateSurfaceEvidence(cwd, executorQa, artifactRefs);
|
|
1014
1904
|
const adversarialCases = validateAdversarialCases(executorQa, artifactRefs);
|
|
1015
1905
|
validateContractCoverage(executorQa, surfaceEvidence, adversarialCases, artifactRefs);
|
|
1016
1906
|
}
|
|
1017
1907
|
|
|
1908
|
+
async function validateExecutorQaRedTeamEvidence(cwd: string, executorQa: JsonObject): Promise<void> {
|
|
1909
|
+
await validateExecutorQaRedTeamEvidenceInternal(cwd, executorQa, { mode: "checkpoint" });
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
export async function validateExecutorQaRedTeamEvidenceForReview(
|
|
1913
|
+
cwd: string,
|
|
1914
|
+
executorQa: Record<string, unknown>,
|
|
1915
|
+
options: { mode?: "review" } = {},
|
|
1916
|
+
): Promise<void> {
|
|
1917
|
+
await validateExecutorQaRedTeamEvidenceInternal(cwd, executorQa as JsonObject, options);
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1018
1920
|
async function validateCompletionQualityGate(cwd: string, gate: JsonObject): Promise<void> {
|
|
1019
1921
|
const codeReview = qualityGateObject(gate.codeReview);
|
|
1020
1922
|
if (codeReview) {
|
|
@@ -1163,6 +2065,26 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
1163
2065
|
if (!goal) throw new Error(`No ultragoal goal found for ${input.goalId}.`);
|
|
1164
2066
|
const evidence = input.evidence.trim();
|
|
1165
2067
|
if (!evidence) throw new Error("checkpoint evidence is required");
|
|
2068
|
+
const ledgerBefore = await readUltragoalLedger(input.cwd);
|
|
2069
|
+
if (
|
|
2070
|
+
goal.status === input.status &&
|
|
2071
|
+
goal.evidence === evidence &&
|
|
2072
|
+
ledgerBefore.some(
|
|
2073
|
+
event =>
|
|
2074
|
+
event.event === "goal_checkpointed" &&
|
|
2075
|
+
event.goalId === goal.id &&
|
|
2076
|
+
event.status === input.status &&
|
|
2077
|
+
event.evidence === evidence,
|
|
2078
|
+
)
|
|
2079
|
+
) {
|
|
2080
|
+
// Idempotent re-checkpoint: this goal is already recorded in the target status with the same
|
|
2081
|
+
// evidence, so skip the plan rewrite and ledger append to avoid duplicate goal_checkpointed
|
|
2082
|
+
// events. The ledger is the dedup source of truth because it is exactly what a duplicate write
|
|
2083
|
+
// would corrupt (mirrors the ralplan #638 guard). Requiring a matching ledger row means an
|
|
2084
|
+
// interrupted prior write (plan persisted, ledger append lost) still re-appends the event
|
|
2085
|
+
// instead of silently dropping it.
|
|
2086
|
+
return plan;
|
|
2087
|
+
}
|
|
1166
2088
|
const qualityGateJson =
|
|
1167
2089
|
input.status === "complete"
|
|
1168
2090
|
? await readRequiredCompletionQualityGate(input.cwd, input.qualityGateJson)
|
|
@@ -1170,7 +2092,6 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
1170
2092
|
? await readStructuredValue(input.cwd, input.qualityGateJson)
|
|
1171
2093
|
: undefined;
|
|
1172
2094
|
const now = new Date().toISOString();
|
|
1173
|
-
const ledgerBefore = await readUltragoalLedger(input.cwd);
|
|
1174
2095
|
const beforeStatus = goal.status;
|
|
1175
2096
|
if (input.status === "complete") {
|
|
1176
2097
|
const blockedGoalId =
|
|
@@ -1690,6 +2611,244 @@ export async function recordUltragoalReviewBlockers(input: {
|
|
|
1690
2611
|
return plan;
|
|
1691
2612
|
}
|
|
1692
2613
|
|
|
2614
|
+
type UltragoalReviewMode = "review-only" | "review-start";
|
|
2615
|
+
type UltragoalReviewContractStrength = "strong" | "thin-derived";
|
|
2616
|
+
|
|
2617
|
+
interface UltragoalReviewFinding extends JsonObject {
|
|
2618
|
+
severity: "blocker";
|
|
2619
|
+
message: string;
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
interface UltragoalReviewResult extends JsonObject {
|
|
2623
|
+
verdict: "pass" | "fail" | "inconclusive: weak-contract";
|
|
2624
|
+
contractStrength: UltragoalReviewContractStrength;
|
|
2625
|
+
cleanPassEligible: boolean;
|
|
2626
|
+
source: JsonObject;
|
|
2627
|
+
findings: UltragoalReviewFinding[];
|
|
2628
|
+
artifactValidationSummary: JsonObject;
|
|
2629
|
+
weakContractCapApplied: boolean;
|
|
2630
|
+
blockerGoalIds?: string[];
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
function parseReviewMode(value: string | undefined): UltragoalReviewMode {
|
|
2634
|
+
if (value === undefined || value === "review-only") return "review-only";
|
|
2635
|
+
if (value === "review-start") return "review-start";
|
|
2636
|
+
throw new Error("review --mode must be review-only or review-start");
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
async function readOptionalExecutorQa(cwd: string, value: string | undefined): Promise<JsonObject> {
|
|
2640
|
+
if (!value) {
|
|
2641
|
+
return {
|
|
2642
|
+
status: "passed",
|
|
2643
|
+
e2eStatus: "passed",
|
|
2644
|
+
redTeamStatus: "passed",
|
|
2645
|
+
evidence: "review evidence bundle was not supplied; runtime reports this as a finding",
|
|
2646
|
+
e2eCommands: ["gjc ultragoal review"],
|
|
2647
|
+
redTeamCommands: ["gjc ultragoal review"],
|
|
2648
|
+
artifactRefs: [],
|
|
2649
|
+
contractCoverage: [],
|
|
2650
|
+
surfaceEvidence: [],
|
|
2651
|
+
adversarialCases: [],
|
|
2652
|
+
blockers: [],
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
const structured = await readStructuredValue(cwd, value);
|
|
2656
|
+
if (typeof structured !== "object" || structured === null || Array.isArray(structured)) {
|
|
2657
|
+
throw new Error("review --executor-qa-json must resolve to an executorQa object");
|
|
2658
|
+
}
|
|
2659
|
+
return structured as JsonObject;
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
async function spawnText(
|
|
2663
|
+
command: string[],
|
|
2664
|
+
options: { cwd: string; timeoutMs?: number },
|
|
2665
|
+
): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
2666
|
+
try {
|
|
2667
|
+
const proc = Bun.spawn(command, { cwd: options.cwd, stdout: "pipe", stderr: "pipe" });
|
|
2668
|
+
const timeout = setTimeout(() => proc.kill(), options.timeoutMs ?? 5000);
|
|
2669
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
2670
|
+
new Response(proc.stdout).text(),
|
|
2671
|
+
new Response(proc.stderr).text(),
|
|
2672
|
+
proc.exited,
|
|
2673
|
+
]);
|
|
2674
|
+
clearTimeout(timeout);
|
|
2675
|
+
return { ok: exitCode === 0, stdout, stderr };
|
|
2676
|
+
} catch (error) {
|
|
2677
|
+
return { ok: false, stdout: "", stderr: error instanceof Error ? error.message : String(error) };
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
async function resolveGitBase(cwd: string, branch?: string): Promise<string> {
|
|
2682
|
+
const candidates = branch ? [branch] : ["origin/main", "origin/master", "main", "master"];
|
|
2683
|
+
for (const candidate of candidates) {
|
|
2684
|
+
const exists = await spawnText(["git", "rev-parse", "--verify", candidate], { cwd, timeoutMs: 3000 });
|
|
2685
|
+
if (exists.ok) return candidate;
|
|
2686
|
+
}
|
|
2687
|
+
const mergeBase = await spawnText(["git", "merge-base", "HEAD", "origin/main"], { cwd, timeoutMs: 3000 });
|
|
2688
|
+
if (mergeBase.ok && mergeBase.stdout.trim()) return mergeBase.stdout.trim();
|
|
2689
|
+
return "HEAD";
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
async function localDiffSource(cwd: string, sourceKind: string, branch?: string): Promise<JsonObject> {
|
|
2693
|
+
if (sourceKind === "worktree") {
|
|
2694
|
+
const [status, diff] = await Promise.all([
|
|
2695
|
+
spawnText(["git", "status", "--short"], { cwd, timeoutMs: 5000 }),
|
|
2696
|
+
spawnText(["git", "diff", "--stat"], { cwd, timeoutMs: 5000 }),
|
|
2697
|
+
]);
|
|
2698
|
+
return { kind: "worktree", status: status.stdout, diffStat: diff.stdout };
|
|
2699
|
+
}
|
|
2700
|
+
const base = await resolveGitBase(cwd, branch);
|
|
2701
|
+
const diff = await spawnText(["git", "diff", "--stat", `${base}...HEAD`], { cwd, timeoutMs: 5000 });
|
|
2702
|
+
return { kind: sourceKind, base, branch, diffStat: diff.stdout };
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
async function resolveReviewSource(
|
|
2706
|
+
cwd: string,
|
|
2707
|
+
args: readonly string[],
|
|
2708
|
+
specPath: string | undefined,
|
|
2709
|
+
): Promise<{ contractStrength: UltragoalReviewContractStrength; source: JsonObject }> {
|
|
2710
|
+
if (specPath) {
|
|
2711
|
+
const absolute = path.resolve(cwd, specPath);
|
|
2712
|
+
return {
|
|
2713
|
+
contractStrength: "strong",
|
|
2714
|
+
source: { kind: "spec", path: specPath, contract: await Bun.file(absolute).text() },
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
const pr = flagValue(args, "--pr");
|
|
2718
|
+
if (pr) {
|
|
2719
|
+
const [view, diff] = await Promise.all([
|
|
2720
|
+
spawnText(["gh", "pr", "view", pr, "--json", "title,body,baseRefName"], { cwd, timeoutMs: 5000 }),
|
|
2721
|
+
spawnText(["gh", "pr", "diff", pr], { cwd, timeoutMs: 5000 }),
|
|
2722
|
+
]);
|
|
2723
|
+
if (view.ok && diff.ok)
|
|
2724
|
+
return {
|
|
2725
|
+
contractStrength: "thin-derived",
|
|
2726
|
+
source: { kind: "pr", pr, prSource: "gh", metadata: view.stdout, diff: diff.stdout },
|
|
2727
|
+
};
|
|
2728
|
+
return {
|
|
2729
|
+
contractStrength: "thin-derived",
|
|
2730
|
+
source: {
|
|
2731
|
+
kind: "pr",
|
|
2732
|
+
pr,
|
|
2733
|
+
prSource: "gh-unavailable",
|
|
2734
|
+
ghError: `${view.stderr}${diff.stderr}`.trim(),
|
|
2735
|
+
local: await localDiffSource(cwd, "pr-fallback"),
|
|
2736
|
+
},
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
const branch = flagValue(args, "--branch");
|
|
2740
|
+
if (branch) return { contractStrength: "thin-derived", source: await localDiffSource(cwd, "branch", branch) };
|
|
2741
|
+
return { contractStrength: "thin-derived", source: await localDiffSource(cwd, "worktree") };
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
function findingFromError(error: unknown): UltragoalReviewFinding {
|
|
2745
|
+
return { severity: "blocker", message: error instanceof Error ? error.message : String(error) };
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function executorQaBlockers(executorQa: JsonObject): UltragoalReviewFinding[] {
|
|
2749
|
+
const blockers = nonEmptyStringArray(executorQa.blockers);
|
|
2750
|
+
return (blockers ?? []).map(message => ({ severity: "blocker", message: `executorQa.blockers: ${message}` }));
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
const RESOLVED_REVIEW_BLOCKER_STATUSES = new Set<UltragoalGoalStatus>(["complete", "superseded"]);
|
|
2754
|
+
|
|
2755
|
+
function findOpenReviewBlockerGoal(plan: UltragoalPlan, message: string): UltragoalGoal | undefined {
|
|
2756
|
+
const objective = message.trim();
|
|
2757
|
+
return plan.goals.find(
|
|
2758
|
+
goal =>
|
|
2759
|
+
goal.steering?.kind === "review_blocker" &&
|
|
2760
|
+
goal.objective.trim() === objective &&
|
|
2761
|
+
!RESOLVED_REVIEW_BLOCKER_STATUSES.has(goal.status),
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
async function recordReviewFindingGoals(cwd: string, findings: readonly UltragoalReviewFinding[]): Promise<string[]> {
|
|
2766
|
+
let plan = await readUltragoalPlan(cwd);
|
|
2767
|
+
const now = new Date().toISOString();
|
|
2768
|
+
if (!plan) {
|
|
2769
|
+
plan = {
|
|
2770
|
+
version: 1,
|
|
2771
|
+
gjcObjective: DEFAULT_ULTRAGOAL_OBJECTIVE,
|
|
2772
|
+
brief: "Ultragoal review-start findings",
|
|
2773
|
+
gjcGoalMode: "aggregate",
|
|
2774
|
+
createdAt: now,
|
|
2775
|
+
updatedAt: now,
|
|
2776
|
+
goals: [],
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
const blockerGoalIds: string[] = [];
|
|
2780
|
+
const createdGoalIds: string[] = [];
|
|
2781
|
+
for (const finding of findings) {
|
|
2782
|
+
const existing = findOpenReviewBlockerGoal(plan, finding.message);
|
|
2783
|
+
if (existing) {
|
|
2784
|
+
if (!blockerGoalIds.includes(existing.id)) blockerGoalIds.push(existing.id);
|
|
2785
|
+
continue;
|
|
2786
|
+
}
|
|
2787
|
+
const id = nextUltragoalGoalId(plan);
|
|
2788
|
+
plan.goals.push({
|
|
2789
|
+
id,
|
|
2790
|
+
title: "Resolve ultragoal review finding",
|
|
2791
|
+
objective: finding.message,
|
|
2792
|
+
status: "pending",
|
|
2793
|
+
createdAt: now,
|
|
2794
|
+
updatedAt: now,
|
|
2795
|
+
steering: { kind: "review_blocker" },
|
|
2796
|
+
});
|
|
2797
|
+
blockerGoalIds.push(id);
|
|
2798
|
+
createdGoalIds.push(id);
|
|
2799
|
+
}
|
|
2800
|
+
if (createdGoalIds.length > 0) {
|
|
2801
|
+
plan.updatedAt = now;
|
|
2802
|
+
await writePlan(cwd, plan);
|
|
2803
|
+
await appendLedger(cwd, {
|
|
2804
|
+
event: "review_blockers_recorded",
|
|
2805
|
+
blockerGoalIds: createdGoalIds,
|
|
2806
|
+
findings: findings.map(finding => finding.message),
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
return blockerGoalIds;
|
|
2810
|
+
}
|
|
2811
|
+
|
|
2812
|
+
export async function runUltragoalReview(cwd: string, args: readonly string[]): Promise<UltragoalReviewResult> {
|
|
2813
|
+
const mode = parseReviewMode(flagValue(args, "--mode"));
|
|
2814
|
+
const specPath = flagValue(args, "--spec");
|
|
2815
|
+
const { contractStrength, source } = await resolveReviewSource(cwd, args, specPath);
|
|
2816
|
+
const executorQa = await readOptionalExecutorQa(
|
|
2817
|
+
cwd,
|
|
2818
|
+
flagValue(args, "--executor-qa-json") ?? flagValue(args, "--executor-qa"),
|
|
2819
|
+
);
|
|
2820
|
+
const findings: UltragoalReviewFinding[] = [];
|
|
2821
|
+
try {
|
|
2822
|
+
await validateExecutorQaRedTeamEvidenceForReview(cwd, executorQa, { mode: "review" });
|
|
2823
|
+
} catch (error) {
|
|
2824
|
+
findings.push(findingFromError(error));
|
|
2825
|
+
}
|
|
2826
|
+
findings.push(...executorQaBlockers(executorQa));
|
|
2827
|
+
const weakContractCapApplied = contractStrength === "thin-derived";
|
|
2828
|
+
const cleanPassEligible = contractStrength === "strong" && findings.length === 0;
|
|
2829
|
+
const result: UltragoalReviewResult = {
|
|
2830
|
+
verdict: cleanPassEligible
|
|
2831
|
+
? "pass"
|
|
2832
|
+
: weakContractCapApplied && findings.length === 0
|
|
2833
|
+
? "inconclusive: weak-contract"
|
|
2834
|
+
: "fail",
|
|
2835
|
+
contractStrength,
|
|
2836
|
+
cleanPassEligible,
|
|
2837
|
+
source,
|
|
2838
|
+
findings,
|
|
2839
|
+
artifactValidationSummary: {
|
|
2840
|
+
validator: "validateExecutorQaRedTeamEvidenceForReview",
|
|
2841
|
+
mode: "review",
|
|
2842
|
+
passed: findings.length === 0,
|
|
2843
|
+
findingCount: findings.length,
|
|
2844
|
+
},
|
|
2845
|
+
weakContractCapApplied,
|
|
2846
|
+
};
|
|
2847
|
+
if (mode === "review-start" && findings.length > 0)
|
|
2848
|
+
result.blockerGoalIds = await recordReviewFindingGoals(cwd, findings);
|
|
2849
|
+
return result;
|
|
2850
|
+
}
|
|
2851
|
+
|
|
1693
2852
|
function flagValue(args: readonly string[], flag: string): string | undefined {
|
|
1694
2853
|
const index = args.indexOf(flag);
|
|
1695
2854
|
if (index < 0) return undefined;
|
|
@@ -1711,6 +2870,12 @@ const FLAGS_WITH_VALUES = new Set([
|
|
|
1711
2870
|
"--evidence",
|
|
1712
2871
|
"--gjc-goal-json",
|
|
1713
2872
|
"--quality-gate-json",
|
|
2873
|
+
"--executor-qa-json",
|
|
2874
|
+
"--executor-qa",
|
|
2875
|
+
"--pr",
|
|
2876
|
+
"--branch",
|
|
2877
|
+
"--spec",
|
|
2878
|
+
"--mode",
|
|
1714
2879
|
"--kind",
|
|
1715
2880
|
"--title",
|
|
1716
2881
|
"--objective",
|
|
@@ -1771,6 +2936,26 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
|
|
|
1771
2936
|
"",
|
|
1772
2937
|
].join("\n");
|
|
1773
2938
|
}
|
|
2939
|
+
if (subject === "review") {
|
|
2940
|
+
return [
|
|
2941
|
+
"Run native GJC Ultragoal workflow commands",
|
|
2942
|
+
"",
|
|
2943
|
+
"USAGE",
|
|
2944
|
+
" $ gjc ultragoal review [--pr <n> | --branch <ref>] [--spec <path>] [--executor-qa-json <json-or-path>] [FLAGS]",
|
|
2945
|
+
"",
|
|
2946
|
+
"FLAGS",
|
|
2947
|
+
" --pr=<value> Review a GitHub PR; falls back to local diff when gh is unavailable",
|
|
2948
|
+
" --branch=<value> Review the current branch against a base ref",
|
|
2949
|
+
" --spec=<value> Contract/spec override; enables strong-contract clean PASS eligibility",
|
|
2950
|
+
" --executor-qa-json=<value> executorQa JSON string or path using checkpoint qualityGate.executorQa shape",
|
|
2951
|
+
" --mode=<value> review-only|review-start (default review-only)",
|
|
2952
|
+
" --json Output the machine-readable verdict report",
|
|
2953
|
+
"",
|
|
2954
|
+
"OUTPUT",
|
|
2955
|
+
" JSON includes verdict, contractStrength, cleanPassEligible, source, findings, artifactValidationSummary, and weakContractCapApplied.",
|
|
2956
|
+
"",
|
|
2957
|
+
].join("\n");
|
|
2958
|
+
}
|
|
1774
2959
|
return [
|
|
1775
2960
|
"Run native GJC Ultragoal workflow commands",
|
|
1776
2961
|
"",
|
|
@@ -1782,10 +2967,11 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
|
|
|
1782
2967
|
" create-goals",
|
|
1783
2968
|
" complete-goals",
|
|
1784
2969
|
" checkpoint",
|
|
2970
|
+
" review",
|
|
1785
2971
|
" steer",
|
|
1786
2972
|
" record-review-blockers",
|
|
1787
2973
|
"",
|
|
1788
|
-
"Run `gjc ultragoal checkpoint --help`
|
|
2974
|
+
"Run `gjc ultragoal checkpoint --help` or `gjc ultragoal review --help` for command-specific requirements.",
|
|
1789
2975
|
"",
|
|
1790
2976
|
].join("\n");
|
|
1791
2977
|
}
|
|
@@ -2057,6 +3243,15 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
|
|
|
2057
3243
|
stdout: renderCheckpointContinuation(result, status, json, cwd),
|
|
2058
3244
|
};
|
|
2059
3245
|
}
|
|
3246
|
+
case "review": {
|
|
3247
|
+
const result = await runUltragoalReview(cwd, args);
|
|
3248
|
+
return {
|
|
3249
|
+
status: 0,
|
|
3250
|
+
stdout: json ? `${JSON.stringify(result, null, 2)}\n` : `${result.verdict}\n`,
|
|
3251
|
+
reviewBlockerGoalIds: result.blockerGoalIds,
|
|
3252
|
+
createdReviewPlan: (result.blockerGoalIds?.length ?? 0) > 0,
|
|
3253
|
+
};
|
|
3254
|
+
}
|
|
2060
3255
|
case "steer": {
|
|
2061
3256
|
const result = await executeUltragoalSteeringCommand(args, cwd);
|
|
2062
3257
|
return {
|
|
@@ -2097,6 +3292,7 @@ const RECONCILE_COMMANDS = new Set([
|
|
|
2097
3292
|
"checkpoint",
|
|
2098
3293
|
"steer",
|
|
2099
3294
|
"record-review-blockers",
|
|
3295
|
+
"review",
|
|
2100
3296
|
]);
|
|
2101
3297
|
|
|
2102
3298
|
/**
|