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.
@@ -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
+ }