protect-context 0.0.0-stage → 1.0.0
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 +21 -0
- package/README.md +613 -2
- package/extension/index.ts +1024 -0
- package/extension/lib/brief.ts +128 -0
- package/extension/lib/config.ts +67 -0
- package/extension/lib/contract.ts +160 -0
- package/extension/lib/contract_check.ts +62 -0
- package/extension/lib/defaults.ts +124 -0
- package/extension/lib/events.ts +69 -0
- package/extension/lib/fingerprint.ts +56 -0
- package/extension/lib/gitparse.ts +229 -0
- package/extension/lib/journal.ts +100 -0
- package/extension/lib/loops.ts +151 -0
- package/extension/lib/orientation.ts +209 -0
- package/extension/lib/status.ts +88 -0
- package/extension/lib/store.ts +500 -0
- package/extension/lib/text.ts +24 -0
- package/extension/lib/verify.ts +96 -0
- package/extension/lib/widget.ts +28 -0
- package/package.json +54 -4
- package/preview.png +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact status dashboard shared by /guard-status, /guard-report and the
|
|
3
|
+
* guard_status tool (SPEC §5). Pure formatting — no pi imports.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LoopInfo } from "./loops.ts";
|
|
7
|
+
import { CMD_DISPLAY_MAX, truncLine } from "./brief.ts";
|
|
8
|
+
import { formatVerifyLine, type VerifyRun } from "./verify.ts";
|
|
9
|
+
|
|
10
|
+
export interface StatusData {
|
|
11
|
+
session: string;
|
|
12
|
+
gitHead: string | null;
|
|
13
|
+
/** True when inside a repository that has no first commit yet. */
|
|
14
|
+
gitUnborn?: boolean;
|
|
15
|
+
/** False when Git scope measurement failed or the cwd is not a worktree. */
|
|
16
|
+
diffAvailable?: boolean;
|
|
17
|
+
diffLines: number;
|
|
18
|
+
diffFiles: number;
|
|
19
|
+
/** Bounded untracked scanning makes diffLines a disclosed lower bound. */
|
|
20
|
+
diffScanLimited?: boolean;
|
|
21
|
+
diffSkippedFiles?: number;
|
|
22
|
+
diffTruncatedFiles?: number;
|
|
23
|
+
loops: LoopInfo[];
|
|
24
|
+
evidenceCount: number;
|
|
25
|
+
contractCount: number;
|
|
26
|
+
lastVerify: VerifyRun | null;
|
|
27
|
+
ctxPct: number | null;
|
|
28
|
+
/** Configured diff thresholds (gatherStatus populates these from config.json,
|
|
29
|
+
* so the nudge never lies about a tuned threshold). */
|
|
30
|
+
diffWarnLines: number;
|
|
31
|
+
diffHardLines: number;
|
|
32
|
+
diffWarnFiles: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Two compact lines + Nudge. */
|
|
36
|
+
export function buildStatus(d: StatusData): string {
|
|
37
|
+
const pct = d.ctxPct === null ? "?" : `${Math.round(d.ctxPct)}%`;
|
|
38
|
+
const head = d.gitUnborn ? "unborn" : d.gitHead === null ? "norepo" : d.gitHead.slice(0, 8);
|
|
39
|
+
const diff = d.diffAvailable === false
|
|
40
|
+
? "Δ=unknown"
|
|
41
|
+
: `${d.diffScanLimited ? "Δ≥" : "Δ+"}${d.diffLines}L/${d.diffFiles}f`;
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
lines.push(
|
|
44
|
+
`PC s=${d.session.slice(0, 8)} git=${head} ${diff} ` +
|
|
45
|
+
`loops=${d.loops.length} ev=${d.evidenceCount} contract=${d.contractCount} ctx=${pct}`,
|
|
46
|
+
);
|
|
47
|
+
if (d.diffAvailable === false) {
|
|
48
|
+
lines.push("diff audit: scope unavailable");
|
|
49
|
+
}
|
|
50
|
+
if (d.diffScanLimited) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`diff scan: lower bound (${d.diffSkippedFiles ?? 0} skipped, ${d.diffTruncatedFiles ?? 0} truncated)`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (d.lastVerify) {
|
|
56
|
+
lines.push(`last verify: ${formatVerifyLine(d.lastVerify)}`);
|
|
57
|
+
} else {
|
|
58
|
+
lines.push("last verify: (none)");
|
|
59
|
+
}
|
|
60
|
+
lines.push(`Nudge: ${statusNudge(d)}`);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function statusNudge(d: StatusData): string {
|
|
65
|
+
if (d.loops.length > 0) {
|
|
66
|
+
return `change strategy — '${truncLine(d.loops[0]?.cmd ?? "", CMD_DISPLAY_MAX)}' failed ${d.loops[0]?.failures}x; do not re-run unchanged.`;
|
|
67
|
+
}
|
|
68
|
+
if (d.diffAvailable === false) {
|
|
69
|
+
return "scope audit unavailable — inspect Git before treating the working tree as clean.";
|
|
70
|
+
}
|
|
71
|
+
if (d.diffLines >= d.diffHardLines) {
|
|
72
|
+
return `diff hit the hard limit (≥ ${d.diffHardLines} lines) — stop and split before continuing.`;
|
|
73
|
+
}
|
|
74
|
+
if (d.diffLines >= d.diffWarnLines || d.diffFiles >= d.diffWarnFiles) {
|
|
75
|
+
const threshold =
|
|
76
|
+
d.diffFiles >= d.diffWarnFiles && d.diffLines < d.diffWarnLines
|
|
77
|
+
? `≥ ${d.diffWarnFiles} files`
|
|
78
|
+
: `≥ ${d.diffWarnLines} lines`;
|
|
79
|
+
return `diff is large (${threshold}) — split work before continuing.`;
|
|
80
|
+
}
|
|
81
|
+
if (d.diffScanLimited) {
|
|
82
|
+
return "untracked scan was bounded — treat changed lines as a lower bound and inspect skipped files.";
|
|
83
|
+
}
|
|
84
|
+
if (d.lastVerify === null) {
|
|
85
|
+
return "run /guard-verify <cmd> to create evidence before claiming success.";
|
|
86
|
+
}
|
|
87
|
+
return "verify before success claims; keep tool outputs bounded.";
|
|
88
|
+
}
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store access for `.agent-guard/` (SPEC §2).
|
|
3
|
+
* Node builtins only. lib modules stay free of pi imports.
|
|
4
|
+
*
|
|
5
|
+
* Appends are serialized by the shared atomic-directory lease below and are
|
|
6
|
+
* single O_APPEND line writes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
mkdirSync,
|
|
11
|
+
appendFileSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
statSync,
|
|
17
|
+
lstatSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
realpathSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
23
|
+
import { hostname } from "node:os";
|
|
24
|
+
import { dirname, join } from "node:path";
|
|
25
|
+
import { performance } from "node:perf_hooks";
|
|
26
|
+
|
|
27
|
+
export const STORE_DIR_NAME = ".agent-guard";
|
|
28
|
+
|
|
29
|
+
export function storeDir(cwd: string): string {
|
|
30
|
+
return join(cwd, STORE_DIR_NAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ensureStore(cwd: string): string {
|
|
34
|
+
const dir = storeDir(cwd);
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function configPath(cwd: string): string {
|
|
40
|
+
return join(storeDir(cwd), "config.json");
|
|
41
|
+
}
|
|
42
|
+
export function contractPath(cwd: string): string {
|
|
43
|
+
return join(storeDir(cwd), "contract.json");
|
|
44
|
+
}
|
|
45
|
+
export function journalPath(cwd: string): string {
|
|
46
|
+
return join(storeDir(cwd), "journal.jsonl");
|
|
47
|
+
}
|
|
48
|
+
export function eventsPath(cwd: string): string {
|
|
49
|
+
return join(storeDir(cwd), "events.jsonl");
|
|
50
|
+
}
|
|
51
|
+
export function verifyRunsPath(cwd: string): string {
|
|
52
|
+
return join(storeDir(cwd), "verify_runs.jsonl");
|
|
53
|
+
}
|
|
54
|
+
export function statePath(cwd: string): string {
|
|
55
|
+
return join(storeDir(cwd), "state.json");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Cross-runtime store lock. mkdir is the one atomic primitive shared by Node
|
|
60
|
+
// and Python without a runtime dependency. The owner record prevents one
|
|
61
|
+
// process from releasing another's lease; stale recovery is both age-gated
|
|
62
|
+
// and same-host PID-aware, so a live lock is never broken merely because a
|
|
63
|
+
// 500 ms timer elapsed.
|
|
64
|
+
|
|
65
|
+
export const STORE_LOCK_MIN_STALE_MS = 10_000;
|
|
66
|
+
export const STORE_LOCK_STALE_MS = 30_000;
|
|
67
|
+
export const STORE_LOCK_TIMEOUT_MS = 5_000;
|
|
68
|
+
export const STORE_LOCK_RETRY_MS = 25;
|
|
69
|
+
const LOCK_DIR_NAME = ".lock.d";
|
|
70
|
+
const LOCK_OWNER_NAME = "owner.json";
|
|
71
|
+
const LOCK_REAP_MARKER_NAME = ".reap-marker";
|
|
72
|
+
const LOCK_RENAME_TIMEOUT_MS = 1_000;
|
|
73
|
+
const LOCK_RENAME_RETRY_MS = 5;
|
|
74
|
+
|
|
75
|
+
export interface StoreLockOptions {
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
staleMs?: number;
|
|
78
|
+
retryMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface LockOwner {
|
|
82
|
+
token: string;
|
|
83
|
+
pid: number;
|
|
84
|
+
hostname: string;
|
|
85
|
+
created_ms: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface LockSnapshot {
|
|
89
|
+
identity: { dev: number; ino: number };
|
|
90
|
+
mtimeMs: number;
|
|
91
|
+
owner: LockOwner | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface HeldStoreLock {
|
|
95
|
+
depth: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const heldStoreLocks = new Map<string, HeldStoreLock>();
|
|
99
|
+
|
|
100
|
+
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
101
|
+
|
|
102
|
+
function sleepSync(ms: number): void {
|
|
103
|
+
try {
|
|
104
|
+
Atomics.wait(sleepBuffer, 0, 0, ms);
|
|
105
|
+
} catch {
|
|
106
|
+
const end = Date.now() + ms;
|
|
107
|
+
while (Date.now() < end) {
|
|
108
|
+
// busy-wait fallback when Atomics.wait is unavailable on this thread
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renameLease(source: string, destination: string): void {
|
|
114
|
+
const deadline = performance.now() + LOCK_RENAME_TIMEOUT_MS;
|
|
115
|
+
for (;;) {
|
|
116
|
+
try {
|
|
117
|
+
renameSync(source, destination);
|
|
118
|
+
return;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
121
|
+
const transient = process.platform === "win32" && ["EACCES", "EBUSY", "EPERM"].includes(code ?? "");
|
|
122
|
+
if (!transient || performance.now() >= deadline) throw err;
|
|
123
|
+
sleepSync(Math.min(LOCK_RENAME_RETRY_MS, Math.max(1, deadline - performance.now())));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readLockOwner(lockDir: string): LockOwner | null {
|
|
129
|
+
try {
|
|
130
|
+
const value = JSON.parse(readFileSync(join(lockDir, LOCK_OWNER_NAME), "utf8")) as Partial<LockOwner>;
|
|
131
|
+
return typeof value?.token === "string" && value.token.length > 0 ? (value as LockOwner) : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function pidIsLive(pid: unknown): boolean {
|
|
138
|
+
if (!Number.isSafeInteger(pid) || (pid as number) <= 0 || (pid as number) > 2_147_483_647) return false;
|
|
139
|
+
try {
|
|
140
|
+
process.kill(pid as number, 0);
|
|
141
|
+
return true;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
144
|
+
if (code === "ESRCH") return false;
|
|
145
|
+
// EPERM and unknown platform failures are conservative: they do not grant
|
|
146
|
+
// permission to steal a possibly-live lock.
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sameHost(value: unknown): boolean {
|
|
152
|
+
return typeof value === "string" && value.toLowerCase() === hostname().toLowerCase();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function lockSnapshot(lockDir: string): LockSnapshot | null {
|
|
156
|
+
try {
|
|
157
|
+
const before = lstatSync(lockDir);
|
|
158
|
+
if (!before.isDirectory()) return null;
|
|
159
|
+
const owner = readLockOwner(lockDir);
|
|
160
|
+
const after = lstatSync(lockDir);
|
|
161
|
+
if (!after.isDirectory() || before.dev !== after.dev || before.ino !== after.ino) return null;
|
|
162
|
+
return { identity: { dev: after.dev, ino: after.ino }, mtimeMs: after.mtimeMs, owner };
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function snapshotIsStale(snapshot: LockSnapshot, staleMs: number): boolean {
|
|
169
|
+
const ageMs = Date.now() - snapshot.mtimeMs;
|
|
170
|
+
if (ageMs < staleMs) return false;
|
|
171
|
+
if (snapshot.owner === null) return true;
|
|
172
|
+
// Age alone cannot prove a foreign-host owner dead. Failing closed avoids
|
|
173
|
+
// stealing a legitimate long-running lease on a shared filesystem.
|
|
174
|
+
if (!sameHost(snapshot.owner.hostname)) return false;
|
|
175
|
+
return !pidIsLive(snapshot.owner.pid);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function sameSnapshot(left: LockSnapshot, right: LockSnapshot): boolean {
|
|
179
|
+
return left.identity.dev === right.identity.dev
|
|
180
|
+
&& left.identity.ino === right.identity.ino
|
|
181
|
+
&& left.owner?.token === right.owner?.token;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function staleTombPath(lockDir: string, snapshot: LockSnapshot): string {
|
|
185
|
+
const token = snapshot.owner?.token ?? "unowned";
|
|
186
|
+
const material = `${snapshot.identity.dev}:${snapshot.identity.ino}:${token}`;
|
|
187
|
+
const key = createHash("sha256").update(material).digest("hex").slice(0, 24);
|
|
188
|
+
return `${lockDir}.stale.${key}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function markStaleLock(lockDir: string): boolean {
|
|
192
|
+
try {
|
|
193
|
+
writeFileSync(join(lockDir, LOCK_REAP_MARKER_NAME), "protect-context stale lease quarantine\n", {
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
flag: "wx",
|
|
196
|
+
});
|
|
197
|
+
return true;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
if ((err as NodeJS.ErrnoException)?.code === "EEXIST") return true;
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function oldEnoughToReap(lockDir: string, staleMs: number): boolean {
|
|
205
|
+
try {
|
|
206
|
+
const value = lstatSync(lockDir);
|
|
207
|
+
return value.isDirectory() && Date.now() - value.mtimeMs >= staleMs;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function breakStaleLock(lockDir: string, staleMs: number): boolean {
|
|
214
|
+
// The normal contention path needs only one stat. Avoid repeatedly opening
|
|
215
|
+
// owner.json: on Windows that can transiently prevent the owner from
|
|
216
|
+
// renaming its directory during release.
|
|
217
|
+
if (!oldEnoughToReap(lockDir, staleMs)) return false;
|
|
218
|
+
const observed = lockSnapshot(lockDir);
|
|
219
|
+
if (observed === null || !snapshotIsStale(observed, staleMs)) return false;
|
|
220
|
+
const tomb = staleTombPath(lockDir, observed);
|
|
221
|
+
if (existsSync(tomb) || !markStaleLock(lockDir)) return false;
|
|
222
|
+
|
|
223
|
+
// Adding the marker refreshes mtime. The original snapshot was already
|
|
224
|
+
// age/liveness checked, so only identity and owner must remain unchanged.
|
|
225
|
+
const current = lockSnapshot(lockDir);
|
|
226
|
+
if (current === null || !sameSnapshot(observed, current)) return false;
|
|
227
|
+
try {
|
|
228
|
+
renameLease(lockDir, tomb);
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
const moved = lockSnapshot(tomb);
|
|
233
|
+
if (moved === null || !sameSnapshot(current, moved)) {
|
|
234
|
+
try {
|
|
235
|
+
if (!existsSync(lockDir)) renameLease(tomb, lockDir);
|
|
236
|
+
} catch {
|
|
237
|
+
// Do not delete an object whose identity could not be verified.
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
// The non-empty tomb is deliberately retained as a durable guard against a
|
|
242
|
+
// delayed breaker moving a replacement lease after this process exits.
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function releaseStoreLock(lockDir: string, token: string): void {
|
|
247
|
+
const observed = lockSnapshot(lockDir);
|
|
248
|
+
if (observed?.owner?.token !== token) {
|
|
249
|
+
throw new Error(`store lock ownership changed before release: ${lockDir}`);
|
|
250
|
+
}
|
|
251
|
+
const tomb = `${lockDir}.release.${process.pid}.${randomBytes(8).toString("hex")}`;
|
|
252
|
+
try {
|
|
253
|
+
renameLease(lockDir, tomb);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
throw new Error(`could not release store lock ${lockDir}`, { cause: err });
|
|
256
|
+
}
|
|
257
|
+
const moved = lockSnapshot(tomb);
|
|
258
|
+
if (moved === null || !sameSnapshot(observed, moved)) {
|
|
259
|
+
try {
|
|
260
|
+
if (!existsSync(lockDir)) renameLease(tomb, lockDir);
|
|
261
|
+
} catch {
|
|
262
|
+
// Preserve the unexpected object under its quarantine name.
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`store lock ownership changed before release: ${lockDir}`);
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
rmSync(tomb, { recursive: true, force: true });
|
|
268
|
+
} catch (err) {
|
|
269
|
+
throw new Error(`could not clean released store lock ${tomb}`, { cause: err });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function lockNumber(value: number | undefined, fallback: number, minimum: number): number {
|
|
274
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(minimum, value) : fallback;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Run `fn` while holding the shared cross-runtime store lease for `dir`.
|
|
278
|
+
* Acquisition either succeeds or throws; protected code never runs unlocked. */
|
|
279
|
+
export function withStoreLock<T>(dir: string, fn: () => T, options: StoreLockOptions = {}): T {
|
|
280
|
+
mkdirSync(dir, { recursive: true });
|
|
281
|
+
const lockDir = join(realpathSync(dir), LOCK_DIR_NAME);
|
|
282
|
+
const alreadyHeld = heldStoreLocks.get(lockDir);
|
|
283
|
+
if (alreadyHeld) {
|
|
284
|
+
alreadyHeld.depth += 1;
|
|
285
|
+
try {
|
|
286
|
+
return fn();
|
|
287
|
+
} finally {
|
|
288
|
+
alreadyHeld.depth -= 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const timeoutMs = lockNumber(options.timeoutMs, STORE_LOCK_TIMEOUT_MS, 0);
|
|
293
|
+
const staleMs = lockNumber(options.staleMs, STORE_LOCK_STALE_MS, STORE_LOCK_MIN_STALE_MS);
|
|
294
|
+
const retryMs = lockNumber(options.retryMs, STORE_LOCK_RETRY_MS, 1);
|
|
295
|
+
const deadline = performance.now() + timeoutMs;
|
|
296
|
+
const token = randomBytes(16).toString("hex");
|
|
297
|
+
|
|
298
|
+
for (;;) {
|
|
299
|
+
try {
|
|
300
|
+
mkdirSync(lockDir);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err;
|
|
303
|
+
if (breakStaleLock(lockDir, staleMs)) continue;
|
|
304
|
+
if (performance.now() >= deadline) {
|
|
305
|
+
throw new Error(`timed out acquiring store lock ${lockDir}`);
|
|
306
|
+
}
|
|
307
|
+
sleepSync(Math.min(retryMs, Math.max(1, deadline - performance.now())));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const owner: LockOwner = {
|
|
312
|
+
token,
|
|
313
|
+
pid: process.pid,
|
|
314
|
+
hostname: hostname(),
|
|
315
|
+
created_ms: Date.now(),
|
|
316
|
+
};
|
|
317
|
+
try {
|
|
318
|
+
writeFileSync(join(lockDir, LOCK_OWNER_NAME), `${JSON.stringify(owner)}\n`, { encoding: "utf8", flag: "wx" });
|
|
319
|
+
} catch (err) {
|
|
320
|
+
try {
|
|
321
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
322
|
+
} catch {
|
|
323
|
+
// The original initialization error is more useful to the caller.
|
|
324
|
+
}
|
|
325
|
+
throw err;
|
|
326
|
+
}
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
heldStoreLocks.set(lockDir, { depth: 1 });
|
|
330
|
+
try {
|
|
331
|
+
return fn();
|
|
332
|
+
} finally {
|
|
333
|
+
heldStoreLocks.delete(lockDir);
|
|
334
|
+
releaseStoreLock(lockDir, token);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Append one JSON record as a single O_APPEND line write. The caller must
|
|
339
|
+
* already hold the store lock (see withStoreLock). */
|
|
340
|
+
export function appendJsonlLocked(file: string, record: unknown): void {
|
|
341
|
+
appendFileSync(file, `${JSON.stringify(record)}\n`, "utf8");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Append one JSON record under the shared cross-runtime store lock. */
|
|
345
|
+
export function appendJsonl(file: string, record: unknown): void {
|
|
346
|
+
withStoreLock(dirname(file), () => appendJsonlLocked(file, record));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
interface StatLike {
|
|
350
|
+
mtimeMs: number;
|
|
351
|
+
size: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function statOf(file: string): StatLike | null {
|
|
355
|
+
try {
|
|
356
|
+
const st = statSync(file);
|
|
357
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Cheap change-detection stamp for a file ("mtimeMs:size"), or null when it
|
|
364
|
+
* does not exist. readJsonl caches the parse by the same signal; this lets
|
|
365
|
+
* callers also memoize higher-level derived state (e.g. the per-session
|
|
366
|
+
* evidence slice) so hot paths stay O(1) without re-reading the ledger. */
|
|
367
|
+
export function fileStamp(file: string): string | null {
|
|
368
|
+
const st = statOf(file);
|
|
369
|
+
return st ? `${st.mtimeMs}:${st.size}` : null;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const jsonlCache = new Map<string, { mtimeMs: number; size: number; data: unknown[] }>();
|
|
373
|
+
|
|
374
|
+
/** Read a JSONL file into object records; missing file → []. Malformed and
|
|
375
|
+
* non-object lines are skipped, matching the Python ledger reader. Results
|
|
376
|
+
* are cached by mtime+size. */
|
|
377
|
+
export function readJsonl<T>(file: string): T[] {
|
|
378
|
+
if (!existsSync(file)) return [];
|
|
379
|
+
const st = statOf(file);
|
|
380
|
+
if (!st) return [];
|
|
381
|
+
const cached = jsonlCache.get(file);
|
|
382
|
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
|
383
|
+
return cached.data as T[];
|
|
384
|
+
}
|
|
385
|
+
const data: unknown[] = [];
|
|
386
|
+
const text = readFileSync(file, "utf8");
|
|
387
|
+
for (const line of text.split("\n")) {
|
|
388
|
+
const trimmed = line.trim();
|
|
389
|
+
if (trimmed === "") continue;
|
|
390
|
+
try {
|
|
391
|
+
const record = JSON.parse(trimmed) as unknown;
|
|
392
|
+
if (record !== null && typeof record === "object" && !Array.isArray(record)) {
|
|
393
|
+
data.push(record);
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
// Skip malformed lines; the ledger stays readable.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
jsonlCache.set(file, { mtimeMs: st.mtimeMs, size: st.size, data });
|
|
400
|
+
return data as T[];
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function readJson<T>(file: string): T | null {
|
|
404
|
+
if (!existsSync(file)) return null;
|
|
405
|
+
try {
|
|
406
|
+
return JSON.parse(readFileSync(file, "utf8")) as T;
|
|
407
|
+
} catch {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Atomic-ish JSON write: unique tmp file + rename. Creates parent dirs.
|
|
413
|
+
* Unique per-write tmp name avoids clobbering a concurrent writer's tmp. */
|
|
414
|
+
export function writeJson(file: string, value: unknown): void {
|
|
415
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
416
|
+
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
417
|
+
try {
|
|
418
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
419
|
+
renameSync(tmp, file);
|
|
420
|
+
} catch (e) {
|
|
421
|
+
try {
|
|
422
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
423
|
+
} catch {
|
|
424
|
+
/* best-effort cleanup */
|
|
425
|
+
}
|
|
426
|
+
throw e;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// state.json (SPEC §2)
|
|
432
|
+
|
|
433
|
+
export interface GuardState {
|
|
434
|
+
current_session: { id: string; started: string; git_head: string | null };
|
|
435
|
+
brief_digest: string | null;
|
|
436
|
+
notify_watermarks: { ctx_pct: number; diff_lines: number; diff_files: number };
|
|
437
|
+
/** Opaque passthrough on the Python side; the extension stores digests here
|
|
438
|
+
* (e.g. the orientation digest) alongside numeric counters. */
|
|
439
|
+
counters: Record<string, number | string>;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function defaultState(sessionId: string, gitHead: string | null): GuardState {
|
|
443
|
+
return {
|
|
444
|
+
current_session: { id: sessionId, started: new Date().toISOString(), git_head: gitHead },
|
|
445
|
+
brief_digest: null,
|
|
446
|
+
notify_watermarks: { ctx_pct: 0, diff_lines: 0, diff_files: 0 },
|
|
447
|
+
counters: {},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function loadState(cwd: string): GuardState | null {
|
|
452
|
+
const raw = readJson<unknown>(statePath(cwd));
|
|
453
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
454
|
+
const value = raw as Record<string, unknown>;
|
|
455
|
+
const rawSession =
|
|
456
|
+
value.current_session !== null &&
|
|
457
|
+
typeof value.current_session === "object" &&
|
|
458
|
+
!Array.isArray(value.current_session)
|
|
459
|
+
? (value.current_session as Record<string, unknown>)
|
|
460
|
+
: {};
|
|
461
|
+
const rawWatermarks =
|
|
462
|
+
value.notify_watermarks !== null &&
|
|
463
|
+
typeof value.notify_watermarks === "object" &&
|
|
464
|
+
!Array.isArray(value.notify_watermarks)
|
|
465
|
+
? (value.notify_watermarks as Record<string, unknown>)
|
|
466
|
+
: {};
|
|
467
|
+
const numberOrZero = (candidate: unknown): number =>
|
|
468
|
+
typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0
|
|
469
|
+
? candidate
|
|
470
|
+
: 0;
|
|
471
|
+
const rawCounters =
|
|
472
|
+
value.counters !== null && typeof value.counters === "object" && !Array.isArray(value.counters)
|
|
473
|
+
? (value.counters as Record<string, unknown>)
|
|
474
|
+
: {};
|
|
475
|
+
const counters: Record<string, number | string> = {};
|
|
476
|
+
for (const [key, candidate] of Object.entries(rawCounters)) {
|
|
477
|
+
if (typeof candidate === "string" || (typeof candidate === "number" && Number.isFinite(candidate))) {
|
|
478
|
+
counters[key] = candidate;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
const gitHead = rawSession.git_head;
|
|
482
|
+
return {
|
|
483
|
+
current_session: {
|
|
484
|
+
id: typeof rawSession.id === "string" ? rawSession.id : "",
|
|
485
|
+
started: typeof rawSession.started === "string" ? rawSession.started : "",
|
|
486
|
+
git_head: typeof gitHead === "string" || gitHead === null ? gitHead : null,
|
|
487
|
+
},
|
|
488
|
+
brief_digest: typeof value.brief_digest === "string" ? value.brief_digest : null,
|
|
489
|
+
notify_watermarks: {
|
|
490
|
+
ctx_pct: numberOrZero(rawWatermarks.ctx_pct),
|
|
491
|
+
diff_lines: numberOrZero(rawWatermarks.diff_lines),
|
|
492
|
+
diff_files: numberOrZero(rawWatermarks.diff_files),
|
|
493
|
+
},
|
|
494
|
+
counters,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export function saveState(cwd: string, state: GuardState): void {
|
|
499
|
+
writeJson(statePath(cwd), state);
|
|
500
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared text utilities (goal #12): hard one-line caps so model-supplied text
|
|
3
|
+
* can never bloat re-injected briefs, journals or evidence lines.
|
|
4
|
+
* Pure logic — no pi imports.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Truncate `s` to `max` chars with a trailing ellipsis when longer. */
|
|
8
|
+
export function trunc(s: string, max = 200): string {
|
|
9
|
+
const chars = Array.from(s);
|
|
10
|
+
return chars.length > max ? `${chars.slice(0, Math.max(0, max - 1)).join("")}…` : s;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Normalize untrusted/legacy text into one printable bounded line. Unicode
|
|
14
|
+
* control, format and surrogate code points are replaced before whitespace is
|
|
15
|
+
* collapsed so they cannot forge terminal/brief lines or invisibly join words. */
|
|
16
|
+
export function normalizeOneLine(value: unknown, max: number): string {
|
|
17
|
+
const normalized = String(value ?? "")
|
|
18
|
+
.replace(/[\p{Cc}\p{Cf}\p{Cs}]/gu, " ")
|
|
19
|
+
.replace(/\s+/g, " ")
|
|
20
|
+
.trim();
|
|
21
|
+
// Slice code points, not UTF-16 code units: cutting a valid astral symbol
|
|
22
|
+
// in half would manufacture the very unpaired surrogate we just removed.
|
|
23
|
+
return Array.from(normalized).slice(0, Math.max(0, max)).join("").trim();
|
|
24
|
+
}
|