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 +42 -11
- package/extension/index.ts +234 -37
- package/extension/lib/brief.ts +56 -9
- package/extension/lib/journal.ts +62 -0
- package/extension/lib/status.ts +8 -0
- package/extension/lib/store.ts +26 -0
- package/package.json +1 -1
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** |
|
|
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
|
|
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
|
|
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
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
666
|
-
|
|
667
|
-
|
|
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
|
package/extension/index.ts
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
570
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
"
|
|
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",
|
package/extension/lib/brief.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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(`- ${
|
|
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
|
|
88
|
-
* ≤3 trusted corrections, ≤2 loops, and
|
|
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
|
-
|
|
111
|
-
|
|
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
|
}
|
package/extension/lib/journal.ts
CHANGED
|
@@ -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);
|
package/extension/lib/status.ts
CHANGED
|
@@ -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 {
|
package/extension/lib/store.ts
CHANGED
|
@@ -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": "
|
|
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",
|