forgeos 0.1.0-alpha.65 → 0.1.0-alpha.66
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/AGENTS.md +1 -1
- package/CHANGELOG.md +10 -0
- package/docs/agent-fabric.md +39 -0
- package/package.json +1 -1
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/agent-fabric/codex-adversarial-review.ts +229 -0
- package/src/forge/agent-fabric/local-change-review-service.ts +374 -0
- package/src/forge/agent-memory/mcp.ts +48 -0
- package/src/forge/cli/fabric.ts +31 -1
- package/src/forge/cli/main.ts +4 -0
- package/src/forge/cli/parse.ts +7 -3
- package/src/forge/version.ts +1 -1
package/AGENTS.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.66 input=57341432921f2c43eddb033a05cc7ad25dabd9813fcf2fd64c67f74ae5f61859 content=721818a6f9a664aa898092d4aa5cd68cbbd8ad6e150c7d3ad3ef785a2e3fcf53
|
|
2
2
|
# AGENTS.md
|
|
3
3
|
|
|
4
4
|
<!-- forge-generated:start -->
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# forgeos
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.66
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#66](https://github.com/Stahldavid/forge/pull/66) [`472cde3`](https://github.com/Stahldavid/forge/commit/472cde3647cf055afd627460bf015ab5467ab8d5) Thanks [@Stahldavid](https://github.com/Stahldavid)! - Add a local Agent Fabric change-review flow for Codex App authored diffs. It
|
|
8
|
+
captures an exact staged, unstaged, and new-file snapshot, runs a bounded
|
|
9
|
+
read-only Codex CLI reviewer on an isolated checkout, and keeps digest-bound
|
|
10
|
+
review rounds and findings. CLI and MCP expose proposal and evidence while
|
|
11
|
+
reviewer dispatch remains an explicit CLI action.
|
|
12
|
+
|
|
3
13
|
## 0.1.0-alpha.65
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
package/docs/agent-fabric.md
CHANGED
|
@@ -375,6 +375,45 @@ it does not prove the human popup flow or coding quality on real projects.
|
|
|
375
375
|
exercises the service, approved verification, Docker Desktop, durable readback,
|
|
376
376
|
and acceptance with a synthetic approval callback.
|
|
377
377
|
|
|
378
|
+
### Codex App implementation with adversarial review (local preview)
|
|
379
|
+
|
|
380
|
+
The Codex App may implement a change in the current checkout. Register the work
|
|
381
|
+
before editing with a JSON request such as
|
|
382
|
+
`{"objective":"Change the parser","acceptanceCriteria":["The new case is handled"],"implementer":"codex-app"}`.
|
|
383
|
+
Place the request under the ignored `.forge/local/` directory, then use:
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
node bin/forge.mjs fabric change-propose --file .forge/local/change-request.json --json
|
|
387
|
+
node bin/forge.mjs fabric change-status --task-id <change-id> --json
|
|
388
|
+
node bin/forge.mjs fabric change-review --task-id <change-id> --json
|
|
389
|
+
node bin/forge.mjs fabric change-evidence --task-id <change-id> --json
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
`change-review` is an explicit owner CLI action that starts a **new Codex CLI
|
|
393
|
+
review turn** using the machine's existing Codex login. That turn may consume
|
|
394
|
+
Codex usage. The MCP tools `fabric_change_propose`, `fabric_change_status`, and
|
|
395
|
+
`fabric_change_evidence` can register and inspect a change but cannot start a
|
|
396
|
+
review or mark it accepted. The request pins the base commit; review later
|
|
397
|
+
captures staged, unstaged, and non-ignored new files without modifying the
|
|
398
|
+
user's Git index. Local Forge state under `.forge/local` and `.forge/delta` is
|
|
399
|
+
excluded. A review is limited to 1 MiB, 100 paths, and eight rounds; binary
|
|
400
|
+
patches, symlinks, and submodules are rejected.
|
|
401
|
+
|
|
402
|
+
Each round records an immutable diff and request digest before dispatch. The
|
|
403
|
+
reviewer runs against a separate checkout of that exact diff in read-only
|
|
404
|
+
sandbox mode. A valid structured report bound to the request digest is required;
|
|
405
|
+
process exit zero alone is insufficient. `canAccept` becomes true only for a
|
|
406
|
+
passing report on the **current** diff. Editing the checkout after a pass makes
|
|
407
|
+
it `needs_review`, and the next review creates a new round. An interrupted
|
|
408
|
+
review remains uncertain and will not automatically spend another turn. An
|
|
409
|
+
inconclusive completed review may be retried explicitly. For an interrupted
|
|
410
|
+
review, inspect its evidence and create a fresh change request if another paid
|
|
411
|
+
turn is warranted; the uncertain intent remains in the original record.
|
|
412
|
+
`canAccept` is evidence for the human workflow; this preview does not intercept
|
|
413
|
+
Git merges or edits made outside the CLI. Same-account shell access can alter
|
|
414
|
+
the local ledger and is outside the pilot's security boundary. The Codex CLI
|
|
415
|
+
review protocol has fake-process tests but no paid, real-model smoke yet.
|
|
416
|
+
|
|
378
417
|
### Local Evolution Registry (single owner)
|
|
379
418
|
|
|
380
419
|
The local extension workflow pins a candidate's bytes before evaluation. A
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.
|
|
1
|
+
{"defaultProvider":"local","diagnostics":[],"env":{"deployEnv":"FORGE_DEPLOY_ENV","deployId":"FORGE_DEPLOY_ID","publicReleaseId":"NEXT_PUBLIC_FORGE_RELEASE_ID","releaseId":"FORGE_RELEASE_ID"},"gitSha":"unknown","optionalProviders":["local","sentry-compatible","sentry","glitchtip","bugsink","otel","custom"],"packageName":"forgeos","packageVersion":"0.1.0-alpha.66","releaseId":"forgeos@0.1.0-alpha.66+unknown","schemaVersion":"0.1.0"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// @forge-generated generator=0.1.0-alpha.
|
|
1
|
+
// @forge-generated generator=0.1.0-alpha.66 input=57341432921f2c43eddb033a05cc7ad25dabd9813fcf2fd64c67f74ae5f61859 content=1c305c2f9d3a0ad52157e8b8858790d338296f1d0cc59c0d70f93c65d9c19fc0
|
|
2
2
|
export const releaseManifest = {
|
|
3
3
|
"defaultProvider": "local",
|
|
4
4
|
"diagnostics": [],
|
|
@@ -19,7 +19,7 @@ export const releaseManifest = {
|
|
|
19
19
|
"custom"
|
|
20
20
|
],
|
|
21
21
|
"packageName": "forgeos",
|
|
22
|
-
"packageVersion": "0.1.0-alpha.
|
|
23
|
-
"releaseId": "forgeos@0.1.0-alpha.
|
|
22
|
+
"packageVersion": "0.1.0-alpha.66",
|
|
23
|
+
"releaseId": "forgeos@0.1.0-alpha.66+unknown",
|
|
24
24
|
"schemaVersion": "0.1.0"
|
|
25
25
|
} as const;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { realpathSync, statSync } from "node:fs";
|
|
4
|
+
import type { Digest } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
7
|
+
const MAX_TIMEOUT_MS = 10 * 60_000;
|
|
8
|
+
const MAX_OUTPUT_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
const MAX_PROMPT_BYTES = 64 * 1024;
|
|
10
|
+
const MAX_FINDINGS = 100;
|
|
11
|
+
|
|
12
|
+
export interface CodexAdversarialFinding {
|
|
13
|
+
severity: "blocker" | "high" | "medium" | "low";
|
|
14
|
+
path: string;
|
|
15
|
+
line?: number;
|
|
16
|
+
title: string;
|
|
17
|
+
explanation: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CodexAdversarialReport {
|
|
21
|
+
requestDigest: Digest;
|
|
22
|
+
verdict: "pass" | "changes_requested" | "inconclusive";
|
|
23
|
+
summary: string;
|
|
24
|
+
findings: readonly CodexAdversarialFinding[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CodexReviewUsage {
|
|
28
|
+
inputTokens: number;
|
|
29
|
+
outputTokens: number;
|
|
30
|
+
cachedInputTokens?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CodexAdversarialReviewResult =
|
|
34
|
+
| { state: "reported"; report: CodexAdversarialReport; outputDigest: Digest; usage?: CodexReviewUsage }
|
|
35
|
+
| { state: "inconclusive"; error: string; report?: CodexAdversarialReport; outputDigest?: Digest; usage?: CodexReviewUsage };
|
|
36
|
+
|
|
37
|
+
export interface CodexAdversarialReviewOptions {
|
|
38
|
+
/** A prepared, exact review checkout. The caller must verify its Git identity and diff before and after this call. */
|
|
39
|
+
workspaceRoot: string;
|
|
40
|
+
/** Canonical digest of the review request and exact diff, supplied by the caller. */
|
|
41
|
+
requestDigest: Digest;
|
|
42
|
+
prompt: string;
|
|
43
|
+
timeoutMs: number;
|
|
44
|
+
maxOutputBytes: number;
|
|
45
|
+
executable?: string;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
/** Injection point for tests; production uses node:child_process.spawn. */
|
|
48
|
+
spawnProcess?: (command: string, args: string[], options: SpawnOptionsWithoutStdio) => ChildProcessWithoutNullStreams;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function exactKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[] = []): boolean {
|
|
56
|
+
return required.every((key) => Object.hasOwn(value, key)) &&
|
|
57
|
+
Object.keys(value).every((key) => required.includes(key) || optional.includes(key));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nonEmptyText(value: unknown, maxLength: number): value is string {
|
|
61
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= maxLength && !value.includes("\u0000");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validFinding(value: unknown): value is CodexAdversarialFinding {
|
|
65
|
+
if (!isRecord(value) || !exactKeys(value, ["severity", "path", "title", "explanation"], ["line"])) return false;
|
|
66
|
+
if (!["blocker", "high", "medium", "low"].includes(String(value.severity))) return false;
|
|
67
|
+
if (!nonEmptyText(value.path, 512) || value.path.includes("\\") || value.path.startsWith("/") ||
|
|
68
|
+
/^[a-z]:/iu.test(value.path) || value.path.split("/").some((segment) => segment === "" || segment === "." || segment === "..")) return false;
|
|
69
|
+
if (!nonEmptyText(value.title, 500) || !nonEmptyText(value.explanation, 4_000)) return false;
|
|
70
|
+
return value.line === undefined || (Number.isSafeInteger(value.line) && Number(value.line) > 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseReport(text: string, requestDigest: Digest): CodexAdversarialReport | undefined {
|
|
74
|
+
let value: unknown;
|
|
75
|
+
try { value = JSON.parse(text); } catch { return undefined; }
|
|
76
|
+
if (!isRecord(value) || !exactKeys(value, ["requestDigest", "verdict", "summary", "findings"])) return undefined;
|
|
77
|
+
if (value.requestDigest !== requestDigest || !["pass", "changes_requested", "inconclusive"].includes(String(value.verdict)) ||
|
|
78
|
+
!nonEmptyText(value.summary, 4_000) || !Array.isArray(value.findings) ||
|
|
79
|
+
value.findings.length > MAX_FINDINGS || !value.findings.every(validFinding)) return undefined;
|
|
80
|
+
if ((value.verdict === "pass" && value.findings.length !== 0) ||
|
|
81
|
+
(value.verdict === "changes_requested" && value.findings.length === 0)) return undefined;
|
|
82
|
+
return value as unknown as CodexAdversarialReport;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseUsage(value: unknown): CodexReviewUsage | undefined {
|
|
86
|
+
if (!isRecord(value)) return undefined;
|
|
87
|
+
const input = value.input_tokens;
|
|
88
|
+
const output = value.output_tokens;
|
|
89
|
+
const cached = value.cached_input_tokens;
|
|
90
|
+
if (!Number.isSafeInteger(input) || Number(input) < 0 ||
|
|
91
|
+
!Number.isSafeInteger(output) || Number(output) < 0) return undefined;
|
|
92
|
+
return {
|
|
93
|
+
inputTokens: Number(input), outputTokens: Number(output),
|
|
94
|
+
...(Number.isSafeInteger(cached) && Number(cached) >= 0 ? { cachedInputTokens: Number(cached) } : {}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseCodexEvents(stdout: string, requestDigest: Digest):
|
|
99
|
+
{ report?: CodexAdversarialReport; usage?: CodexReviewUsage; error?: string } {
|
|
100
|
+
let finalText: string | undefined;
|
|
101
|
+
let usage: CodexReviewUsage | undefined;
|
|
102
|
+
let completed = false;
|
|
103
|
+
let failure = false;
|
|
104
|
+
for (const line of stdout.split(/\r?\n/u)) {
|
|
105
|
+
if (!line) continue;
|
|
106
|
+
let event: unknown;
|
|
107
|
+
try { event = JSON.parse(line); } catch { return { error: "invalid_jsonl" }; }
|
|
108
|
+
if (!isRecord(event) || typeof event.type !== "string") return { error: "invalid_event" };
|
|
109
|
+
if (event.type === "item.completed" && isRecord(event.item) && event.item.type === "agent_message") {
|
|
110
|
+
if (typeof event.item.text !== "string") return { error: "invalid_agent_message" };
|
|
111
|
+
finalText = event.item.text;
|
|
112
|
+
}
|
|
113
|
+
if (event.type === "turn.completed") {
|
|
114
|
+
completed = true;
|
|
115
|
+
usage = parseUsage(event.usage);
|
|
116
|
+
}
|
|
117
|
+
if (event.type === "turn.failed" || event.type === "error") failure = true;
|
|
118
|
+
}
|
|
119
|
+
if (failure) return { error: "codex_turn_failed", usage };
|
|
120
|
+
if (!completed) return { error: "missing_completed_turn", usage };
|
|
121
|
+
if (finalText === undefined) return { error: "missing_agent_report", usage };
|
|
122
|
+
const report = parseReport(finalText, requestDigest);
|
|
123
|
+
if (!report) return { error: "invalid_agent_report", usage };
|
|
124
|
+
return { report, usage };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function digestOutput(stdout: Buffer, stderr: Buffer): Digest {
|
|
128
|
+
const hash = createHash("sha256");
|
|
129
|
+
hash.update(stdout);
|
|
130
|
+
hash.update(stderr);
|
|
131
|
+
return `sha256:${hash.digest("hex")}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Codex's read-only sandbox is defense in depth; the caller owns checkout isolation and exact-diff verification. */
|
|
135
|
+
export async function runCodexAdversarialReview(options: CodexAdversarialReviewOptions): Promise<CodexAdversarialReviewResult> {
|
|
136
|
+
if (!DIGEST_PATTERN.test(options.requestDigest) || !nonEmptyText(options.prompt, MAX_PROMPT_BYTES) ||
|
|
137
|
+
Buffer.byteLength(options.prompt, "utf8") > MAX_PROMPT_BYTES ||
|
|
138
|
+
!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1 || options.timeoutMs > MAX_TIMEOUT_MS ||
|
|
139
|
+
!Number.isSafeInteger(options.maxOutputBytes) || options.maxOutputBytes < 1 || options.maxOutputBytes > MAX_OUTPUT_BYTES) {
|
|
140
|
+
throw new TypeError("Invalid Codex review bounds or request");
|
|
141
|
+
}
|
|
142
|
+
const workspaceRoot = realpathSync(options.workspaceRoot);
|
|
143
|
+
if (!statSync(workspaceRoot).isDirectory()) throw new TypeError("Codex review workspace must be a directory");
|
|
144
|
+
if (options.signal?.aborted) return { state: "inconclusive", error: "cancelled" };
|
|
145
|
+
|
|
146
|
+
const args = ["--sandbox", "read-only", "--ask-for-approval", "never", "exec", "review",
|
|
147
|
+
"--uncommitted", "--json", "--ephemeral", "-"];
|
|
148
|
+
const prompt = `${options.prompt.trimEnd()}\n\nReturn ONLY a JSON object as the final answer with exactly these keys: ` +
|
|
149
|
+
`requestDigest, verdict, summary, findings. Set requestDigest to ${options.requestDigest}. ` +
|
|
150
|
+
`verdict is pass, changes_requested, or inconclusive. Each finding has severity ` +
|
|
151
|
+
`(blocker, high, medium, low), repository-relative path, optional positive line, title, and explanation. ` +
|
|
152
|
+
`Use an empty findings array only for pass or inconclusive. Do not use Markdown fences.\n`;
|
|
153
|
+
|
|
154
|
+
return await new Promise((resolve) => {
|
|
155
|
+
let child: ChildProcessWithoutNullStreams;
|
|
156
|
+
try {
|
|
157
|
+
child = (options.spawnProcess ?? spawn)(options.executable ?? "codex", args, {
|
|
158
|
+
cwd: workspaceRoot, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"],
|
|
159
|
+
});
|
|
160
|
+
} catch {
|
|
161
|
+
resolve({ state: "inconclusive", error: "spawn_failed" });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const stdout: Buffer[] = [];
|
|
166
|
+
const stderr: Buffer[] = [];
|
|
167
|
+
let captured = 0;
|
|
168
|
+
let reason: string | undefined;
|
|
169
|
+
let settled = false;
|
|
170
|
+
let hardTimer: ReturnType<typeof setTimeout> | undefined;
|
|
171
|
+
const finish = (code: number | null) => {
|
|
172
|
+
if (settled) return;
|
|
173
|
+
settled = true;
|
|
174
|
+
clearTimeout(timer);
|
|
175
|
+
if (hardTimer) clearTimeout(hardTimer);
|
|
176
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
177
|
+
const out = Buffer.concat(stdout);
|
|
178
|
+
const err = Buffer.concat(stderr);
|
|
179
|
+
const outputDigest = digestOutput(out, err);
|
|
180
|
+
if (reason) {
|
|
181
|
+
resolve({ state: "inconclusive", error: reason, outputDigest });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (code !== 0) {
|
|
185
|
+
resolve({ state: "inconclusive", error: "codex_nonzero_exit", outputDigest });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const parsed = parseCodexEvents(out.toString("utf8"), options.requestDigest);
|
|
189
|
+
if (!parsed.report) {
|
|
190
|
+
resolve({ state: "inconclusive", error: parsed.error ?? "invalid_agent_report", outputDigest,
|
|
191
|
+
...(parsed.usage ? { usage: parsed.usage } : {}) });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (parsed.report.verdict === "inconclusive") {
|
|
195
|
+
resolve({ state: "inconclusive", error: "reviewer_inconclusive", report: parsed.report, outputDigest,
|
|
196
|
+
...(parsed.usage ? { usage: parsed.usage } : {}) });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
resolve({ state: "reported", report: parsed.report, outputDigest,
|
|
200
|
+
...(parsed.usage ? { usage: parsed.usage } : {}) });
|
|
201
|
+
};
|
|
202
|
+
const stop = (why: string) => {
|
|
203
|
+
if (reason || settled) return;
|
|
204
|
+
reason = why;
|
|
205
|
+
child.kill();
|
|
206
|
+
hardTimer = setTimeout(() => finish(null), 2_000);
|
|
207
|
+
};
|
|
208
|
+
const capture = (chunk: Buffer, target: Buffer[]) => {
|
|
209
|
+
if (reason || settled) return;
|
|
210
|
+
const remaining = options.maxOutputBytes - captured;
|
|
211
|
+
if (remaining > 0) {
|
|
212
|
+
const part = chunk.subarray(0, remaining);
|
|
213
|
+
target.push(part);
|
|
214
|
+
captured += part.length;
|
|
215
|
+
}
|
|
216
|
+
if (chunk.length > remaining) stop("output_limit");
|
|
217
|
+
};
|
|
218
|
+
const cancel = () => stop("cancelled");
|
|
219
|
+
child.stdout.on("data", (chunk: Buffer) => capture(chunk, stdout));
|
|
220
|
+
child.stderr.on("data", (chunk: Buffer) => capture(chunk, stderr));
|
|
221
|
+
child.on("error", () => stop("spawn_failed"));
|
|
222
|
+
child.on("close", finish);
|
|
223
|
+
const timer = setTimeout(() => stop("timeout"), options.timeoutMs);
|
|
224
|
+
options.signal?.addEventListener("abort", cancel, { once: true });
|
|
225
|
+
if (options.signal?.aborted) cancel();
|
|
226
|
+
child.stdin.on("error", () => stop("stdin_failed"));
|
|
227
|
+
try { child.stdin.end(prompt); } catch { stop("stdin_failed"); }
|
|
228
|
+
});
|
|
229
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { sha256Digest, stableStringify } from "./canonical.ts";
|
|
6
|
+
import { runCodexAdversarialReview } from "./codex-adversarial-review.ts";
|
|
7
|
+
import { AgentFabricError } from "./errors.ts";
|
|
8
|
+
import { localFabricPath } from "./local-paths.ts";
|
|
9
|
+
|
|
10
|
+
const MAX_DIFF_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_PATHS = 100;
|
|
12
|
+
const MAX_ROUNDS = 8;
|
|
13
|
+
|
|
14
|
+
export interface LocalChangeProposal {
|
|
15
|
+
objective: string;
|
|
16
|
+
acceptanceCriteria: string[];
|
|
17
|
+
implementer: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Contract extends LocalChangeProposal {
|
|
21
|
+
schemaVersion: 1;
|
|
22
|
+
changeId: string;
|
|
23
|
+
repositoryRoot: string;
|
|
24
|
+
baseCommit: string;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
digest: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RoundIntent {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
round: number;
|
|
32
|
+
baseCommit: string;
|
|
33
|
+
contractDigest: string;
|
|
34
|
+
diffDigest: string;
|
|
35
|
+
diffBytes: number;
|
|
36
|
+
changedPaths: string[];
|
|
37
|
+
requestDigest: string;
|
|
38
|
+
reviewer: "codex-cli";
|
|
39
|
+
startedAt: string;
|
|
40
|
+
digest: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RoundResult {
|
|
44
|
+
schemaVersion: 1;
|
|
45
|
+
intentDigest: string;
|
|
46
|
+
finishedAt: string;
|
|
47
|
+
state: "reported" | "inconclusive";
|
|
48
|
+
report?: unknown;
|
|
49
|
+
outputDigest?: string;
|
|
50
|
+
usage?: unknown;
|
|
51
|
+
error?: string;
|
|
52
|
+
digest: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LocalChangeStatus {
|
|
56
|
+
changeId: string;
|
|
57
|
+
state: "proposed" | "needs_review" | "review_uncertain" | "changes_requested" | "inconclusive" | "ready";
|
|
58
|
+
contractDigest: string;
|
|
59
|
+
baseCommit: string;
|
|
60
|
+
currentDiffDigest: string | null;
|
|
61
|
+
latestRound: number;
|
|
62
|
+
reviewedDiffDigest: string | null;
|
|
63
|
+
reviewerVerdict: string | null;
|
|
64
|
+
canAccept: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function git(root: string, args: string[], options: { index?: string; input?: Buffer; maxBuffer?: number } = {}): Buffer {
|
|
68
|
+
try {
|
|
69
|
+
return execFileSync("git", ["-c", "core.fsmonitor=false", "-c", "core.hooksPath=NUL", ...args], {
|
|
70
|
+
cwd: root, env: options.index ? { ...process.env, GIT_INDEX_FILE: options.index } : process.env,
|
|
71
|
+
input: options.input, encoding: "buffer", windowsHide: true, timeout: 30_000,
|
|
72
|
+
maxBuffer: options.maxBuffer ?? MAX_DIFF_BYTES + 4096, stdio: ["pipe", "pipe", "ignore"],
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
throw new AgentFabricError("AF_INVALID_STATE", `Git operation failed: ${args[0] ?? "unknown"}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function gitText(root: string, args: string[], options?: { index?: string; maxBuffer?: number }): string {
|
|
80
|
+
return git(root, args, options).toString("utf8").trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function inertFilterOptions(root: string): string[] {
|
|
84
|
+
let names = "";
|
|
85
|
+
try {
|
|
86
|
+
names = execFileSync("git", ["config", "--name-only", "--get-regexp",
|
|
87
|
+
"^filter\\..*\\.(process|smudge|clean)$"], {
|
|
88
|
+
cwd: root, encoding: "utf8", windowsHide: true, timeout: 10_000,
|
|
89
|
+
maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "ignore"],
|
|
90
|
+
});
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as { status?: number }).status !== 1) {
|
|
93
|
+
throw new AgentFabricError("AF_INVALID_STATE", "Cannot inspect Git filters");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const options: string[] = [];
|
|
97
|
+
for (const name of new Set(names.split(/\r?\n/u).filter(Boolean))) {
|
|
98
|
+
if (!/^filter\.[a-z0-9_.-]+\.(?:process|smudge|clean)$/iu.test(name)) {
|
|
99
|
+
throw new AgentFabricError("AF_INVALID_STATE", "Unsupported Git filter configuration");
|
|
100
|
+
}
|
|
101
|
+
options.push("-c", `${name}=`);
|
|
102
|
+
}
|
|
103
|
+
return options;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function digestBytes(bytes: Buffer): string { return `sha256:${createHash("sha256").update(bytes).digest("hex")}`; }
|
|
107
|
+
|
|
108
|
+
function checkedObject(value: unknown): Record<string, unknown> {
|
|
109
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new AgentFabricError("AF_INVALID_STATE", "Invalid change proposal");
|
|
110
|
+
return value as Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateProposal(value: unknown): LocalChangeProposal {
|
|
114
|
+
const object = checkedObject(value);
|
|
115
|
+
if (Object.keys(object).sort().join(",") !== "acceptanceCriteria,implementer,objective" ||
|
|
116
|
+
typeof object.objective !== "string" || !object.objective.trim() || object.objective.length > 4000 ||
|
|
117
|
+
!Array.isArray(object.acceptanceCriteria) || object.acceptanceCriteria.length < 1 || object.acceptanceCriteria.length > 20 ||
|
|
118
|
+
!object.acceptanceCriteria.every((item) => typeof item === "string" && item.trim() && item.length <= 1000) ||
|
|
119
|
+
typeof object.implementer !== "string" || !/^[a-z][a-z0-9-]{1,63}$/u.test(object.implementer)) {
|
|
120
|
+
throw new AgentFabricError("AF_INVALID_STATE", "Change proposal requires bounded objective, acceptanceCriteria, and implementer");
|
|
121
|
+
}
|
|
122
|
+
return object as unknown as LocalChangeProposal;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sealed<T extends object>(value: T): T & { digest: string } {
|
|
126
|
+
return { ...value, digest: sha256Digest(stableStringify(value)) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readSealed<T extends { digest: string }>(path: string): T {
|
|
130
|
+
const bytes = readFileSync(path);
|
|
131
|
+
if (bytes.length > MAX_DIFF_BYTES) throw new AgentFabricError("AF_CONFLICT", "Change record exceeds limit");
|
|
132
|
+
let value: T;
|
|
133
|
+
try { value = JSON.parse(bytes.toString("utf8")) as T; }
|
|
134
|
+
catch { throw new AgentFabricError("AF_CONFLICT", "Change record is invalid JSON"); }
|
|
135
|
+
if (!value || typeof value !== "object" || typeof value.digest !== "string") {
|
|
136
|
+
throw new AgentFabricError("AF_CONFLICT", "Change record is invalid");
|
|
137
|
+
}
|
|
138
|
+
const { digest, ...unsigned } = value;
|
|
139
|
+
if (sha256Digest(stableStringify(unsigned)) !== digest) throw new AgentFabricError("AF_CONFLICT", "Change record digest mismatch");
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function writeNew(path: string, value: unknown): void {
|
|
144
|
+
writeFileSync(path, Buffer.isBuffer(value) ? value : JSON.stringify(value), { flag: "wx", mode: 0o600 });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function recordPath(root: string, id: string, filename: string): string {
|
|
148
|
+
if (!/^change:[0-9a-f-]{36}$/u.test(id)) throw new AgentFabricError("AF_INVALID_STATE", "Invalid change ID");
|
|
149
|
+
return localFabricPath(root, "change-reviews", id.slice(7), filename);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function roundName(round: number, suffix: string): string { return `round-${String(round).padStart(3, "0")}.${suffix}`; }
|
|
153
|
+
|
|
154
|
+
function reviewPrompt(contract: Contract, diffDigest: string, paths: string[]): string {
|
|
155
|
+
return `Adversarially review this exact change against its acceptance criteria. Report only concrete findings. Do not edit files.\nObjective: ${contract.objective}\nAcceptance criteria: ${JSON.stringify(contract.acceptanceCriteria)}\nBase commit: ${contract.baseCommit}\nDiff digest: ${diffDigest}\nChanged paths: ${JSON.stringify(paths)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface CapturedDiff { bytes: Buffer; digest: string; paths: string[] }
|
|
159
|
+
|
|
160
|
+
function capture(root: string, baseCommit: string): CapturedDiff {
|
|
161
|
+
if (gitText(root, ["rev-parse", "HEAD"]) !== baseCommit) {
|
|
162
|
+
throw new AgentFabricError("AF_CONFLICT", "Change checkout moved from its pinned base commit");
|
|
163
|
+
}
|
|
164
|
+
// A separate index captures staged, unstaged, and non-ignored new files without changing the user's index.
|
|
165
|
+
const tempParent = localFabricPath(root, "change-reviews", "tmp");
|
|
166
|
+
mkdirSync(tempParent, { recursive: true, mode: 0o700 });
|
|
167
|
+
const temp = mkdtempSync(join(tempParent, "index-"));
|
|
168
|
+
try {
|
|
169
|
+
const index = join(temp, "index");
|
|
170
|
+
git(root, ["read-tree", baseCommit], { index });
|
|
171
|
+
git(root, [...inertFilterOptions(root), "add", "-A", "--", ".", ":(exclude).forge/local/**", ":(exclude).forge/delta/**"], { index });
|
|
172
|
+
const paths = git(root, ["diff", "--cached", "--name-only", "-z", baseCommit], { index }).toString("utf8").split("\0").filter(Boolean);
|
|
173
|
+
if (paths.length > MAX_PATHS || paths.some((path) => path.startsWith(".forge/local/") || path.startsWith(".forge/delta/") || path.includes("\0"))) {
|
|
174
|
+
throw new AgentFabricError("AF_INVALID_STATE", "Change contains too many paths or local state");
|
|
175
|
+
}
|
|
176
|
+
for (const path of paths) {
|
|
177
|
+
const mode = gitText(root, ["ls-files", "-s", "--", path], { index }).split(" ")[0];
|
|
178
|
+
if (mode && mode !== "100644" && mode !== "100755") {
|
|
179
|
+
throw new AgentFabricError("AF_INVALID_STATE", "Change contains a symlink or submodule");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
git(root, ["diff", "--cached", "--check", baseCommit], { index });
|
|
183
|
+
const bytes = git(root, ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--binary", "--full-index", baseCommit], { index });
|
|
184
|
+
if (bytes.length > MAX_DIFF_BYTES || bytes.includes(Buffer.from("GIT binary patch"))) {
|
|
185
|
+
throw new AgentFabricError("AF_INVALID_STATE", "Change diff exceeds limit or contains binary data");
|
|
186
|
+
}
|
|
187
|
+
return { bytes, digest: digestBytes(bytes), paths };
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(temp, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function materialize(root: string, id: string, round: number, base: string, diff: CapturedDiff): string {
|
|
194
|
+
const checkout = localFabricPath(root, "change-reviews", id.slice(7), `checkout-${round}`);
|
|
195
|
+
const hooks = localFabricPath(root, "change-reviews", "empty-hooks");
|
|
196
|
+
mkdirSync(hooks, { recursive: true });
|
|
197
|
+
git(root, ["-c", `core.hooksPath=${hooks}`, "worktree", "add", "--detach", "--no-checkout", "--", checkout, base]);
|
|
198
|
+
git(checkout, ["-c", `core.hooksPath=${hooks}`, ...inertFilterOptions(checkout), "reset", "--hard", base]);
|
|
199
|
+
git(checkout, [...inertFilterOptions(checkout), "apply", "--index", "--binary", "-"], { input: diff.bytes });
|
|
200
|
+
const staged = git(checkout, ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--binary", "--full-index", base]);
|
|
201
|
+
if (digestBytes(staged) !== diff.digest) throw new AgentFabricError("AF_CONFLICT", "Review snapshot differs from captured change");
|
|
202
|
+
return checkout;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function verifySnapshot(checkout: string, base: string, expected: string): void {
|
|
206
|
+
if (gitText(checkout, ["rev-parse", "HEAD"]) !== base ||
|
|
207
|
+
git(checkout, ["diff", "--no-ext-diff", "--binary"]).length ||
|
|
208
|
+
git(checkout, ["ls-files", "--others", "--exclude-standard"]).length ||
|
|
209
|
+
digestBytes(git(checkout, ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--binary", "--full-index", base])) !== expected) {
|
|
210
|
+
throw new AgentFabricError("AF_CONFLICT", "Reviewer snapshot was modified");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export class LocalChangeReviewService {
|
|
215
|
+
private constructor(readonly repositoryRoot: string,
|
|
216
|
+
private readonly reviewRunner: typeof runCodexAdversarialReview) {}
|
|
217
|
+
|
|
218
|
+
static async open(repositoryRoot: string, options: { reviewRunner?: typeof runCodexAdversarialReview } = {}): Promise<LocalChangeReviewService> {
|
|
219
|
+
const root = realpathSync(repositoryRoot);
|
|
220
|
+
if (realpathSync(gitText(root, ["rev-parse", "--show-toplevel"])) !== root) {
|
|
221
|
+
throw new AgentFabricError("AF_INVALID_STATE", "Change review requires a repository root");
|
|
222
|
+
}
|
|
223
|
+
return new LocalChangeReviewService(root, options.reviewRunner ?? runCodexAdversarialReview);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async close(): Promise<void> { /* filesystem records do not retain handles */ }
|
|
227
|
+
|
|
228
|
+
private contract(id: string): Contract {
|
|
229
|
+
const contract = readSealed<Contract>(recordPath(this.repositoryRoot, id, "contract.json"));
|
|
230
|
+
if (contract.changeId !== id || contract.repositoryRoot !== this.repositoryRoot || contract.schemaVersion !== 1) {
|
|
231
|
+
throw new AgentFabricError("AF_CONFLICT", "Change contract identity mismatch");
|
|
232
|
+
}
|
|
233
|
+
return contract;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private withLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
|
237
|
+
const path = recordPath(this.repositoryRoot, id, "operation.lock");
|
|
238
|
+
let fd: number;
|
|
239
|
+
try { fd = openSync(path, "wx", 0o600); }
|
|
240
|
+
catch { throw new AgentFabricError("AF_CONFLICT", "Change operation already active or interrupted; inspect evidence before recovery"); }
|
|
241
|
+
return fn().finally(() => { closeSync(fd); unlinkSync(path); });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async propose(value: unknown): Promise<LocalChangeStatus> {
|
|
245
|
+
const request = validateProposal(value);
|
|
246
|
+
const changeId = `change:${randomUUID()}`;
|
|
247
|
+
const directory = localFabricPath(this.repositoryRoot, "change-reviews", changeId.slice(7));
|
|
248
|
+
mkdirSync(localFabricPath(this.repositoryRoot, "change-reviews"), { recursive: true, mode: 0o700 });
|
|
249
|
+
mkdirSync(directory, { recursive: false, mode: 0o700 });
|
|
250
|
+
const contract = sealed({ schemaVersion: 1 as const, ...request, changeId,
|
|
251
|
+
repositoryRoot: this.repositoryRoot, baseCommit: gitText(this.repositoryRoot, ["rev-parse", "HEAD"]),
|
|
252
|
+
createdAt: new Date().toISOString() });
|
|
253
|
+
writeNew(join(directory, "contract.json"), contract);
|
|
254
|
+
return this.status(changeId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async status(id: string): Promise<LocalChangeStatus> {
|
|
258
|
+
const contract = this.contract(id);
|
|
259
|
+
const directory = localFabricPath(this.repositoryRoot, "change-reviews", id.slice(7));
|
|
260
|
+
const names = readdirSync(directory).filter((name) => /^round-\d{3}\.intent\.json$/u.test(name)).sort();
|
|
261
|
+
if (names.length > MAX_ROUNDS) throw new AgentFabricError("AF_CONFLICT", "Change has too many review rounds");
|
|
262
|
+
let last: RoundIntent | undefined;
|
|
263
|
+
let result: RoundResult | undefined;
|
|
264
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
265
|
+
const intent = readSealed<RoundIntent>(join(directory, names[index]!));
|
|
266
|
+
if (intent.round !== index + 1 || intent.baseCommit !== contract.baseCommit ||
|
|
267
|
+
intent.contractDigest !== contract.digest || intent.schemaVersion !== 1) {
|
|
268
|
+
throw new AgentFabricError("AF_CONFLICT", "Review round chain is inconsistent");
|
|
269
|
+
}
|
|
270
|
+
const prompt = reviewPrompt(contract, intent.diffDigest, intent.changedPaths);
|
|
271
|
+
if (intent.requestDigest !== sha256Digest(stableStringify({ contractDigest: contract.digest,
|
|
272
|
+
diffDigest: intent.diffDigest, round: intent.round, prompt }))) {
|
|
273
|
+
throw new AgentFabricError("AF_CONFLICT", "Review request digest mismatch");
|
|
274
|
+
}
|
|
275
|
+
const diff = readFileSync(join(directory, roundName(intent.round, "patch")));
|
|
276
|
+
if (diff.length !== intent.diffBytes || digestBytes(diff) !== intent.diffDigest) {
|
|
277
|
+
throw new AgentFabricError("AF_CONFLICT", "Recorded review diff was modified");
|
|
278
|
+
}
|
|
279
|
+
last = intent;
|
|
280
|
+
const resultPath = join(directory, roundName(intent.round, "result.json"));
|
|
281
|
+
result = existsSync(resultPath) ? readSealed<RoundResult>(resultPath) : undefined;
|
|
282
|
+
if (result && result.intentDigest !== intent.digest) throw new AgentFabricError("AF_CONFLICT", "Review result is detached from its intent");
|
|
283
|
+
if (result?.state === "reported") {
|
|
284
|
+
const report = result.report as { requestDigest?: string; verdict?: string; findings?: unknown[] } | undefined;
|
|
285
|
+
if (!report || report.requestDigest !== intent.requestDigest ||
|
|
286
|
+
!["pass", "changes_requested"].includes(report.verdict ?? "") ||
|
|
287
|
+
!Array.isArray(report.findings) ||
|
|
288
|
+
(report.verdict === "pass" ? report.findings.length !== 0 : report.findings.length === 0) ||
|
|
289
|
+
!/^sha256:[0-9a-f]{64}$/u.test(result.outputDigest ?? "")) {
|
|
290
|
+
throw new AgentFabricError("AF_CONFLICT", "Stored reviewer report is invalid");
|
|
291
|
+
}
|
|
292
|
+
} else if (result && result.state !== "inconclusive") {
|
|
293
|
+
throw new AgentFabricError("AF_CONFLICT", "Stored reviewer state is invalid");
|
|
294
|
+
}
|
|
295
|
+
if (!result && index < names.length - 1) throw new AgentFabricError("AF_CONFLICT", "New round follows unfinished review");
|
|
296
|
+
}
|
|
297
|
+
const current = capture(this.repositoryRoot, contract.baseCommit);
|
|
298
|
+
const same = !!last && current.digest === last.diffDigest;
|
|
299
|
+
const verdict = result?.state === "reported" && result.report && typeof result.report === "object"
|
|
300
|
+
? (result.report as { verdict?: string }).verdict ?? null : null;
|
|
301
|
+
const state: LocalChangeStatus["state"] = !last ? "proposed" : !same ? "needs_review" : !result ? "review_uncertain"
|
|
302
|
+
: result.state === "inconclusive" ? "inconclusive" : verdict === "pass" ? "ready" : "changes_requested";
|
|
303
|
+
return { changeId: id, state, contractDigest: contract.digest, baseCommit: contract.baseCommit,
|
|
304
|
+
currentDiffDigest: current.bytes.length ? current.digest : null, latestRound: last?.round ?? 0,
|
|
305
|
+
reviewedDiffDigest: last?.diffDigest ?? null, reviewerVerdict: verdict, canAccept: state === "ready" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async evidence(id: string): Promise<{ status: LocalChangeStatus; rounds: unknown[] }> {
|
|
309
|
+
const status = await this.status(id);
|
|
310
|
+
const directory = localFabricPath(this.repositoryRoot, "change-reviews", id.slice(7));
|
|
311
|
+
const rounds: unknown[] = [];
|
|
312
|
+
for (let round = 1; round <= status.latestRound; round += 1) {
|
|
313
|
+
const intent = readSealed<RoundIntent>(join(directory, roundName(round, "intent.json")));
|
|
314
|
+
const resultPath = join(directory, roundName(round, "result.json"));
|
|
315
|
+
const result = existsSync(resultPath) ? readSealed<RoundResult>(resultPath) : undefined;
|
|
316
|
+
rounds.push({ round, diffDigest: intent.diffDigest, changedPaths: intent.changedPaths,
|
|
317
|
+
requestDigest: intent.requestDigest, reviewer: intent.reviewer, startedAt: intent.startedAt,
|
|
318
|
+
state: result?.state ?? "uncertain", report: result?.report ?? null, error: result?.error ?? null,
|
|
319
|
+
outputDigest: result?.outputDigest ?? null, usage: result?.usage ?? null });
|
|
320
|
+
}
|
|
321
|
+
return { status, rounds };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async review(id: string): Promise<LocalChangeStatus> {
|
|
325
|
+
const contract = this.contract(id);
|
|
326
|
+
if (contract.implementer === "codex-cli") throw new AgentFabricError("AF_CONFLICT", "Implementer cannot review its own change");
|
|
327
|
+
return this.withLock(id, async () => {
|
|
328
|
+
const previous = await this.status(id);
|
|
329
|
+
if (previous.state === "review_uncertain") throw new AgentFabricError("AF_CONFLICT", "Interrupted review requires owner inspection");
|
|
330
|
+
if (previous.latestRound >= MAX_ROUNDS) throw new AgentFabricError("AF_RESOURCE_EXHAUSTED", "Review round limit reached");
|
|
331
|
+
const diff = capture(this.repositoryRoot, contract.baseCommit);
|
|
332
|
+
if (!diff.bytes.length) throw new AgentFabricError("AF_INVALID_STATE", "No change diff to review");
|
|
333
|
+
if (previous.reviewedDiffDigest === diff.digest && previous.state !== "inconclusive") {
|
|
334
|
+
throw new AgentFabricError("AF_CONFLICT", "This exact diff was already reviewed");
|
|
335
|
+
}
|
|
336
|
+
const round = previous.latestRound + 1;
|
|
337
|
+
const directory = localFabricPath(this.repositoryRoot, "change-reviews", id.slice(7));
|
|
338
|
+
const prompt = reviewPrompt(contract, diff.digest, diff.paths);
|
|
339
|
+
const requestDigest = sha256Digest(stableStringify({ contractDigest: contract.digest, diffDigest: diff.digest, round, prompt }));
|
|
340
|
+
const intent = sealed({ schemaVersion: 1 as const, round, baseCommit: contract.baseCommit,
|
|
341
|
+
contractDigest: contract.digest, diffDigest: diff.digest, diffBytes: diff.bytes.length,
|
|
342
|
+
changedPaths: diff.paths, requestDigest, reviewer: "codex-cli" as const,
|
|
343
|
+
startedAt: new Date().toISOString() });
|
|
344
|
+
const patchPath = join(directory, roundName(round, "patch"));
|
|
345
|
+
if (existsSync(patchPath)) {
|
|
346
|
+
// An orphan patch means a prior process stopped before recording its intent,
|
|
347
|
+
// which is before model dispatch. The operation lock prevents a live peer.
|
|
348
|
+
if (!lstatSync(patchPath).isFile() || existsSync(join(directory, roundName(round, "intent.json")))) {
|
|
349
|
+
throw new AgentFabricError("AF_CONFLICT", "Review patch already has an intent or unsafe file type");
|
|
350
|
+
}
|
|
351
|
+
if (!readFileSync(patchPath).equals(diff.bytes)) unlinkSync(patchPath);
|
|
352
|
+
}
|
|
353
|
+
if (!existsSync(patchPath)) writeNew(patchPath, diff.bytes);
|
|
354
|
+
writeNew(join(directory, roundName(round, "intent.json")), intent);
|
|
355
|
+
// From this point a crash leaves an uncertain durable intent. Never auto-dispatch a duplicate paid turn.
|
|
356
|
+
let outcome: Awaited<ReturnType<typeof runCodexAdversarialReview>>;
|
|
357
|
+
try {
|
|
358
|
+
const checkout = materialize(this.repositoryRoot, id, round, contract.baseCommit, diff);
|
|
359
|
+
outcome = await this.reviewRunner({ workspaceRoot: checkout, prompt, requestDigest,
|
|
360
|
+
timeoutMs: 120_000, maxOutputBytes: 256 * 1024 });
|
|
361
|
+
verifySnapshot(checkout, contract.baseCommit, diff.digest);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
outcome = { state: "inconclusive", error: error instanceof Error ? error.message : "Review failed" };
|
|
364
|
+
}
|
|
365
|
+
const result = sealed({ schemaVersion: 1 as const, intentDigest: intent.digest,
|
|
366
|
+
finishedAt: new Date().toISOString(), state: outcome.state,
|
|
367
|
+
...(outcome.outputDigest ? { outputDigest: outcome.outputDigest } : {}),
|
|
368
|
+
...(outcome.usage ? { usage: outcome.usage } : {}),
|
|
369
|
+
...(outcome.state === "reported" ? { report: outcome.report } : { error: outcome.error ?? "Review inconclusive" }) });
|
|
370
|
+
writeNew(join(directory, roundName(round, "result.json")), result);
|
|
371
|
+
return this.status(id);
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
@@ -5,6 +5,7 @@ import { buildAgentMemoryContext } from "./context-pack.ts";
|
|
|
5
5
|
import { ingestEnvelope } from "./bridge.ts";
|
|
6
6
|
import { normalizeAgentEvent } from "./normalize.ts";
|
|
7
7
|
import { requestLocalTask } from "../agent-fabric/local-task-server.ts";
|
|
8
|
+
import { LocalChangeReviewService } from "../agent-fabric/local-change-review-service.ts";
|
|
8
9
|
|
|
9
10
|
interface JsonRpcRequest {
|
|
10
11
|
jsonrpc?: "2.0";
|
|
@@ -55,6 +56,28 @@ export async function handleMcpRequest(workspaceRoot: string, request: JsonRpcRe
|
|
|
55
56
|
inputSchema: { type: "object", properties: { taskId: { type: "string" } },
|
|
56
57
|
required: ["taskId"], additionalProperties: false },
|
|
57
58
|
},
|
|
59
|
+
{
|
|
60
|
+
name: "fabric_change_propose",
|
|
61
|
+
description: "Register a change request for independent review. The exact diff is pinned when the owner runs the reviewer; this tool does not start a paid reviewer or approve the change.",
|
|
62
|
+
inputSchema: { type: "object", properties: { request: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: { objective: { type: "string" }, acceptanceCriteria: { type: "array", items: { type: "string" } },
|
|
65
|
+
implementer: { type: "string" } },
|
|
66
|
+
required: ["objective", "acceptanceCriteria", "implementer"], additionalProperties: false,
|
|
67
|
+
} }, required: ["request"], additionalProperties: false },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "fabric_change_status",
|
|
71
|
+
description: "Read the current state of a pinned change review without starting a reviewer.",
|
|
72
|
+
inputSchema: { type: "object", properties: { changeId: { type: "string" } },
|
|
73
|
+
required: ["changeId"], additionalProperties: false },
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "fabric_change_evidence",
|
|
77
|
+
description: "Read exact-diff and review evidence for a pinned change without starting a reviewer.",
|
|
78
|
+
inputSchema: { type: "object", properties: { changeId: { type: "string" } },
|
|
79
|
+
required: ["changeId"], additionalProperties: false },
|
|
80
|
+
},
|
|
58
81
|
{
|
|
59
82
|
name: "agent_context",
|
|
60
83
|
description: "Read the ForgeOS Agent Memory context pack for the current work or a runtime entry.",
|
|
@@ -154,6 +177,9 @@ async function runTool(workspaceRoot: string, name: string, args: Record<string,
|
|
|
154
177
|
cancellation: "owner_cli_only_best_effort",
|
|
155
178
|
taskMutationTools: ["fabric_propose"],
|
|
156
179
|
taskReadTools: ["fabric_status", "fabric_evidence"],
|
|
180
|
+
changeMutationTools: ["fabric_change_propose"],
|
|
181
|
+
changeReadTools: ["fabric_change_status", "fabric_change_evidence"],
|
|
182
|
+
changeReviewDispatch: "owner_cli_only",
|
|
157
183
|
cli: "forge fabric capabilities --json",
|
|
158
184
|
};
|
|
159
185
|
}
|
|
@@ -172,6 +198,28 @@ async function runTool(workspaceRoot: string, name: string, args: Record<string,
|
|
|
172
198
|
}
|
|
173
199
|
return { ok: true, status };
|
|
174
200
|
}
|
|
201
|
+
if (name === "fabric_change_propose" || name === "fabric_change_status" || name === "fabric_change_evidence") {
|
|
202
|
+
const keys = Object.keys(args).sort().join(",");
|
|
203
|
+
if (name === "fabric_change_propose" &&
|
|
204
|
+
(keys !== "request" || !args.request || typeof args.request !== "object" || Array.isArray(args.request))) {
|
|
205
|
+
throw new Error("fabric_change_propose requires only request");
|
|
206
|
+
}
|
|
207
|
+
if (name !== "fabric_change_propose" &&
|
|
208
|
+
(keys !== "changeId" || typeof args.changeId !== "string" || args.changeId.length === 0)) {
|
|
209
|
+
throw new Error(`${name} requires only changeId`);
|
|
210
|
+
}
|
|
211
|
+
const service = await LocalChangeReviewService.open(realpathSync(workspaceRoot));
|
|
212
|
+
try {
|
|
213
|
+
const result = name === "fabric_change_propose"
|
|
214
|
+
? await service.propose(args.request as Parameters<LocalChangeReviewService["propose"]>[0])
|
|
215
|
+
: name === "fabric_change_status"
|
|
216
|
+
? await service.status(args.changeId as string)
|
|
217
|
+
: await service.evidence(args.changeId as string);
|
|
218
|
+
return { ok: true, ...(name === "fabric_change_evidence" ? { evidence: result } : { status: result }) };
|
|
219
|
+
} finally {
|
|
220
|
+
await service.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
175
223
|
if (name === "agent_context") {
|
|
176
224
|
return buildAgentMemoryContext({
|
|
177
225
|
workspaceRoot,
|
package/src/forge/cli/fabric.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import { LocalTaskService } from "../agent-fabric/local-task-service.ts";
|
|
4
|
+
import { LocalChangeReviewService } from "../agent-fabric/local-change-review-service.ts";
|
|
4
5
|
import { LOCAL_CODING_MODEL, LOCAL_CODING_TARGET } from "../agent-fabric/local-task-contract.ts";
|
|
5
6
|
import { requestLocalMemory, requestLocalTask, serveLocalTasks, type LocalMemoryAction, type LocalTaskAction } from "../agent-fabric/local-task-server.ts";
|
|
6
7
|
import { runAdaptiveCommand, type AdaptiveCliOptions } from "./adaptive.ts";
|
|
7
8
|
|
|
8
9
|
export interface FabricCliOptions {
|
|
9
|
-
subcommand: "capabilities" | "propose" | "status" | "evidence" | "review" | "run" | "cancel" | "reconcile" | "verify" | "recover-verification" | "review-result" | "serve" | "memory-add" | "memory-list" | "memory-delete" | AdaptiveCliOptions["subcommand"];
|
|
10
|
+
subcommand: "capabilities" | "propose" | "status" | "evidence" | "review" | "run" | "cancel" | "reconcile" | "verify" | "recover-verification" | "review-result" | "serve" | "memory-add" | "memory-list" | "memory-delete" | "change-propose" | "change-status" | "change-review" | "change-evidence" | AdaptiveCliOptions["subcommand"];
|
|
10
11
|
workspaceRoot: string;
|
|
11
12
|
json: boolean;
|
|
12
13
|
file?: string;
|
|
@@ -30,6 +31,8 @@ export async function runFabricCommand(options: FabricCliOptions): Promise<numbe
|
|
|
30
31
|
privateMemory: "owner_cli_only",
|
|
31
32
|
adaptiveHarness: { twoProcessDataOnly: true, ownerReview: true, durableReadback: true,
|
|
32
33
|
selectedDataProfile: "optional_canary_or_stable" },
|
|
34
|
+
adversarialChangeReview: { propose: true, status: true, evidence: true,
|
|
35
|
+
reviewer: "codex_cli_owner_command_only", automaticPaidReview: false },
|
|
33
36
|
mcpTaskMutation: "proposal_only", mcpEvidence: true,
|
|
34
37
|
nativeCodexHookProofRequired: true,
|
|
35
38
|
};
|
|
@@ -39,6 +42,7 @@ export async function runFabricCommand(options: FabricCliOptions): Promise<numbe
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
let service: LocalTaskService | undefined;
|
|
45
|
+
let changeService: LocalChangeReviewService | undefined;
|
|
42
46
|
try {
|
|
43
47
|
const repositoryRoot = realpathSync(options.workspaceRoot);
|
|
44
48
|
if (options.subcommand === "serve") {
|
|
@@ -55,6 +59,31 @@ export async function runFabricCommand(options: FabricCliOptions): Promise<numbe
|
|
|
55
59
|
}
|
|
56
60
|
return 0;
|
|
57
61
|
}
|
|
62
|
+
if (options.subcommand === "change-propose" || options.subcommand === "change-status" ||
|
|
63
|
+
options.subcommand === "change-review" || options.subcommand === "change-evidence") {
|
|
64
|
+
let request: unknown;
|
|
65
|
+
if (options.subcommand === "change-propose") {
|
|
66
|
+
if (!options.file) throw new Error("A change request file is required");
|
|
67
|
+
const file = realpathSync(resolve(repositoryRoot, options.file));
|
|
68
|
+
const relation = relative(repositoryRoot, file);
|
|
69
|
+
if (!relation || relation.startsWith("..") || isAbsolute(relation)) {
|
|
70
|
+
throw new Error("Change request file must be inside the current repository");
|
|
71
|
+
}
|
|
72
|
+
if (statSync(file).size > 32 * 1024) throw new Error("Change request file exceeds byte limit");
|
|
73
|
+
request = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
74
|
+
}
|
|
75
|
+
changeService = await LocalChangeReviewService.open(repositoryRoot);
|
|
76
|
+
const changeId = options.taskId ?? "";
|
|
77
|
+
const status = options.subcommand === "change-propose"
|
|
78
|
+
? await changeService.propose(request as Parameters<LocalChangeReviewService["propose"]>[0])
|
|
79
|
+
: options.subcommand === "change-status"
|
|
80
|
+
? await changeService.status(changeId)
|
|
81
|
+
: options.subcommand === "change-evidence"
|
|
82
|
+
? await changeService.evidence(changeId)
|
|
83
|
+
: await changeService.review(changeId);
|
|
84
|
+
process.stdout.write(`${JSON.stringify({ ok: true, status }, null, 2)}\n`);
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
58
87
|
let proposal: unknown;
|
|
59
88
|
if (options.subcommand === "propose" || options.subcommand === "memory-add" || options.subcommand === "memory-list") {
|
|
60
89
|
if (!options.file) throw new Error("A request file is required");
|
|
@@ -127,5 +156,6 @@ export async function runFabricCommand(options: FabricCliOptions): Promise<numbe
|
|
|
127
156
|
return 1;
|
|
128
157
|
} finally {
|
|
129
158
|
await service?.close();
|
|
159
|
+
await changeService?.close();
|
|
130
160
|
}
|
|
131
161
|
}
|
package/src/forge/cli/main.ts
CHANGED
|
@@ -43,6 +43,10 @@ function formatHelp(): string {
|
|
|
43
43
|
" forge fabric memory-delete <memory-id> --json Delete a private note",
|
|
44
44
|
" forge fabric status <task-id> --json Read durable task control state",
|
|
45
45
|
" forge fabric evidence <task-id> --json Read bounded task provenance and result digests",
|
|
46
|
+
" forge fabric change-propose --file request.json --json Register a change request for independent review",
|
|
47
|
+
" forge fabric change-status --task-id <change-id> --json Read pinned change review state",
|
|
48
|
+
" forge fabric change-evidence --task-id <change-id> --json Read exact-diff review evidence",
|
|
49
|
+
" forge fabric change-review --task-id <change-id> --json Run the owner-invoked Codex CLI reviewer (uses Codex credits)",
|
|
46
50
|
" forge fabric review <task-id> --json Open the local owner review window",
|
|
47
51
|
" forge fabric run <task-id> --json Run one approved local Ollama attempt",
|
|
48
52
|
" forge fabric cancel <task-id> --json Request best-effort task cancellation",
|
package/src/forge/cli/parse.ts
CHANGED
|
@@ -277,7 +277,7 @@ export type ForgeCommand =
|
|
|
277
277
|
| { kind: "bench"; options: BenchCommandOptions }
|
|
278
278
|
| { kind: "cair"; options: CairCommandOptions }
|
|
279
279
|
| { kind: "agent"; options: AgentCommandOptions }
|
|
280
|
-
| { kind: "fabric"; subcommand: "capabilities" | "propose" | "status" | "evidence" | "review" | "run" | "cancel" | "reconcile" | "verify" | "recover-verification" | "review-result" | "serve" | "memory-add" | "memory-list" | "memory-delete" | "adaptive-propose" | "adaptive-review" | "adaptive-run" | "adaptive-status"; workspaceRoot: string; json: boolean; file?: string; taskId?: string; channel?: "canary" | "stable" }
|
|
280
|
+
| { kind: "fabric"; subcommand: "capabilities" | "propose" | "status" | "evidence" | "review" | "run" | "cancel" | "reconcile" | "verify" | "recover-verification" | "review-result" | "serve" | "memory-add" | "memory-list" | "memory-delete" | "adaptive-propose" | "adaptive-review" | "adaptive-run" | "adaptive-status" | "change-propose" | "change-status" | "change-review" | "change-evidence"; workspaceRoot: string; json: boolean; file?: string; taskId?: string; channel?: "canary" | "stable" }
|
|
281
281
|
| ({ kind: "evolution" } & EvolutionCliOptions)
|
|
282
282
|
| { kind: "mcp"; subcommand: "serve"; workspaceRoot: string }
|
|
283
283
|
| { kind: "review"; options: ReviewCommandOptions }
|
|
@@ -1272,15 +1272,19 @@ export function parseCli(argv: string[]): ParsedCli {
|
|
|
1272
1272
|
}
|
|
1273
1273
|
case "fabric": {
|
|
1274
1274
|
const subcommand = rest[0];
|
|
1275
|
-
if (subcommand !== "capabilities" && subcommand !== "propose" && subcommand !== "status" && subcommand !== "evidence" && subcommand !== "review" && subcommand !== "run" && subcommand !== "cancel" && subcommand !== "reconcile" && subcommand !== "verify" && subcommand !== "recover-verification" && subcommand !== "review-result" && subcommand !== "serve" && subcommand !== "memory-add" && subcommand !== "memory-list" && subcommand !== "memory-delete" && subcommand !== "adaptive-propose" && subcommand !== "adaptive-review" && subcommand !== "adaptive-run" && subcommand !== "adaptive-status") {
|
|
1275
|
+
if (subcommand !== "capabilities" && subcommand !== "propose" && subcommand !== "status" && subcommand !== "evidence" && subcommand !== "review" && subcommand !== "run" && subcommand !== "cancel" && subcommand !== "reconcile" && subcommand !== "verify" && subcommand !== "recover-verification" && subcommand !== "review-result" && subcommand !== "serve" && subcommand !== "memory-add" && subcommand !== "memory-list" && subcommand !== "memory-delete" && subcommand !== "adaptive-propose" && subcommand !== "adaptive-review" && subcommand !== "adaptive-run" && subcommand !== "adaptive-status" && subcommand !== "change-propose" && subcommand !== "change-status" && subcommand !== "change-review" && subcommand !== "change-evidence") {
|
|
1276
1276
|
errors.push("forge fabric requires a supported task or memory subcommand");
|
|
1277
1277
|
return { command: null, workspaceRoot, errors };
|
|
1278
1278
|
}
|
|
1279
1279
|
const file = parseOptionValue(argv, "--file");
|
|
1280
1280
|
const adaptiveChannel = parseOptionValue(argv, "--channel");
|
|
1281
1281
|
if (adaptiveChannel && (subcommand !== "adaptive-propose" || (adaptiveChannel !== "canary" && adaptiveChannel !== "stable"))) errors.push("--channel requires adaptive-propose and canary or stable");
|
|
1282
|
-
const
|
|
1282
|
+
const isChangeReadOrReview = subcommand === "change-status" || subcommand === "change-review" || subcommand === "change-evidence";
|
|
1283
|
+
const taskId = isChangeReadOrReview ? parseOptionValue(argv, "--task-id")
|
|
1284
|
+
: subcommand === "status" || subcommand === "evidence" || subcommand === "review" || subcommand === "run" || subcommand === "cancel" || subcommand === "reconcile" || subcommand === "verify" || subcommand === "recover-verification" || subcommand === "review-result" || subcommand === "memory-delete" || subcommand === "adaptive-review" || subcommand === "adaptive-run" || subcommand === "adaptive-status" ? rest[1] : undefined;
|
|
1283
1285
|
if (subcommand === "propose" && (!file || file.startsWith("--"))) errors.push("forge fabric propose requires --file <proposal.json>");
|
|
1286
|
+
if (subcommand === "change-propose" && (!file || file.startsWith("--"))) errors.push("forge fabric change-propose requires --file <request.json>");
|
|
1287
|
+
if (isChangeReadOrReview && (!taskId || taskId.startsWith("--"))) errors.push(`forge fabric ${subcommand} requires --task-id <change-id>`);
|
|
1284
1288
|
if (subcommand === "adaptive-propose" && (!file || file.startsWith("--"))) errors.push("forge fabric adaptive-propose requires --file <input.json>");
|
|
1285
1289
|
if ((subcommand === "memory-add" || subcommand === "memory-list") && (!file || file.startsWith("--"))) errors.push(`forge fabric ${subcommand} requires --file <request.json>`);
|
|
1286
1290
|
if ((subcommand === "status" || subcommand === "evidence" || subcommand === "review" || subcommand === "run" || subcommand === "cancel" || subcommand === "reconcile" || subcommand === "verify" || subcommand === "recover-verification" || subcommand === "review-result" || subcommand === "memory-delete") && !taskId) errors.push(`forge fabric ${subcommand} requires an id`);
|
package/src/forge/version.ts
CHANGED