protect-context 1.1.0 → 1.3.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/README.md CHANGED
@@ -113,6 +113,28 @@ pi install npm:protect-context
113
113
  pi -e npm:protect-context
114
114
  ```
115
115
 
116
+ Install it through pi, not through `npm install`. The extension declares pi's
117
+ host modules (`@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`,
118
+ `typebox`) as `"*"` peer dependencies, as Pi requires; Pi suppresses peer
119
+ installation for managed npm packages, while a plain `npm install
120
+ protect-context` does not. The second path downloads the whole pi runtime and
121
+ surfaces advisories from that tree, which belong to the host and not to this
122
+ zero-runtime-dependency extension.
123
+
124
+ Remove any hand-installed copy first — a directory such as
125
+ `~/.pi/agent/extensions/protect-context` or `.pi/extensions/protect-context`,
126
+ or `pi remove npm:protect-context` if that copy came from npm. Two copies
127
+ register the same tool names, and pi drops the second one:
128
+
129
+ ```text
130
+ Error: Failed to load extension "…/.pi/npm/node_modules/protect-context/extension/index.ts":
131
+ Tool "guard_verify" conflicts with /home/you/.pi/agent/extensions/protect-context/index.ts
132
+ ```
133
+
134
+ One error is reported per tool (`guard_verify`, `guard_note`,
135
+ `guard_contract_check`, `guard_status`), so nothing from the npm copy loads
136
+ until the other copy is gone.
137
+
116
138
  Or copy/symlink the checkout into a trusted extension directory:
117
139
 
118
140
  ```sh
@@ -318,8 +340,8 @@ protect-context status
318
340
  protect-context verify "pytest -q" [--timeout 180]
319
341
  protect-context evidence [--limit 5]
320
342
  protect-context contract list
321
- protect-context contract add "Keep the public API stable"
322
- protect-context contract remove ID
343
+ protect-context contract add "Keep the public API stable" [--global]
344
+ protect-context contract remove ID [--global]
323
345
  protect-context note correction "use rg, not grep" --tags search
324
346
  protect-context journal [--limit 5] [--type decision]
325
347
  protect-context orientation
@@ -354,6 +376,68 @@ fully checked. Version probes have a two-second per-tool timeout, share a
354
376
  15-second overall deadline, and decode at most 4 KiB of output; a missing
355
377
  version is reported as an incomplete preflight.
356
378
 
379
+ ### Making it yours: the personal layer
380
+
381
+ The twelve seeds and the config defaults are the same for everyone, on purpose:
382
+ they are the floor that makes the guardrail comparable across machines. What is
383
+ *yours* goes in a personal layer that every project inherits:
384
+
385
+ ```text
386
+ built-in defaults < ~/.config/protect-context/ < .agent-guard/
387
+ contract.json contract.json
388
+ config.json config.json
389
+ ```
390
+
391
+ The directory is `$PC_USER_DIR`, else `$XDG_CONFIG_HOME/protect-context`, else
392
+ `~/.config/protect-context`. Write it from the owner surfaces:
393
+
394
+ ```console
395
+ $ protect-context contract add "Prefer stdlib over new dependencies" --global
396
+ added 13-prefer-stdlib-over-new-dependencies to personal layer
397
+ …
398
+
399
+ $ protect-context contract list # every project sees it
400
+ personal layer: 1 from /home/you/.config/protect-context/contract.json (applies to every project; a project row with the same id or text wins)
401
+ ```
402
+
403
+ The same flag works in pi: `/guard-contract add <text> --global` and
404
+ `/guard-contract remove <id> --global`. Without it, writes go to the project
405
+ store as before, and `guard_contract` (the agent-callable tool) never touches
406
+ the personal layer.
407
+
408
+ Rules that make the layering predictable:
409
+
410
+ - **The project wins.** A project row with the same id overrides the personal
411
+ one, and a personal constraint identical in text to a project row is not
412
+ listed twice. Give a personal override the same id (e.g. `03-stay-in-scope`) to
413
+ reword a seed for every repo.
414
+ - **Defaults mean "no opinion".** The extension seeds `config.json` with every
415
+ default value; a project value equal to the built-in default therefore does not
416
+ pin it, so your personal `ctx_warn_pct` still applies. Any *different* project
417
+ value is an explicit choice and wins.
418
+ - **Nothing is created behind your back.** The layer is never seeded or repaired:
419
+ a missing file means "no personal constraints", and an unreadable one is
420
+ ignored in memory and left untouched. Deleting a personal constraint requires
421
+ `--global`; a project-scope remove says so instead of silently doing nothing.
422
+ - **Each layer has its own lease.** A `--global` write locks the personal
423
+ directory, not the project store, so two projects writing personal state cannot
424
+ race each other.
425
+
426
+ ### Sharing a contract with a team
427
+
428
+ `.agent-guard/` is runtime state and normally gitignored. If a team wants one
429
+ shared owner contract, track only that file:
430
+
431
+ ```gitignore
432
+ .agent-guard/*
433
+ !.agent-guard/contract.json
434
+ ```
435
+
436
+ Runtime files (events, evidence, journal, session state) stay local, while
437
+ `contract.json` becomes reviewable in pull requests. Personal constraints still
438
+ apply on top — each teammate's personal layer is weaker than the shared project
439
+ row.
440
+
357
441
  The nine catastrophic patterns that trigger the pi confirmation are frozen and
358
442
  deliberately narrow, so benign lookalikes stay silent:
359
443
 
@@ -366,6 +450,7 @@ deliberately narrow, so benign lookalikes stay silent:
366
450
  | Variable | Effect |
367
451
  |---|---|
368
452
  | `PC_SESSION_ID` | Names the session used for evidence and loop views, so parallel hosts do not share a view |
453
+ | `PC_USER_DIR` | Overrides the personal-layer directory (default `$XDG_CONFIG_HOME/protect-context` or `~/.config/protect-context`) |
369
454
 
370
455
  ### Runtime store and concurrency
371
456
 
@@ -374,6 +459,18 @@ Runtime files stay in the frozen `.agent-guard/` layout and are gitignored:
374
459
  `config.json` · `contract.json` · `journal.jsonl` · `events.jsonl` ·
375
460
  `verify_runs.jsonl` · `state.json`
376
461
 
