@hydraharness/harness-fs-review 0.1.1-rc.5

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # File review
2
+
3
+ `@hydraharness/harness-fs-review` provides `ctx.fileReview`: chronological, session-owned Write/Edit/Create evidence, Keep, and hash-guarded Undo. [PI source map](PI-SOURCE-MAP.md) records the implementation studied and explicit differences.
4
+
5
+ Its client types also describe read-only `WorkspaceReview` Git comparisons. The host resolves these comparisons from the session cwd; this package owns their shared mode, file, hunk and metadata types, while Keep and Undo remain exclusive to persisted `ReviewChange` records.
6
+
7
+ The local filesystem emits `fs/mutate` inside its existing per-file lock, after the sandbox fence. The review plugin captures raw bytes before delegating and commits SHA-256 hashes, attribution, counts, and bounded hunks outside the workspace. Git operations cannot erase these records. Keep marks an applied change reviewed and retains Undo; it does not write the workspace or change tool permissions.
8
+
9
+ Undo verifies the owner, workspace, canonical path, snapshot hash and current after hash. New files are removed. Sequential edits must be undone in reverse order when their post-edit states differ. Interrupted pending/undoing records remain unavailable and never trigger automatic restoration. Records use the tool execution's session, tool call, root call, turn/step sequence, preset and parent session metadata.
10
+
11
+ ## Configuration
12
+
13
+ `directory` defaults to `$HYDRA_HOME/review-changes`. It must be absolute and outside the tracked workspace. `snapshotMaxBytes` defaults to 16 MiB inclusive; larger existing files have hashes but no reversible before copy. `diffMaxBytes` defaults to 512 KiB per side, `diffMaxLines` to 4,000, and `diffMaxCells` to 2,000,000. Preview limits do not discard snapshot bytes. Binary or invalid UTF-8 content has no text preview.
14
+
15
+ ## Storage and actions
16
+
17
+ Each session directory is the SHA-256 of its session id, containing UUID change directories with a raw optional `before` file and versioned `meta.json`. `ReviewChange` owns the session/call/turn/step/preset lineage, relative path, hashes, status, state, and stored hunks. `ChangeId` identifies this directory. `ReviewOutcome` returns the record plus kept, rolledBack, alreadyRolledBack, conflict or unavailable. The [source map](PI-SOURCE-MAP.md) documents all PI comparisons.
18
+
19
+ Startup removes review directories for sessions absent from persistence and the live session store. A failed mutation discards evidence only when the target still equals the before hash. Surviving sessions retain their records without age expiry; no session-deletion API is available for immediate deletion cleanup. No automatic recovery writes workspace files.
20
+
21
+ ## Model Experience
22
+
23
+ None, as review evidence is user-facing storage keyed to existing session/tool identities.
24
+
25
+ #### KV Cache effect
26
+
27
+ None; review records are not model request inputs.
28
+
29
+ ## Known Limitations and Deferred Work
30
+
31
+ - Shell commands, external editors, Delete/Move tools and non-local filesystem providers do not produce reversible review history. External edits are detected by Undo's current hash.
32
+ - Cooperating local writes share a provider lock; an unrelated process can still race the final check and OS publication. The service retains uncertain operations for inspection instead of guessing after a crash. Snapshot preparation failure prevents an untracked mutation, a deliberate difference from PI's best-effort capture.
33
+ - Metadata replacement is atomic, but the shared atomic-write helper does not fsync metadata or directory entries. Power-loss durability is not guaranteed. A stale action lock after process failure requires operator inspection; the service never steals it.
package/lib/client.js ADDED
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ //#region lib/types/client.js
3
+ /** Browser-safe validation for durable/wire review records. @module */
4
+ const hash = z.string().regex(/^[a-f0-9]{64}$/).nullable();
5
+ /** Reject unknown record versions, unsafe paths, and malformed snapshot evidence. */
6
+ const reviewChangeSchema = z.object({
7
+ version: z.literal(1),
8
+ id: z.uuid(),
9
+ sessionId: z.string().min(1),
10
+ callId: z.string().min(1),
11
+ rootCallId: z.string().min(1),
12
+ toolName: z.string().min(1),
13
+ seq: z.number().int().nonnegative(),
14
+ turnSeq: z.number().int().nonnegative().nullable(),
15
+ stepSeq: z.number().int().nonnegative().nullable(),
16
+ parentSessionId: z.string().nullable(),
17
+ agentPreset: z.string().nullable(),
18
+ createdAt: z.number().int().nonnegative(),
19
+ workspace: z.string().min(1),
20
+ path: z.string().min(1).refine((p) => !p.startsWith("/") && !p.includes("\\") && !p.includes(":") && !p.split("/").includes("..")),
21
+ operation: z.enum(["write", "edit"]),
22
+ status: z.enum([
23
+ "added",
24
+ "modified",
25
+ "deleted"
26
+ ]),
27
+ state: z.enum([
28
+ "pending",
29
+ "active",
30
+ "kept",
31
+ "undoing",
32
+ "rolledBack"
33
+ ]),
34
+ beforeHash: hash,
35
+ afterHash: hash,
36
+ reversible: z.boolean(),
37
+ binary: z.boolean(),
38
+ truncated: z.boolean(),
39
+ additions: z.number().int().nonnegative(),
40
+ deletions: z.number().int().nonnegative(),
41
+ hunks: z.array(z.object({
42
+ header: z.string(),
43
+ lines: z.array(z.string())
44
+ }))
45
+ }).strict();
46
+ //#endregion
47
+ export { reviewChangeSchema };
package/lib/index.js ADDED
@@ -0,0 +1,361 @@
1
+ import { reviewChangeSchema } from "./client.js";
2
+ import { Service } from "@hydraharness/cordis";
3
+ import z from "@hydraharness/schemastery";
4
+ import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { mkdir, open, readFile, readdir, rm } from "node:fs/promises";
7
+ import { isAbsolute, join, relative, sep } from "node:path";
8
+ import { withFileLock, writeFileAtomic } from "@hydraharness/harness-atomic-write";
9
+ import { resolveHydraHome } from "@hydraharness/harness-home-paths";
10
+ import { FsError } from "@hydraharness/harness-fs";
11
+ import { effectiveSandboxMode } from "@hydraharness/harness-sandbox-policy";
12
+ import { structuredPatch } from "diff";
13
+ //#region lib/types/diff.js
14
+ /** Bounded, stored diff evidence with PI Desktop's preview limits. @module */
15
+ /**
16
+ * Compute persisted counts/hunks without re-reading the workspace on review.
17
+ * @param before - bounded raw before bytes; null marks unavailable content.
18
+ * @param after - bounded raw after bytes; null marks unavailable content.
19
+ * @param limits - inclusive per-side limits and diff work bound.
20
+ * @returns preview evidence or an explicit binary/truncated marker.
21
+ */
22
+ function preview(before, after, limits) {
23
+ const empty = {
24
+ binary: false,
25
+ truncated: false,
26
+ additions: 0,
27
+ deletions: 0,
28
+ hunks: []
29
+ };
30
+ if (!before || !after || before.length > limits.diffMaxBytes || after.length > limits.diffMaxBytes) return {
31
+ ...empty,
32
+ truncated: true
33
+ };
34
+ if (before.includes(0) || after.includes(0)) return {
35
+ ...empty,
36
+ binary: true
37
+ };
38
+ let oldText;
39
+ let newText;
40
+ try {
41
+ const decoder = new TextDecoder("utf-8", {
42
+ fatal: true,
43
+ ignoreBOM: true
44
+ });
45
+ oldText = decoder.decode(before).replaceAll("\r\n", "\n");
46
+ newText = decoder.decode(after).replaceAll("\r\n", "\n");
47
+ } catch (_invalidUtf8) {
48
+ return {
49
+ ...empty,
50
+ binary: true
51
+ };
52
+ }
53
+ const count = (text) => text === "" ? 0 : text.split("\n").length - Number(text.endsWith("\n"));
54
+ const oldLines = count(oldText);
55
+ const newLines = count(newText);
56
+ if (oldLines > limits.diffMaxLines || newLines > limits.diffMaxLines || (oldLines + 1) * (newLines + 1) > limits.diffMaxCells) return {
57
+ ...empty,
58
+ truncated: true
59
+ };
60
+ const patch = structuredPatch("", "", oldText, newText, void 0, void 0, { context: 3 });
61
+ let additions = 0;
62
+ let deletions = 0;
63
+ const hunks = patch.hunks.map((hunk) => {
64
+ for (const line of hunk.lines) {
65
+ if (line.startsWith("+")) additions++;
66
+ if (line.startsWith("-")) deletions++;
67
+ }
68
+ return {
69
+ header: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
70
+ lines: hunk.lines
71
+ };
72
+ });
73
+ return {
74
+ ...empty,
75
+ additions,
76
+ deletions,
77
+ hunks
78
+ };
79
+ }
80
+ //#endregion
81
+ //#region lib/types/index.js
82
+ /** Agent-owned snapshots and hash-guarded review actions over the filesystem provider. @module */
83
+ /** Durable review service; records are scoped by exact session and random change id. */
84
+ var FileReview = class extends Service {
85
+ static inject = [
86
+ "fs",
87
+ "tools",
88
+ "sessions",
89
+ "sessionPersistence"
90
+ ];
91
+ static Config = z.object({
92
+ directory: z.string().default(join(resolveHydraHome(), "review-changes")),
93
+ snapshotMaxBytes: z.number().step(1).min(1).default(16 * 1024 * 1024),
94
+ diffMaxBytes: z.number().step(1).min(1).default(512 * 1024),
95
+ diffMaxLines: z.number().step(1).min(1).default(4e3),
96
+ diffMaxCells: z.number().step(1).min(1).default(2e6)
97
+ });
98
+ executions = new AsyncLocalStorage();
99
+ /** Schema-resolved storage location and resource limits. */
100
+ config;
101
+ constructor(ctx, options) {
102
+ super(ctx, "fileReview");
103
+ const config = this.config = {
104
+ directory: options.directory ?? join(resolveHydraHome(), "review-changes"),
105
+ snapshotMaxBytes: options.snapshotMaxBytes ?? 16 * 1024 * 1024,
106
+ diffMaxBytes: options.diffMaxBytes ?? 512 * 1024,
107
+ diffMaxLines: options.diffMaxLines ?? 4e3,
108
+ diffMaxCells: options.diffMaxCells ?? 2e6
109
+ };
110
+ if (!isAbsolute(config.directory)) throw new Error("file-review directory must be absolute");
111
+ ctx.effect(async function* () {
112
+ await this.sweep();
113
+ }.bind(this));
114
+ ctx.on("tools/execute", (exec, next) => this.executions.run(exec, next));
115
+ ctx.on("fs/mutate", async (target, operation, next) => {
116
+ const exec = this.executions.getStore();
117
+ const session = exec?.agent?.session;
118
+ if (!exec || !session?.header.cwd || !["write", "edit"].includes(exec.name)) return next();
119
+ const root = await ctx.fs.resolve(session.header.cwd);
120
+ if (!ctx.fs.contains(root, target) || root.targetKey === target.targetKey) return next();
121
+ const storage = await ctx.fs.resolve(config.directory);
122
+ if (ctx.fs.contains(root, storage)) throw new Error("review snapshots must be stored outside the workspace");
123
+ const before = await ctx.fs.snapshot(target, config.snapshotMaxBytes);
124
+ const id = randomUUID();
125
+ const dir = this.changeDir(session.id, id);
126
+ await mkdir(dir, {
127
+ recursive: true,
128
+ mode: 448
129
+ });
130
+ if (before.bytes) {
131
+ const file = await open(join(dir, "before"), "wx", 384);
132
+ try {
133
+ await file.writeFile(before.bytes);
134
+ await file.sync();
135
+ } finally {
136
+ await file.close();
137
+ }
138
+ }
139
+ const change = {
140
+ version: 1,
141
+ id,
142
+ sessionId: session.id,
143
+ callId: exec.callId,
144
+ rootCallId: exec.rootCallId,
145
+ toolName: exec.name,
146
+ seq: session.seq,
147
+ turnSeq: session.events.findLast((e) => e.type === "turn/start")?.seq ?? null,
148
+ stepSeq: session.events.findLast((e) => e.type === "step/start")?.seq ?? null,
149
+ parentSessionId: session.header.origin === "subagent" ? session.header.parentSession ?? null : null,
150
+ agentPreset: session.header.agentPreset ?? null,
151
+ createdAt: Date.now(),
152
+ workspace: ctx.fs.processPath(root),
153
+ path: relative(ctx.fs.processPath(root), ctx.fs.processPath(target)).split(sep).join("/"),
154
+ operation,
155
+ status: before.hash === null ? "added" : "modified",
156
+ state: "pending",
157
+ beforeHash: before.hash,
158
+ afterHash: null,
159
+ reversible: false,
160
+ binary: false,
161
+ truncated: true,
162
+ additions: 0,
163
+ deletions: 0,
164
+ hunks: []
165
+ };
166
+ await this.save(change);
167
+ let result;
168
+ try {
169
+ result = await next();
170
+ } catch (error) {
171
+ if ((await ctx.fs.snapshot(target, 0)).hash === before.hash) await rm(dir, {
172
+ recursive: true,
173
+ force: true
174
+ });
175
+ throw error;
176
+ }
177
+ const after = await ctx.fs.snapshot(target, config.diffMaxBytes);
178
+ Object.assign(change, {
179
+ afterHash: after.hash,
180
+ state: "active",
181
+ status: after.hash === null ? "deleted" : change.status,
182
+ reversible: before.hash === null ? after.hash !== null : before.bytes !== null,
183
+ ...preview(before.hash === null ? new Uint8Array() : before.bytes, after.hash === null ? new Uint8Array() : after.bytes, config)
184
+ });
185
+ await this.save(change);
186
+ return result;
187
+ });
188
+ }
189
+ sessionDir(id) {
190
+ return join(this.config.directory, createHash("sha256").update(id).digest("hex"));
191
+ }
192
+ changeDir(sessionId, id) {
193
+ if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("invalid review change id");
194
+ return join(this.sessionDir(sessionId), id);
195
+ }
196
+ async save(change) {
197
+ const meta = join(this.changeDir(change.sessionId, change.id), "meta.json");
198
+ await withFileLock(meta, () => this.saveUnlocked(change, meta));
199
+ this.ctx.emit("file-review/changed", change.sessionId);
200
+ }
201
+ async saveUnlocked(change, meta) {
202
+ await writeFileAtomic(meta, JSON.stringify(reviewChangeSchema.parse(change)), {
203
+ mode: 384,
204
+ dirMode: 448
205
+ });
206
+ }
207
+ async read(sessionId, id) {
208
+ const meta = join(this.changeDir(sessionId, id), "meta.json");
209
+ return withFileLock(meta, () => this.readUnlocked(sessionId, id, meta));
210
+ }
211
+ async readUnlocked(sessionId, id, meta) {
212
+ const change = reviewChangeSchema.parse(JSON.parse(await readFile(meta, "utf8")));
213
+ if (change.sessionId !== sessionId || change.id !== id) throw new Error("review change does not belong to this session");
214
+ return change;
215
+ }
216
+ /**
217
+ * Read chronological snapshot history without consulting Git or current file contents.
218
+ * @param sessionId - exact owner; forks do not inherit mutation authority.
219
+ * @returns every committed record, including unavailable interrupted operations.
220
+ */
221
+ async list(sessionId) {
222
+ let entries;
223
+ try {
224
+ entries = await readdir(this.sessionDir(sessionId), { withFileTypes: true });
225
+ } catch (error) {
226
+ if (error.code === "ENOENT") return [];
227
+ throw error;
228
+ }
229
+ return (await Promise.all(entries.filter((e) => e.isDirectory()).map(async (e) => {
230
+ try {
231
+ return await this.read(sessionId, e.name);
232
+ } catch (error) {
233
+ if (error.code === "ENOENT") return void 0;
234
+ throw error;
235
+ }
236
+ }))).filter((record) => record !== void 0).sort((a, b) => a.seq - b.seq || a.createdAt - b.createdAt || a.id.localeCompare(b.id));
237
+ }
238
+ /**
239
+ * Mark a change kept, retaining its evidence and ability to undo.
240
+ * @param sessionId - owning session id.
241
+ * @param id - exact change id.
242
+ * @returns the committed review state.
243
+ */
244
+ async keep(sessionId, id) {
245
+ return this.act(sessionId, id, false);
246
+ }
247
+ /**
248
+ * Undo only the recorded post-edit state; verifies snapshot integrity before writing.
249
+ * @param sessionId - owning session id.
250
+ * @param id - exact change id.
251
+ * @returns success, conflict, or unavailable; unavailable/uncertain records never write.
252
+ */
253
+ async undo(sessionId, id) {
254
+ return this.act(sessionId, id, true);
255
+ }
256
+ async act(sessionId, id, undo) {
257
+ const meta = join(this.changeDir(sessionId, id), "meta.json");
258
+ return withFileLock(meta, async () => {
259
+ const change = await this.readUnlocked(sessionId, id, meta);
260
+ if (change.state === "rolledBack") return {
261
+ status: "alreadyRolledBack",
262
+ change
263
+ };
264
+ if (change.state === "pending" || change.state === "undoing") return {
265
+ status: "unavailable",
266
+ change
267
+ };
268
+ const { meta: header, events } = await this.ctx.sessionPersistence.inspect(sessionId);
269
+ const root = header.cwd === void 0 ? void 0 : await this.ctx.fs.resolve(header.cwd);
270
+ if (!root || this.ctx.fs.processPath(root) !== change.workspace) throw new Error("review workspace does not match its session");
271
+ if (!undo) {
272
+ change.state = "kept";
273
+ await this.saveUnlocked(change, meta);
274
+ this.ctx.emit("file-review/changed", change.sessionId);
275
+ return {
276
+ status: "kept",
277
+ change
278
+ };
279
+ }
280
+ if (!change.reversible) return {
281
+ status: "unavailable",
282
+ change
283
+ };
284
+ const target = await this.ctx.fs.resolve(change.path, { cwd: change.workspace });
285
+ if (!this.ctx.fs.contains(root, target) || root.targetKey === target.targetKey || relative(change.workspace, this.ctx.fs.processPath(target)).split(sep).join("/") !== change.path) throw new Error("review path changed or escaped its workspace");
286
+ let bytes = null;
287
+ if (change.beforeHash !== null) {
288
+ const snapshot = await open(join(this.changeDir(sessionId, id), "before"), "r");
289
+ try {
290
+ const info = await snapshot.stat();
291
+ if (!info.isFile() || info.size > this.config.snapshotMaxBytes) throw new Error("review snapshot exceeds size limit or is not a regular file");
292
+ const size = info.size;
293
+ const buffer = Buffer.alloc(size + 1);
294
+ let length = 0;
295
+ while (length < buffer.length) {
296
+ const { bytesRead } = await snapshot.read(buffer, length, buffer.length - length, null);
297
+ if (bytesRead === 0) break;
298
+ length += bytesRead;
299
+ }
300
+ if (length !== size) throw new Error("review snapshot changed while reading");
301
+ bytes = buffer.subarray(0, size);
302
+ } finally {
303
+ await snapshot.close();
304
+ }
305
+ if (createHash("sha256").update(bytes).digest("hex") !== change.beforeHash) throw new Error("review snapshot is corrupt");
306
+ }
307
+ const priorState = change.state;
308
+ change.state = "undoing";
309
+ await this.saveUnlocked(change, meta);
310
+ this.ctx.emit("file-review/changed", change.sessionId);
311
+ const policyService = this.ctx.get("sandboxPolicy");
312
+ const policy = policyService === void 0 ? void 0 : {
313
+ mode: effectiveSandboxMode(events) ?? policyService.defaultMode,
314
+ workspaceRoot: change.workspace
315
+ };
316
+ let restored;
317
+ try {
318
+ restored = await this.ctx.fs.restoreSnapshot(target, bytes, change.afterHash, policy);
319
+ } catch (error) {
320
+ if (error instanceof FsError && error.code === "FS_SANDBOX_DENIED") {
321
+ change.state = priorState;
322
+ await this.saveUnlocked(change, meta);
323
+ this.ctx.emit("file-review/changed", change.sessionId);
324
+ }
325
+ throw error;
326
+ }
327
+ change.state = restored ? "rolledBack" : priorState;
328
+ await this.saveUnlocked(change, meta);
329
+ this.ctx.emit("file-review/changed", change.sessionId);
330
+ return {
331
+ status: restored ? "rolledBack" : "conflict",
332
+ change
333
+ };
334
+ });
335
+ }
336
+ /**
337
+ * Remove orphan session directories; surviving sessions retain all review evidence.
338
+ * @returns completion after stale directories are removed; never restores workspace files.
339
+ */
340
+ async sweep() {
341
+ let entries;
342
+ try {
343
+ entries = await readdir(this.config.directory, { withFileTypes: true });
344
+ } catch (error) {
345
+ if (error.code === "ENOENT") return;
346
+ throw error;
347
+ }
348
+ const live = new Set((await this.ctx.sessionPersistence.list()).map((h) => this.sessionDir(h.id)));
349
+ for (const session of this.ctx.sessions.list()) live.add(this.sessionDir(session.id));
350
+ for (const entry of entries) {
351
+ if (!entry.isDirectory() || !/^[a-f0-9]{64}$/.test(entry.name)) continue;
352
+ const path = join(this.config.directory, entry.name);
353
+ if (!live.has(path)) await rm(path, {
354
+ recursive: true,
355
+ force: true
356
+ });
357
+ }
358
+ }
359
+ };
360
+ //#endregion
361
+ export { FileReview, FileReview as default };
@@ -0,0 +1,14 @@
1
+ //#region lib/types/invariant.js
2
+ /** Companion plugin identity. */
3
+ const name = "fs-review-invariant";
4
+ /** Registration owner. */
5
+ const inject = ["invariants"];
6
+ const install = () => {};
7
+ /**
8
+ * Register the package's invariant ownership.
9
+ * @param ctx - companion context.
10
+ * @returns registration disposer.
11
+ */
12
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register("@hydraharness/harness-fs-review", install));
13
+ //#endregion
14
+ export { apply, inject, name };
@@ -0,0 +1,18 @@
1
+ /** Browser-safe validation for durable/wire review records. @module */
2
+ import { z } from 'zod';
3
+ import type { ReviewChange } from './types.ts';
4
+ import type { SessionId } from '@hydraharness/harness-session/types';
5
+ export type { ChangeId, ReviewChange, ReviewHunk, ReviewMode, ReviewOutcome, WorkspaceReview, WorkspaceReviewFile } from './types.ts';
6
+ declare module '@hydraharness/cordis' {
7
+ interface Events {
8
+ /**
9
+ * A durable record was created or changed; clients refetch its history.
10
+ * @param sessionId - session owning the committed record.
11
+ * @mode emit
12
+ */
13
+ 'file-review/changed'(sessionId: SessionId): void;
14
+ }
15
+ }
16
+ /** Reject unknown record versions, unsafe paths, and malformed snapshot evidence. */
17
+ export declare const reviewChangeSchema: z.ZodType<ReviewChange>;
18
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,21 @@
1
+ import type { ReviewChange } from './types.ts';
2
+ /** Deployment limits for snapshot storage and preview work. */
3
+ export interface ReviewLimits {
4
+ /** Inclusive before-snapshot byte cap; larger files retain hashes only. */
5
+ snapshotMaxBytes: number;
6
+ /** Inclusive text-preview byte cap for each side. */
7
+ diffMaxBytes: number;
8
+ /** Inclusive text-preview line cap for each side. */
9
+ diffMaxLines: number;
10
+ /** Maximum product of the two line counts plus their empty-prefix rows. */
11
+ diffMaxCells: number;
12
+ }
13
+ /**
14
+ * Compute persisted counts/hunks without re-reading the workspace on review.
15
+ * @param before - bounded raw before bytes; null marks unavailable content.
16
+ * @param after - bounded raw after bytes; null marks unavailable content.
17
+ * @param limits - inclusive per-side limits and diff work bound.
18
+ * @returns preview evidence or an explicit binary/truncated marker.
19
+ */
20
+ export declare function preview(before: Uint8Array | null, after: Uint8Array | null, limits: ReviewLimits): Pick<ReviewChange, 'binary' | 'truncated' | 'additions' | 'deletions' | 'hunks'>;
21
+ //# sourceMappingURL=diff.d.ts.map
@@ -0,0 +1,60 @@
1
+ /** Agent-owned snapshots and hash-guarded review actions over the filesystem provider. @module */
2
+ import { Context, Service } from '@hydraharness/cordis';
3
+ import z from '@hydraharness/schemastery';
4
+ import type { SessionId } from '@hydraharness/harness-session';
5
+ import { type ReviewLimits } from './diff.ts';
6
+ import type { ChangeId, ReviewChange, ReviewOutcome } from './types.ts';
7
+ export type { ChangeId, ReviewChange, ReviewOutcome } from './types.ts';
8
+ declare module '@hydraharness/cordis' {
9
+ interface Context {
10
+ fileReview: FileReview;
11
+ }
12
+ }
13
+ /** Snapshot location and inclusive resource limits. */
14
+ export interface Config extends Partial<ReviewLimits> {
15
+ /** Defaults to the review-changes directory in the Hydra home, outside workspaces. */
16
+ directory?: string;
17
+ }
18
+ /** Durable review service; records are scoped by exact session and random change id. */
19
+ export declare class FileReview extends Service {
20
+ static inject: string[];
21
+ static Config: z<Config>;
22
+ private readonly executions;
23
+ /** Schema-resolved storage location and resource limits. */
24
+ readonly config: Required<Config>;
25
+ constructor(ctx: Context, options: Config);
26
+ private sessionDir;
27
+ private changeDir;
28
+ private save;
29
+ private saveUnlocked;
30
+ private read;
31
+ private readUnlocked;
32
+ /**
33
+ * Read chronological snapshot history without consulting Git or current file contents.
34
+ * @param sessionId - exact owner; forks do not inherit mutation authority.
35
+ * @returns every committed record, including unavailable interrupted operations.
36
+ */
37
+ list(sessionId: SessionId): Promise<ReviewChange[]>;
38
+ /**
39
+ * Mark a change kept, retaining its evidence and ability to undo.
40
+ * @param sessionId - owning session id.
41
+ * @param id - exact change id.
42
+ * @returns the committed review state.
43
+ */
44
+ keep(sessionId: SessionId, id: ChangeId): Promise<ReviewOutcome>;
45
+ /**
46
+ * Undo only the recorded post-edit state; verifies snapshot integrity before writing.
47
+ * @param sessionId - owning session id.
48
+ * @param id - exact change id.
49
+ * @returns success, conflict, or unavailable; unavailable/uncertain records never write.
50
+ */
51
+ undo(sessionId: SessionId, id: ChangeId): Promise<ReviewOutcome>;
52
+ private act;
53
+ /**
54
+ * Remove orphan session directories; surviving sessions retain all review evidence.
55
+ * @returns completion after stale directories are removed; never restores workspace files.
56
+ */
57
+ sweep(): Promise<void>;
58
+ }
59
+ export default FileReview;
60
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,13 @@
1
+ /** Review storage validates ownership and hashes at its durable parser. @module */
2
+ import type { Context } from '@hydraharness/cordis';
3
+ /** Companion plugin identity. */
4
+ export declare const name = "fs-review-invariant";
5
+ /** Registration owner. */
6
+ export declare const inject: string[];
7
+ /**
8
+ * Register the package's invariant ownership.
9
+ * @param ctx - companion context.
10
+ * @returns registration disposer.
11
+ */
12
+ export declare const apply: (ctx: Context) => Promise<() => void>;
13
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,76 @@
1
+ /** Durable agent-owned change evidence, independent of current Git state. @module */
2
+ import type { Branded } from '@hydraharness/harness-brand';
3
+ import type { SessionId } from '@hydraharness/harness-session/types';
4
+ import type { CallId } from '@hydraharness/harness-llm/brand';
5
+ /** Identifies one snapshot and its review record. */
6
+ export type ChangeId = Branded<'ChangeId'>;
7
+ /** One stored unified-diff hunk. */
8
+ export interface ReviewHunk {
9
+ header: string;
10
+ lines: string[];
11
+ }
12
+ /** Live Git comparison modes shown beside persisted session evidence. */
13
+ export type ReviewMode = 'uncommitted' | 'unstaged' | 'staged' | 'committed' | 'branch';
14
+ /** One read-only current-workspace file comparison. */
15
+ export interface WorkspaceReviewFile {
16
+ path: string;
17
+ status: 'added' | 'modified' | 'deleted';
18
+ additions: number;
19
+ deletions: number;
20
+ hunks: ReviewHunk[];
21
+ binary: boolean;
22
+ truncated: boolean;
23
+ patch: string | null;
24
+ }
25
+ /** Git metadata and changes restricted to a session's recorded workspace. */
26
+ export interface WorkspaceReview {
27
+ workspace: string;
28
+ repository: string | null;
29
+ branch: string | null;
30
+ branches: string[];
31
+ commits: {
32
+ oid: string;
33
+ subject: string;
34
+ }[];
35
+ mode: ReviewMode;
36
+ baseRef: string | null;
37
+ files: WorkspaceReviewFile[];
38
+ truncated: boolean;
39
+ }
40
+ /** Snapshot metadata and review evidence committed together. */
41
+ export interface ReviewChange {
42
+ version: 1;
43
+ id: ChangeId;
44
+ sessionId: SessionId;
45
+ callId: CallId;
46
+ rootCallId: CallId;
47
+ toolName: string;
48
+ /** Session sequence immediately before the mutation; turn/step use their opening seq. */
49
+ seq: number;
50
+ turnSeq: number | null;
51
+ stepSeq: number | null;
52
+ parentSessionId: SessionId | null;
53
+ agentPreset: string | null;
54
+ createdAt: number;
55
+ /** Canonical workspace root and relative file path, validated again before Undo. */
56
+ workspace: string;
57
+ path: string;
58
+ operation: 'write' | 'edit';
59
+ status: 'added' | 'modified' | 'deleted';
60
+ /** Pending/undoing records are uncertain after interruption and cannot mutate files. */
61
+ state: 'pending' | 'active' | 'kept' | 'undoing' | 'rolledBack';
62
+ beforeHash: string | null;
63
+ afterHash: string | null;
64
+ reversible: boolean;
65
+ binary: boolean;
66
+ truncated: boolean;
67
+ additions: number;
68
+ deletions: number;
69
+ hunks: ReviewHunk[];
70
+ }
71
+ /** User review action outcome. Conflicts never modify workspace files. */
72
+ export interface ReviewOutcome {
73
+ status: 'kept' | 'rolledBack' | 'alreadyRolledBack' | 'conflict' | 'unavailable';
74
+ change: ReviewChange;
75
+ }
76
+ //# sourceMappingURL=types.d.ts.map
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@hydraharness/harness-fs-review",
3
+ "description": "Durable agent-owned file change review and hash-guarded rollback",
4
+ "hydra": {
5
+ "plugin": {
6
+ "application": "Record agent-made file changes and support undo when the file still matches the recorded change."
7
+ }
8
+ },
9
+ "version": "0.1.1-rc.5",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
16
+ "directory": "packages/fs/fs-review"
17
+ },
18
+ "type": "module",
19
+ "main": "lib/index.js",
20
+ "types": "lib/types/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./lib/types/index.d.ts",
24
+ "default": "./lib/index.js"
25
+ },
26
+ "./client": {
27
+ "types": "./lib/types/client.d.ts",
28
+ "default": "./lib/client.js"
29
+ },
30
+ "./invariant": {
31
+ "types": "./lib/types/invariant.d.ts",
32
+ "default": "./lib/invariant.js"
33
+ },
34
+ "./src/*": "./src/*",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "lib/index.js",
39
+ "lib/invariant.js",
40
+ "lib/client.js",
41
+ "lib/types/**/*.d.ts"
42
+ ],
43
+ "license": "MIT",
44
+ "dependencies": {
45
+ "diff": "^9.0.0",
46
+ "zod": "^4.4.3",
47
+ "@hydraharness/schemastery": "^3.18.2"
48
+ },
49
+ "peerDependencies": {
50
+ "@hydraharness/harness-atomic-write": "^0.1.1-rc.5",
51
+ "@hydraharness/harness-fs": "^0.1.1-rc.5",
52
+ "@hydraharness/harness-home-paths": "^0.1.1-rc.5",
53
+ "@hydraharness/harness-invariants": "^0.1.1-rc.5",
54
+ "@hydraharness/harness-llm": "^0.1.1-rc.5",
55
+ "@hydraharness/harness-sandbox-policy": "^0.1.1-rc.5",
56
+ "@hydraharness/harness-session-persistence": "^0.1.1-rc.5",
57
+ "@hydraharness/harness-session": "^0.1.1-rc.5",
58
+ "@hydraharness/harness-tools": "^0.1.1-rc.5",
59
+ "@hydraharness/cordis": "^4.0.2",
60
+ "@hydraharness/harness-brand": "^0.1.1-rc.5"
61
+ },
62
+ "devDependencies": {
63
+ "@hydraharness/harness-atomic-write": "^0.1.1-rc.5",
64
+ "@hydraharness/cordis": "^4.0.2",
65
+ "@hydraharness/harness-brand": "^0.1.1-rc.5",
66
+ "@hydraharness/harness-fs": "^0.1.1-rc.5",
67
+ "@hydraharness/harness-fs-local": "^0.1.1-rc.5",
68
+ "@hydraharness/harness-home-paths": "^0.1.1-rc.5",
69
+ "@hydraharness/harness-llm": "^0.1.1-rc.5",
70
+ "@hydraharness/harness-session": "^0.1.1-rc.5",
71
+ "@hydraharness/harness-sandbox-policy": "^0.1.1-rc.5",
72
+ "@hydraharness/harness-session-persistence": "^0.1.1-rc.5",
73
+ "@hydraharness/harness-invariants": "^0.1.1-rc.5",
74
+ "@hydraharness/harness-session-persistence-jsonl": "^0.1.1-rc.5",
75
+ "@hydraharness/harness-system-prompt": "^0.1.1-rc.5",
76
+ "@hydraharness/harness-tool-fs": "^0.1.1-rc.5",
77
+ "@hydraharness/harness-tools": "^0.1.1-rc.5"
78
+ }
79
+ }