protect-context 1.3.2 → 2.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/README.md CHANGED
@@ -83,7 +83,7 @@ without turning the agent into a different agent.
83
83
 
84
84
  | Primitive | What it gives you | Where you meet it |
85
85
  |---|---|---|
86
- | **Brief** | Contract, recent trusted decisions and corrections, open loops, and the last evidence ID, injected before the agent starts a turn | Automatic in pi; `orientation`, `status`, `guard_journal` elsewhere |
86
+ | **Brief** | Owner task, contract, recent trusted decisions and corrections, a short unverified section for the agent's own notes, open loops, and the last evidence ID — injected before a turn and re-asserted mid-run after a compaction | Automatic in pi; `orientation`, `status`, `guard_journal` elsewhere |
87
87
  | **Orientation** | Bounded repo digest: tree, languages, and conventions files (`CLAUDE.md`, `AGENTS.md`, `CONVENTIONS.md`, `README.md`, `README`) | Injected once per digest in pi; `orientation` in CLI/MCP |
88
88
  | **Evidence ledger** | Every verification run recorded as `ev-NNNN` with exit code, duration, command, and a bounded output digest | `guard_verify`, `protect-context verify`, `guard_evidence` |
89
89
  | **Loop brake** | Identical failing commands are fingerprinted; the Nth attempt is blocked instead of re-run | Hard block in pi and the CLI; reported through MCP |
@@ -284,8 +284,10 @@ PC Δ+88L/6f 0loop ev5 ctx8%
284
284
  | Verification evidence (`ev-NNNN`) | `guard_verify`, `/guard-verify` | `guard_verify` | `verify` |
285
285
  | Loop blocking | blocks the command | `guard_loop_check` reports and advises | `verify` refuses; `loops` exits 1 |
286
286
  | Scope audit | session-start baseline + warnings | `guard_diff_audit` (explicit baseline) | `diff --baseline REF` |
287
- | Journal write/read | `guard_note`, `/guard-note` | `guard_note`, `guard_journal` | `note`, `journal` |
288
- | Trusted re-injection of decisions | yes, after change or compaction | read views only | read views only |
287
+ | Journal write/read | `guard_note`, `/guard-note`, `guard_journal` | `guard_note`, `guard_journal` | `note`, `journal` |
288
+ | Trusted re-injection of decisions | yes, after change and after every compaction (incl. mid-run) | read views only | read views only |
289
+ | Re-injection of the agent's own notes | yes, in an explicitly unverified section | read views only | read views only |
290
+ | Owner task anchor | `/guard-task`, auto-captured from the first prompt | — | — |
289
291
  | Contract management | `/guard-contract` | `guard_contract` | `contract list\|add\|remove` |
290
292
  | Risky-command confirmation | one grouped confirm | host approval policy | — |
291
293
  | Status widget | yes | — | `status` |
@@ -302,6 +304,7 @@ Slash commands:
302
304
  | `/guard-contract` | `list` · `add <text>` · `remove <id>` — edit the persistent contract |
303
305
  | `/guard-verify` | Run a command through the evidence ledger |
304
306
  | `/guard-note` | Journal a `decision` · `correction` · `risk` · `note` |
307
+ | `/guard-task` | Show · set `<text>` · `--clear` the owner task anchor that the brief re-injects |
305
308
  | `/guard-report` | Full session summary for handoff or review |
306
309
 
307
310
  Model-callable tools:
@@ -310,8 +313,9 @@ Model-callable tools:
310
313
  |---|---|---|
311
314
  | `guard_verify` | `command`, `timeout_s` (default 180, clamped 1–900) | Records `ev-NNNN`; refusal on a blocked loop |
312
315
  | `guard_note` | `type`, `text`, optional `tags` | Forced to the `agent` trust level |
316
+ | `guard_journal` | `limit` (0–20, default 5), optional `type` | Read-only; recovers decisions, corrections, and the agent's own earlier notes after a compaction |
313
317
  | `guard_contract_check` | `summary` | Deterministic keyword coverage checklist, never a proof |
314
- | `guard_status` | — | Same data that feeds the widget |
318
+ | `guard_status` | — | Same data that feeds the widget, including journal totals |
315
319
 
316
320
  ### MCP tools
317
321
 
@@ -497,7 +501,7 @@ when owner rows are malformed, preserving the original file for repair.
497
501
  | 3 | Editing too much / scope creep | Git diff audit over tracked, staged, and bounded non-ignored untracked work; safe explicit baselines | Warn/advisory |
498
502
  | 4 | Misunderstanding the repo | Worktree-root-aware bounded orientation digest in pi, CLI, and MCP; conventions-file discovery | Automatic context + explicit read |
499
503
  | 5 | Plausible but subtly wrong code | Deterministic contract checklist, verification gate, test-required seed | Heuristic/advisory |
500
- | 6 | Losing earlier context | Persistent journal; recent trusted decisions re-injected after change and compaction; CLI/MCP read views | Automatic context + explicit read |
504
+ | 6 | Losing earlier context | Persistent journal; owner task anchor; recent trusted decisions re-injected after change and after every compaction (including mid-run threshold/overflow); agent notes re-injected as unverified; `guard_journal` read views | Automatic context + explicit read |
501
505
  | 7 | Repeating corrected behavior | Bounded tagged corrections; trusted corrections re-injected; model-authored entries retained for owner review | Automatic context + trust boundary |
502
506
  | 8 | Large diffs | Configurable line/file warning and hard-level notifications, status verdict, widget | Warn/advisory |
503
507
  | 9 | Repetitive debugging loops | Shared bash/evidence streak reconstruction; third identical failing command blocked by default in pi, CLI, and MCP | Hard block at configured threshold |
@@ -571,9 +575,13 @@ its count treated as a lower bound — the widget renders it as `Δ≥`.
571
575
  ### Trust and provenance