462
+ The optional personal layer lives outside the repository at
463
+ `~/.config/protect-context/` (`contract.json`, `config.json`) and is never
464
+ written implicitly; see *Making it yours* above.
465
+
466
+ Ledgers bound themselves instead of growing forever: once `events.jsonl`,
467
+ `verify_runs.jsonl` or `journal.jsonl` passes 1 MiB it rotates to
468
+ `<name>.1.jsonl`, keeping at most three rotated segments and dropping the oldest.
469
+ Reads concatenate the segments oldest-first, so recent rows and the highest
470
+ evidence id always survive; `budget` and loop streaks describe the retained
471
+ window. Session baselines are keyed per session inside `state.json`, so parallel
472
+ pi sessions in one repo no longer overwrite each other's watermarks.
473
+
377
474
  Python and TypeScript use the same atomic-directory lease at
378
475
  `.agent-guard/.lock.d`, with an owner token, PID/hostname checks, bounded
379
476
  waiting, and conservative stale recovery. Python also takes its legacy local
@@ -422,7 +519,7 @@ machine: there are no network calls and no LLM calls in the guardrail logic.
422
519
  |---|---|
423
520
  | `session_start` | Seed config if missing, scan orientation, freeze the diff baseline, start the status widget |
424
521
  | `before_agent_start` | Build the brief; inject orientation and/or brief as a hidden message when their digests changed |
425
- | `tool_call` (bash) | Match the nine risky patterns → one grouped confirm; check the loop fingerprint → block at the threshold |
522
+ | `tool_call` (bash) | Match the nine risky patterns → one grouped confirm; check the loop fingerprint → block at the threshold; record the normalized command, with `ok=false` when the call was refused |
426
523
  | `tool_execution_start` / `tool_execution_end` | Record bash outcomes and verify results into the shared loop state and evidence ledger |
427
524
  | `turn_end` | Refresh the widget: diff stats, open loops, evidence count, context percentage |
428
525
  | `session_compact` | Re-inject trusted context so compaction cannot silently erase it |
@@ -32,11 +32,19 @@ import {
32
32
  loadState,
33
33
  saveState,
34
34
  readJsonl,
35
+ sessionState,
36
+ type SessionState,
35
37
  storeDir,
36
38
  verifyRunsPath,
37
39
  withStoreLock,
38
40
  } from "./lib/store.ts";
