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.
Files changed (57) hide show
  1. package/.agents/skills/forge-agent-fabric/SKILL.md +21 -1
  2. package/.agents/skills/forge-agent-fabric/references/repository-analysis.md +102 -0
  3. package/.agents/skills/forge-agent-fabric/scripts/repository.mjs +44 -0
  4. package/AGENTS.md +1 -1
  5. package/CHANGELOG.md +16 -0
  6. package/README.md +12 -3
  7. package/docs/agent-fabric.md +13 -0
  8. package/docs/architecture/agent-fabric/REPOSITORY_MANIFEST_PLAN.md +272 -0
  9. package/docs/changelog.md +32 -0
  10. package/docs/repository-agent-benchmark.md +179 -0
  11. package/docs/repository-analysis.md +139 -0
  12. package/docs/repository-context-quality.md +179 -0
  13. package/docs/repository-runtime-observation.md +82 -0
  14. package/package.json +14 -2
  15. package/schemas/forge-manifest.schema.json +385 -53
  16. package/src/forge/_generated/releaseManifest.json +1 -1
  17. package/src/forge/_generated/releaseManifest.ts +3 -3
  18. package/src/forge/agent-fabric/managed-run-contract.ts +1 -0
  19. package/src/forge/agent-fabric/managed-run-service.ts +10 -1
  20. package/src/forge/agent-fabric/managed-run-store.ts +20 -0
  21. package/src/forge/agent-fabric/managed-workspace.ts +56 -18
  22. package/src/forge/agent-fabric/repository-context.ts +83 -0
  23. package/src/forge/agent-memory/mcp.ts +38 -1
  24. package/src/forge/cair/index.ts +15 -3
  25. package/src/forge/cair/types.ts +9 -2
  26. package/src/forge/cli/commands.ts +19 -0
  27. package/src/forge/cli/main.ts +15 -0
  28. package/src/forge/cli/parse.ts +70 -16
  29. package/src/forge/cli/repository.ts +151 -0
  30. package/src/forge/compiler/external-manifest/registry.ts +21 -9
  31. package/src/forge/delta/process-identity.ts +22 -16
  32. package/src/forge/repository-analysis/adapters/common.ts +72 -0
  33. package/src/forge/repository-analysis/adapters/docker.ts +185 -0
  34. package/src/forge/repository-analysis/adapters/java.ts +133 -0
  35. package/src/forge/repository-analysis/adapters/typescript.ts +350 -0
  36. package/src/forge/repository-analysis/adapters/vue.ts +71 -0
  37. package/src/forge/repository-analysis/analyze.ts +434 -0
  38. package/src/forge/repository-analysis/benchmark.ts +336 -0
  39. package/src/forge/repository-analysis/cache-gc.ts +154 -0
  40. package/src/forge/repository-analysis/check-selection.ts +123 -0
  41. package/src/forge/repository-analysis/context-node.ts +17 -0
  42. package/src/forge/repository-analysis/context.ts +456 -0
  43. package/src/forge/repository-analysis/http-resolution.ts +135 -0
  44. package/src/forge/repository-analysis/identity.ts +45 -0
  45. package/src/forge/repository-analysis/index.ts +18 -0
  46. package/src/forge/repository-analysis/nuxt-resolution.ts +188 -0
  47. package/src/forge/repository-analysis/quality.ts +60 -0
  48. package/src/forge/repository-analysis/retrieval.ts +309 -0
  49. package/src/forge/repository-analysis/runtime-artifacts.ts +195 -0
  50. package/src/forge/repository-analysis/runtime-observation.ts +316 -0
  51. package/src/forge/repository-analysis/scanner.ts +174 -0
  52. package/src/forge/repository-analysis/storage.ts +193 -0
  53. package/src/forge/repository-analysis/test-associations.ts +59 -0
  54. package/src/forge/repository-analysis/types.ts +99 -0
  55. package/src/forge/repository-manifest/index.ts +178 -0
  56. package/src/forge/repository-manifest/types.ts +63 -0
  57. 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: string }
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
- async function inventory(root: string, scope?: string[], excluded: string[] = []): Promise<Inventory> {
48
- const files: Entry[] = []; let bytes = 0;
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(stat.size <= MAX_SNAPSHOT_FILE_BYTES, "file size bound exceeded");
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: hash(JSON.stringify(files.map(({ path, digest, mode }) => ({ path, digest, mode })))) };
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: string | null; mode?: number }) {
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 inventory(base.baselineDirectory)).digest === baselineDigest && (await inventory(base.baselineDirectory, base.scope)).digest === base.digest, "immutable baseline modified");
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 inventory(baselineDirectory)).digest); return base;
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 inventory(base.baselineDirectory), desired = applyEntries(baseline.files, dependencies, base.scope), directory = await clone(base.baselineDirectory, "forge-managed-attempt-");
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 inventory(directory);
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 inventory(directory), metadata: WorkspaceMetadata = { baseDigest: base.digest, runId: base.runId, attemptId, directory, input };
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 === hash(JSON.stringify(metadata.input.files.map(({ path, digest, mode }) => ({ path, digest, mode })))) && metadata.input.files.every(file => hash(Buffer.from(file.contentBase64, "base64")) === file.digest), "input inventory corrupted or coordinator digest mismatch");
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 inventory(workspaceDirectory, undefined, environment?.derivedPaths ?? []), prior = new Map(metadata.input.files.map(file => [file.path, file])), after = new Map(current.files.map(file => [file.path, file]));
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?.contentBase64 ?? null, ...(next ? { mode: next.mode } : {}) });
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.contentBase64, "base64")); }
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: hash(JSON.stringify(after.map(({ path, digest, mode }) => ({ path, digest, mode })))), 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() };
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");
@@ -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 snapshot = buildCairSnapshot(
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
  );
@@ -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) {
@@ -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,