572
576
 
573
577
  Direct CLI and pi slash-command contract writes are treated as owner input.
574
- MCP/model journal writes are forcibly tagged `agent`: they stay inspectable
575
- through journal views but are not re-injected as trusted decisions or
576
- corrections. CLI and pi slash-command journal entries are trusted. Untagged
578
+ MCP/model journal writes are forcibly tagged `agent`: they never enter the
579
+ trusted `decisions:`/`corrections:` sections. They are not discarded either —
580
+ the brief shows the newest two under `agent notes (unverified):`, clearly
581
+ labelled and capped shorter than trusted text, and `guard_journal` returns the
582
+ full stored entry. That keeps the model's own reasoning recoverable after a
583
+ compaction without granting it authority. CLI and pi slash-command journal
584
+ entries are trusted. Untagged
577
585
  legacy entries are treated as trusted for backward compatibility, because the
578
586
  frozen journal schema has no dedicated author field.
579
587
 
@@ -625,6 +633,9 @@ instead of leaving stale numbers in this README.
625
633
  | `Δ?` in the widget | Not a Git worktree, so no diff baseline | Run inside a repository, or use the CLI with an explicit `--baseline` |
626
634
  | `Δ≥` in the widget | Bounded untracked scan hit a cap | Treat the count as a lower bound; narrow the scan or review scope manually |
627
635
  | Loop never blocks | Commands differ (paths, flags) so fingerprints differ | Compare with `loops` / `guard_loop_check`; fingerprints intentionally ignore whitespace only |
636
+ | Agent forgot its own earlier decision after a compaction | The brief shows only the newest 2 agent notes, 120 chars each | Read the full entry with `guard_journal` |
637
+ | No guard block after an automatic compaction | Host emits no `context` event, so mid-run re-assertion cannot run | It returns at the next user prompt; check the host supports the `context` hook |
638
+ | `task:` line is wrong or missing | Auto-capture ignores short prompts and slash commands, and an owner task always wins | Set it explicitly with `/guard-task <text>` or `/guard-task --clear` |
628
639
  | `PC_SESSION_ID` changes nothing in evidence | It scopes ledger *views*, not `state.json` | Expected: state is project-global by design |
629
640
  | MCP tool missing in host | Server not started with the venv interpreter | Point `command` at `.venv/bin/python` and pass `-m protect_context.mcp_server` |
630
641
  | Extension does not load | Node older than 22.19 or pi not restarted | Check `pi list`, then restart; use `pi -ne` to start without extensions |
@@ -662,15 +673,35 @@ instead of leaving stale numbers in this README.
662
673
 
663
674
  #### Determinism
664
675
 
665
- - The frozen brief carries at most 12 constraints. Overflow is visible, and
666
- owner constraints are prioritized, but hidden constraints are not enforced by
667
- context injection.
676
+ - The frozen brief carries at most 12 constraints, 3 trusted decisions, 3
677
+ trusted corrections, and 2 agent notes. Overflow is visible (each section
678
+ announces what it hid) and points at `guard_journal`, but hidden entries are
679
+ not enforced by context injection.
668
680
  - Frozen fingerprint normalization collapses whitespace everywhere, including
669
681
  inside quoted shell strings. Semantically different commands can therefore
670
682
  share a loop fingerprint.
671
683
  - The deterministic contract checker is keyword coverage, not program analysis.
672
684
  Treat every `addressed` result as a review prompt, not proof.
673
685
 
686
+ #### Compaction
687
+
688
+ - Re-injection is only as good as the store. A constraint, decision, or task
689
+ that was never written down cannot be restored, and the brief is a bounded
690
+ pointer rather than a transcript; `guard_journal` returns the full entry.
691
+ - A mid-run compaction (threshold or overflow recovery) resumes the turn
692
+ through `AgentStart`, where `before_agent_start` does not run. The extension
693
+ arms a `context` hook on `session_compact` and re-asserts the block on every
694
+ request until a persisted one is back in the conversation. On a host that
695
+ does not emit `context`, the block returns at the next user prompt instead.
696
+ - Task capture stores the first substantive prompt (≤200 chars) in
697
+ `.agent-guard/state.json`. That is conversation content: it stays local and
698
+ gitignored, but review it before sharing a store, and clear it with
699
+ `/guard-task --clear`.
700
+ - Agent notes are unverified context, never authority. A model that journals
701
+ "ignore the contract" is shown that sentence back under an explicitly
702
+ unverified heading; treat it as data, and keep owner constraints in the
703
+ contract where they are trusted.
704
+
674
705
  #### Store
675
706
 
676
707
  - `state.json` is project-global. Atomic replacement prevents torn JSON, but
@@ -46,12 +46,17 @@ import {
46
46
  } from "./lib/contract.ts";
47
47
  import {
48
48
  appendJournal,
49
+ boundedReadLimit,
50
+ countJournal,
49
51
  isJournalType,
50
52
  type JournalType,
51
53
  readJournal,
52
- recentCorrections,
53
- recentDecisions,
54
+ READ_LIMIT_DEFAULT,
55
+ recentAgentEntries,
56
+ recentJournal,
57
+ renderJournal,
54
58
  tagsWithRequired,
59
+ trustedEntries,
55
60
  } from "./lib/journal.ts";
56
61
  import { toolCallEvent, toolResultEvent, bashExitFromResult, type EventRecord } from "./lib/events.ts";