39
- import { addConstraintWithStatus, contractLines, loadContract, removeConstraint } from "./lib/contract.ts";
41
+ import {
42
+ addConstraintWithStatus,
43
+ contractLines,
44
+ loadContract,
45
+ personalConstraintCount,
46
+ removeConstraint,
47
+ } from "./lib/contract.ts";
40
48
  import {
41
49
  appendJournal,
42
50
  isJournalType,
@@ -81,6 +89,14 @@ export default function (pi: ExtensionAPI) {
81
89
  let session = { id: "", started: "", gitHead: null as string | null, gitUnborn: false };
82
90
  let cfg: GuardConfig = { ...DEFAULT_CONFIG, preflight_tools: [...DEFAULT_CONFIG.preflight_tools] };
83
91
  let state = defaultState("", null);
92
+ // Live view of THIS session's baseline inside `state.sessions`; the scratch
93
+ // default only covers handlers that fire before session_start.
94
+ let view: SessionState = {
95
+ started: "",
96
+ git_head: null,
97
+ brief_digest: null,
98
+ notify_watermarks: { ctx_pct: 0, diff_lines: 0, diff_files: 0 },
99
+ };
84
100
  const startTimes = new Map<string, { start: number; tool: string; cmd: string | null }>();
85
101
  let lastDiff = { lines: 0, files: 0, available: false, lowerBound: false };
86
102
  let lastCtxPct: number | null = null;
@@ -146,7 +162,7 @@ export default function (pi: ExtensionAPI) {
146
162
  return `pc-${Math.random().toString(36).slice(2, 10)}`;
147
163
  }
148
164
 
149
- async function runGit(ctx: ExtensionContext, args: string[]): Promise<string | null> {
165
+ async function runGit(_ctx: ExtensionContext, args: string[]): Promise<string | null> {
150
166
  try {
151
167
  const res = await pi.exec("git", args, { timeout: 5000 });
152
168
  if (res.code !== 0) return null;
@@ -492,19 +508,26 @@ export default function (pi: ExtensionAPI) {
492
508
  loadContract(ctx.cwd); // seeds contract.json from DEFAULT_CONTRACT if missing
493
509
 
494
510
  const prev = loadState(ctx.cwd);
495
- if (prev && prev.current_session?.id === session.id && prev.notify_watermarks) {
496
- state = { ...prev, counters: prev.counters ?? {} };
511
+ state = prev ?? defaultState(session.id, null);
512
+ // A session keeps ITS OWN baseline: a second pi session in the same
513
+ // project must not reset this one's digest or watermarks (SPEC §2).
514
+ const returning = prev !== null && prev.sessions[session.id] !== undefined;
515
+ view = sessionState(state, session.id);
516
+ if (returning && view.started !== "") {
497
517
  // Extension reloads can fire session_start again for the same pi
498
518
  // session. Preserve the original baseline from state instead of
499
519
  // silently resetting it to a newer commit.
500
- session.started = prev.current_session.started || new Date().toISOString();
501
- session.gitHead = prev.current_session.git_head;
520
+ session.started = view.started;
521
+ session.gitHead = view.git_head;
502
522
  } else {
503
523
  session.started = new Date().toISOString();
504
524
  session.gitHead = head?.trim() || null;
505
- state = defaultState(session.id, session.gitHead);
506
- saveState(ctx.cwd, state);
525
+ view.started = session.started;
526
+ view.git_head = session.gitHead;
527
+ view.brief_digest = null;
507
528
  }
529
+ state.current_session = { id: session.id, started: session.started, git_head: session.gitHead };
530
+ saveState(ctx.cwd, state);
508
531
  // A null baseline inside a repository means the session began before a
509
532
  // first commit (or before repository creation). Keep using the empty
510
533
  // tree even if HEAD appears later, so commits cannot erase session diff.
@@ -541,7 +564,7 @@ export default function (pi: ExtensionAPI) {
541
564
  lastEvidence: last ? { id: last.id, exit: last.exit, cmd: last.cmd, ms: last.ms } : null,
542
565
  });
543
566
  const digest = sha1Hex(brief);
544
- const briefDue = brief.trim() !== "" && (cfg.brief_mode !== "changed" || state.brief_digest !== digest);
567
+ const briefDue = brief.trim() !== "" && (cfg.brief_mode !== "changed" || view.brief_digest !== digest);
545
568
 
546
569
  // Orientation (goal #4): injected once per distinct digest, like the
547
570
  // brief. Lazily re-scanned if session_start did not populate it.
@@ -563,7 +586,7 @@ export default function (pi: ExtensionAPI) {
563
586
  if (parts.length === 0) return undefined;
564
587
 
565
588
  if (orientDue && orientDigest !== null) state.counters.orient_sha1 = orientDigest;
566
- if (briefDue) state.brief_digest = digest;
589
+ if (briefDue) view.brief_digest = digest;
567
590
  saveState(ctx.cwd, state);
568
591
  return { message: { customType: BRIEF_CUSTOM_TYPE, content: parts.join("\n\n"), display: false } };
569
592
  }),
@@ -590,6 +613,15 @@ export default function (pi: ExtensionAPI) {
590
613
  // block reason as the result) — mark it so the end handler does
591
614
  // not record the decline as a failed command.
592
615
  blockedToolCallIds.add(event.toolCallId);
616
+ // Record the refused attempt: otherwise the shared audit trail keeps
617
+ // no trace of the command the guard refused to run (SPEC §2).
618
+ withStoreLock(storeDir(ctx.cwd), () => {
619
+ appendJsonlLocked(
620
+ eventsPath(ctx.cwd),
621
+ toolCallEvent(session.id, "bash", { cmd: fp, blocked: true }),
622
+ );
623
+ loopLedgerMemo = loopLedgerStamp(ctx.cwd);
624
+ });
593
625
  return { block: true, reason: `guard: risky command declined (${risk})` };
594
626
  }
595
627
  } else {
@@ -614,9 +646,15 @@ export default function (pi: ExtensionAPI) {
614
646
  const blockedFailures = withStoreLock(storeDir(ctx.cwd), () => {
615
647
  if (loopLedgerMemo !== loopLedgerStamp(ctx.cwd)) rebuildLoopStateUnlocked(ctx.cwd);
616
648
  const failures = loopState.get(fp)?.failures ?? 0;
617
- if (shouldBlockLoop(failures, cfg.loop_block_after)) return failures;
618
- appendJsonlLocked(eventsPath(ctx.cwd), toolCallEvent(session.id, "bash"));
619
- loopLedgerMemo = loopLedgerStamp(ctx.cwd);
649
+ if (shouldBlockLoop(failures, cfg.loop_block_after)) {
650
+ appendJsonlLocked(
651
+ eventsPath(ctx.cwd),
652
+ toolCallEvent(session.id, "bash", { cmd: fp, blocked: true }),
653
+ );
654
+ loopLedgerMemo = loopLedgerStamp(ctx.cwd);
655
+ return failures;
656
+ }
657
+ appendJsonlLocked(eventsPath(ctx.cwd), toolCallEvent(session.id, "bash", { cmd: fp }));
620
658
  return null;
621
659
  });
622
660
  if (blockedFailures !== null) {
@@ -705,10 +743,10 @@ export default function (pi: ExtensionAPI) {
705
743
  const step = Math.floor(usage.percent / 10) * 10;
706
744
  const contextDue =
707
745
  step >= cfg.ctx_warn_pct &&
708
- (step > state.notify_watermarks.ctx_pct ||
746
+ (step > view.notify_watermarks.ctx_pct ||
709
747
  (cfg.ctx_warn_pct === 0 && state.counters.ctx_zero_notified !== 1));
710
748
  if (contextDue) {
711
- state.notify_watermarks.ctx_pct = step;
749
+ view.notify_watermarks.ctx_pct = step;
712
750
  markZeroThreshold(cfg.ctx_warn_pct, "ctx_zero_notified");
713
751
  saveState(ctx.cwd, state);
714
752
  if (ctx.hasUI) {
@@ -733,7 +771,7 @@ export default function (pi: ExtensionAPI) {
733
771
  };
734
772
  if (diff.repo && diff.available) {
735
773
  const { files, lines } = diff;
736
- const wm = state.notify_watermarks;
774
+ const wm = view.notify_watermarks;
737
775
  if (lines >= cfg.diff_hard_lines) {
738
776
  if (thresholdDue(lines, cfg.diff_hard_lines, wm.diff_lines, "diff_hard_zero_notified")) {
739
777
  wm.diff_lines = Math.max(wm.diff_lines, cfg.diff_hard_lines);
@@ -777,7 +815,7 @@ export default function (pi: ExtensionAPI) {
777
815
  // trusted journal entries and orientation even when their text is unchanged.
778
816
  pi.on("session_compact", async (_event, ctx) =>
779
817
  attempt(ctx, async () => {
780
- state.brief_digest = null;
818
+ view.brief_digest = null;
781
819
  delete state.counters.orient_sha1;
782
820
  saveState(ctx.cwd, state);
783
821
  return undefined;
@@ -820,15 +858,25 @@ export default function (pi: ExtensionAPI) {
820
858
  handler: async (args, ctx) =>
821
859
  attempt(ctx, async () => {
822
860
  const trimmed = (args ?? "").trim();
861
+ // `--global` edits the personal layer (~/.config/protect-context) so one
862
+ // constraint applies to every project; it is a owner-only surface.
863
+ const global_requested = /(^|\s)--global(\s|$)/.test(trimmed);
823
864
  const parts = trimmed.split(/\s+/);
824
865
  const verb = parts[0] ?? "";
825
- const rest = parts.slice(1).join(" ").trim();
866
+ const rest = parts
867
+ .slice(1)
868
+ .filter((part) => part !== "--global")
869
+ .join(" ")
870
+ .trim();
871
+ const scope = global_requested ? "user" : "project";
826
872
  if (verb === "" || verb === "list") {
827
873
  const current = loadContract(ctx.cwd);
828
874
  const lines = contractLines(current);
875
+ const personal = personalConstraintCount();
829
876
  if (ctx.hasUI) {
877
+ const note = personal > 0 ? `\npersonal layer: ${personal} (applies to every project)` : "";
830
878
  ctx.ui.notify(
831
- `contract (${current.constraints.length}):\n${lines.join("\n")}\nNudge: add missing constraints with /guard-contract add <text>.`,
879
+ `contract (${current.constraints.length}):\n${lines.join("\n")}${note}\nNudge: add missing constraints with /guard-contract add <text>.`,
832
880
  "info",
833
881
  );
834
882
  }
@@ -836,14 +884,15 @@ export default function (pi: ExtensionAPI) {
836
884
  }
837
885
  if (verb === "add") {
838
886
  if (rest === "") {
839
- if (ctx.hasUI) ctx.ui.notify("usage: /guard-contract add <text>", "warning");
887
+ if (ctx.hasUI) ctx.ui.notify("usage: /guard-contract add <text> [--global]", "warning");
840
888
  return undefined;
841
889
  }
842
890
  // Slash commands are direct owner interaction and therefore trusted
843
891
  // by brief prioritization; model-callable writes use provenance tags.
844
- const result = addConstraintWithStatus(ctx.cwd, rest, "user");
892
+ const result = addConstraintWithStatus(ctx.cwd, rest, "user", scope);
845
893
  const c = result.constraint;
846
894
  const total = result.total;
895
+ const where = scope === "user" ? " (personal layer — every project)" : "";
847
896
  if (ctx.hasUI) {
848
897
  // SPEC §5 caps the re-injected brief at 12 contract lines: a
849
898
  // constraint past the cap would be silently DROPPED while the
@@ -852,21 +901,29 @@ export default function (pi: ExtensionAPI) {
852
901
  const ack = !result.added
853
902
  ? `constraint ${c.id} already exists\nNudge: no duplicate was written.`
854
903
  : over
855
- ? `added ${c.id}\nwarning: ${total} constraints exceed the ${BRIEF_MAX_CONTRACT_LINES}-line brief cap — ` +
904
+ ? `added ${c.id}${where}\nwarning: ${total} constraints exceed the ${BRIEF_MAX_CONTRACT_LINES}-line brief cap — ` +
856
905
  `only the ${BRIEF_MAX_CONTRACT_LINES} highest-priority owner/seed constraints are re-injected.` +
857
906
  `\nNudge: keep the contract tight — fewer, sharper constraints survive re-injection.`
858
- : `added ${c.id}\nNudge: the brief re-injects on your next prompt.`;
907
+ : `added ${c.id}${where}\nNudge: the brief re-injects on your next prompt.`;
859
908
  ctx.ui.notify(ack, result.added && over ? "warning" : "info");
860
909
  }
861
910
  return undefined;
862
911
  }
863
912
  if (verb === "remove") {
864
- const removed = rest !== "" && removeConstraint(ctx.cwd, rest);
913
+ const removed = rest !== "" && removeConstraint(ctx.cwd, rest, scope);
865
914
  const safeId = truncLine(rest, 80);
915
+ // A project-scope remove cannot delete personal state: point at it
916
+ // instead of claiming the id does not exist.
917
+ const inPersonalLayer =
918
+ !removed &&
919
+ scope === "project" &&
920
+ loadContract(ctx.cwd).constraints.some((constraint) => constraint.id === rest);
866
921
  if (ctx.hasUI) {
867
922
  ctx.ui.notify(
868
923
  removed
869
924
  ? `removed ${safeId}\nNudge: the contract re-injects on your next prompt.`
925
+ : inPersonalLayer
926
+ ? `${safeId} lives in the personal layer\nNudge: re-run with /guard-contract remove ${safeId} --global.`
870
927
  : `no constraint '${safeId}'\nNudge: run /guard-contract list first.`,
871
928
  removed ? "info" : "warning",
872
929
  );
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { existsSync } from "node:fs";
6
6
  import { DEFAULT_CONFIG, type GuardConfig } from "./defaults.ts";
7
- import { configPath, readJson, storeDir, withStoreLock, writeJson } from "./store.ts";
7
+ import { configPath, readJson, storeDir, userConfigPath, withStoreLock, writeJson } from "./store.ts";
8
8
 
9
9
  function isInteger(v: unknown): v is number {
10
10
  return typeof v === "number" && Number.isInteger(v);
@@ -19,8 +19,11 @@ function strArray(v: unknown): string[] | null {
19
19
  * Merge a partial config over the frozen defaults. Unknown keys are ignored;
20
20
  * wrong-typed values fall back to the default (deterministic, test-pinned).
21
21
  */
22
- export function mergeConfig(partial: Record<string, unknown> | null | undefined): GuardConfig {
23
- const cfg: GuardConfig = { ...DEFAULT_CONFIG, preflight_tools: [...DEFAULT_CONFIG.preflight_tools] };
22
+ export function mergeConfig(
23
+ partial: Record<string, unknown> | null | undefined,
24
+ base: GuardConfig = DEFAULT_CONFIG,
25
+ ): GuardConfig {
26
+ const cfg: GuardConfig = { ...base, preflight_tools: [...base.preflight_tools] };
24
27
  if (!partial || typeof partial !== "object") return cfg;
25
28
 
26
29
  const p = partial as Record<string, unknown>;
@@ -38,20 +41,51 @@ export function mergeConfig(partial: Record<string, unknown> | null | undefined)
38
41
 
39
42
  export interface LoadedConfig {
40
43
  config: GuardConfig;
44
+ /** Unknown keys from the personal/project files, preserved but never
45
+ * interpreted by the guard (strongest layer wins). Kept out of
46
+ * `GuardConfig` so a typo in a known key still fails to type-check. */
47
+ extras: Record<string, unknown>;
41
48
  path: string;
42
49
  existed: boolean;
43
50
  }
44
51
 
45
- /** Load `.agent-guard/config.json` merged over defaults. Missing file →
46
- * defaults; extension startup can request an on-disk seed so an extension-only
47
- * install has the documented configuration surface without first running the
48
- * Python CLI. Existing corrupt files are never overwritten. */
52
+ const KNOWN_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
53
+
54
+ /** Unknown keys of one layer, for pass-through only. */
55
+ function layerExtras(raw: Record<string, unknown> | null): Record<string, unknown> {
56
+ if (!raw) return {};
57
+ return Object.fromEntries(Object.entries(raw).filter(([key]) => !KNOWN_KEYS.has(key)));
58
+ }
59
+
60
+ /** Project values identical to the built-in default mean "no opinion". A seeded
61
+ * project file records every default, so reading those as explicit choices
62
+ * would shadow the personal layer forever. */
63
+ function explicitProjectKeys(raw: Record<string, unknown> | null): Record<string, unknown> | undefined {
64
+ if (!raw) return undefined;
65
+ const defaults: Record<string, unknown> = Object.fromEntries(Object.entries(DEFAULT_CONFIG));
66
+ const out: Record<string, unknown> = {};
67
+ for (const [key, value] of Object.entries(raw)) {
68
+ if (JSON.stringify(value) === JSON.stringify(defaults[key])) continue;
69
+ out[key] = value;
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Load `.agent-guard/config.json` over the personal layer
75
+ * (`~/.config/protect-context/config.json`) over the defaults (SPEC §2).
76
+ * Extension startup can request an on-disk seed so an extension-only install
77
+ * has the documented configuration surface without the Python CLI; the seed is
78
+ * the built-in defaults, never the merged view. Corrupt files are never
79
+ * overwritten. */
49
80
  function readLoadedConfig(cwd: string): LoadedConfig {
50
81
  const path = configPath(cwd);
51
82
  const existed = existsSync(path);
52
- const raw = readJson<Record<string, unknown>>(path);
53
- const config = mergeConfig(raw ?? undefined);
54
- return { config, path, existed };
83
+ const personalRaw = readJson<Record<string, unknown>>(userConfigPath());
84
+ const projectRaw = readJson<Record<string, unknown>>(path);
85
+ const personal = mergeConfig(personalRaw ?? undefined);
86
+ const config = mergeConfig(explicitProjectKeys(projectRaw), personal);
87
+ const extras = { ...layerExtras(personalRaw), ...layerExtras(projectRaw) };
88
+ return { config, extras, path, existed };
55
89
  }
56
90
 
57
91
  export function loadConfig(cwd: string, seedMissing = false): LoadedConfig {
@@ -61,7 +95,7 @@ export function loadConfig(cwd: string, seedMissing = false): LoadedConfig {
61
95
  // by defaults between an optimistic exists() and writeJson().
62
96
  return withStoreLock(storeDir(cwd), () => {
63
97
  const loaded = readLoadedConfig(cwd);
64
- if (!loaded.existed) writeJson(loaded.path, loaded.config);
98
+ if (!loaded.existed) writeJson(loaded.path, DEFAULT_CONFIG);
65
99
  return loaded;
66
100
  });
67
101
  }
@@ -4,7 +4,15 @@
4
4
 
5
5
  import { existsSync } from "node:fs";
6
6
  import { DEFAULT_CONTRACT, type Constraint, type Contract } from "./defaults.ts";
7
- import { contractPath, readJson, storeDir, withStoreLock, writeJson } from "./store.ts";
7
+ import {
8
+ contractPath,
9
+ readJson,
10
+ storeDir,
11
+ userContractPath,
12
+ userDir,
13
+ withStoreLock,
14
+ writeJson,
15
+ } from "./store.ts";
8
16
  import { normalizeOneLine } from "./text.ts";
9
17
 
10
18
  /** Free-text cap (chars): constraint text flows into re-injected briefs (goal #12). */
@@ -75,8 +83,41 @@ function saveContractUnlocked(cwd: string, contract: Contract): void {
75
83
  writeJson(contractPath(cwd), contract);
76
84
  }
77
85
 
86
+ /** Personal-layer rows: a missing file means "no personal constraints" (never
87
+ * seeded), and an unreadable one is ignored in memory so a transient error can
88
+ * never become a write. SPEC §2: the personal layer is owner-only surface. */
89
+ function personalConstraints(): Constraint[] {
90
+ const data = readJson<unknown>(userContractPath());
91
+ if (!data || typeof data !== "object" || Array.isArray(data)) return [];
92
+ const rows = (data as { constraints?: unknown }).constraints;
93
+ return Array.isArray(rows) ? rows.filter(isConstraint) : [];
94
+ }
95
+
96
+ /** Project rows first, then personal rows the project does not already carry
97
+ * (same id or identical text): the project overrides the personal layer. */
98
+ export function mergeConstraints(project: Constraint[], personal: Constraint[]): Constraint[] {
99
+ const out: Constraint[] = [];
100
+ const ids = new Set<string>();
101
+ const signatures = new Set<string>();
102
+ for (const row of [...project, ...personal]) {
103
+ const id = normalizeOneLine(row.id, 80);
104
+ const signature = normalizeOneLine(row.text, TEXT_CAP);
105
+ if (ids.has(id) || signatures.has(signature)) continue;
106
+ ids.add(id);
107
+ signatures.add(signature);
108
+ out.push(row);
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Effective contract (project + personal) while the caller holds the lease. */
114
+ function effectiveContractUnlocked(cwd: string, forMutation = false): Contract {
115
+ const project = loadContractUnlocked(cwd, forMutation);
116
+ return { version: 1, constraints: mergeConstraints(project.constraints, personalConstraints()) };
117
+ }
118
+
78
119
  export function loadContract(cwd: string): Contract {
79
- return withStoreLock(storeDir(cwd), () => loadContractUnlocked(cwd));
120
+ return withStoreLock(storeDir(cwd), () => effectiveContractUnlocked(cwd));
80
121
  }
81
122
 
82
123
  export function saveContract(cwd: string, contract: Contract): void {
@@ -99,36 +140,102 @@ export interface AddConstraintResult {
99
140
  total: number;
100
141
  }
101
142
 
102
- export function addConstraintWithStatus(cwd: string, text: string, source = "agent"): AddConstraintResult {
143
+ export type ContractScope = "project" | "user";
144
+
145
+ /** Lease for the layer being written: the project store, or the personal
146
+ * directory (two projects must never share the wrong lease). */
147
+ function scopeRoot(cwd: string, scope: ContractScope): string {
148
+ return scope === "user" ? userDir() : storeDir(cwd);
149
+ }
150
+
151
+ /** Personal rows for a read-modify-write cycle. A missing personal layer is not
152
+ * an error (it is never seeded); a damaged one fails closed so a mutation
153
+ * cannot silently erase owner data. */
154
+ function personalForMutation(): Constraint[] {
155
+ const file = userContractPath();
156
+ if (!existsSync(file)) return [];
157
+ const data = readJson<unknown>(file);
158
+ if (!data || typeof data !== "object" || !Array.isArray((data as { constraints?: unknown }).constraints)) {
159
+ throw new ContractIntegrityError(`personal contract is malformed; repair ${file} before mutating it`);
160
+ }
161
+ const rows = (data as { constraints: unknown[] }).constraints;
162
+ const valid = rows.filter(isConstraint);
163
+ if (valid.length !== rows.length) {
164
+ throw new ContractIntegrityError(`personal contract contains malformed rows; repair ${file} before mutating it`);
165
+ }
166
+ return valid;
167
+ }
168
+
169
+ /** Project rows without seeding or writing, as a basis for personal-layer
170
+ * mutations (never hold the user lease while writing the project store). */
171
+ function projectRowsReadonly(cwd: string): Constraint[] {
172
+ const data = readJson<unknown>(contractPath(cwd));
173
+ if (!data || typeof data !== "object" || !Array.isArray((data as { constraints?: unknown }).constraints)) {
174
+ return cloneDefaultContract().constraints;
175
+ }
176
+ const rows = (data as { constraints: unknown[] }).constraints;
177
+ const valid = rows.filter(isConstraint);
178
+ return rows.length > 0 && valid.length === 0 ? cloneDefaultContract().constraints : valid;
179
+ }
180
+
181
+ function scopeRows(cwd: string, scope: ContractScope): Constraint[] {
182
+ return scope === "user" ? personalForMutation() : loadContractUnlocked(cwd, true).constraints;
183
+ }
184
+
185
+ function saveScope(cwd: string, scope: ContractScope, constraints: Constraint[]): void {
186
+ const contract: Contract = { version: 1, constraints };
187
+ if (scope === "user") {
188
+ writeJson(userContractPath(), contract);
189
+ return;
190
+ }
191
+ saveContractUnlocked(cwd, contract);
192
+ }
193
+
194
+ /** Constraints the personal layer contributes to the effective contract. */
195
+ export function personalConstraintCount(): number {
196
+ return personalConstraints().length;
197
+ }
198
+
199
+ export function addConstraintWithStatus(
200
+ cwd: string,
201
+ text: string,
202
+ source = "agent",
203
+ scope: ContractScope = "project",
204
+ ): AddConstraintResult {
103
205
  const cleanText = normalizeOneLine(text, TEXT_CAP);
104
206
  if (cleanText === "") throw new Error("constraint text must not be blank");
105
- return withStoreLock(storeDir(cwd), () => {
106
- const contract = loadContractUnlocked(cwd, true);
107
- const existing = contract.constraints.find((constraint) => normalizeOneLine(constraint.text, TEXT_CAP) === cleanText);
108
- if (existing) return { constraint: existing, added: false, total: contract.constraints.length };
207
+ return withStoreLock(scopeRoot(cwd, scope), () => {
208
+ const rows = scopeRows(cwd, scope);
209
+ // Dedupe and number against the EFFECTIVE contract: a personal constraint
210
+ // must never be duplicated into a project, and ids never collide.
211
+ const effective =
212
+ scope === "user"
213
+ ? mergeConstraints(projectRowsReadonly(cwd), personalConstraints())
214
+ : mergeConstraints(rows, personalConstraints());
215
+ const existing = effective.find((constraint) => normalizeOneLine(constraint.text, TEXT_CAP) === cleanText);
216
+ if (existing) return { constraint: existing, added: false, total: effective.length };
109
217
  const constraint: Constraint = {
110
- id: nextConstraintId(contract.constraints),
218
+ id: nextConstraintId(effective),
111
219
  text: cleanText,
112
220
  added: new Date().toISOString().slice(0, 10),
113
221
  source: normalizeOneLine(source, 40) || "agent",
114
222
  };
115
- contract.constraints.push(constraint);
116
- saveContractUnlocked(cwd, contract);
117
- return { constraint, added: true, total: contract.constraints.length };
223
+ rows.push(constraint);
224
+ saveScope(cwd, scope, rows);
225
+ return { constraint, added: true, total: effective.length + 1 };
118
226
  });
119
227
  }
120
228
 
121
- export function addConstraint(cwd: string, text: string, source = "agent"): Constraint {
122
- return addConstraintWithStatus(cwd, text, source).constraint;
229
+ export function addConstraint(cwd: string, text: string, source = "agent", scope: ContractScope = "project"): Constraint {
230
+ return addConstraintWithStatus(cwd, text, source, scope).constraint;
123
231
  }
124
232
 
125
- export function removeConstraint(cwd: string, id: string): boolean {
126
- return withStoreLock(storeDir(cwd), () => {
127
- const contract = loadContractUnlocked(cwd, true);
128
- const before = contract.constraints.length;
129
- contract.constraints = contract.constraints.filter((c) => c.id !== id);
130
- if (contract.constraints.length === before) return false;
131
- saveContractUnlocked(cwd, contract);
233
+ export function removeConstraint(cwd: string, id: string, scope: ContractScope = "project"): boolean {
234
+ return withStoreLock(scopeRoot(cwd, scope), () => {
235
+ const rows = scopeRows(cwd, scope);
236
+ const kept = rows.filter((c) => c.id !== id);
237
+ if (kept.length === rows.length) return false;
238
+ saveScope(cwd, scope, kept);
132
239
  return true;
133
240
  });
134
241
  }
@@ -10,10 +10,11 @@ export interface EventRecord {
10
10
  session: string;
11
11
  kind: EventKind;
12
12
  tool: string;
13
+ /** null on an ordinary tool_call; false when the call was refused. */
13
14
  ok: boolean | null;
14
15
  ms: number | null;
15
16
  out_chars: number | null;
16
- /** Only set for bash tool_result events; fingerprint-normalized. */
17
+ /** bash rows only, fingerprint-normalized (tool_call and tool_result). */
17
18
  cmd: string | null;
18
19
  exit: number | null;
19
20
  }
@@ -22,16 +23,22 @@ function nowIso(): string {
22
23
  return new Date().toISOString();
23
24
  }
24
25
 
25
- export function toolCallEvent(session: string, tool: string): EventRecord {
26
+ export function toolCallEvent(
27
+ session: string,
28
+ tool: string,
29
+ opts: { cmd?: string | null; blocked?: boolean } = {},
30
+ ): EventRecord {
26
31
  return {
27
32
  ts: nowIso(),
28
33
  session,
29
34
  kind: "tool_call",
30
35
  tool,
31
- ok: null,
36
+ // A refused call (loop block / declined risk confirm) has no result, so the
37
+ // attempt is recorded as ok=false; an ordinary call has no outcome yet.
38
+ ok: opts.blocked ? false : null,
32
39
  ms: null,
33
40
  out_chars: null,
34
- cmd: null,
41
+ cmd: opts.cmd ?? null,
35
42
  exit: null,
36
43
  };
37
44
  }
@@ -20,8 +20,8 @@ import {
20
20
  realpathSync,
21
21
  } from "node:fs";
22
22
  import { createHash, randomBytes } from "node:crypto";
23
- import { hostname } from "node:os";
24
- import { dirname, join } from "node:path";
23
+ import { hostname, homedir } from "node:os";
24
+ import { basename, dirname, join } from "node:path";
25
25
  import { performance } from "node:perf_hooks";
26
26
 
27
27
  export const STORE_DIR_NAME = ".agent-guard";
@@ -36,6 +36,26 @@ export function ensureStore(cwd: string): string {
36
36
  return dir;
37
37
  }
38
38
 
39
+ export const USER_DIR_ENV = "PC_USER_DIR";
40
+
41
+ /** Personal layer directory (SPEC §2): PC_USER_DIR, else $XDG_CONFIG_HOME,
42
+ * else ~/.config, always under `protect-context/`. Read at call time so tests
43
+ * and dotfiles-managed machines can redirect it. */
44
+ export function userDir(): string {
45
+ const override = (process.env[USER_DIR_ENV] ?? "").trim();
46
+ if (override !== "") return override;
47
+ const xdg = (process.env.XDG_CONFIG_HOME ?? "").trim();
48
+ return join(xdg !== "" ? xdg : join(homedir(), ".config"), "protect-context");
49
+ }
50
+
51
+ export function userContractPath(): string {
52
+ return join(userDir(), "contract.json");
53
+ }
54
+
55
+ export function userConfigPath(): string {
56
+ return join(userDir(), "config.json");
57
+ }
58
+
39
59
  export function configPath(cwd: string): string {
40
60
  return join(storeDir(cwd), "config.json");
41
61
  }
@@ -338,6 +358,7 @@ export function withStoreLock<T>(dir: string, fn: () => T, options: StoreLockOpt
338
358
  /** Append one JSON record as a single O_APPEND line write. The caller must
339
359
  * already hold the store lock (see withStoreLock). */
340
360
  export function appendJsonlLocked(file: string, record: unknown): void {
361
+ maybeRotateLedger(file);
341
362
  appendFileSync(file, `${JSON.stringify(record)}\n`, "utf8");
342
363
  }
343
364
 
@@ -371,11 +392,50 @@ export function fileStamp(file: string): string | null {
371
392
 
372
393
  const jsonlCache = new Map<string, { mtimeMs: number; size: number; data: unknown[] }>();
373
394
 
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 [];
395
+ /** Ledger bounding (SPEC §2): a ledger rotates to `<stem>.N.jsonl` once it
396
+ * passes LEDGER_MAX_BYTES, keeping at most LEDGER_SEGMENTS rotated files.
397
+ * Readers concatenate oldest-first, so "last N rows" stays correct and the
398
+ * newest evidence id (the basis for `ev-NNNN`) is never rotated away. */
399
+ export const LEDGER_MAX_BYTES = 1_048_576;
400
+ export const LEDGER_SEGMENTS = 3;
401
+
402
+ function segmentPath(file: string, index: number): string {
403
+ const name = basename(file);
404
+ const dot = name.lastIndexOf(".");
405
+ const stem = dot === -1 ? name : name.slice(0, dot);
406
+ const ext = dot === -1 ? "" : name.slice(dot);
407
+ return join(dirname(file), `${stem}.${index}${ext}`);
408
+ }
409
+
410
+ /** Existing ledger files for `file`, OLDEST first: rotated segments (highest
411
+ * index first) and the live file last. */
412
+ export function ledgerFiles(file: string): string[] {
413
+ const files: string[] = [];
414
+ for (let index = LEDGER_SEGMENTS; index >= 1; index -= 1) {
415
+ const segment = segmentPath(file, index);
416
+ if (existsSync(segment)) files.push(segment);
417
+ }
418
+ if (existsSync(file)) files.push(file);
419
+ return files;
420
+ }
421
+
422
+ function maybeRotateLedger(file: string): void {
423
+ let size: number;
424
+ try {
425
+ size = statSync(file).size;
426
+ } catch {
427
+ return;
428
+ }
429
+ if (size < LEDGER_MAX_BYTES) return;
430
+ // Caller holds the store lease, so the shift cannot race another writer.
431
+ for (let index = LEDGER_SEGMENTS - 1; index >= 1; index -= 1) {
432
+ const src = segmentPath(file, index);
433
+ if (existsSync(src)) renameSync(src, segmentPath(file, index + 1));
434
+ }
435
+ renameSync(file, segmentPath(file, 1));
436
+ }
437
+
438
+ function readOneJsonl<T>(file: string): T[] {
379
439
  const st = statOf(file);
380
440
  if (!st) return [];
381
441
  const cached = jsonlCache.get(file);
@@ -400,6 +460,15 @@ export function readJsonl<T>(file: string): T[] {
400
460
  return data as T[];
401
461
  }
402
462
 
463
+ /** Read a ledger into object records, oldest row first across rotated
464
+ * segments; missing file → []. Malformed and non-object lines are skipped,
465
+ * matching the Python ledger reader. Results are cached by mtime+size. */
466
+ export function readJsonl<T>(file: string): T[] {
467
+ const data: unknown[] = [];
468
+ for (const segment of ledgerFiles(file)) data.push(...readOneJsonl<unknown>(segment));
469
+ return data as T[];
470
+ }
471
+
403
472
  export function readJson<T>(file: string): T | null {
404
473
  if (!existsSync(file)) return null;
405
474
  try {
@@ -430,21 +499,78 @@ export function writeJson(file: string, value: unknown): void {
430
499
  // ---------------------------------------------------------------------------
431
500
  // state.json (SPEC §2)
432
501
 
433
- export interface GuardState {
434
- current_session: { id: string; started: string; git_head: string | null };
502
+ export interface SessionState {
503
+ started: string;
504
+ git_head: string | null;
435
505
  brief_digest: string | null;
436
506
  notify_watermarks: { ctx_pct: number; diff_lines: number; diff_files: number };
507
+ /** ISO timestamp of the last touch, used only to bound the file. */
508
+ updated?: string;
509
+ }
510
+
511
+ /** Baseline that belongs to ONE pi session. Concurrent sessions in one project
512
+ * keep separate entries so they cannot clobber each other (SPEC §2). */
513
+ export interface GuardState {
514
+ current_session: { id: string; started: string; git_head: string | null };
515
+ sessions: Record<string, SessionState>;
437
516
  /** Opaque passthrough on the Python side; the extension stores digests here
438
517
  * (e.g. the orientation digest) alongside numeric counters. */
439
518
  counters: Record<string, number | string>;
440
519
  }
441
520
 
521
+ /** How many per-session baselines to retain (newest first, current always). */
522
+ export const SESSION_KEEP = 8;
523
+
442
524
  export function defaultState(sessionId: string, gitHead: string | null): GuardState {
443
525
  return {
444
526
  current_session: { id: sessionId, started: new Date().toISOString(), git_head: gitHead },
527
+ sessions: {},
528
+ counters: {},
529
+ };
530
+ }
531
+
532
+ function freshSession(started: string, gitHead: string | null): SessionState {
533
+ return {
534
+ started,
535
+ git_head: gitHead,
445
536
  brief_digest: null,
446
537
  notify_watermarks: { ctx_pct: 0, diff_lines: 0, diff_files: 0 },
447
- counters: {},
538
+ };
539
+ }
540
+
541
+ /** Live per-session entry of `state`, created (and touched) when absent. */
542
+ export function sessionState(state: GuardState, sessionId: string): SessionState {
543
+ const existing = state.sessions[sessionId];
544
+ const entry = existing ?? freshSession(state.current_session.started || "", state.current_session.git_head ?? null);
545
+ entry.updated = new Date().toISOString();
546
+ state.sessions[sessionId] = entry;
547
+ return entry;
548
+ }
549
+
550
+ function numberOrZero(candidate: unknown): number {
551
+ return typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0 ? candidate : 0;
552
+ }
553
+
554
+ function readWatermarks(raw: unknown): SessionState["notify_watermarks"] {
555
+ const value =
556
+ raw !== null && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
557
+ return {
558
+ ctx_pct: numberOrZero(value.ctx_pct),
559
+ diff_lines: numberOrZero(value.diff_lines),
560
+ diff_files: numberOrZero(value.diff_files),
561
+ };
562
+ }
563
+
564
+ function readSession(raw: unknown): SessionState | null {
565
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
566
+ const value = raw as Record<string, unknown>;
567
+ const gitHead = value.git_head;
568
+ return {
569
+ started: typeof value.started === "string" ? value.started : "",
570
+ git_head: typeof gitHead === "string" || gitHead === null ? gitHead : null,
571
+ brief_digest: typeof value.brief_digest === "string" ? value.brief_digest : null,
572
+ notify_watermarks: readWatermarks(value.notify_watermarks),
573
+ updated: typeof value.updated === "string" ? value.updated : undefined,
448
574
  };
449
575
  }
450
576
 
@@ -458,16 +584,29 @@ export function loadState(cwd: string): GuardState | null {
458
584
  !Array.isArray(value.current_session)
459
585
  ? (value.current_session as Record<string, unknown>)
460
586
  : {};
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;
587
+ const gitHead = rawSession.git_head;
588
+ const sessions: Record<string, SessionState> = {};
589
+ if (value.sessions !== null && typeof value.sessions === "object" && !Array.isArray(value.sessions)) {
590
+ for (const [id, entry] of Object.entries(value.sessions as Record<string, unknown>)) {
591
+ const parsed = readSession(entry);
592
+ if (parsed) sessions[id] = parsed;
593
+ }
594
+ }
595
+ const currentId = typeof rawSession.id === "string" ? rawSession.id : "";
596
+ // Legacy v1 files kept one digest/watermark pair at the top level; migrate it
597
+ // into the current session's entry in memory rather than dropping it.
598
+ if (currentId !== "" && sessions[currentId] === undefined) {
599
+ const legacyDigest = typeof value.brief_digest === "string" ? value.brief_digest : null;
600
+ const legacyWatermarks = readWatermarks(value.notify_watermarks);
601
+ if (legacyDigest !== null || Object.values(legacyWatermarks).some((n) => n > 0)) {
602
+ sessions[currentId] = {
603
+ started: typeof rawSession.started === "string" ? rawSession.started : "",
604
+ git_head: typeof gitHead === "string" || gitHead === null ? gitHead : null,
605
+ brief_digest: legacyDigest,
606
+ notify_watermarks: legacyWatermarks,
607
+ };
608
+ }
609
+ }
471
610
  const rawCounters =
472
611
  value.counters !== null && typeof value.counters === "object" && !Array.isArray(value.counters)
473
612
  ? (value.counters as Record<string, unknown>)
@@ -478,23 +617,31 @@ export function loadState(cwd: string): GuardState | null {
478
617
  counters[key] = candidate;
479
618
  }
480
619
  }
481
- const gitHead = rawSession.git_head;
482
620
  return {
483
621
  current_session: {
484
- id: typeof rawSession.id === "string" ? rawSession.id : "",
622
+ id: currentId,
485
623
  started: typeof rawSession.started === "string" ? rawSession.started : "",
486
624
  git_head: typeof gitHead === "string" || gitHead === null ? gitHead : null,
487
625
  },
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
- },
626
+ sessions,
494
627
  counters,
495
628
  };
496
629
  }
497
630
 
631
+ /** Persist state.json, keeping the file bounded (SESSION_KEEP entries, the
632
+ * current session always among them so its baseline is never pruned). */
498
633
  export function saveState(cwd: string, state: GuardState): void {
499
- writeJson(statePath(cwd), state);
634
+ const currentId = state.current_session.id;
635
+ const ordered = Object.entries(state.sessions).sort(([, a], [, b]) =>
636
+ String(b.updated ?? b.started ?? "").localeCompare(String(a.updated ?? a.started ?? "")),
637
+ );
638
+ const reserve = currentId !== "" && state.sessions[currentId] !== undefined ? 1 : 0;
639
+ const kept: Record<string, SessionState> = {};
640
+ for (const [id, entry] of ordered) {
641
+ if (id === currentId) continue;
642
+ if (Object.keys(kept).length >= SESSION_KEEP - reserve) break;
643
+ kept[id] = entry;
644
+ }
645
+ if (reserve === 1) kept[currentId] = state.sessions[currentId];
646
+ writeJson(statePath(cwd), { ...state, sessions: kept });
500
647
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protect-context",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Pi extension that adds agent guardrails: context brief, verification evidence, loop blocking, diff-scope warnings, and a durable journal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "node": ">=22.19.0"
37
37
  },
38
38
  "scripts": {
39
+ "pretest": "node --input-type=module -e \"import{existsSync,readdirSync}from'node:fs';const d='extension/test';const ok=existsSync(d)&&readdirSync(d).some(f=>f.endsWith('.test.ts'));if(!ok){console.error('pretest: no extension/test/*.test.ts found here. Tests ship only in the repository checkout, not in the npm tarball.');process.exit(1)}\"",
39
40
  "test": "node --experimental-strip-types --test extension/test/*.test.ts",
40
41
  "typecheck": "tsc --noEmit -p extension/tsconfig.json",
41
42
  "check": "npm run typecheck && npm test",