forgeos 0.1.0-alpha.68 → 0.1.0-alpha.69
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/skills/forge-agent-fabric/SKILL.md +21 -1
- package/.agents/skills/forge-agent-fabric/references/repository-analysis.md +102 -0
- package/.agents/skills/forge-agent-fabric/scripts/repository.mjs +44 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +12 -3
- package/docs/agent-fabric.md +13 -0
- package/docs/architecture/agent-fabric/REPOSITORY_MANIFEST_PLAN.md +272 -0
- package/docs/changelog.md +32 -0
- package/docs/repository-agent-benchmark.md +179 -0
- package/docs/repository-analysis.md +139 -0
- package/docs/repository-context-quality.md +179 -0
- package/docs/repository-runtime-observation.md +82 -0
- package/package.json +14 -2
- package/schemas/forge-manifest.schema.json +385 -53
- package/src/forge/_generated/releaseManifest.json +1 -1
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/agent-fabric/managed-run-contract.ts +1 -0
- package/src/forge/agent-fabric/managed-run-service.ts +10 -1
- package/src/forge/agent-fabric/managed-run-store.ts +20 -0
- package/src/forge/agent-fabric/managed-workspace.ts +56 -18
- package/src/forge/agent-fabric/repository-context.ts +83 -0
- package/src/forge/agent-memory/mcp.ts +38 -1
- package/src/forge/cair/index.ts +15 -3
- package/src/forge/cair/types.ts +9 -2
- package/src/forge/cli/commands.ts +19 -0
- package/src/forge/cli/main.ts +15 -0
- package/src/forge/cli/parse.ts +70 -16
- package/src/forge/cli/repository.ts +151 -0
- package/src/forge/compiler/external-manifest/registry.ts +21 -9
- package/src/forge/delta/process-identity.ts +22 -16
- package/src/forge/repository-analysis/adapters/common.ts +72 -0
- package/src/forge/repository-analysis/adapters/docker.ts +185 -0
- package/src/forge/repository-analysis/adapters/java.ts +133 -0
- package/src/forge/repository-analysis/adapters/typescript.ts +350 -0
- package/src/forge/repository-analysis/adapters/vue.ts +71 -0
- package/src/forge/repository-analysis/analyze.ts +434 -0
- package/src/forge/repository-analysis/benchmark.ts +336 -0
- package/src/forge/repository-analysis/cache-gc.ts +154 -0
- package/src/forge/repository-analysis/check-selection.ts +123 -0
- package/src/forge/repository-analysis/context-node.ts +17 -0
- package/src/forge/repository-analysis/context.ts +456 -0
- package/src/forge/repository-analysis/http-resolution.ts +135 -0
- package/src/forge/repository-analysis/identity.ts +45 -0
- package/src/forge/repository-analysis/index.ts +18 -0
- package/src/forge/repository-analysis/nuxt-resolution.ts +188 -0
- package/src/forge/repository-analysis/quality.ts +60 -0
- package/src/forge/repository-analysis/retrieval.ts +309 -0
- package/src/forge/repository-analysis/runtime-artifacts.ts +195 -0
- package/src/forge/repository-analysis/runtime-observation.ts +316 -0
- package/src/forge/repository-analysis/scanner.ts +174 -0
- package/src/forge/repository-analysis/storage.ts +193 -0
- package/src/forge/repository-analysis/test-associations.ts +59 -0
- package/src/forge/repository-analysis/types.ts +99 -0
- package/src/forge/repository-manifest/index.ts +178 -0
- package/src/forge/repository-manifest/types.ts +63 -0
- package/src/forge/version.ts +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
|
-
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { stableStringify } from "./canonical.ts";
|
|
@@ -10,12 +10,15 @@ import { verifyManagedEnvironment, type ManagedEnvironment } from "./managed-env
|
|
|
10
10
|
export interface ManagedBase { root: string; runId: string; head: string; scope: string[]; digest: string; baselineDirectory: string; contextScope?: string[]; contextDigest?: string }
|
|
11
11
|
export interface ManagedFile { path: string; beforeDigest: string | null; contentBase64: string | null; mode?: number }
|
|
12
12
|
export interface ManagedArtifact { digest: string; files: ManagedFile[] }
|
|
13
|
-
interface Entry { path: string; digest: string; mode: number; contentBase64
|
|
13
|
+
interface Entry { path: string; digest: string; mode: number; contentBase64?: string; readonlySize?: number }
|
|
14
14
|
interface Inventory { files: Entry[]; digest: string }
|
|
15
15
|
interface WorkspaceMetadata { baseDigest: string; runId: string; attemptId: string; directory: string; input: Inventory }
|
|
16
16
|
const runFile = promisify(execFile);
|
|
17
17
|
// Baselines include generated Forge graphs (~10MiB); output artifacts retain the smaller bound.
|
|
18
18
|
const MAX_FILES = 20_000, MAX_BYTES = 128 * 1024 * 1024, MAX_SNAPSHOT_FILE_BYTES = 16 * 1024 * 1024, MAX_ARTIFACT_FILE_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
// Large assets outside the editable/context scope stay in Git clones. Only their
|
|
20
|
+
// streaming fingerprints enter inventories; editable snapshots retain their limits.
|
|
21
|
+
const MAX_READONLY_ASSET_BYTES = 512 * 1024 * 1024, MAX_READONLY_BYTES = 1024 * 1024 * 1024;
|
|
19
22
|
const hash = (data: string | Uint8Array) => `sha256:${createHash("sha256").update(data).digest("hex")}`;
|
|
20
23
|
function assert(value: unknown, message: string): asserts value { if (!value) throw new Error(`managed workspace: ${message}`); }
|
|
21
24
|
function pathName(value: string): string {
|
|
@@ -34,7 +37,7 @@ async function safePath(root: string, path: string): Promise<string> {
|
|
|
34
37
|
return absolute;
|
|
35
38
|
}
|
|
36
39
|
async function git(root: string, args: string[]) { return (await runFile("git", ["-C", root, ...args], { windowsHide: true, maxBuffer: 16 * 1024 * 1024 })).stdout.trim(); }
|
|
37
|
-
const contextNames = ["package.json", "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "yarn.lock", ".yarnrc.yml", "bun.lock", "bun.lockb", "bunfig.toml"];
|
|
40
|
+
const contextNames = ["package.json", "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", "yarn.lock", ".yarnrc.yml", "bun.lock", "bun.lockb", "bunfig.toml", "forge.manifest.json"];
|
|
38
41
|
async function readonlyContextPaths(root: string, scope: string[]): Promise<string[]> {
|
|
39
42
|
const tracked = (await git(root, ["ls-files", "-z"])).split("\0").filter(Boolean);
|
|
40
43
|
const result = new Set(tracked.filter(path => contextNames.includes(path.split("/").at(-1)!) && !withinScope(path, scope)));
|
|
@@ -44,8 +47,28 @@ async function readonlyContextPaths(root: string, scope: string[]): Promise<stri
|
|
|
44
47
|
async function currentContextMatches(base: ManagedBase): Promise<boolean> {
|
|
45
48
|
return base.contextScope === undefined || (await inventory(base.root, base.contextScope)).digest === base.contextDigest;
|
|
46
49
|
}
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
function inventoryDigest(files: Entry[]): string {
|
|
51
|
+
return hash(JSON.stringify(files.map(({ path, digest, mode, readonlySize }) => ({ path, digest, mode, ...(readonlySize === undefined ? {} : { readonlySize }) }))));
|
|
52
|
+
}
|
|
53
|
+
function entryContent(file: Entry): string { assert(typeof file.contentBase64 === "string" && file.readonlySize === undefined, "read-only asset cannot be materialized or published"); return file.contentBase64; }
|
|
54
|
+
async function readonlyAssetDigest(absolute: string, size: number): Promise<string> {
|
|
55
|
+
const handle = await open(absolute, "r");
|
|
56
|
+
try {
|
|
57
|
+
const before = await handle.stat();
|
|
58
|
+
assert(before.isFile() && before.size === size, "asset changed before hashing");
|
|
59
|
+
const digest = createHash("sha256"); let bytes = 0;
|
|
60
|
+
for await (const chunk of handle.createReadStream({ autoClose: false, highWaterMark: 256 * 1024 })) {
|
|
61
|
+
bytes += chunk.length; assert(bytes <= size && bytes <= MAX_READONLY_ASSET_BYTES, "asset changed or size bound exceeded"); digest.update(chunk);
|
|
62
|
+
}
|
|
63
|
+
const after = await handle.stat(), current = await lstat(absolute);
|
|
64
|
+
assert(bytes === size && after.size === before.size && after.mtimeMs === before.mtimeMs && after.ctimeMs === before.ctimeMs
|
|
65
|
+
&& current.isFile() && !current.isSymbolicLink() && current.ino === before.ino && current.size === before.size
|
|
66
|
+
&& current.mtimeMs === before.mtimeMs && current.ctimeMs === before.ctimeMs, "asset changed during hashing");
|
|
67
|
+
return `sha256:${digest.digest("hex")}`;
|
|
68
|
+
} finally { await handle.close(); }
|
|
69
|
+
}
|
|
70
|
+
async function inventory(root: string, scope?: string[], excluded: string[] = [], boundedScope: string[] = []): Promise<Inventory> {
|
|
71
|
+
const files: Entry[] = []; let bytes = 0, readonlyBytes = 0;
|
|
49
72
|
const visit = async (directory: string, prefix: string) => {
|
|
50
73
|
for (const name of (await readdir(directory)).sort()) {
|
|
51
74
|
if (!prefix && name.toLowerCase() === ".git") continue;
|
|
@@ -56,7 +79,14 @@ async function inventory(root: string, scope?: string[], excluded: string[] = []
|
|
|
56
79
|
assert(!stat.isSymbolicLink(), "symlink rejected");
|
|
57
80
|
if (stat.isDirectory()) await visit(absolute, path);
|
|
58
81
|
else {
|
|
59
|
-
assert(stat.isFile(), "special file rejected"); assert(
|
|
82
|
+
assert(stat.isFile(), "special file rejected"); assert(files.length < MAX_FILES, "snapshot bound exceeded");
|
|
83
|
+
if (stat.size > MAX_SNAPSHOT_FILE_BYTES && !scope && !withinScope(path, boundedScope)) {
|
|
84
|
+
readonlyBytes += stat.size;
|
|
85
|
+
assert(stat.size <= MAX_READONLY_ASSET_BYTES && readonlyBytes <= MAX_READONLY_BYTES, "read-only asset size bound exceeded");
|
|
86
|
+
files.push({ path, digest: await readonlyAssetDigest(absolute, stat.size), mode: stat.mode & 0o111 ? 0o755 : 0o644, readonlySize: stat.size });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
assert(stat.size <= MAX_SNAPSHOT_FILE_BYTES, "file size bound exceeded");
|
|
60
90
|
const content = await readFile(absolute); bytes += content.length;
|
|
61
91
|
assert(bytes <= MAX_BYTES && files.length < MAX_FILES, "snapshot bound exceeded");
|
|
62
92
|
files.push({ path, digest: hash(content), mode: stat.mode & 0o111 ? 0o755 : 0o644, contentBase64: content.toString("base64") });
|
|
@@ -65,11 +95,15 @@ async function inventory(root: string, scope?: string[], excluded: string[] = []
|
|
|
65
95
|
};
|
|
66
96
|
await visit(resolve(root), ""); files.sort((a, b) => a.path.localeCompare(b.path));
|
|
67
97
|
assert(new Set(files.map(file => file.path.toLowerCase())).size === files.length, "case-colliding paths rejected");
|
|
68
|
-
return { files, digest:
|
|
98
|
+
return { files, digest: inventoryDigest(files) };
|
|
99
|
+
}
|
|
100
|
+
function workspaceInventory(base: ManagedBase, directory: string, excluded: string[] = []): Promise<Inventory> {
|
|
101
|
+
return inventory(directory, undefined, excluded, [...base.scope, ...(base.contextScope ?? [])]);
|
|
69
102
|
}
|
|
70
|
-
async function setFile(root: string, file: { path: string; contentBase64
|
|
103
|
+
async function setFile(root: string, file: { path: string; contentBase64?: string | null; mode?: number }) {
|
|
71
104
|
const absolute = await safePath(root, file.path);
|
|
72
105
|
if (file.contentBase64 === null) { try { const stat = await lstat(absolute); assert(stat.isFile(), "delete target is not a regular file"); await rm(absolute); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } return; }
|
|
106
|
+
assert(typeof file.contentBase64 === "string", "read-only asset cannot be materialized or published");
|
|
73
107
|
await mkdir(dirname(absolute), { recursive: true }); await writeFile(absolute, Buffer.from(file.contentBase64, "base64")); await chmod(absolute, file.mode ?? 0o644);
|
|
74
108
|
}
|
|
75
109
|
async function clone(root: string, prefix: string): Promise<string> {
|
|
@@ -96,6 +130,7 @@ function applyEntries(input: Entry[], artifacts: ManagedArtifact[], scope: strin
|
|
|
96
130
|
const map = new Map(input.map(file => [file.path, { ...file }]));
|
|
97
131
|
for (const artifact of artifacts) { validateArtifact(artifact, scope); for (const file of artifact.files) {
|
|
98
132
|
const before = map.get(file.path); assert((before?.digest ?? null) === file.beforeDigest, `conflicting beforeimage: ${file.path}`);
|
|
133
|
+
assert(before?.readonlySize === undefined, "read-only asset cannot be changed by artifacts");
|
|
99
134
|
if (file.contentBase64 === null) { assert(before, "deletion of missing file"); map.delete(file.path); }
|
|
100
135
|
else map.set(file.path, { path: file.path, digest: hash(Buffer.from(file.contentBase64, "base64")), mode: file.mode ?? before?.mode ?? 0o644, contentBase64: file.contentBase64 });
|
|
101
136
|
} }
|
|
@@ -110,7 +145,7 @@ async function validateBase(base: ManagedBase) {
|
|
|
110
145
|
assert(stableStringify(marker) === stableStringify(base), "base identity mismatch");
|
|
111
146
|
if (base.contextScope !== undefined) { assert(Array.isArray(base.contextScope) && base.contextScope.length <= 1000 && /^sha256:[a-f0-9]{64}$/.test(base.contextDigest ?? ""), "invalid context metadata"); for (const path of base.contextScope) pathName(path); }
|
|
112
147
|
const baselineDigest = await readFile(join(base.baselineDirectory, ".git", "forge-managed-baseline-digest"), "utf8");
|
|
113
|
-
assert((await
|
|
148
|
+
assert((await workspaceInventory(base, base.baselineDirectory)).digest === baselineDigest && (await inventory(base.baselineDirectory, base.scope)).digest === base.digest, "immutable baseline modified");
|
|
114
149
|
}
|
|
115
150
|
export async function captureManagedBase(root: string, runId: string, scope: string[]): Promise<ManagedBase> {
|
|
116
151
|
root = resolve(root); assert(typeof runId === "string" && runId.trim().length > 0, "runId required"); scope = scopes(scope);
|
|
@@ -124,17 +159,17 @@ export async function captureManagedBase(root: string, runId: string, scope: str
|
|
|
124
159
|
assert((await inventory(root, scope)).digest === snapshot.digest && (await inventory(root, contextScope)).digest === context.digest && await git(root, ["rev-parse", "HEAD"]) === head, "source changed during capture");
|
|
125
160
|
const base: ManagedBase = { root, runId, head, scope, digest: snapshot.digest, baselineDirectory, ...(contextScope.length ? { contextScope, contextDigest: context.digest } : {}) };
|
|
126
161
|
await writeFile(join(baselineDirectory, ".git", "forge-managed-base.json"), JSON.stringify(base));
|
|
127
|
-
await writeFile(join(baselineDirectory, ".git", "forge-managed-baseline-digest"), (await
|
|
162
|
+
await writeFile(join(baselineDirectory, ".git", "forge-managed-baseline-digest"), (await workspaceInventory(base, baselineDirectory)).digest); return base;
|
|
128
163
|
}
|
|
129
164
|
export async function prepareManagedWorkspace(base: ManagedBase, attemptId: string, dependencies: ManagedArtifact[]): Promise<{ directory: string; inputDigest: string }> {
|
|
130
165
|
await validateBase(base); assert(typeof attemptId === "string" && attemptId.trim().length > 0, "attemptId required");
|
|
131
|
-
const baseline = await
|
|
166
|
+
const baseline = await workspaceInventory(base, base.baselineDirectory), desired = applyEntries(baseline.files, dependencies, base.scope), directory = await clone(base.baselineDirectory, "forge-managed-attempt-");
|
|
132
167
|
await git(directory, ["checkout", "--quiet", "--detach", base.head]);
|
|
133
|
-
const initial = await
|
|
168
|
+
const initial = await workspaceInventory(base, directory);
|
|
134
169
|
const desiredByPath = new Map(desired.map(file => [file.path, file])), initialByPath = new Map(initial.files.map(file => [file.path, file]));
|
|
135
170
|
for (const file of initial.files) if (!desiredByPath.has(file.path)) await setFile(directory, { path: file.path, contentBase64: null });
|
|
136
171
|
for (const file of desired) if (initialByPath.get(file.path)?.digest !== file.digest || initialByPath.get(file.path)?.mode !== file.mode) await setFile(directory, file);
|
|
137
|
-
const input = await
|
|
172
|
+
const input = await workspaceInventory(base, directory), metadata: WorkspaceMetadata = { baseDigest: base.digest, runId: base.runId, attemptId, directory, input };
|
|
138
173
|
await writeFile(join(directory, ".git", "forge-managed-input.json"), JSON.stringify(metadata)); return { directory, inputDigest: input.digest };
|
|
139
174
|
}
|
|
140
175
|
export async function captureManagedArtifact(base: ManagedBase, workspaceDirectory: string, writeScope: string[], expectedInputDigest: string, environment?: ManagedEnvironment): Promise<ManagedArtifact> {
|
|
@@ -142,14 +177,17 @@ export async function captureManagedArtifact(base: ManagedBase, workspaceDirecto
|
|
|
142
177
|
workspaceDirectory = resolve(workspaceDirectory);
|
|
143
178
|
const metadata = JSON.parse(await readFile(join(workspaceDirectory, ".git", "forge-managed-input.json"), "utf8")) as WorkspaceMetadata;
|
|
144
179
|
assert(metadata.directory === workspaceDirectory && metadata.baseDigest === base.digest && metadata.runId === base.runId, "workspace identity mismatch");
|
|
145
|
-
assert(/^sha256:[a-f0-9]{64}$/.test(expectedInputDigest) && metadata.input.digest === expectedInputDigest && metadata.input.digest ===
|
|
180
|
+
assert(/^sha256:[a-f0-9]{64}$/.test(expectedInputDigest) && metadata.input.digest === expectedInputDigest && metadata.input.digest === inventoryDigest(metadata.input.files)
|
|
181
|
+
&& metadata.input.files.every(file => file.readonlySize === undefined ? typeof file.contentBase64 === "string" && hash(Buffer.from(file.contentBase64, "base64")) === file.digest
|
|
182
|
+
: file.contentBase64 === undefined && Number.isSafeInteger(file.readonlySize) && file.readonlySize > MAX_SNAPSHOT_FILE_BYTES && file.readonlySize <= MAX_READONLY_ASSET_BYTES
|
|
183
|
+
&& !withinScope(file.path, [...base.scope, ...(base.contextScope ?? [])]) && /^sha256:[a-f0-9]{64}$/.test(file.digest)), "input inventory corrupted or coordinator digest mismatch");
|
|
146
184
|
if (environment) { assert(environment.directory === workspaceDirectory, "environment belongs to another checkout"); await verifyManagedEnvironment(environment); assert(!metadata.input.files.some(file => environment.derivedPaths.some(path => file.path === path || file.path.startsWith(`${path}/`))), "derived environment would conceal captured source"); }
|
|
147
|
-
const current = await
|
|
185
|
+
const current = await workspaceInventory(base, workspaceDirectory, environment?.derivedPaths ?? []), prior = new Map(metadata.input.files.map(file => [file.path, file])), after = new Map(current.files.map(file => [file.path, file]));
|
|
148
186
|
const files: ManagedFile[] = [];
|
|
149
187
|
for (const path of [...new Set([...prior.keys(), ...after.keys()])].sort()) {
|
|
150
188
|
const before = prior.get(path), next = after.get(path); if (before?.digest === next?.digest && before?.mode === next?.mode) continue;
|
|
151
189
|
assert(withinScope(path, writeScope), `unauthorized change: ${path}`);
|
|
152
|
-
files.push({ path, beforeDigest: before?.digest ?? null, contentBase64: next
|
|
190
|
+
files.push({ path, beforeDigest: before?.digest ?? null, contentBase64: next ? entryContent(next) : null, ...(next ? { mode: next.mode } : {}) });
|
|
153
191
|
}
|
|
154
192
|
const artifact = { digest: artifactDigest(files), files }; validateArtifact(artifact, writeScope); return artifact;
|
|
155
193
|
}
|
|
@@ -165,7 +203,7 @@ export async function publishManagedArtifacts(base: ManagedBase, artifacts: Mana
|
|
|
165
203
|
const stageParent = await safePath(base.root, ".forge/local/agent-fabric/publication-staging");
|
|
166
204
|
await mkdir(stageParent, { recursive: true }); await safePath(base.root, ".forge/local/agent-fabric/publication-staging");
|
|
167
205
|
const stage = await mkdtemp(join(stageParent, "publication-"));
|
|
168
|
-
for (let index = 0; index < changedFiles.length; index++) { const file = desired.get(changedFiles[index]); if (file) await writeFile(join(stage, `${index}`), Buffer.from(file
|
|
206
|
+
for (let index = 0; index < changedFiles.length; index++) { const file = desired.get(changedFiles[index]); if (file) await writeFile(join(stage, `${index}`), Buffer.from(entryContent(file), "base64")); }
|
|
169
207
|
assert((await inventory(base.root, base.scope)).digest === before.digest && await currentContextMatches(base), "source or context changed before publication");
|
|
170
208
|
const applied: { path: string; writtenDigest: string | null; writtenMode: number | null }[] = [];
|
|
171
209
|
try {
|
|
@@ -202,7 +240,7 @@ export async function publishManagedArtifacts(base: ManagedBase, artifacts: Mana
|
|
|
202
240
|
export async function previewManagedArtifacts(base: ManagedBase, artifacts: ManagedArtifact[]): Promise<{ digest: string; changedFiles: string[] }> {
|
|
203
241
|
await validateBase(base); const before = await inventory(base.baselineDirectory, base.scope), after = applyEntries(before.files, artifacts, base.scope);
|
|
204
242
|
const old = new Map(before.files.map(file => [file.path, file])), desired = new Map(after.map(file => [file.path, file]));
|
|
205
|
-
return { digest:
|
|
243
|
+
return { digest: inventoryDigest(after), changedFiles: [...new Set([...old.keys(), ...desired.keys()])].filter(path => old.get(path)?.digest !== desired.get(path)?.digest || old.get(path)?.mode !== desired.get(path)?.mode).sort() };
|
|
206
244
|
}
|
|
207
245
|
/** Crash recovery confirms only a completely materialized publication. It never writes. */
|
|
208
246
|
export async function confirmManagedPublication(base: ManagedBase, artifacts: ManagedArtifact[]): Promise<boolean> {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readRepositoryManifest } from "../repository-manifest/index.ts";
|
|
2
|
+
import { analyzeRepository } from "../repository-analysis/analyze.ts";
|
|
3
|
+
import { repositoryContext } from "../repository-analysis/context.ts";
|
|
4
|
+
import { readRepositorySnapshot } from "../repository-analysis/context.ts";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { readRuntimeObservation, runtimeSourceDigest, selectRuntimeObservation } from "../repository-analysis/runtime-observation.ts";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { selectRepositoryContext } from "../repository-analysis/retrieval.ts";
|
|
9
|
+
import { selectRepositoryChecks } from "../repository-analysis/check-selection.ts";
|
|
10
|
+
import { repositoryQualitySummary, type RepositoryQualitySummary } from "../repository-analysis/quality.ts";
|
|
11
|
+
|
|
12
|
+
export interface FabricRepositoryContextMetadata {
|
|
13
|
+
provider: "repository";
|
|
14
|
+
phase: "prepared-input";
|
|
15
|
+
status: "ready" | "unavailable";
|
|
16
|
+
sourceRoot: string;
|
|
17
|
+
cloneRoot: string;
|
|
18
|
+
snapshotId?: string;
|
|
19
|
+
diagnostics: string[];
|
|
20
|
+
quality?: RepositoryQualitySummary;
|
|
21
|
+
runtime?: { reportId: string; environmentId: string; phase: "source-observed-matching-input" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Optional static maps, taken from the clone after upstream composition and preparation.
|
|
25
|
+
* Nothing is written: an analysis cache in the clone would violate artifact write scope.
|
|
26
|
+
* Suggested checks are never executed here and maps never relax publication obligations.
|
|
27
|
+
*/
|
|
28
|
+
export async function prepareFabricRepositoryContext(sourceRoot: string, cloneRoot: string, query: string, scope: string[] = []): Promise<{ metadata: FabricRepositoryContextMetadata; prompt: string } | undefined> {
|
|
29
|
+
let loaded: ReturnType<typeof readRepositoryManifest>;
|
|
30
|
+
try { loaded = readRepositoryManifest(cloneRoot); }
|
|
31
|
+
catch { loaded = { manifest: null, diagnostics: ["Repository manifest cannot be read safely"] }; }
|
|
32
|
+
const { manifest, diagnostics } = loaded;
|
|
33
|
+
if (!manifest && !diagnostics.length) return undefined;
|
|
34
|
+
// JSON parser diagnostics can echo source values. Public worker metadata needs only
|
|
35
|
+
// an actionable status; detailed manifest validation is a separate user operation.
|
|
36
|
+
const metadata: FabricRepositoryContextMetadata = { provider: "repository", phase: "prepared-input", status: "unavailable", sourceRoot, cloneRoot, diagnostics: diagnostics.length ? ["Repository manifest missing, invalid or unsafe"] : [] };
|
|
37
|
+
if (!manifest) return { metadata, prompt: "Repository maps unavailable: invalid manifest. Inspect the code directly; no analysis result is claimed." };
|
|
38
|
+
try {
|
|
39
|
+
const snapshot = await analyzeRepository(cloneRoot, manifest, { write: false, factsCacheRoot: join(sourceRoot, ".forge", "repository") });
|
|
40
|
+
// Executor prompts are natural-language instructions, not CAIR query programs.
|
|
41
|
+
const context = await repositoryContext(cloneRoot, snapshot, `locate ${query.slice(0, 1000)}`, { snapshotId: snapshot.snapshotId, maxChars: 12000, limit: 20, includeSource: false });
|
|
42
|
+
metadata.snapshotId = snapshot.snapshotId;
|
|
43
|
+
metadata.diagnostics = context.diagnostics.slice(0, 20).map(item => item.slice(0, 512));
|
|
44
|
+
if (!context.ok) return { metadata, prompt: "Repository maps unavailable or stale. Inspect current clone files directly; do not reuse source-checkout analysis." };
|
|
45
|
+
metadata.quality = repositoryQualitySummary(snapshot);
|
|
46
|
+
const selection = selectRepositoryContext(snapshot, query, { writeScope: scope, maxChars: 10000, maxNodes: 30, maxEdges: 30 });
|
|
47
|
+
const checks = selectRepositoryChecks(snapshot, { scope, nodeIds: selection.nodes.map(node => node.id) }).checks.slice(0, 10);
|
|
48
|
+
// An observation is supplementary evidence about the source input, never an
|
|
49
|
+
// assertion that the SDK worker ran the application. Upstream composition or
|
|
50
|
+
// another manifest/scenario must invalidate reuse even when task files match.
|
|
51
|
+
let runtime: unknown;
|
|
52
|
+
if (existsSync(join(sourceRoot, ".forge/repository/runtime-observation.json"))) {
|
|
53
|
+
try {
|
|
54
|
+
const sourceSnapshot = readRepositorySnapshot(sourceRoot);
|
|
55
|
+
if (sourceSnapshot) {
|
|
56
|
+
const report = readRuntimeObservation(sourceRoot, sourceSnapshot);
|
|
57
|
+
if (report.binding.manifestHash === snapshot.manifestHash && report.binding.scenarioHash === snapshot.scenarioHash
|
|
58
|
+
&& runtimeSourceDigest(cloneRoot, manifest) === report.binding.inputDigest) {
|
|
59
|
+
runtime = selectRuntimeObservation(report, { query, scope: [...new Set(selection.nodes.map(node => node.component))], maxChars: 4000 });
|
|
60
|
+
metadata.runtime = { reportId: report.reportId, environmentId: report.environmentId, phase: "source-observed-matching-input" };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch { /* Missing/stale observations cannot downgrade valid static maps. */ }
|
|
64
|
+
}
|
|
65
|
+
const packet = { ...selection, cloneRoot, quality: metadata.quality, coverage: { found: snapshot.coverage.found, analyzed: snapshot.coverage.analyzed,
|
|
66
|
+
unsupported: snapshot.coverage.unsupported, errors: snapshot.coverage.errors, limitations: snapshot.coverage.limitations.slice(0, 10) },
|
|
67
|
+
suggestedCheckIds: checks.map(check => ({ id: check.id, component: check.component })), suggestedChecks: checks, runtime };
|
|
68
|
+
if (JSON.stringify(packet).length > 16000 && runtime) { delete packet.runtime; delete metadata.runtime; packet.truncated = true; }
|
|
69
|
+
while (JSON.stringify(packet).length > 16000 && packet.suggestedChecks.length) { packet.suggestedChecks.pop(); packet.suggestedCheckIds.pop(); packet.truncated = true; }
|
|
70
|
+
const serialized = JSON.stringify(packet);
|
|
71
|
+
const text = serialized.length <= 16000 ? serialized : JSON.stringify({ snapshotId: snapshot.snapshotId, cloneRoot, truncated: true, reason: "Context packet exceeded budget; inspect clone directly" });
|
|
72
|
+
metadata.status = "ready";
|
|
73
|
+
return { metadata, prompt: `Repository analysis of THIS prepared clone (static evidence, not executed coverage). Optional runtime evidence was observed in an independent source copy with matching input, not executed in this worker clone; it describes only its recorded scenario and time. Source project identity: ${sourceRoot}. Ready means input prepared, not semantic completeness. Snapshot handles apply only to this snapshot. Read-only neighbors never extend write scope. Suggested checks are informational; only authorized workflow executors run checks. Maps do not replace required review, verification or file scope.\n${text}` };
|
|
74
|
+
} catch {
|
|
75
|
+
// Analysis is an optional aid. Do not expose parser exceptions or claim a fallback
|
|
76
|
+
// graph from the source checkout; the existing worker and publication gates remain.
|
|
77
|
+
metadata.status = "unavailable";
|
|
78
|
+
delete metadata.quality;
|
|
79
|
+
delete metadata.runtime;
|
|
80
|
+
metadata.diagnostics = ["Repository analysis unavailable in this prepared clone"];
|
|
81
|
+
return { metadata, prompt: "Repository analysis failed in this clone. Inspect the current files directly; no map evidence is available." };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -9,6 +9,7 @@ import { MANAGED_RUN_ACTIONS, requestManagedRun, ATTACHED_TASK_ACTIONS, isAttach
|
|
|
9
9
|
import { LocalChangeReviewService } from "../agent-fabric/local-change-review-service.ts";
|
|
10
10
|
import { CODEX_EVENTS, CODEX_HOOK_RUNNER_RELATIVE, CODEX_MCP_HOOK_TOOL } from "./sources/codex.ts";
|
|
11
11
|
import { listFabricProjects, registerFabricProject, resolveFabricProject } from "../agent-fabric/project-registry.ts";
|
|
12
|
+
import { runRepositoryCommand } from "../cli/repository.ts";
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
const managedCommon = { runId: { type: "string" }, requestId: { type: "string" }, expectedVersion: { type: "integer", minimum: 1 } };
|
|
@@ -84,6 +85,12 @@ export async function handleMcpRequest(workspaceRoot: string, request: JsonRpcRe
|
|
|
84
85
|
if (request.method === "tools/list") {
|
|
85
86
|
return response(request.id, {
|
|
86
87
|
tools: [
|
|
88
|
+
{ name: "fabric_repository_discover", description: "Propose a repository analysis manifest without writing files or executing project tools.", inputSchema: { type: "object", properties: {}, additionalProperties: false } },
|
|
89
|
+
{ name: "fabric_repository_analyze", description: "Analyze repository sources statically. write=true explicitly saves local map artifacts; never starts models, builds or containers.", inputSchema: { type: "object", properties: { write: { type: "boolean", default: false } }, additionalProperties: false } },
|
|
90
|
+
{ name: "fabric_repository_context", description: "Read bounded snapshot-bound repository maps and evidence. Requires previously saved analysis.", inputSchema: { type: "object", properties: { query: { type: "string", maxLength: 2000 }, snapshotId: { type: "string", maxLength: 2000 }, limit: { type: "integer", minimum: 1, maximum: 100 }, maxChars: { type: "integer", minimum: 2048, maximum: 50000 }, cursor: { type: "string", maxLength: 1000 } }, additionalProperties: false } },
|
|
91
|
+
{ name: "fabric_repository_runtime_plan", description: "Review manifest-declared runtime commands and artifacts without executing them.", inputSchema: { type: "object", properties: { environmentId: { type: "string", maxLength: 80 }, observationId: { type: "string", maxLength: 80 } }, required: ["environmentId"], additionalProperties: false } },
|
|
92
|
+
{ name: "fabric_repository_runtime_observe", description: "Explicitly execute trusted manifest commands in a temporary source copy. May install dependencies, access network or containers as declared; this is filesystem isolation, not an OS sandbox. Review the plan first. Requires execute=true; write=true saves a sanitized local report.", inputSchema: { type: "object", properties: { environmentId: { type: "string", maxLength: 80 }, observationId: { type: "string", maxLength: 80 }, execute: { const: true }, write: { type: "boolean", default: false } }, required: ["environmentId", "execute"], additionalProperties: false } },
|
|
93
|
+
{ name: "fabric_repository_runtime_context", description: "Read bounded current runtime evidence; rejects expired reports and changed inputs. Does not start processes.", inputSchema: { type: "object", properties: { environmentId: { type: "string", maxLength: 80 }, query: { type: "string", maxLength: 2000 }, maxChars: { type: "integer", minimum: 2048, maximum: 16000 } }, additionalProperties: false } },
|
|
87
94
|
{ name: "fabric_project_register", description: "Explicitly register a Git project root for isolated Agent Fabric routing. Does not start workers.",
|
|
88
95
|
inputSchema: { type: "object", properties: { root: { type: "string", minLength: 1, description: "Absolute path to the Git project or one of its subdirectories." }, id: { type: "string", minLength: 1 } }, required: ["root"], additionalProperties: false } },
|
|
89
96
|
{ name: "fabric_project_list", description: "List registered Agent Fabric projects and their canonical roots.",
|
|
@@ -226,7 +233,7 @@ export async function handleMcpRequest(workspaceRoot: string, request: JsonRpcRe
|
|
|
226
233
|
const toolResult = await runTool(toolRoot, name, toolArgs, options);
|
|
227
234
|
const result = typeof args.projectId === "string" && name.startsWith("fabric_") && toolResult && typeof toolResult === "object"
|
|
228
235
|
? { ...toolResult, projectContext: { id: args.projectId, root: toolRoot } } : toolResult;
|
|
229
|
-
if (name !== CODEX_MCP_HOOK_TOOL) {
|
|
236
|
+
if (name !== CODEX_MCP_HOOK_TOOL && !name.startsWith("fabric_repository_")) {
|
|
230
237
|
await logMcpToolCall(toolRoot, name, args, "completed").catch(() => undefined);
|
|
231
238
|
}
|
|
232
239
|
return response(request.id, {
|
|
@@ -269,6 +276,36 @@ export async function runMcpServe(workspaceRoot: string): Promise<number> {
|
|
|
269
276
|
}
|
|
270
277
|
|
|
271
278
|
async function runTool(workspaceRoot: string, name: string, args: Record<string, unknown>, options: FabricMcpOptions): Promise<unknown> {
|
|
279
|
+
if (name.startsWith("fabric_repository_")) {
|
|
280
|
+
const action = name.slice("fabric_repository_".length).replaceAll("_", "-");
|
|
281
|
+
if (!["discover", "analyze", "context", "runtime-plan", "runtime-observe", "runtime-context"].includes(action)) throw new Error("Unknown repository operation");
|
|
282
|
+
const allowed = action === "runtime-plan" ? ["environmentId", "observationId"] : action === "runtime-observe" ? ["environmentId", "observationId", "execute", "write"] : action === "runtime-context" ? ["environmentId", "query", "maxChars"] : action === "context" ? ["query", "snapshotId", "limit", "maxChars", "cursor"] : action === "analyze" ? ["write"] : [];
|
|
283
|
+
if (Object.keys(args).some(key => !allowed.includes(key))) throw new Error("Unknown repository argument");
|
|
284
|
+
for (const [key, max] of [["query", 2000], ["snapshotId", 2000], ["cursor", 1000]] as const) if (key in args && (typeof args[key] !== "string" || (args[key] as string).length > max)) throw new Error(`Invalid ${key}`);
|
|
285
|
+
for (const [key, min, max] of [["limit", 1, 100], ["maxChars", 2048, 50000]] as const) if (key in args && (!Number.isSafeInteger(args[key]) || (args[key] as number) < min || (args[key] as number) > max)) throw new Error(`Invalid ${key}`);
|
|
286
|
+
if ("write" in args && typeof args.write !== "boolean") throw new Error("write must be boolean");
|
|
287
|
+
for (const key of ["environmentId", "observationId"]) if (key in args && (typeof args[key] !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(args[key] as string))) throw new Error("Invalid runtime label");
|
|
288
|
+
if ("execute" in args && args.execute !== true) throw new Error("Runtime execution requires execute=true");
|
|
289
|
+
const result = await runRepositoryCommand({ action: action as import("../cli/repository.ts").RepositoryCliOptions["action"], cwd: workspaceRoot, root: workspaceRoot,
|
|
290
|
+
json: true, write: args.write === true, query: args.query as string | undefined, snapshotId: args.snapshotId as string | undefined,
|
|
291
|
+
limit: args.limit as number | undefined, maxChars: args.maxChars as number | undefined, cursor: args.cursor as string | undefined,
|
|
292
|
+
environmentId: args.environmentId as string | undefined, observationId: args.observationId as string | undefined, execute: args.execute === true });
|
|
293
|
+
const { snapshot, ...compact } = result;
|
|
294
|
+
if (action === "runtime-observe" && result.report) {
|
|
295
|
+
const report = result.report as import("../repository-analysis/runtime-observation.ts").RuntimeObservationReport;
|
|
296
|
+
const { selectRuntimeObservation } = await import("../repository-analysis/runtime-observation.ts");
|
|
297
|
+
return { ok: result.ok, exitCode: result.exitCode, wroteArtifacts: result.wroteArtifacts, reportId: report.reportId,
|
|
298
|
+
observations: report.observations.map(item => ({ id: item.id, component: item.component, status: item.status, factCount: item.facts.length, artifactCount: item.artifacts.length })),
|
|
299
|
+
context: selectRuntimeObservation(report, { maxChars: 4000 }), nextAction: "fabric_repository_runtime_context" };
|
|
300
|
+
}
|
|
301
|
+
if (snapshot) {
|
|
302
|
+
const coverage = (snapshot as import("../repository-analysis/types.ts").RepositorySnapshot).coverage;
|
|
303
|
+
return { ...compact, coverage: { ...coverage, diagnostics: coverage.diagnostics.slice(0, 20), ignoredPaths: coverage.ignoredPaths.slice(0, 20), limitations: coverage.limitations.slice(0, 20),
|
|
304
|
+
omitted: { diagnostics: Math.max(0, coverage.diagnostics.length - 20), ignoredPaths: Math.max(0, coverage.ignoredPaths.length - 20), limitations: Math.max(0, coverage.limitations.length - 20) } },
|
|
305
|
+
nextAction: args.write === true ? "fabric_repository_context" : "fabric_repository_analyze with write=true explicitly saves this analysis for later queries" };
|
|
306
|
+
}
|
|
307
|
+
return compact;
|
|
308
|
+
}
|
|
272
309
|
if (name === "fabric_project_register") {
|
|
273
310
|
if (Object.keys(args).some(key => key !== "root" && key !== "id") || typeof args.root !== "string" || !isAbsolute(args.root) ||
|
|
274
311
|
("id" in args && (typeof args.id !== "string" || !args.id.trim()))) throw new Error("fabric_project_register requires an absolute root and optional id");
|
package/src/forge/cair/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ import type { Diagnostic } from "../compiler/types/diagnostic.ts";
|
|
|
3
3
|
import { runCairActionScript, statActionInput } from "./actions.ts";
|
|
4
4
|
import { buildCairSnapshot } from "./snapshot.ts";
|
|
5
5
|
import { runCairQuery } from "./query.ts";
|
|
6
|
-
import type { CairCommandOptions, CairCommandResult, CairSnapshotLimits } from "./types.ts";
|
|
6
|
+
import type { CairCommandOptions, CairCommandResult, CairSnapshotLimits } from "./types.ts";
|
|
7
|
+
import { readRepositoryManifest } from "../repository-manifest/index.ts";
|
|
8
|
+
import { readRepositorySnapshot, runRepositoryCairCommand, unavailableRepositoryCairCommand } from "../repository-analysis/context.ts";
|
|
7
9
|
|
|
8
10
|
export {
|
|
9
11
|
CAIR_SCHEMA_VERSION,
|
|
@@ -24,8 +26,18 @@ const QUERY_LIMITS: CairSnapshotLimits = {
|
|
|
24
26
|
tests: 1_000_000,
|
|
25
27
|
};
|
|
26
28
|
|
|
27
|
-
export function runCairCommand(options: CairCommandOptions): CairCommandResult {
|
|
28
|
-
const
|
|
29
|
+
export function runCairCommand(options: CairCommandOptions): CairCommandResult {
|
|
30
|
+
const repository = readRepositoryManifest(options.workspaceRoot, { manifestPath: options.manifestPath });
|
|
31
|
+
if (repository.manifest || repository.diagnostics.length > 0) {
|
|
32
|
+
try {
|
|
33
|
+
const repositorySnapshot = readRepositorySnapshot(options.workspaceRoot, { cacheRoot: options.cacheRoot });
|
|
34
|
+
if (repositorySnapshot && repository.diagnostics.length === 0) return runRepositoryCairCommand(options, repositorySnapshot);
|
|
35
|
+
return unavailableRepositoryCairCommand(options, repository.diagnostics.join("; ") || "Repository snapshot is unavailable; run forge repository analyze first.");
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return unavailableRepositoryCairCommand(options, error instanceof Error ? error.message : String(error));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const snapshot = buildCairSnapshot(
|
|
29
41
|
options.workspaceRoot,
|
|
30
42
|
options.subcommand === "query" || options.subcommand === "action" ? QUERY_LIMITS : undefined,
|
|
31
43
|
);
|
package/src/forge/cair/types.ts
CHANGED
|
@@ -18,7 +18,13 @@ export interface CairCommandOptions {
|
|
|
18
18
|
inputPath?: string;
|
|
19
19
|
dryRun?: boolean;
|
|
20
20
|
plan?: boolean;
|
|
21
|
-
allowGenerated?: boolean;
|
|
21
|
+
allowGenerated?: boolean;
|
|
22
|
+
/** Required when resolving repository snapshot-local handles. */
|
|
23
|
+
snapshotId?: string;
|
|
24
|
+
manifestPath?: string;
|
|
25
|
+
cacheRoot?: string;
|
|
26
|
+
root?: string;
|
|
27
|
+
projectId?: string;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
export interface CairProjectRef {
|
|
@@ -108,7 +114,8 @@ export interface CairSnapshotLimits {
|
|
|
108
114
|
tests: number;
|
|
109
115
|
}
|
|
110
116
|
|
|
111
|
-
export interface CairSnapshot {
|
|
117
|
+
export interface CairSnapshot {
|
|
118
|
+
provider?: "forge-application" | "repository";
|
|
112
119
|
schemaVersion: typeof CAIR_SCHEMA_VERSION;
|
|
113
120
|
kind: "cair.snapshot";
|
|
114
121
|
snapshotId: string;
|
|
@@ -76,6 +76,8 @@ import {
|
|
|
76
76
|
formatCairJson,
|
|
77
77
|
runCairCommand,
|
|
78
78
|
} from "../cair/index.ts";
|
|
79
|
+
import { runRepositoryCommand } from "./repository.ts";
|
|
80
|
+
import { readRepositoryManifest, resolveRepositoryRoot } from "../repository-manifest/index.ts";
|
|
79
81
|
import { uniqueNextActions } from "./next-actions.ts";
|
|
80
82
|
import {
|
|
81
83
|
formatRunJson,
|
|
@@ -1602,6 +1604,8 @@ function runManifestCommand(command: Extract<ForgeCommand, { kind: "manifest" }>
|
|
|
1602
1604
|
exitCode: number;
|
|
1603
1605
|
} {
|
|
1604
1606
|
if (command.subcommand === "validate") {
|
|
1607
|
+
const checked = readRepositoryManifest(command.workspaceRoot, { manifestPath: command.path });
|
|
1608
|
+
if (checked.manifest || checked.diagnostics.length) return { subcommand: "validate", path: command.path, diagnostics: checked.diagnostics.map(message => createDiagnostic({ severity: "error", code: "FORGE_REPOSITORY_MANIFEST", message })), exitCode: checked.manifest ? 0 : 1 };
|
|
1605
1609
|
const result = readExternalManifestFile(command.path);
|
|
1606
1610
|
const hasErrors = result.diagnostics.some((diagnostic) => diagnostic.severity === "error");
|
|
1607
1611
|
return {
|
|
@@ -2304,6 +2308,16 @@ export async function executeCommand(command: ForgeCommand): Promise<number> {
|
|
|
2304
2308
|
return result.exitCode;
|
|
2305
2309
|
}
|
|
2306
2310
|
case "cair": {
|
|
2311
|
+
let root: string;
|
|
2312
|
+
try { root = await resolveRepositoryRoot({ cwd: command.options.workspaceRoot, root: command.options.root, projectId: command.options.projectId, manifestPath: command.options.manifestPath }); }
|
|
2313
|
+
catch (error) {
|
|
2314
|
+
const diagnostic = createDiagnostic({ severity: "error", code: "FORGE_REPOSITORY_ROOT", message: (error as Error).message });
|
|
2315
|
+
if (command.options.json || command.options.format === "json") process.stdout.write(formatJsonResult({ ok: false, diagnostics: [diagnostic], exitCode: 1 }));
|
|
2316
|
+
else console.error(`error ${diagnostic.code}: ${diagnostic.message}`);
|
|
2317
|
+
return 1;
|
|
2318
|
+
}
|
|
2319
|
+
const repository = readRepositoryManifest(root, { manifestPath: command.options.manifestPath });
|
|
2320
|
+
if (repository.manifest || repository.diagnostics.length || command.options.root || command.options.projectId) command.options.workspaceRoot = root;
|
|
2307
2321
|
const result = runCairCommand(command.options);
|
|
2308
2322
|
process.stdout.write(
|
|
2309
2323
|
command.options.json || command.options.format === "json"
|
|
@@ -2477,6 +2491,11 @@ export async function executeCommand(command: ForgeCommand): Promise<number> {
|
|
|
2477
2491
|
}
|
|
2478
2492
|
return result.exitCode;
|
|
2479
2493
|
}
|
|
2494
|
+
case "repository": {
|
|
2495
|
+
const result = await runRepositoryCommand(command.options);
|
|
2496
|
+
process.stdout.write(`${JSON.stringify(result, null, command.options.json ? 2 : 2)}\n`);
|
|
2497
|
+
return result.exitCode;
|
|
2498
|
+
}
|
|
2480
2499
|
case "import": {
|
|
2481
2500
|
const result = runBrownfieldImportCommand(command.options);
|
|
2482
2501
|
if (command.options.json) {
|
package/src/forge/cli/main.ts
CHANGED
|
@@ -5,10 +5,21 @@ import { executeCommand } from "./commands.ts";
|
|
|
5
5
|
import { hasUnknownOption, parseCli } from "./parse.ts";
|
|
6
6
|
import { formatJsonResult } from "./output.ts";
|
|
7
7
|
import { recordParsedCliCommand } from "../delta/index.ts";
|
|
8
|
+
import { readRepositoryManifest } from "../repository-manifest/index.ts";
|
|
8
9
|
|
|
9
10
|
function formatHelp(): string {
|
|
10
11
|
return [
|
|
11
12
|
"ForgeOS",
|
|
13
|
+
" forge manifest discover --root <path> --json Propose a repository manifest without writing",
|
|
14
|
+
" forge repository analyze --write --json Analyze Vue/Java/Docker and save a coherent local map",
|
|
15
|
+
" forge repository context --query routes --json Read snapshot-bound repository context",
|
|
16
|
+
" forge repository quality --cases tasks.json --json Evaluate reviewed static task fixtures without running a model",
|
|
17
|
+
" forge repository cache-gc --json Preview cache cleanup; --write applies after retention",
|
|
18
|
+
" forge repository benchmark-plan --cases tasks.json --model-config model.json --json Prepare paired agent experiment",
|
|
19
|
+
" forge repository benchmark-report --plan plan.json --observations observations.json --json Compare supplied evidence",
|
|
20
|
+
" forge repository runtime-plan --environment-id local --json Review declared runtime commands without executing",
|
|
21
|
+
" forge repository runtime-observe --environment-id local --execute --write --json Execute in a temporary source copy and save observations",
|
|
22
|
+
" forge repository runtime-context --environment-id local --query checkout --json Read current, bounded runtime evidence",
|
|
12
23
|
"",
|
|
13
24
|
"Start with one of these:",
|
|
14
25
|
" forge status --json Compact project health, handoff state, and next actions",
|
|
@@ -249,6 +260,10 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
|
|
|
249
260
|
|
|
250
261
|
const startedAt = Date.now();
|
|
251
262
|
const exitCode = await executeCommand(parsed.command);
|
|
263
|
+
const repositoryRead = parsed.command.kind === "repository" ||
|
|
264
|
+
(parsed.command.kind === "manifest" && parsed.command.subcommand === "validate") ||
|
|
265
|
+
(parsed.command.kind === "cair" && (() => { const repository = readRepositoryManifest(parsed.command.options.workspaceRoot, { manifestPath: parsed.command.options.manifestPath }); return !!repository.manifest || repository.diagnostics.length > 0; })());
|
|
266
|
+
if (repositoryRead) return exitCode;
|
|
252
267
|
await recordParsedCliCommand({
|
|
253
268
|
command: parsed.command,
|
|
254
269
|
argv,
|