57
62
  import {
@@ -61,7 +66,14 @@ import {
61
66
  shouldBlockLoop,
62
67
  type LoopInfo,
63
68
  } from "./lib/loops.ts";
64
- import { buildBrief, sha1Hex, BRIEF_MAX_CONTRACT_LINES, CMD_DISPLAY_MAX, truncLine } from "./lib/brief.ts";
69
+ import {
70
+ buildBrief,
71
+ sha1Hex,
72
+ BRIEF_MAX_CONTRACT_LINES,
73
+ CMD_DISPLAY_MAX,
74
+ TASK_DISPLAY_MAX,
75
+ truncLine,
76
+ } from "./lib/brief.ts";
65
77
  import { parseNumstat, parseUntrackedPaths, scanUntrackedFiles } from "./lib/gitparse.ts";
66
78
  import { renderWidget } from "./lib/widget.ts";
67
79
  import { checkContractSummary, renderChecklist } from "./lib/contract_check.ts";
@@ -82,6 +94,9 @@ const WIDGET_ID = "protect-context";
82
94
  const BRIEF_CUSTOM_TYPE = "guard-brief";
83
95
  /** Upper clamp for guard_verify timeouts (keep in sync with verify.TIMEOUT_MAX_S). */
84
96
  const MAX_VERIFY_TIMEOUT_S = 900;
97
+ /** A prompt shorter than this is a follow-up ("continue", "go on"), not a task
98
+ * statement — capturing it would erase the real request. */
99
+ const TASK_MIN_CAPTURE_CHARS = 20;
85
100
 
86
101
  export default function (pi: ExtensionAPI) {
87
102
  // Session-scoped state (rebuilt on every session_start; new extension
@@ -100,6 +115,13 @@ export default function (pi: ExtensionAPI) {
100
115
  const startTimes = new Map<string, { start: number; tool: string; cmd: string | null }>();
101
116
  let lastDiff = { lines: 0, files: 0, available: false, lowerBound: false };
102
117
  let lastCtxPct: number | null = null;
118
+ // Mid-run re-injection (SPEC §5): a threshold/overflow compaction retries the
119
+ // run via AgentStart, so before_agent_start never fires for it. session_compact
120
+ // arms this flag; the `context` handler (per LLM request) then re-asserts the
121
+ // guard block until a persisted one is seen again. Outside that window the
122
+ // handler is an O(1) no-op; inside it the block is rebuilt per request, so a
123
+ // note written after the compaction is included.
124
+ let pendingReinject = false;
103
125
 
104
126
  // In-memory guardrail state (goal #12: O(1) hot paths — the event handlers
105
127
  // and widget never re-parse the JSONL files; disk reads happen once per
@@ -138,8 +160,10 @@ export default function (pi: ExtensionAPI) {
138
160
 
139
161
  // --- helpers ---------------------------------------------------------
140
162
 
141
- /** Run an event/command body without ever throwing (SPEC §5). */
142
- async function attempt<T>(ctx: ExtensionContext, fn: () => Promise<T>): Promise<T | undefined> {
163
+ /** Run an event/command body without ever throwing (SPEC §5). Synchronous
164
+ * bodies are accepted so a handler that only formats text does not need a
165
+ * token `async` wrapper. */
166
+ async function attempt<T>(ctx: ExtensionContext, fn: () => Promise<T> | T): Promise<T | undefined> {
143
167
  try {
144
168
  return await fn();
145
169
  } catch (err) {
@@ -385,6 +409,7 @@ export default function (pi: ExtensionAPI) {
385
409
  const d = await gatherDiff(ctx);
386
410
  lastDiff = { files: d.files, lines: d.lines, available: d.available, lowerBound: d.scanLimited };
387
411
  const ev = sessionEvidence(ctx.cwd);
412
+ const journal = readJournal(ctx.cwd);
388
413
  return {
389
414
  session: session.id,
390
415
  gitHead: session.gitHead,
@@ -398,6 +423,10 @@ export default function (pi: ExtensionAPI) {
398
423
  loops: openLoopsFromMap(loopState, cfg.loop_block_after),
399
424
  evidenceCount: ev.count,
400
425
  contractCount: loadContract(ctx.cwd).constraints.length,
426
+ journalCount: journal.length,
427
+ agentNoteCount: journal.filter((entry) =>
428
+ (Array.isArray(entry.tags) ? entry.tags : []).includes("agent"),
429
+ ).length,
401
430
  lastVerify: ev.last,
402
431
  ctxPct: lastCtxPct,
403
432
  diffWarnLines: cfg.diff_warn_lines,
@@ -533,6 +562,7 @@ export default function (pi: ExtensionAPI) {
533
562
  // tree even if HEAD appears later, so commits cannot erase session diff.
534
563
  session.gitUnborn = repoRoot !== null && session.gitHead === null;
535
564
  // Warm the in-memory counters once per session (O(1) hot paths after this).
565
+ pendingReinject = false;
536
566
  rebuildLoopState(ctx.cwd);
537
567
  evidenceCount = sessionEvidence(ctx.cwd).count;
538
568
  try {
@@ -545,53 +575,136 @@ export default function (pi: ExtensionAPI) {
545
575
  }),
546
576
  );
547
577
 
548
- pi.on("before_agent_start", async (_event, ctx) =>
549
- attempt(ctx, async () => {
550
- refreshLoopStateIfChanged(ctx.cwd);
551
- const contract = loadContract(ctx.cwd);
552
- const journal = readJournal(ctx.cwd);
553
- const decisions = recentDecisions(journal, 3);
554
- const corrections = recentCorrections(journal, 3);
555
- // In-memory streaks are the merged view (bash events + guard_verify
556
- // outcomes) — the disk events alone would miss verify streaks.
557
- const loops = openLoopsFromMap(loopState, cfg.loop_block_after).slice(0, 2);
558
- const last = sessionEvidence(ctx.cwd).last;
559
- const brief = buildBrief({
560
- contract: contract.constraints,
561
- decisions,
562
- corrections,
563
- loops,
564
- lastEvidence: last ? { id: last.id, exit: last.exit, cmd: last.cmd, ms: last.ms } : null,
565
- });
578
+ /** Orientation text, scanned lazily once per session (goal #4). */
579
+ function ensureOrientation(ctx: ExtensionContext): string | null {
580
+ if (orientationText === null) {
581
+ try {
582
+ orientationText = renderOrientation(scanOrientation(ctx.cwd));
583
+ } catch {
584
+ orientationText = null;
585
+ }
586
+ }
587
+ return orientationText;
588
+ }
589
+
590
+ /** Compose the brief from the store. Cold path: the digest gate decides
591
+ * re-injection, so this runs only when the text changed or after a
592
+ * compaction (goal #12). */
593
+ function composeBrief(ctx: ExtensionContext): string {
594
+ refreshLoopStateIfChanged(ctx.cwd);
595
+ const contract = loadContract(ctx.cwd);
596
+ const journal = readJournal(ctx.cwd);
597
+ // In-memory streaks are the merged view (bash events + guard_verify
598
+ // outcomes) — the disk events alone would miss verify streaks.
599
+ const loops = openLoopsFromMap(loopState, cfg.loop_block_after).slice(0, 2);
600
+ const last = sessionEvidence(ctx.cwd).last;
601
+ return buildBrief({
602
+ task: state.task?.text ?? null,
603
+ contract: contract.constraints,
604
+ // Full lists, not a pre-slice: buildBrief owns the display caps and
605
+ // reports exactly how many entries those caps hid.
606
+ decisions: trustedEntries(journal, "decision"),
607
+ corrections: trustedEntries(journal, "correction"),
608
+ agentNotes: recentAgentEntries(journal, journal.length),
609
+ loops,
610
+ lastEvidence: last ? { id: last.id, exit: last.exit, cmd: last.cmd, ms: last.ms } : null,
611
+ });
612
+ }
613
+
614
+ /** Full guard block (orientation + brief), forced past the digest gate. Only a
615
+ * compaction needs this: it removed the previously injected block. */
616
+ function composeGuardBlock(ctx: ExtensionContext): string {
617
+ const parts: string[] = [];
618
+ const orientation = ensureOrientation(ctx);
619
+ if (orientation !== null) parts.push(orientation);
620
+ const brief = composeBrief(ctx);
621
+ if (brief.trim() !== "") parts.push(brief);
622
+ return parts.join("\n\n");
623
+ }
624
+
625
+ pi.on("before_agent_start", (_event, ctx) =>
626
+ attempt(ctx, () => {
627
+ const brief = composeBrief(ctx);
566
628
  const digest = sha1Hex(brief);
567
629
  const briefDue = brief.trim() !== "" && (cfg.brief_mode !== "changed" || view.brief_digest !== digest);
568
630
 
569
- // Orientation (goal #4): injected once per distinct digest, like the
570
- // brief. Lazily re-scanned if session_start did not populate it.
571
- if (orientationText === null) {
572
- try {
573
- orientationText = renderOrientation(scanOrientation(ctx.cwd));
574
- } catch {
575
- orientationText = null;
576
- }
577
- }
578
- const orientDigest = orientationText === null ? null : sha1Hex(orientationText);
631
+ const orientation = ensureOrientation(ctx);
632
+ const orientDigest = orientation === null ? null : sha1Hex(orientation);
579
633
  const orientDue = orientDigest !== null && state.counters.orient_sha1 !== orientDigest;
580
634
 
581
635
  if (!briefDue && !orientDue) return undefined;
582
636
 
583
637
  const parts: string[] = [];
584
- if (orientDue && orientationText !== null) parts.push(orientationText);
638
+ if (orientDue && orientation !== null) parts.push(orientation);
585
639
  if (briefDue) parts.push(brief);
586
640
  if (parts.length === 0) return undefined;
587
641
 
588
642
  if (orientDue && orientDigest !== null) state.counters.orient_sha1 = orientDigest;
589
643
  if (briefDue) view.brief_digest = digest;
644
+ // A persisted block now exists for this conversation, so the mid-run
645
+ // re-assertion below has nothing left to repair.
646
+ pendingReinject = false;
590
647
  saveState(ctx.cwd, state);
591
648
  return { message: { customType: BRIEF_CUSTOM_TYPE, content: parts.join("\n\n"), display: false } };
592
649
  }),
593
650
  );
594
651
 
652
+ // Mid-run compaction (threshold / overflow recovery) resumes the run through
653
+ // AgentStart, where before_agent_start never fires — so without this hook the
654
+ // hidden brief would stay gone until the next user prompt (SPEC §1 #6). The
655
+ // hook re-asserts the block on every request until a PERSISTED one is back in
656
+ // the conversation; the restored message itself is per-request, so it must be
657
+ // re-applied rather than remembered.
658
+ pi.on("context", (event, ctx) =>
659
+ attempt(ctx, () => {
660
+ if (!pendingReinject) return undefined;
661
+ const messages = Array.isArray(event.messages) ? event.messages : [];
662
+ const persisted = messages.some(
663
+ (message) => (message as { customType?: unknown } | null)?.customType === BRIEF_CUSTOM_TYPE,
664
+ );
665
+ if (persisted) {
666
+ pendingReinject = false;
667
+ return undefined;
668
+ }
669
+ const block = composeGuardBlock(ctx);
670
+ if (block.trim() === "") return undefined;
671
+ return {
672
+ messages: [
673
+ ...messages,
674
+ {
675
+ role: "custom",
676
+ customType: BRIEF_CUSTOM_TYPE,
677
+ content: block,
678
+ display: false,
679
+ timestamp: Date.now(),
680
+ },
681
+ ],
682
+ };
683
+ }),
684
+ );
685
+
686
+ // Task capture (SPEC §1 #6): the owner's actual request is what a compaction
687
+ // summary paraphrases away most often. The first substantive prompt becomes
688
+ // the project task; `/guard-task` can replace or clear it (owner text wins).
689
+ pi.on("input", (event, ctx) =>
690
+ attempt(ctx, () => {
691
+ // Extension-injected messages are not owner input, and `!` runs a shell
692
+ // command rather than stating a task.
693
+ if (event.source === "extension") return undefined;
694
+ const text = typeof event.text === "string" ? event.text.trim() : "";
695
+ if (text === "" || text.startsWith("/") || text.startsWith("!")) return undefined;
696
+ const captured = truncLine(text, TASK_DISPLAY_MAX);
697
+ if (captured === "" || Array.from(captured).length < TASK_MIN_CAPTURE_CHARS) return undefined;
698
+ const existing = state.task;
699
+ if (existing?.source === "owner" || existing?.text === captured) return undefined;
700
+ state.task = { text: captured, source: "prompt", ts: new Date().toISOString() };
701
+ // The brief changed: let this same turn re-inject it.
702
+ view.brief_digest = null;
703
+ saveState(ctx.cwd, state);
704
+ return undefined;
705
+ }),
706
+ );
707
+
595
708
  pi.on("tool_call", async (event, ctx) =>
596
709
  attempt(ctx, async () => {
597
710
  if (event.toolName !== "bash") return undefined;
@@ -812,11 +925,13 @@ export default function (pi: ExtensionAPI) {
812
925
 
813
926
  // Compaction removes the previously injected hidden brief from active
814
927
  // context. Invalidate both digests so the next turn re-injects constraints,
815
- // trusted journal entries and orientation even when their text is unchanged.
928
+ // trusted journal entries, the task and orientation even when their text is
929
+ // unchanged, and arm the per-request re-assertion for a mid-run compaction.
816
930
  pi.on("session_compact", async (_event, ctx) =>
817
931
  attempt(ctx, async () => {
818
932
  view.brief_digest = null;
819
933
  delete state.counters.orient_sha1;
934
+ pendingReinject = true;
820
935
  saveState(ctx.cwd, state);
821
936
  return undefined;
822
937
  }),
@@ -966,6 +1081,54 @@ export default function (pi: ExtensionAPI) {
966
1081
  }),
967
1082
  });
968
1083
 
1084
+ // Task anchor (SPEC §1 #6): explicit owner control over the `task:` line that
1085
+ // the brief re-injects, so a compaction summary cannot redefine the ask.
1086
+ pi.registerCommand("guard-task", {
1087
+ description: "protect-context: show | set <text> | --clear the owner task anchor",
1088
+ handler: (args, ctx) =>
1089
+ attempt(ctx, () => {
1090
+ const trimmed = (args ?? "").trim();
1091
+ let capped = false;
1092
+ if (trimmed === "") {
1093
+ const current = state.task;
1094
+ if (ctx.hasUI) {
1095
+ ctx.ui.notify(
1096
+ current
1097
+ ? `task (${current.source}): ${current.text}\nNudge: use /guard-task <text> to replace it, or /guard-task --clear.`
1098
+ : "task: (none captured yet)\nNudge: it is captured from the first substantive prompt; /guard-task <text> sets it explicitly.",
1099
+ "info",
1100
+ );
1101
+ }
1102
+ return undefined;
1103
+ }
1104
+ if (trimmed === "--clear") {
1105
+ state.task = undefined;
1106
+ } else {
1107
+ const text = truncLine(trimmed, TASK_DISPLAY_MAX);
1108
+ if (text === "") {
1109
+ if (ctx.hasUI) ctx.ui.notify("usage: /guard-task <text> | --clear", "warning");
1110
+ return undefined;
1111
+ }
1112
+ state.task = { text, source: "owner", ts: new Date().toISOString() };
1113
+ // Store and display share one cap: the brief can always show what was
1114
+ // stored, and the owner is told when the input was cut.
1115
+ capped = Array.from(trimmed).length > TASK_DISPLAY_MAX;
1116
+ }
1117
+ // The brief text changed: force the next prompt to re-inject it.
1118
+ view.brief_digest = null;
1119
+ saveState(ctx.cwd, state);
1120
+ if (ctx.hasUI) {
1121
+ ctx.ui.notify(
1122
+ trimmed === "--clear"
1123
+ ? "task cleared\nNudge: the brief no longer anchors the original request."
1124
+ : `task recorded${capped ? ` (capped at ${TASK_DISPLAY_MAX} chars)` : ""}\nNudge: the brief re-injects it on your next prompt and after compaction.`,
1125
+ "info",
1126
+ );
1127
+ }
1128
+ return undefined;
1129
+ }),
1130
+ });
1131
+
969
1132
  pi.registerCommand("guard-report", {
970
1133
  description: "protect-context: full session summary",
971
1134
  handler: async (_args, ctx) =>
@@ -1018,11 +1181,13 @@ export default function (pi: ExtensionAPI) {
1018
1181
  label: "Guard Note",
1019
1182
  description:
1020
1183
  "Append a decision/correction/risk/note to the persistent journal (.agent-guard/journal.jsonl). " +
1021
- "Model-written entries are tagged agent and persisted for owner review; they are not trusted brief input. " +
1022
- "Only trusted slash-command/CLI decisions and corrections are re-injected.",
1184
+ "Model-written entries are tagged agent and persisted for owner review; they are not trusted brief input, " +
1185
+ "though the brief shows the newest few under an explicitly unverified heading. " +
1186
+ "Only trusted slash-command/CLI decisions and corrections are re-injected as authority; read any entry back with guard_journal.",
1023
1187
  promptSnippet: "Record a durable decision or correction in the journal",
1024
1188
  promptGuidelines: [
1025
1189
  "Use guard_note to record durable decisions; when corrected, log a tagged correction with type=correction.",
1190
+ "After a compaction, use guard_journal to re-read earlier decisions and your own notes.",
1026
1191
  ],
1027
1192
  parameters: Type.Object({
1028
1193
  // Enum as a plain string schema: providers that reject anyOf/const still
@@ -1054,6 +1219,38 @@ export default function (pi: ExtensionAPI) {
1054
1219
  },
1055
1220
  });
1056
1221
 
1222
+ // Read path for the journal (SPEC §4 parity with the MCP tool of the same
1223
+ // name). Without it, the agent's own notes would be write-only in pi: the
1224
+ // brief excludes `agent` entries from trusted sections, and a compaction then
1225
+ // leaves no route back to them (SPEC §1 #6).
1226
+ pi.registerTool({
1227
+ name: "guard_journal",
1228
+ label: "Guard Journal",
1229
+ description:
1230
+ "Read recent journal entries (read-only): timestamp, type, text, tags. limit is clamped to 0-20; type filters " +
1231
+ "decision|correction|risk|note. Entries tagged agent are model-written notes — unverified, never owner authority, " +
1232
+ "but the durable record of earlier reasoning; read them to recover decisions and rejected approaches.",
1233
+ promptSnippet: "Read recent journal entries",
1234
+ promptGuidelines: [
1235
+ "Use guard_journal to recover earlier decisions, corrections, and your own notes after a compaction.",
1236
+ ],
1237
+ parameters: Type.Object({
1238
+ limit: Type.Optional(Type.Integer({ description: "How many entries to read (0-20, default 5)" })),
1239
+ type: Type.Optional(
1240
+ Type.Unsafe<JournalType>({
1241
+ type: "string",
1242
+ enum: ["decision", "correction", "risk", "note"],
1243
+ }),
1244
+ ),
1245
+ }),
1246
+ execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1247
+ const journal = readJournal(ctx.cwd);
1248
+ const type = params.type;
1249
+ const limit = params.limit === undefined ? READ_LIMIT_DEFAULT : boundedReadLimit(params.limit);
1250
+ const text = renderJournal(recentJournal(journal, limit, type), countJournal(journal, type), type);
1251
+ return Promise.resolve({ content: [{ type: "text", text }], details: {} });
1252
+ },
1253
+ });
1057
1254
  pi.registerTool({
1058
1255
  name: "guard_contract_check",
1059
1256
  label: "Guard Contract Check",
@@ -13,11 +13,17 @@ export function sha1Hex(s: string): string {
13
13
  }
14
14
 
15
15
  export interface BriefInput {
16
+ /** Owner-stated task captured for this project (SPEC §1 #6): the request that
17
+ * a compaction summary is most likely to have paraphrased away. */
18
+ task?: string | null;
16
19
  contract: Constraint[];
17
20
  /** Most recent trusted decisions, newest first (already sliced). */
18
21
  decisions?: Array<{ type: string; text: string; tags: string[] }>;
19
22
  /** Most recent corrections, newest first (already sliced). */
20
23
  corrections: Array<{ type: string; text: string; tags: string[] }>;
24
+ /** Most recent model-written entries, newest first. Shown as explicitly
25
+ * unverified: they are the agent's own notes, never owner authority. */
26
+ agentNotes?: Array<{ type: string; text: string; tags: string[] }>;
21
27
  loops: LoopInfo[];
22
28
  lastEvidence: { id: string; exit: number; cmd: string; ms: number | null } | null;
23
29
  }
@@ -25,7 +31,12 @@ export interface BriefInput {
25
31
  export const BRIEF_MAX_CONTRACT_LINES = 12;
26
32
  export const BRIEF_MAX_DECISIONS = 3;
27
33
  export const BRIEF_MAX_CORRECTIONS = 3;
34
+ export const BRIEF_MAX_AGENT_NOTES = 2;
28
35
  export const BRIEF_MAX_LOOPS = 2;
36
+ /** Owner task display cap (chars). */
37
+ export const TASK_DISPLAY_MAX = 200;
38
+ /** Agent-note display cap (chars) — shorter than a trusted entry on purpose. */
39
+ export const AGENT_NOTE_DISPLAY_MAX = 120;
29
40
  /** Command display cap (chars) — matches Python's cmd[:60] discipline. */
30
41
  export const CMD_DISPLAY_MAX = 60;
31
42
 
@@ -68,27 +79,44 @@ export function selectBriefConstraints(contract: Constraint[]): Constraint[] {
68
79
  .map(({ constraint }) => constraint);
69
80
  }
70
81
 
82
+ interface SectionInfo {
83
+ hidden: number;
84
+ truncated: boolean;
85
+ }
86
+
87
+ /** Render one journal section within its cap and report what the cap dropped,
88
+ * so the brief can point at the read path instead of silently truncating. */
71
89
  function appendJournalSection(
72
90
  lines: string[],
73
- heading: "decisions:" | "corrections:",
91
+ heading: "decisions:" | "corrections:" | "agent notes (unverified):",
74
92
  entries: Array<{ text: string; tags: string[] }>,
75
93
  cap: number,
76
- ): void {
77
- if (entries.length === 0) return;
94
+ textMax = 200,
95
+ ): SectionInfo {
96
+ if (entries.length === 0) return { hidden: 0, truncated: false };
78
97
  lines.push(heading);
98
+ let truncated = false;
79
99
  for (const entry of entries.slice(0, cap)) {
100
+ const full = normalizeOneLine(entry.text, 10_000);
101
+ const shown = trunc(full, textMax);
102
+ if (shown !== full) truncated = true;
80
103
  const safeTags = [...new Set((Array.isArray(entry.tags) ? entry.tags : []).map((tag) => truncLine(tag, 40)).filter(Boolean))]
81
104
  .slice(0, 8);
82
105
  const tags = safeTags.length > 0 ? ` [${safeTags.join(",")}]` : "";
83
- lines.push(`- ${truncLine(entry.text)}${tags}`);
106
+ lines.push(`- ${shown}${tags}`);
84
107
  }
108
+ return { hidden: Math.max(0, entries.length - cap), truncated };
85
109
  }
86
110
 
87
- /** Build the compact brief: contract (≤12 lines), ≤3 trusted decisions,
88
- * ≤3 trusted corrections, ≤2 loops, and last evidence. */
111
+ /** Build the compact brief: owner task, contract (≤12 lines), ≤3 trusted
112
+ * decisions, ≤3 trusted corrections, ≤2 unverified agent notes, ≤2 loops, and
113
+ * last evidence. Anything a cap dropped is announced with the read path. */
89
114
  export function buildBrief(input: BriefInput): string {
90
115
  const lines: string[] = [];
91
116
 
117
+ const task = truncLine(input.task ?? "", TASK_DISPLAY_MAX);
118
+ if (task !== "") lines.push(`task: ${task}`);
119
+
92
120
  const contractLines = selectBriefConstraints(input.contract);
93
121
  if (contractLines.length > 0) {
94
122
  lines.push("contract:");
@@ -107,9 +135,19 @@ export function buildBrief(input: BriefInput): string {
107
135
  }
108
136
  }
109
137
 
110
- appendJournalSection(lines, "decisions:", input.decisions ?? [], BRIEF_MAX_DECISIONS);
111
- appendJournalSection(lines, "corrections:", input.corrections, BRIEF_MAX_CORRECTIONS);
112
-
138
+ const sections = [
139
+ appendJournalSection(lines, "decisions:", input.decisions ?? [], BRIEF_MAX_DECISIONS),
140
+ appendJournalSection(lines, "corrections:", input.corrections, BRIEF_MAX_CORRECTIONS),
141
+ appendJournalSection(
142
+ lines,
143
+ "agent notes (unverified):",
144
+ input.agentNotes ?? [],
145
+ BRIEF_MAX_AGENT_NOTES,
146
+ AGENT_NOTE_DISPLAY_MAX,
147
+ ),
148
+ ];
149
+ const hiddenEntries = sections.reduce((sum, info) => sum + info.hidden, 0);
150
+ const truncatedEntries = sections.some((info) => info.truncated);
113
151
  if (input.loops.length > 0) {
114
152
  lines.push("loops:");
115
153
  for (const l of input.loops.slice(0, BRIEF_MAX_LOOPS)) {
@@ -124,5 +162,14 @@ export function buildBrief(input: BriefInput): string {
124
162
  );
125
163
  }
126
164
 
165
+ // The caps above keep the brief constant-cost; these two lines keep the loss
166
+ // recoverable instead of silent (goal #6/#12).
167
+ if (hiddenEntries > 0) {
168
+ lines.push(`+${hiddenEntries} more journal entries — guard_journal reads them`);
169
+ }
170
+ if (truncatedEntries) {
171
+ lines.push("journal text truncated — guard_journal shows the full entry");
172
+ }
173
+
127
174
  return lines.join("\n");
128
175
  }
@@ -23,6 +23,9 @@ export function isJournalType(v: unknown): v is JournalType {
23
23
  export const TEXT_CAP = 500;
24
24
  export const TAG_COUNT_CAP = 8;
25
25
  export const TAG_TEXT_CAP = 40;
26
+ /** Read-view bounds (mirror journal.READ_LIMIT_* in Python). */
27
+ export const READ_LIMIT_DEFAULT = 5;
28
+ export const READ_LIMIT_MAX = 20;
26
29
 
27
30
  /** Replace terminal/control formatting, collapse whitespace and bound output. */
28
31
  export function sanitizeJournalText(value: unknown, cap = TEXT_CAP): string {
@@ -89,6 +92,65 @@ export function recentTrustedEntries(
89
92
  .reverse();
90
93
  }
91
94
 
95
+ /** Entries the model wrote itself (reserved `agent` provenance tag).
96
+ *
97
+ * They are never trusted input, but they are the agent's own durable notes:
98
+ * after a compaction the brief surfaces the newest few as explicitly
99
+ * unverified, and `guard_journal` returns the full text on demand (SPEC §1 #6).
100
+ * `risk` entries are excluded — they are telemetry, not memory. */
101
+ export function recentAgentEntries(journal: JournalEntry[], n: number): JournalEntry[] {
102
+ // slice(-0) === slice(0) would return the WHOLE journal for n=0.
103
+ if (!(n > 0)) return [];
104
+ return journal
105
+ .filter((entry) => entry.type !== "risk" && sanitizeTags(entry.tags).includes("agent"))
106
+ .slice(-n)
107
+ .reverse();
108
+ }
109
+ /** All trusted (non-`agent`) entries of one type, newest first. Used by the
110
+ * brief so its hidden-entry count reflects the whole journal, not a window. */
111
+ export function trustedEntries(
112
+ journal: JournalEntry[],
113
+ type: "decision" | "correction",
114
+ ): JournalEntry[] {
115
+ return recentTrustedEntries(journal, type, journal.length);
116
+ }
117
+
118
+ /** Bounded read limit (mirrors journal._bounded_limit: never negative, never
119
+ * unbounded). A model-supplied 0 yields no lines rather than a huge view. */
120
+ export function boundedReadLimit(limit: unknown): number {
121
+ const value = typeof limit === "number" && Number.isFinite(limit) ? Math.trunc(limit) : READ_LIMIT_DEFAULT;
122
+ return Math.max(0, Math.min(value, READ_LIMIT_MAX));
123
+ }
124
+
125
+ /** Entries of one type (or all), oldest first — the read view's window. */
126
+ export function recentJournal(journal: JournalEntry[], limit: number, type?: JournalType): JournalEntry[] {
127
+ const bounded = boundedReadLimit(limit);
128
+ if (bounded === 0) return [];
129
+ const filtered = type === undefined ? journal : journal.filter((entry) => entry.type === type);
130
+ return filtered.slice(-bounded);
131
+ }
132
+
133
+ export function countJournal(journal: JournalEntry[], type?: JournalType): number {
134
+ return type === undefined ? journal.length : journal.filter((entry) => entry.type === type).length;
135
+ }
136
+
137
+ /** Render a bounded one-line-per-entry journal view (same shape as
138
+ * journal.render_recent on the Python side, so both surfaces read alike). */
139
+ export function renderJournal(entries: JournalEntry[], total: number, type?: JournalType): string {
140
+ const window = entries.slice(-READ_LIMIT_MAX);
141
+ const scope = type === undefined ? "" : ` ${type}`;
142
+ const lines = [`PC journal${scope} · last ${window.length} of ${Math.max(0, Math.trunc(total))}`];
143
+ for (const entry of window) {
144
+ const ts = normalizeOneLine(entry.ts, 32) || "unknown-time";
145
+ const entryType = normalizeOneLine(entry.type, 16) || "unknown";
146
+ const text = sanitizeJournalText(entry.text) || "(blank legacy entry)";
147
+ const tags = sanitizeTags(entry.tags);
148
+ lines.push(`${ts} · ${entryType} · ${text}${tags.length > 0 ? ` [${tags.join(",")}]` : ""}`);
149
+ }
150
+ lines.push("Nudge: re-read durable decisions and corrections before resuming work.");
151
+ return lines.join("\n");
152
+ }
153
+
92
154
  /** Most recent trusted corrections, newest first. */
93
155
  export function recentCorrections(journal: JournalEntry[], n: number): JournalEntry[] {
94
156
  return recentTrustedEntries(journal, "correction", n);
@@ -30,6 +30,10 @@ export interface StatusData {
30
30
  diffWarnLines: number;
31
31
  diffHardLines: number;
32
32
  diffWarnFiles: number;
33
+ /** Journal size (optional: only populated where the view can read it). */
34
+ journalCount?: number;
35
+ /** Subset of journalCount tagged `agent` (model-written, never trusted). */
36
+ agentNoteCount?: number;
33
37
  }
34
38
 
35
39
  /** Two compact lines + Nudge. */
@@ -52,6 +56,10 @@ export function buildStatus(d: StatusData): string {
52
56
  `diff scan: lower bound (${d.diffSkippedFiles ?? 0} skipped, ${d.diffTruncatedFiles ?? 0} truncated)`,
53
57
  );
54
58
  }
59
+ if ((d.journalCount ?? 0) > 0) {
60
+ // Make the read path discoverable: journal entries are not all re-injected.
61
+ lines.push(`journal: ${d.journalCount} entries (${d.agentNoteCount ?? 0} agent) — guard_journal reads them`);
62
+ }
55
63
  if (d.lastVerify) {
56
64
  lines.push(`last verify: ${formatVerifyLine(d.lastVerify)}`);
57
65
  } else {
@@ -508,6 +508,15 @@ export interface SessionState {
508
508
  updated?: string;
509
509
  }
510
510
 
511
+ /** Owner-stated task for this project (SPEC §2). Captured from the first real
512
+ * prompt of a session or set explicitly with `/guard-task`; re-injected at the
513
+ * top of the brief so a compaction summary cannot silently redefine the ask. */
514
+ export interface TaskCapture {
515
+ text: string;
516
+ source: "prompt" | "owner";
517
+ ts: string;
518
+ }
519
+
511
520
  /** Baseline that belongs to ONE pi session. Concurrent sessions in one project
512
521
  * keep separate entries so they cannot clobber each other (SPEC §2). */
513
522
  export interface GuardState {
@@ -516,6 +525,8 @@ export interface GuardState {
516
525
  /** Opaque passthrough on the Python side; the extension stores digests here
517
526
  * (e.g. the orientation digest) alongside numeric counters. */
518
527
  counters: Record<string, number | string>;
528
+ /** Project-level task capture (absent = nothing captured yet). */
529
+ task?: TaskCapture;
519
530
  }
520
531
 
521
532
  /** How many per-session baselines to retain (newest first, current always). */
@@ -574,6 +585,19 @@ function readSession(raw: unknown): SessionState | null {
574
585
  };
575
586
  }
576
587
 
588
+ /** Parse the optional task capture; malformed legacy values are dropped rather
589
+ * than rendered into a brief. */
590
+ function readTask(raw: unknown): TaskCapture | undefined {
591
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
592
+ const value = raw as Record<string, unknown>;
593
+ if (typeof value.text !== "string") return undefined;
594
+ return {
595
+ text: value.text,
596
+ source: value.source === "owner" ? "owner" : "prompt",
597
+ ts: typeof value.ts === "string" ? value.ts : "",
598
+ };
599
+ }
600
+
577
601
  export function loadState(cwd: string): GuardState | null {
578
602
  const raw = readJson<unknown>(statePath(cwd));
579
603
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
@@ -617,6 +641,7 @@ export function loadState(cwd: string): GuardState | null {
617
641
  counters[key] = candidate;
618
642
  }
619
643
  }
644
+ const task = readTask(value.task);
620
645
  return {
621
646
  current_session: {
622
647
  id: currentId,
@@ -625,6 +650,7 @@ export function loadState(cwd: string): GuardState | null {
625
650
  },
626
651
  sessions,
627
652
  counters,
653
+ ...(task === undefined ? {} : { task }),
628
654
  };
629
655
  }
630
656
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protect-context",
3
- "version": "1.3.2",
3
+ "version": "2.0.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",