@flyingrobots/graft 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +218 -0
- package/LICENSE +190 -0
- package/NOTICE +4 -0
- package/README.md +119 -0
- package/bin/graft.js +11 -0
- package/docs/GUIDE.md +374 -0
- package/package.json +76 -0
- package/src/adapters/canonical-json.ts +56 -0
- package/src/adapters/node-fs.ts +39 -0
- package/src/git/diff.ts +96 -0
- package/src/guards/stream-boundary.ts +110 -0
- package/src/hooks/posttooluse-read.ts +107 -0
- package/src/hooks/pretooluse-read.ts +88 -0
- package/src/hooks/shared.ts +168 -0
- package/src/mcp/cache.ts +94 -0
- package/src/mcp/cached-file.ts +38 -0
- package/src/mcp/context.ts +52 -0
- package/src/mcp/metrics.ts +53 -0
- package/src/mcp/receipt.ts +83 -0
- package/src/mcp/server.ts +166 -0
- package/src/mcp/stdio.ts +6 -0
- package/src/mcp/tools/budget.ts +20 -0
- package/src/mcp/tools/changed-since.ts +68 -0
- package/src/mcp/tools/doctor.ts +20 -0
- package/src/mcp/tools/explain.ts +80 -0
- package/src/mcp/tools/file-outline.ts +57 -0
- package/src/mcp/tools/graft-diff.ts +24 -0
- package/src/mcp/tools/read-range.ts +21 -0
- package/src/mcp/tools/run-capture.ts +67 -0
- package/src/mcp/tools/safe-read.ts +135 -0
- package/src/mcp/tools/state.ts +30 -0
- package/src/mcp/tools/stats.ts +20 -0
- package/src/metrics/logger.ts +69 -0
- package/src/metrics/types.ts +12 -0
- package/src/operations/file-outline.ts +38 -0
- package/src/operations/graft-diff.ts +117 -0
- package/src/operations/read-range.ts +65 -0
- package/src/operations/safe-read.ts +96 -0
- package/src/operations/state.ts +33 -0
- package/src/parser/diff.ts +142 -0
- package/src/parser/lang.ts +12 -0
- package/src/parser/outline.ts +327 -0
- package/src/parser/types.ts +67 -0
- package/src/policy/evaluate.ts +178 -0
- package/src/policy/graftignore.ts +6 -0
- package/src/policy/types.ts +86 -0
- package/src/ports/codec.ts +13 -0
- package/src/ports/filesystem.ts +17 -0
- package/src/session/tracker.ts +114 -0
- package/src/session/types.ts +20 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { safeRead } from "../../operations/safe-read.js";
|
|
3
|
+
import type { SafeReadResult } from "../../operations/safe-read.js";
|
|
4
|
+
import { evaluatePolicy } from "../../policy/evaluate.js";
|
|
5
|
+
import { RefusedResult } from "../../policy/types.js";
|
|
6
|
+
import { diffOutlines } from "../../parser/diff.js";
|
|
7
|
+
import { CachedFile } from "../cached-file.js";
|
|
8
|
+
import type { Metrics } from "../metrics.js";
|
|
9
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
10
|
+
|
|
11
|
+
const PROJECTION_METRICS: Readonly<Record<string, ((m: Metrics) => void) | undefined>> = {
|
|
12
|
+
content: (m) => { m.recordRead(); },
|
|
13
|
+
outline: (m) => { m.recordOutline(); },
|
|
14
|
+
refused: (m) => { m.recordRefusal(); },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const CACHEABLE_PROJECTIONS: ReadonlySet<SafeReadResult["projection"]> = new Set(["content", "outline"]);
|
|
18
|
+
|
|
19
|
+
export const safeReadTool: ToolDefinition = {
|
|
20
|
+
name: "safe_read",
|
|
21
|
+
description:
|
|
22
|
+
"Policy-enforced file read. Returns full content for small files, " +
|
|
23
|
+
"structural outline with jump table for large files, or refusal with " +
|
|
24
|
+
"reason code for banned files. Detects re-reads and returns cached " +
|
|
25
|
+
"outlines or structural diffs.",
|
|
26
|
+
schema: { path: z.string(), intent: z.string().optional() },
|
|
27
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
28
|
+
return async (args) => {
|
|
29
|
+
const filePath = ctx.resolvePath(args["path"] as string);
|
|
30
|
+
|
|
31
|
+
// Build CachedFile once — all consumers share the same snapshot,
|
|
32
|
+
// eliminating TOCTOU races where the file changes between reads.
|
|
33
|
+
let cf: CachedFile | null = null;
|
|
34
|
+
try {
|
|
35
|
+
const rawContent = ctx.fs.readFileSync(filePath, "utf-8");
|
|
36
|
+
cf = new CachedFile(filePath, rawContent);
|
|
37
|
+
} catch {
|
|
38
|
+
// File doesn't exist or can't be read — proceed to safeRead for error handling
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check cache if we could read the file
|
|
42
|
+
if (cf !== null) {
|
|
43
|
+
const cacheResult = ctx.cache.check(filePath, cf.rawContent);
|
|
44
|
+
if (cacheResult.hit) {
|
|
45
|
+
// Defense: re-check policy before returning cached data.
|
|
46
|
+
const policy = evaluatePolicy(
|
|
47
|
+
{ path: filePath, lines: cf.actual.lines, bytes: cf.actual.bytes },
|
|
48
|
+
{ sessionDepth: ctx.session.getSessionDepth(), budgetRemaining: ctx.session.getBudget()?.remaining },
|
|
49
|
+
);
|
|
50
|
+
if (policy instanceof RefusedResult) {
|
|
51
|
+
ctx.metrics.recordRefusal();
|
|
52
|
+
return ctx.respond("safe_read", {
|
|
53
|
+
path: filePath,
|
|
54
|
+
projection: "refused",
|
|
55
|
+
reason: policy.reason,
|
|
56
|
+
reasonDetail: policy.reasonDetail,
|
|
57
|
+
next: [...policy.next],
|
|
58
|
+
actual: cf.actual,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
cacheResult.obs.touch();
|
|
63
|
+
ctx.metrics.recordCacheHit(cf.actual.bytes);
|
|
64
|
+
return ctx.respond("safe_read", {
|
|
65
|
+
path: filePath,
|
|
66
|
+
projection: "cache_hit",
|
|
67
|
+
reason: "REREAD_UNCHANGED",
|
|
68
|
+
outline: cacheResult.obs.outline,
|
|
69
|
+
jumpTable: cacheResult.obs.jumpTable,
|
|
70
|
+
actual: cf.actual,
|
|
71
|
+
readCount: cacheResult.obs.readCount,
|
|
72
|
+
estimatedBytesAvoided: cf.actual.bytes,
|
|
73
|
+
lastReadAt: cacheResult.obs.lastReadAt,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// File changed since last observation — compute structural diff
|
|
78
|
+
if (cacheResult.stale !== null) {
|
|
79
|
+
// Defense: re-check policy before returning structural data.
|
|
80
|
+
const policy = evaluatePolicy(
|
|
81
|
+
{ path: filePath, lines: cf.actual.lines, bytes: cf.actual.bytes },
|
|
82
|
+
{ sessionDepth: ctx.session.getSessionDepth(), budgetRemaining: ctx.session.getBudget()?.remaining },
|
|
83
|
+
);
|
|
84
|
+
if (policy instanceof RefusedResult) {
|
|
85
|
+
ctx.metrics.recordRefusal();
|
|
86
|
+
return ctx.respond("safe_read", {
|
|
87
|
+
path: filePath,
|
|
88
|
+
projection: "refused",
|
|
89
|
+
reason: policy.reason,
|
|
90
|
+
reasonDetail: policy.reasonDetail,
|
|
91
|
+
next: [...policy.next],
|
|
92
|
+
actual: cf.actual,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const diff = diffOutlines(cacheResult.stale.outline, cf.outline);
|
|
97
|
+
const newReadCount = cacheResult.stale.readCount + 1;
|
|
98
|
+
ctx.cache.record(filePath, cf.hash, cf.outline, cf.jumpTable, cf.actual);
|
|
99
|
+
const updatedObs = ctx.cache.get(filePath);
|
|
100
|
+
return ctx.respond("safe_read", {
|
|
101
|
+
path: filePath,
|
|
102
|
+
projection: "diff",
|
|
103
|
+
reason: "CHANGED_SINCE_LAST_READ",
|
|
104
|
+
diff,
|
|
105
|
+
outline: cf.outline,
|
|
106
|
+
jumpTable: cf.jumpTable,
|
|
107
|
+
actual: cf.actual,
|
|
108
|
+
readCount: newReadCount,
|
|
109
|
+
lastReadAt: updatedObs?.lastReadAt ?? new Date().toISOString(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// First read — pass rawContent to avoid double-read (TOCTOU)
|
|
115
|
+
const result = await safeRead(filePath, {
|
|
116
|
+
fs: ctx.fs,
|
|
117
|
+
codec: ctx.codec,
|
|
118
|
+
content: cf?.rawContent,
|
|
119
|
+
intent: args["intent"] as string | undefined,
|
|
120
|
+
sessionDepth: ctx.session.getSessionDepth(),
|
|
121
|
+
budgetRemaining: ctx.session.getBudget()?.remaining,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
PROJECTION_METRICS[result.projection]?.(ctx.metrics);
|
|
125
|
+
|
|
126
|
+
// Record observation for cacheable projections — uses CachedFile
|
|
127
|
+
// outline (no re-read) to eliminate the snapshot race.
|
|
128
|
+
if (cf !== null && result.actual !== undefined && CACHEABLE_PROJECTIONS.has(result.projection)) {
|
|
129
|
+
ctx.cache.record(filePath, cf.hash, cf.outline, cf.jumpTable, result.actual);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return ctx.respond("safe_read", result);
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { stateSave, stateLoad } from "../../operations/state.js";
|
|
3
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
4
|
+
|
|
5
|
+
export const stateSaveTool: ToolDefinition = {
|
|
6
|
+
name: "state_save",
|
|
7
|
+
description:
|
|
8
|
+
"Save session working state (max 8 KB). Use for session bookmarks: " +
|
|
9
|
+
"current task, files modified, next planned actions.",
|
|
10
|
+
schema: { content: z.string() },
|
|
11
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
12
|
+
return async (args) => {
|
|
13
|
+
const result = await stateSave(args["content"] as string, { graftDir: ctx.graftDir, fs: ctx.fs });
|
|
14
|
+
return ctx.respond("state_save", result as Record<string, unknown>);
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const stateLoadTool: ToolDefinition = {
|
|
20
|
+
name: "state_load",
|
|
21
|
+
description:
|
|
22
|
+
"Load previously saved session state. Returns null if no state has " +
|
|
23
|
+
"been saved.",
|
|
24
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
25
|
+
return async () => {
|
|
26
|
+
const result = await stateLoad({ graftDir: ctx.graftDir, fs: ctx.fs });
|
|
27
|
+
return ctx.respond("state_load", result as Record<string, unknown>);
|
|
28
|
+
};
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ToolDefinition, ToolContext, ToolHandler } from "../context.js";
|
|
2
|
+
|
|
3
|
+
export const statsTool: ToolDefinition = {
|
|
4
|
+
name: "stats",
|
|
5
|
+
description:
|
|
6
|
+
"Decision metrics for the current session. Total reads, outlines, " +
|
|
7
|
+
"refusals, cache hits, and bytes avoided.",
|
|
8
|
+
createHandler(ctx: ToolContext): ToolHandler {
|
|
9
|
+
return () => {
|
|
10
|
+
const snap = ctx.metrics.snapshot();
|
|
11
|
+
return ctx.respond("stats", {
|
|
12
|
+
totalReads: snap.reads,
|
|
13
|
+
totalOutlines: snap.outlines,
|
|
14
|
+
totalRefusals: snap.refusals,
|
|
15
|
+
totalCacheHits: snap.cacheHits,
|
|
16
|
+
totalBytesAvoidedByCache: snap.bytesAvoided,
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
3
|
+
import type { JsonCodec } from "../ports/codec.js";
|
|
4
|
+
import type { DecisionEntry } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export interface MetricsLoggerOptions {
|
|
7
|
+
readonly fs: FileSystem;
|
|
8
|
+
readonly codec: JsonCodec;
|
|
9
|
+
readonly maxBytes?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_MAX_BYTES = 1_048_576; // 1 MB
|
|
13
|
+
|
|
14
|
+
export class MetricsLogger {
|
|
15
|
+
private readonly logPath: string;
|
|
16
|
+
private readonly fs: FileSystem;
|
|
17
|
+
private readonly codec: JsonCodec;
|
|
18
|
+
private readonly maxBytes: number;
|
|
19
|
+
|
|
20
|
+
constructor(logPath: string, options: MetricsLoggerOptions) {
|
|
21
|
+
this.logPath = logPath;
|
|
22
|
+
this.fs = options.fs;
|
|
23
|
+
this.codec = options.codec;
|
|
24
|
+
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async log(entry: Omit<DecisionEntry, "ts">): Promise<void> {
|
|
28
|
+
const full: DecisionEntry = {
|
|
29
|
+
ts: new Date().toISOString(),
|
|
30
|
+
...entry,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const line = this.codec.encode(full) + "\n";
|
|
34
|
+
|
|
35
|
+
const dir = path.dirname(this.logPath);
|
|
36
|
+
await this.fs.mkdir(dir, { recursive: true });
|
|
37
|
+
await this.fs.appendFile(this.logPath, line, "utf-8");
|
|
38
|
+
|
|
39
|
+
await this.rotateIfNeeded();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async rotateIfNeeded(): Promise<void> {
|
|
43
|
+
let stat: { size: number };
|
|
44
|
+
try {
|
|
45
|
+
stat = await this.fs.stat(this.logPath);
|
|
46
|
+
} catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (stat.size <= this.maxBytes) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const content = await this.fs.readFile(this.logPath, "utf-8");
|
|
55
|
+
const lines = content.trimEnd().split("\n");
|
|
56
|
+
|
|
57
|
+
// Keep the most recent half of lines to get well under the limit
|
|
58
|
+
let kept = lines.slice(Math.ceil(lines.length / 2));
|
|
59
|
+
let result = kept.join("\n") + "\n";
|
|
60
|
+
|
|
61
|
+
// If still over, keep halving
|
|
62
|
+
while (Buffer.byteLength(result, "utf-8") > this.maxBytes && kept.length > 1) {
|
|
63
|
+
kept = kept.slice(Math.ceil(kept.length / 2));
|
|
64
|
+
result = kept.join("\n") + "\n";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await this.fs.writeFile(this.logPath, result, "utf-8");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface DecisionEntry {
|
|
2
|
+
readonly ts: string;
|
|
3
|
+
readonly command: string;
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly projection: string;
|
|
6
|
+
readonly reason: string;
|
|
7
|
+
readonly lines: number;
|
|
8
|
+
readonly bytes: number;
|
|
9
|
+
readonly estimatedBytesAvoided?: number;
|
|
10
|
+
readonly sessionDepth?: string;
|
|
11
|
+
readonly tripwire?: string | null;
|
|
12
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { extractOutline } from "../parser/outline.js";
|
|
2
|
+
import type { OutlineEntry, JumpEntry } from "../parser/types.js";
|
|
3
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
4
|
+
|
|
5
|
+
export interface FileOutlineResult {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
path: string;
|
|
8
|
+
outline: OutlineEntry[];
|
|
9
|
+
jumpTable: JumpEntry[];
|
|
10
|
+
partial?: boolean | undefined;
|
|
11
|
+
error?: string | undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function fileOutline(
|
|
15
|
+
filePath: string,
|
|
16
|
+
opts: { fs: FileSystem },
|
|
17
|
+
): Promise<FileOutlineResult> {
|
|
18
|
+
let content: string;
|
|
19
|
+
try {
|
|
20
|
+
content = await opts.fs.readFile(filePath, "utf-8");
|
|
21
|
+
} catch {
|
|
22
|
+
return {
|
|
23
|
+
path: filePath,
|
|
24
|
+
outline: [],
|
|
25
|
+
jumpTable: [],
|
|
26
|
+
error: "File not found",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = extractOutline(content);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
path: filePath,
|
|
34
|
+
outline: result.entries,
|
|
35
|
+
jumpTable: result.jumpTable ?? [],
|
|
36
|
+
...(result.partial === true ? { partial: true } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
3
|
+
import { getChangedFiles, getFileAtRef } from "../git/diff.js";
|
|
4
|
+
import { detectLang } from "../parser/lang.js";
|
|
5
|
+
import { extractOutline } from "../parser/outline.js";
|
|
6
|
+
import { diffOutlines, OutlineDiff } from "../parser/diff.js";
|
|
7
|
+
|
|
8
|
+
export interface FileDiff {
|
|
9
|
+
path: string;
|
|
10
|
+
status: "modified" | "added" | "deleted";
|
|
11
|
+
summary: string;
|
|
12
|
+
diff: OutlineDiff;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildSummary(filePath: string, status: string, diff: OutlineDiff): string {
|
|
16
|
+
const parts: string[] = [];
|
|
17
|
+
if (diff.added.length > 0) parts.push(`+${String(diff.added.length)} added`);
|
|
18
|
+
if (diff.removed.length > 0) parts.push(`-${String(diff.removed.length)} removed`);
|
|
19
|
+
if (diff.changed.length > 0) parts.push(`~${String(diff.changed.length)} changed`);
|
|
20
|
+
if (diff.unchangedCount > 0) parts.push(`=${String(diff.unchangedCount)} unchanged`);
|
|
21
|
+
const stats = parts.length > 0 ? parts.join(", ") : "no structural changes";
|
|
22
|
+
return `${filePath} | ${status} | ${stats}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GraftDiffResult {
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
base: string;
|
|
28
|
+
head: string;
|
|
29
|
+
files: FileDiff[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface GraftDiffOptions {
|
|
33
|
+
cwd: string;
|
|
34
|
+
fs: FileSystem;
|
|
35
|
+
base?: string | undefined;
|
|
36
|
+
head?: string | undefined;
|
|
37
|
+
path?: string | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function emptyDiff(): OutlineDiff {
|
|
41
|
+
return new OutlineDiff({ added: [], removed: [], changed: [], unchangedCount: 0 });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute structural diffs between two git refs (or working tree).
|
|
47
|
+
*/
|
|
48
|
+
export function graftDiff(opts: GraftDiffOptions): GraftDiffResult {
|
|
49
|
+
const base = opts.base ?? "HEAD";
|
|
50
|
+
const headLabel = opts.head ?? "working tree";
|
|
51
|
+
const cwd = opts.cwd;
|
|
52
|
+
|
|
53
|
+
let changedFiles = getChangedFiles({
|
|
54
|
+
cwd,
|
|
55
|
+
base,
|
|
56
|
+
head: opts.head,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Filter by path if provided
|
|
60
|
+
if (opts.path !== undefined) {
|
|
61
|
+
changedFiles = changedFiles.filter((f) => f === opts.path);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const files: FileDiff[] = [];
|
|
65
|
+
|
|
66
|
+
for (const filePath of changedFiles) {
|
|
67
|
+
const lang = detectLang(filePath);
|
|
68
|
+
|
|
69
|
+
// Get content at base (null = file absent at ref)
|
|
70
|
+
const baseContent = getFileAtRef(base, filePath, cwd);
|
|
71
|
+
|
|
72
|
+
// Get content at head (null = file absent at ref/worktree)
|
|
73
|
+
let headContent: string | null;
|
|
74
|
+
if (opts.head !== undefined) {
|
|
75
|
+
headContent = getFileAtRef(opts.head, filePath, cwd);
|
|
76
|
+
} else {
|
|
77
|
+
const fullPath = path.join(cwd, filePath);
|
|
78
|
+
try {
|
|
79
|
+
headContent = opts.fs.readFileSync(fullPath, "utf-8");
|
|
80
|
+
} catch {
|
|
81
|
+
headContent = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Determine status
|
|
86
|
+
let status: "modified" | "added" | "deleted";
|
|
87
|
+
if (baseContent === null && headContent === null) {
|
|
88
|
+
// Both absent — file listed in diff but unreadable at both refs. Skip.
|
|
89
|
+
continue;
|
|
90
|
+
} else if (baseContent === null) {
|
|
91
|
+
status = "added";
|
|
92
|
+
} else if (headContent === null) {
|
|
93
|
+
status = "deleted";
|
|
94
|
+
} else {
|
|
95
|
+
status = "modified";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Compute structural diff (only for supported languages)
|
|
99
|
+
if (lang === null) {
|
|
100
|
+
const empty = emptyDiff();
|
|
101
|
+
files.push({ path: filePath, status, summary: buildSummary(filePath, status, empty), diff: empty });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const baseOutline = baseContent !== null
|
|
106
|
+
? extractOutline(baseContent, lang).entries
|
|
107
|
+
: [];
|
|
108
|
+
const headOutline = headContent !== null
|
|
109
|
+
? extractOutline(headContent, lang).entries
|
|
110
|
+
: [];
|
|
111
|
+
|
|
112
|
+
const diff = diffOutlines(baseOutline, headOutline);
|
|
113
|
+
files.push({ path: filePath, status, summary: buildSummary(filePath, status, diff), diff });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { base, head: headLabel, files };
|
|
117
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
2
|
+
|
|
3
|
+
const MAX_RANGE_LINES = 250;
|
|
4
|
+
|
|
5
|
+
export interface ReadRangeResult {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
path: string;
|
|
8
|
+
content?: string | undefined;
|
|
9
|
+
startLine?: number | undefined;
|
|
10
|
+
endLine?: number | undefined;
|
|
11
|
+
reason?: string | undefined;
|
|
12
|
+
truncated?: boolean | undefined;
|
|
13
|
+
clipped?: boolean | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function readRange(
|
|
17
|
+
filePath: string,
|
|
18
|
+
start: number,
|
|
19
|
+
end: number,
|
|
20
|
+
opts: { fs: FileSystem },
|
|
21
|
+
): Promise<ReadRangeResult> {
|
|
22
|
+
let raw: string;
|
|
23
|
+
try {
|
|
24
|
+
raw = await opts.fs.readFile(filePath, "utf-8");
|
|
25
|
+
} catch {
|
|
26
|
+
return { path: filePath, reason: "NOT_FOUND" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (start > end) {
|
|
30
|
+
return { path: filePath, reason: "INVALID_RANGE" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const allLines = raw.split("\n");
|
|
34
|
+
const totalLines = allLines.length;
|
|
35
|
+
|
|
36
|
+
let effectiveEnd = end;
|
|
37
|
+
let truncated = false;
|
|
38
|
+
let clipped = false;
|
|
39
|
+
|
|
40
|
+
// Check if range exceeds 250 lines
|
|
41
|
+
if (effectiveEnd - start + 1 > MAX_RANGE_LINES) {
|
|
42
|
+
effectiveEnd = start + MAX_RANGE_LINES - 1;
|
|
43
|
+
truncated = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Clip to EOF
|
|
47
|
+
if (effectiveEnd > totalLines) {
|
|
48
|
+
effectiveEnd = totalLines;
|
|
49
|
+
clipped = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extract lines (1-based to 0-based)
|
|
53
|
+
const selected = allLines.slice(start - 1, effectiveEnd);
|
|
54
|
+
const content = selected.join("\n");
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
path: filePath,
|
|
58
|
+
content,
|
|
59
|
+
startLine: start,
|
|
60
|
+
endLine: effectiveEnd,
|
|
61
|
+
...(truncated ? { truncated: true, reason: "RANGE_EXCEEDED" } : {}),
|
|
62
|
+
...(clipped && !truncated ? { clipped: true } : {}),
|
|
63
|
+
...(clipped && truncated ? { clipped: true } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { evaluatePolicy } from "../policy/evaluate.js";
|
|
2
|
+
import { ContentResult, RefusedResult } from "../policy/types.js";
|
|
3
|
+
import type { SessionDepth } from "../policy/types.js";
|
|
4
|
+
import { extractOutline } from "../parser/outline.js";
|
|
5
|
+
import type { OutlineEntry, JumpEntry } from "../parser/types.js";
|
|
6
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
7
|
+
import type { JsonCodec } from "../ports/codec.js";
|
|
8
|
+
|
|
9
|
+
export interface SafeReadResult {
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
path: string;
|
|
12
|
+
projection: "content" | "outline" | "refused" | "error";
|
|
13
|
+
reason: string;
|
|
14
|
+
content?: string | undefined;
|
|
15
|
+
outline?: OutlineEntry[] | undefined;
|
|
16
|
+
jumpTable?: JumpEntry[] | undefined;
|
|
17
|
+
estimatedBytesAvoided?: number | undefined;
|
|
18
|
+
next?: string[] | undefined;
|
|
19
|
+
actual?: { lines: number; bytes: number } | undefined;
|
|
20
|
+
thresholds?: { lines: number; bytes: number } | undefined;
|
|
21
|
+
sessionDepth?: SessionDepth | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SafeReadOptions {
|
|
25
|
+
fs: FileSystem;
|
|
26
|
+
codec: JsonCodec;
|
|
27
|
+
content?: string | undefined;
|
|
28
|
+
intent?: string | undefined;
|
|
29
|
+
sessionDepth?: SessionDepth | undefined;
|
|
30
|
+
budgetRemaining?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function safeRead(
|
|
34
|
+
filePath: string,
|
|
35
|
+
options: SafeReadOptions,
|
|
36
|
+
): Promise<SafeReadResult> {
|
|
37
|
+
let content: string;
|
|
38
|
+
let bytes: number;
|
|
39
|
+
|
|
40
|
+
if (options.content !== undefined) {
|
|
41
|
+
content = options.content;
|
|
42
|
+
bytes = Buffer.byteLength(content, "utf-8");
|
|
43
|
+
} else {
|
|
44
|
+
let raw: Buffer;
|
|
45
|
+
try {
|
|
46
|
+
raw = await options.fs.readFile(filePath);
|
|
47
|
+
} catch {
|
|
48
|
+
return {
|
|
49
|
+
path: filePath,
|
|
50
|
+
projection: "error",
|
|
51
|
+
reason: "NOT_FOUND",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
content = raw.toString("utf-8");
|
|
55
|
+
bytes = raw.byteLength;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lines = content.split("\n").length;
|
|
59
|
+
|
|
60
|
+
const policy = evaluatePolicy(
|
|
61
|
+
{ path: filePath, lines, bytes },
|
|
62
|
+
{ sessionDepth: options.sessionDepth, budgetRemaining: options.budgetRemaining },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const base: SafeReadResult = {
|
|
66
|
+
path: filePath,
|
|
67
|
+
projection: policy.projection,
|
|
68
|
+
reason: policy.reason,
|
|
69
|
+
actual: policy.actual,
|
|
70
|
+
thresholds: policy.thresholds,
|
|
71
|
+
...(policy.sessionDepth !== undefined ? { sessionDepth: policy.sessionDepth } : {}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (policy instanceof ContentResult) {
|
|
75
|
+
return { ...base, content };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (policy instanceof RefusedResult) {
|
|
79
|
+
return {
|
|
80
|
+
...base,
|
|
81
|
+
next: [...policy.next],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// projection === "outline"
|
|
86
|
+
const outlineResult = extractOutline(content);
|
|
87
|
+
const outlineJson = options.codec.encode(outlineResult);
|
|
88
|
+
const estimatedBytesAvoided = bytes - Buffer.byteLength(outlineJson, "utf-8");
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
outline: outlineResult.entries,
|
|
93
|
+
jumpTable: outlineResult.jumpTable,
|
|
94
|
+
estimatedBytesAvoided: estimatedBytesAvoided > 0 ? estimatedBytesAvoided : 0,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
3
|
+
|
|
4
|
+
const MAX_STATE_BYTES = 8192;
|
|
5
|
+
const STATE_FILENAME = "state.md";
|
|
6
|
+
|
|
7
|
+
export async function stateSave(
|
|
8
|
+
content: string,
|
|
9
|
+
opts: { graftDir: string; fs: FileSystem },
|
|
10
|
+
): Promise<{ ok: boolean; reason?: string | undefined }> {
|
|
11
|
+
const bytes = Buffer.byteLength(content, "utf-8");
|
|
12
|
+
if (bytes > MAX_STATE_BYTES) {
|
|
13
|
+
return { ok: false, reason: `State exceeds 8 KB limit (${String(bytes)} bytes)` };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const filePath = path.join(opts.graftDir, STATE_FILENAME);
|
|
17
|
+
await opts.fs.mkdir(opts.graftDir, { recursive: true });
|
|
18
|
+
await opts.fs.writeFile(filePath, content, "utf-8");
|
|
19
|
+
|
|
20
|
+
return { ok: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function stateLoad(
|
|
24
|
+
opts: { graftDir: string; fs: FileSystem },
|
|
25
|
+
): Promise<{ content: string | null }> {
|
|
26
|
+
const filePath = path.join(opts.graftDir, STATE_FILENAME);
|
|
27
|
+
try {
|
|
28
|
+
const content = await opts.fs.readFile(filePath, "utf-8");
|
|
29
|
+
return { content };
|
|
30
|
+
} catch {
|
|
31
|
+
return { content: null };
|
|
32
|
+
}
|
|
33
|
+
}
|