protect-context 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1024 @@
1
+ /**
2
+ * protect-context — pi extension (automatic guardrail layer, SPEC §5).
3
+ *
4
+ * Entry point / wiring only: all logic lives in ./lib/*.ts (pi-import-free,
5
+ * tested via `node --test --experimental-strip-types`). Zero npm runtime
6
+ * dependencies; `typebox`, `@earendil-works/pi-coding-agent` and
7
+ * `@earendil-works/pi-ai` are provided by the pi runtime (loaded via jiti).
8
+ *
9
+ * Hard rules honored here:
10
+ * - no background processes/sockets/timers from the factory (deferred to
11
+ * session_start; nothing long-lived is started at all);
12
+ * - session_shutdown is idempotent;
13
+ * - every ctx.ui.* dialog is guarded by ctx.hasUI;
14
+ * - event handlers never throw (catch → ctx.ui.notify).
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
19
+ import { Type } from "typebox";
20
+ import { StringEnum } from "@earendil-works/pi-ai";
21
+ import { isAbsolute, relative } from "node:path";
22
+
23
+ import { DEFAULT_CONFIG, type GuardConfig } from "./lib/defaults.ts";
24
+ import { loadConfig } from "./lib/config.ts";
25
+ import { matchRisk, normalizeCommand } from "./lib/fingerprint.ts";
26
+ import {
27
+ appendJsonlLocked,
28
+ defaultState,
29
+ ensureStore,
30
+ eventsPath,
31
+ fileStamp,
32
+ loadState,
33
+ saveState,
34
+ readJsonl,
35
+ storeDir,
36
+ verifyRunsPath,
37
+ withStoreLock,
38
+ } from "./lib/store.ts";
39
+ import { addConstraintWithStatus, contractLines, loadContract, removeConstraint } from "./lib/contract.ts";
40
+ import {
41
+ appendJournal,
42
+ isJournalType,
43
+ readJournal,
44
+ recentCorrections,
45
+ recentDecisions,
46
+ tagsWithRequired,
47
+ } from "./lib/journal.ts";
48
+ import { toolCallEvent, toolResultEvent, bashExitFromResult, type EventRecord } from "./lib/events.ts";
49
+ import {
50
+ failedAttemptsFromLedgers,
51
+ loopBlockReason,
52
+ openLoopsFromMap,
53
+ shouldBlockLoop,
54
+ type LoopInfo,
55
+ } from "./lib/loops.ts";
56
+ import { buildBrief, sha1Hex, BRIEF_MAX_CONTRACT_LINES, CMD_DISPLAY_MAX, truncLine } from "./lib/brief.ts";
57
+ import { parseNumstat, parseUntrackedPaths, scanUntrackedFiles } from "./lib/gitparse.ts";
58
+ import { renderWidget } from "./lib/widget.ts";
59
+ import { checkContractSummary, renderChecklist } from "./lib/contract_check.ts";
60
+ import { renderOrientation, scanOrientation } from "./lib/orientation.ts";
61
+ import {
62
+ clampTimeoutS,
63
+ digestOf,
64
+ formatMs,
65
+ nextEvidenceId,
66
+ readVerifyRuns,
67
+ readVerifyRowsForIds,
68
+ runsForSession,
69
+ type VerifyRun,
70
+ } from "./lib/verify.ts";
71
+ import { buildStatus, type StatusData } from "./lib/status.ts";
72
+
73
+ const WIDGET_ID = "protect-context";
74
+ const BRIEF_CUSTOM_TYPE = "guard-brief";
75
+ /** Upper clamp for guard_verify timeouts (keep in sync with verify.TIMEOUT_MAX_S). */
76
+ const MAX_VERIFY_TIMEOUT_S = 900;
77
+
78
+ export default function (pi: ExtensionAPI) {
79
+ // Session-scoped state (rebuilt on every session_start; new extension
80
+ // instances are created per session by pi).
81
+ let session = { id: "", started: "", gitHead: null as string | null, gitUnborn: false };
82
+ let cfg: GuardConfig = { ...DEFAULT_CONFIG, preflight_tools: [...DEFAULT_CONFIG.preflight_tools] };
83
+ let state = defaultState("", null);
84
+ const startTimes = new Map<string, { start: number; tool: string; cmd: string | null }>();
85
+ let lastDiff = { lines: 0, files: 0, available: false, lowerBound: false };
86
+ let lastCtxPct: number | null = null;
87
+
88
+ // In-memory guardrail state (goal #12: O(1) hot paths — the event handlers
89
+ // and widget never re-parse the JSONL files; disk reads happen once per
90
+ // session and on the cold brief/status paths). Rebuilt on session_start,
91
+ // updated incrementally in the handlers below.
92
+ const loopState = new Map<string, LoopInfo>();
93
+ let loopLedgerMemo: string | null = null;
94
+ let evidenceCount = 0;
95
+ // Evidence memo (goal #12 hot-path invariant): before_agent_start runs every
96
+ // turn, and readJsonl already caches the ledger parse by mtime+size — this
97
+ // memo additionally skips the per-session filter and last-run scan, so the
98
+ // per-turn cost stays O(1) as the ledger grows. Any writer's append
99
+ // (extension, MCP server, CLI) changes mtime+size → memo recomputes, so
100
+ // externally recorded runs are still picked up.
101
+ let evidenceMemo: { stamp: string | null; sessionId: string; last: VerifyRun | null; count: number } | null = null;
102
+ // Blocked tool calls: pi still emits tool_execution_end for them (with the
103
+ // block reason as the "result") — those end events must NOT be recorded as
104
+ // failed tool results, or loop streaks inflate on every blocked attempt.
105
+ const blockedToolCallIds = new Set<string>();
106
+ // Orientation digest (goal #4): one scan per session, injected from
107
+ // before_agent_start when its digest changes.
108
+ let orientationText: string | null = null;
109
+
110
+ /** Frozen watermark defaults are zero, so a configured zero threshold needs
111
+ * an explicit first-notification bit instead of the usual `watermark < 0`.
112
+ * Positive thresholds retain the original monotonic watermark behavior. */
113
+ function thresholdDue(value: number, threshold: number, watermark: number, zeroKey: string): boolean {
114
+ if (value < threshold) return false;
115
+ if (threshold === 0) return state.counters[zeroKey] !== 1;
116
+ return watermark < threshold;
117
+ }
118
+
119
+ function markZeroThreshold(threshold: number, zeroKey: string): void {
120
+ if (threshold === 0) state.counters[zeroKey] = 1;
121
+ }
122
+
123
+ // --- helpers ---------------------------------------------------------
124
+
125
+ /** Run an event/command body without ever throwing (SPEC §5). */
126
+ async function attempt<T>(ctx: ExtensionContext, fn: () => Promise<T>): Promise<T | undefined> {
127
+ try {
128
+ return await fn();
129
+ } catch (err) {
130
+ try {
131
+ if (ctx.hasUI) ctx.ui.notify(`guard: ${err instanceof Error ? err.message : String(err)}`, "error");
132
+ } catch {
133
+ // No UI available; drop the error.
134
+ }
135
+ return undefined;
136
+ }
137
+ }
138
+
139
+ function safeSessionId(ctx: ExtensionContext): string {
140
+ try {
141
+ const id = ctx.sessionManager.getSessionId();
142
+ if (id) return id;
143
+ } catch {
144
+ // fall through to random id
145
+ }
146
+ return `pc-${Math.random().toString(36).slice(2, 10)}`;
147
+ }
148
+
149
+ async function runGit(ctx: ExtensionContext, args: string[]): Promise<string | null> {
150
+ try {
151
+ const res = await pi.exec("git", args, { timeout: 5000 });
152
+ if (res.code !== 0) return null;
153
+ return res.stdout;
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ /** Run a shell command string via sh (mirrors Python shell=True). The
160
+ * timeout is clamped to [1, MAX_VERIFY_TIMEOUT_S] (keep in sync with
161
+ * verify.TIMEOUT_MAX_S on the Python side) so a model-supplied value can
162
+ * never wedge the session for hours. */
163
+ async function shExec(
164
+ command: string,
165
+ timeoutS: number,
166
+ signal?: AbortSignal,
167
+ ): Promise<{ exit: number; stdout: string; stderr: string; ms: number }> {
168
+ const t0 = Date.now();
169
+ const res = await pi.exec("sh", ["-c", command], {
170
+ timeout: Math.min(MAX_VERIFY_TIMEOUT_S, Math.max(1, timeoutS)) * 1000,
171
+ signal,
172
+ });
173
+ const ms = Date.now() - t0;
174
+ const exit = res.killed && res.code === 0 ? 124 : res.code;
175
+ return { exit, stdout: res.stdout ?? "", stderr: res.stderr ?? "", ms };
176
+ }
177
+
178
+ function readEvents(cwd: string): EventRecord[] {
179
+ return readJsonl<EventRecord>(eventsPath(cwd));
180
+ }
181
+
182
+ function loopLedgerStamp(cwd: string): string {
183
+ return `${fileStamp(eventsPath(cwd)) ?? "-"}|${fileStamp(verifyRunsPath(cwd)) ?? "-"}`;
184
+ }
185
+
186
+ /** Replace the in-memory view while the shared store lock is held. */
187
+ function rebuildLoopStateUnlocked(cwd: string): void {
188
+ loopState.clear();
189
+ for (const info of failedAttemptsFromLedgers(readEvents(cwd), readVerifyRuns(cwd), session.id).values()) {
190
+ loopState.set(info.cmd, info);
191
+ }
192
+ loopLedgerMemo = loopLedgerStamp(cwd);
193
+ }
194
+
195
+ /** Rebuild at startup, or when a CLI/MCP peer changed either ledger. */
196
+ function rebuildLoopState(cwd: string): void {
197
+ withStoreLock(storeDir(cwd), () => rebuildLoopStateUnlocked(cwd));
198
+ }
199
+
200
+ function refreshLoopStateIfChanged(cwd: string): void {
201
+ if (loopLedgerMemo === loopLedgerStamp(cwd)) return;
202
+ withStoreLock(storeDir(cwd), () => {
203
+ if (loopLedgerMemo !== loopLedgerStamp(cwd)) rebuildLoopStateUnlocked(cwd);
204
+ });
205
+ }
206
+
207
+ interface DiffSnapshot {
208
+ files: number;
209
+ lines: number;
210
+ repo: boolean;
211
+ unborn: boolean;
212
+ available: boolean;
213
+ scanLimited: boolean;
214
+ skippedFiles: number;
215
+ truncatedFiles: number;
216
+ }
217
+
218
+ function unavailableDiff(repo: boolean, unborn: boolean): DiffSnapshot {
219
+ return {
220
+ files: 0,
221
+ lines: 0,
222
+ repo,
223
+ unborn,
224
+ available: false,
225
+ scanLimited: false,
226
+ skippedFiles: 0,
227
+ truncatedFiles: 0,
228
+ };
229
+ }
230
+
231
+ /** Count tracked, staged and non-ignored work against the session-start
232
+ * baseline. `git status` supplies the untracked set so .gitignore semantics
233
+ * remain Git's own; the bounded scanner only measures those selected files.
234
+ * An unborn/no-repository-at-start session uses one empty-tree-to-working-
235
+ * tree numstat instead of letting a mid-session commit reset the delta. */
236
+ async function gatherDiff(ctx: ExtensionContext): Promise<DiffSnapshot> {
237
+ const rootOut = await runGit(ctx, ["rev-parse", "--show-toplevel"]);
238
+ if (rootOut === null || rootOut.trim() === "") return unavailableDiff(false, false);
239
+ const root = rootOut.trim();
240
+ const unborn = session.gitUnborn || session.gitHead === null;
241
+
242
+ let tracked;
243
+ if (unborn) {
244
+ // A single diff from Git's repository-format-specific empty tree to the
245
+ // final working tree avoids double-counting a staged file edited again.
246
+ // `hash-object` without -w is read-only; pi.exec supplies EOF on stdin.
247
+ const emptyTreeOut = await runGit(ctx, ["hash-object", "-t", "tree", "--stdin"]);
248
+ const emptyTree = emptyTreeOut?.trim() ?? "";
249
+ if (emptyTree === "") return unavailableDiff(true, true);
250
+ const out = await runGit(ctx, ["diff", "--numstat", emptyTree, "--"]);
251
+ if (out === null) return unavailableDiff(true, true);
252
+ tracked = parseNumstat(out);
253
+ } else {
254
+ // A named commit compares the complete current working tree (including
255
+ // commits made after session_start) with the frozen session baseline.
256
+ const out = await runGit(ctx, ["diff", "--numstat", session.gitHead!, "--"]);
257
+ if (out === null) return unavailableDiff(true, false);
258
+ tracked = parseNumstat(out);
259
+ }
260
+
261
+ const status = await runGit(ctx, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--"]);
262
+ if (status === null) return unavailableDiff(true, unborn);
263
+ const storeRelRaw = relative(root, storeDir(ctx.cwd));
264
+ const storeRel = storeRelRaw.replaceAll("\\", "/");
265
+ const activeStore =
266
+ storeRel !== "" &&
267
+ storeRel !== ".." &&
268
+ !storeRel.startsWith("../") &&
269
+ !isAbsolute(storeRelRaw)
270
+ ? storeRel
271
+ : null;
272
+ const untrackedPaths = parseUntrackedPaths(status, activeStore);
273
+ const untracked = scanUntrackedFiles(
274
+ root,
275
+ untrackedPaths,
276
+ Math.max(0, cfg.diff_hard_lines - tracked.lines),
277
+ );
278
+ return {
279
+ files: tracked.fileCount + untracked.files,
280
+ lines: tracked.lines + untracked.lines,
281
+ repo: true,
282
+ unborn,
283
+ available: true,
284
+ scanLimited: untracked.scanLimited,
285
+ skippedFiles: untracked.skippedFiles,
286
+ truncatedFiles: untracked.truncatedFiles,
287
+ };
288
+ }
289
+
290
+ /** Incremental loop-state update for a finished bash call (O(1)). */
291
+ function recordLoopOutcome(cmd: string, ok: boolean, exit: number | null): void {
292
+ if (ok) {
293
+ loopState.delete(cmd); // success resets the streak (mirrors loops.py)
294
+ return;
295
+ }
296
+ const cur = loopState.get(cmd) ?? { cmd, failures: 0, lastExit: null };
297
+ cur.failures += 1;
298
+ if (exit !== null) cur.lastExit = exit;
299
+ loopState.set(cmd, cur);
300
+ }
301
+
302
+ /** Evidence for the current session, memoized by the ledger's mtime+size
303
+ * stamp (judge fix #2): the hot brief/status path never re-filters the
304
+ * whole ledger per turn. */
305
+ function sessionEvidence(cwd: string): { last: VerifyRun | null; count: number } {
306
+ const stamp = fileStamp(verifyRunsPath(cwd));
307
+ if (evidenceMemo && evidenceMemo.sessionId === session.id && evidenceMemo.stamp === stamp) {
308
+ evidenceCount = evidenceMemo.count;
309
+ return { last: evidenceMemo.last, count: evidenceMemo.count };
310
+ }
311
+ const runs = runsForSession(readVerifyRuns(cwd), session.id);
312
+ evidenceMemo = {
313
+ stamp,
314
+ sessionId: session.id,
315
+ last: runs.length > 0 ? runs[runs.length - 1] : null,
316
+ count: runs.length,
317
+ };
318
+ evidenceCount = evidenceMemo.count;
319
+ return { last: evidenceMemo.last, count: evidenceMemo.count };
320
+ }
321
+
322
+ /** Number of fingerprints currently at/over the block threshold. */
323
+ function openLoopCount(): number {
324
+ let n = 0;
325
+ for (const info of loopState.values()) {
326
+ if (shouldBlockLoop(info.failures, cfg.loop_block_after)) n += 1;
327
+ }
328
+ return n;
329
+ }
330
+
331
+ function textLengthOf(result: unknown): { outChars: number; text: string } {
332
+ const content = (result as { content?: unknown } | undefined)?.content;
333
+ let outChars = 0;
334
+ const parts: string[] = [];
335
+ if (Array.isArray(content)) {
336
+ for (const item of content) {
337
+ if (item && typeof item === "object" && (item as { type?: unknown }).type === "text") {
338
+ const t = (item as { text?: unknown }).text;
339
+ if (typeof t === "string") {
340
+ outChars += t.length;
341
+ parts.push(t);
342
+ }
343
+ }
344
+ }
345
+ }
346
+ return { outChars, text: parts.join("\n") };
347
+ }
348
+
349
+ function renderWidgetNow(ctx: ExtensionContext): void {
350
+ try {
351
+ // O(1): renders purely from in-memory counters (no JSONL re-read).
352
+ const line = renderWidget({
353
+ diffLines: lastDiff.lines,
354
+ diffFiles: lastDiff.files,
355
+ diffAvailable: lastDiff.available,
356
+ diffLowerBound: lastDiff.lowerBound,
357
+ loops: openLoopCount(),
358
+ evidence: evidenceCount,
359
+ ctxPct: lastCtxPct,
360
+ });
361
+ if (ctx.hasUI) ctx.ui.setWidget(WIDGET_ID, [line]);
362
+ } catch {
363
+ // Widget is best-effort; never break the session over it.
364
+ }
365
+ }
366
+
367
+ async function gatherStatus(ctx: ExtensionContext): Promise<StatusData> {
368
+ refreshLoopStateIfChanged(ctx.cwd);
369
+ const d = await gatherDiff(ctx);
370
+ lastDiff = { files: d.files, lines: d.lines, available: d.available, lowerBound: d.scanLimited };
371
+ const ev = sessionEvidence(ctx.cwd);
372
+ return {
373
+ session: session.id,
374
+ gitHead: session.gitHead,
375
+ gitUnborn: d.unborn,
376
+ diffAvailable: d.available,
377
+ diffLines: d.lines,
378
+ diffFiles: d.files,
379
+ diffScanLimited: d.scanLimited,
380
+ diffSkippedFiles: d.skippedFiles,
381
+ diffTruncatedFiles: d.truncatedFiles,
382
+ loops: openLoopsFromMap(loopState, cfg.loop_block_after),
383
+ evidenceCount: ev.count,
384
+ contractCount: loadContract(ctx.cwd).constraints.length,
385
+ lastVerify: ev.last,
386
+ ctxPct: lastCtxPct,
387
+ diffWarnLines: cfg.diff_warn_lines,
388
+ diffHardLines: cfg.diff_hard_lines,
389
+ diffWarnFiles: cfg.diff_warn_files,
390
+ };
391
+ }
392
+
393
+ /** Run + record a verification command; never raises on nonzero exit.
394
+ * Enforces the SAME policy as bash tool_call — risk confirm + loop check —
395
+ * so guard_verify cannot be used to bypass either guardrail. */
396
+ async function runVerifyAndRecord(
397
+ ctx: ExtensionContext,
398
+ command: string,
399
+ timeoutSraw: number,
400
+ signal?: AbortSignal,
401
+ ): Promise<string> {
402
+ if (typeof command !== "string" || command.trim() === "") {
403
+ throw new Error("verification command must not be blank");
404
+ }
405
+ const fp = normalizeCommand(command);
406
+ const timeoutS = clampTimeoutS(timeoutSraw);
407
+
408
+ // (a) risk patterns → single grouped confirm; no-UI: log only, never block
409
+ // (same semantics as bash tool_call; mirrors verify.match_risk journaling)
410
+ const risk = matchRisk(fp);
411
+ if (risk) {
412
+ if (ctx.hasUI && cfg.risk_confirm) {
413
+ const ok = await ctx.ui.confirm(
414
+ "Risky command",
415
+ `'${truncLine(fp, 160)}' matches '${risk}'. Run anyway?`,
416
+ );
417
+ if (!ok) {
418
+ return `guard: risky command declined (${risk}) — not run, not recorded.\nNudge: split off the destructive step and run the safe part here.`;
419
+ }
420
+ } else {
421
+ try {
422
+ appendJournal(ctx.cwd, {
423
+ type: "risk",
424
+ text: `guard_verify ran a command matching risk pattern '${risk}': ${fp}`,
425
+ });
426
+ } catch {
427
+ // journal is best-effort; never block the evidence write
428
+ }
429
+ }
430
+ }
431
+
432
+ // (b) loop check on fingerprint — identical arithmetic to the bash path
433
+ refreshLoopStateIfChanged(ctx.cwd);
434
+ const failures = loopState.get(fp)?.failures ?? 0;
435
+ if (shouldBlockLoop(failures, cfg.loop_block_after)) {
436
+ return `${loopBlockReason(truncLine(fp, CMD_DISPLAY_MAX), failures)}\nNudge: not run, not recorded — change approach or gather new information first.`;
437
+ }
438
+
439
+ const res = await shExec(command, timeoutS, signal);
440
+ const run = withStoreLock(storeDir(ctx.cwd), () => {
441
+ // A CLI/MCP writer may have appended while the command ran. Rebuild
442
+ // before applying this outcome so its streak is neither lost nor
443
+ // double-counted.
444
+ if (loopLedgerMemo !== loopLedgerStamp(ctx.cwd)) rebuildLoopStateUnlocked(ctx.cwd);
445
+ const r: VerifyRun = {
446
+ // Id allocated INSIDE the store lock: two concurrent writers
447
+ // (extension + MCP server + CLI) can never mint the same ev-NNNN.
448
+ id: nextEvidenceId(readVerifyRowsForIds(ctx.cwd)),
449
+ ts: new Date().toISOString(),
450
+ session: session.id || "default",
451
+ cmd: command.trim(),
452
+ exit: res.exit,
453
+ ok: res.exit === 0,
454
+ digest: digestOf(res.stdout, res.stderr),
455
+ ms: res.ms,
456
+ };
457
+ appendJsonlLocked(verifyRunsPath(ctx.cwd), r);
458
+ recordLoopOutcome(fp, r.ok, r.exit);
459
+ loopLedgerMemo = loopLedgerStamp(ctx.cwd);
460
+ return r;
461
+ });
462
+ // Recompute from the stamped ledger so concurrent external evidence is
463
+ // reflected in the widget rather than incrementing a stale counter.
464
+ evidenceMemo = null;
465
+ sessionEvidence(ctx.cwd);
466
+ renderWidgetNow(ctx);
467
+ const lines: string[] = [`${run.id} exit=${run.exit} ${formatMs(run.ms)} '${truncLine(run.cmd, CMD_DISPLAY_MAX)}'`];
468
+ if (!run.ok) {
469
+ // Failure diagnostics (pytest summaries, compiler errors) live at the
470
+ // END of output — and the digest rule is "last ≤2000 chars" — so show
471
+ // the TAIL, not the head, or the only visible preview is spent on banners.
472
+ const tail = truncLine(run.digest.slice(-160), 160);
473
+ if (tail) lines.push(`out: ${run.digest.length > 160 ? "…" : ""}${tail}`);
474
+ lines.push(`Nudge: recorded the failure — fix or change approach; do not re-run unchanged.`);
475
+ } else {
476
+ lines.push(`Nudge: cite ${run.id} when claiming success.`);
477
+ }
478
+ return lines.join("\n");
479
+ }
480
+
481
+ // --- events ----------------------------------------------------------
482
+
483
+ pi.on("session_start", async (_event, ctx) =>
484
+ attempt(ctx, async () => {
485
+ ensureStore(ctx.cwd);
486
+ // Seed defaults for extension-only installs; existing user or corrupt
487
+ // files are preserved by loadConfig.
488
+ cfg = loadConfig(ctx.cwd, true).config;
489
+ session.id = safeSessionId(ctx);
490
+ const repoRoot = await runGit(ctx, ["rev-parse", "--show-toplevel"]);
491
+ const head = await runGit(ctx, ["rev-parse", "--verify", "HEAD"]);
492
+ loadContract(ctx.cwd); // seeds contract.json from DEFAULT_CONTRACT if missing
493
+
494
+ const prev = loadState(ctx.cwd);
495
+ if (prev && prev.current_session?.id === session.id && prev.notify_watermarks) {
496
+ state = { ...prev, counters: prev.counters ?? {} };
497
+ // Extension reloads can fire session_start again for the same pi
498
+ // session. Preserve the original baseline from state instead of
499
+ // 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;
502
+ } else {
503
+ session.started = new Date().toISOString();
504
+ session.gitHead = head?.trim() || null;
505
+ state = defaultState(session.id, session.gitHead);
506
+ saveState(ctx.cwd, state);
507
+ }
508
+ // A null baseline inside a repository means the session began before a
509
+ // first commit (or before repository creation). Keep using the empty
510
+ // tree even if HEAD appears later, so commits cannot erase session diff.
511
+ session.gitUnborn = repoRoot !== null && session.gitHead === null;
512
+ // Warm the in-memory counters once per session (O(1) hot paths after this).
513
+ rebuildLoopState(ctx.cwd);
514
+ evidenceCount = sessionEvidence(ctx.cwd).count;
515
+ try {
516
+ orientationText = renderOrientation(scanOrientation(ctx.cwd));
517
+ } catch {
518
+ orientationText = null; // orientation is best-effort
519
+ }
520
+ renderWidgetNow(ctx);
521
+ return undefined;
522
+ }),
523
+ );
524
+
525
+ pi.on("before_agent_start", async (_event, ctx) =>
526
+ attempt(ctx, async () => {
527
+ refreshLoopStateIfChanged(ctx.cwd);
528
+ const contract = loadContract(ctx.cwd);
529
+ const journal = readJournal(ctx.cwd);
530
+ const decisions = recentDecisions(journal, 3);
531
+ const corrections = recentCorrections(journal, 3);
532
+ // In-memory streaks are the merged view (bash events + guard_verify
533
+ // outcomes) — the disk events alone would miss verify streaks.
534
+ const loops = openLoopsFromMap(loopState, cfg.loop_block_after).slice(0, 2);
535
+ const last = sessionEvidence(ctx.cwd).last;
536
+ const brief = buildBrief({
537
+ contract: contract.constraints,
538
+ decisions,
539
+ corrections,
540
+ loops,
541
+ lastEvidence: last ? { id: last.id, exit: last.exit, cmd: last.cmd, ms: last.ms } : null,
542
+ });
543
+ const digest = sha1Hex(brief);
544
+ const briefDue = brief.trim() !== "" && (cfg.brief_mode !== "changed" || state.brief_digest !== digest);
545
+
546
+ // Orientation (goal #4): injected once per distinct digest, like the
547
+ // brief. Lazily re-scanned if session_start did not populate it.
548
+ if (orientationText === null) {
549
+ try {
550
+ orientationText = renderOrientation(scanOrientation(ctx.cwd));
551
+ } catch {
552
+ orientationText = null;
553
+ }
554
+ }
555
+ const orientDigest = orientationText === null ? null : sha1Hex(orientationText);
556
+ const orientDue = orientDigest !== null && state.counters.orient_sha1 !== orientDigest;
557
+
558
+ if (!briefDue && !orientDue) return undefined;
559
+
560
+ const parts: string[] = [];
561
+ if (orientDue && orientationText !== null) parts.push(orientationText);
562
+ if (briefDue) parts.push(brief);
563
+ if (parts.length === 0) return undefined;
564
+
565
+ if (orientDue && orientDigest !== null) state.counters.orient_sha1 = orientDigest;
566
+ if (briefDue) state.brief_digest = digest;
567
+ saveState(ctx.cwd, state);
568
+ return { message: { customType: BRIEF_CUSTOM_TYPE, content: parts.join("\n\n"), display: false } };
569
+ }),
570
+ );
571
+
572
+ pi.on("tool_call", async (event, ctx) =>
573
+ attempt(ctx, async () => {
574
+ if (event.toolName !== "bash") return undefined;
575
+ if (!isToolCallEventType("bash", event)) return undefined;
576
+ const raw = typeof event.input.command === "string" ? event.input.command : "";
577
+ if (raw.trim() === "") return undefined;
578
+ const fp = normalizeCommand(raw);
579
+
580
+ // (a) risk patterns → single grouped confirm; no-UI: log only, never block
581
+ const risk = matchRisk(fp);
582
+ if (risk) {
583
+ if (ctx.hasUI && cfg.risk_confirm) {
584
+ const ok = await ctx.ui.confirm(
585
+ "Risky command",
586
+ `'${truncLine(fp, 160)}' matches '${risk}'. Run anyway?`,
587
+ );
588
+ if (!ok) {
589
+ // pi still emits tool_execution_end for blocked calls (with the
590
+ // block reason as the result) — mark it so the end handler does
591
+ // not record the decline as a failed command.
592
+ blockedToolCallIds.add(event.toolCallId);
593
+ return { block: true, reason: `guard: risky command declined (${risk})` };
594
+ }
595
+ } else {
596
+ // No-UI parity (judge fix #1): a risky bash run without a confirm
597
+ // dialog must still reach the journal — runVerifyAndRecord and
598
+ // Python verify.match_risk already do this, and a silent risky run
599
+ // would be invisible to /guard-report and the brief.
600
+ try {
601
+ appendJournal(ctx.cwd, {
602
+ type: "risk",
603
+ text: `bash tool_call matched risk pattern '${risk}': ${fp}`,
604
+ });
605
+ } catch {
606
+ // journal is best-effort; never block the tool call over it
607
+ }
608
+ }
609
+ }
610
+
611
+ // (b) Recheck + record atomically with respect to CLI/MCP writers. This
612
+ // prevents an external second failure from being missed between a stale
613
+ // in-memory check and the tool-call record.
614
+ const blockedFailures = withStoreLock(storeDir(ctx.cwd), () => {
615
+ if (loopLedgerMemo !== loopLedgerStamp(ctx.cwd)) rebuildLoopStateUnlocked(ctx.cwd);
616
+ 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);
620
+ return null;
621
+ });
622
+ if (blockedFailures !== null) {
623
+ blockedToolCallIds.add(event.toolCallId);
624
+ return { block: true, reason: loopBlockReason(truncLine(fp, CMD_DISPLAY_MAX), blockedFailures) };
625
+ }
626
+
627
+ return undefined;
628
+ }),
629
+ );
630
+
631
+ pi.on("tool_execution_start", async (event, _ctx) => {
632
+ try {
633
+ let cmd: string | null = null;
634
+ if (event.toolName === "bash") {
635
+ const command = (event.args as { command?: unknown } | undefined)?.command;
636
+ if (typeof command === "string") cmd = normalizeCommand(command);
637
+ }
638
+ startTimes.set(event.toolCallId, { start: Date.now(), tool: event.toolName, cmd });
639
+ } catch {
640
+ // timing is best-effort
641
+ }
642
+ return undefined;
643
+ });
644
+
645
+ pi.on("tool_execution_end", async (event, ctx) =>
646
+ attempt(ctx, async () => {
647
+ const meta = startTimes.get(event.toolCallId);
648
+ startTimes.delete(event.toolCallId);
649
+
650
+ if (blockedToolCallIds.has(event.toolCallId)) {
651
+ // Blocked in tool_call: the "result" here is the block reason, not a
652
+ // real tool outcome. Recording it as a failed command would inflate
653
+ // loop streaks (risk-declines and loop-blocks are not command runs).
654
+ blockedToolCallIds.delete(event.toolCallId);
655
+ return undefined;
656
+ }
657
+
658
+ const ms = meta ? Date.now() - meta.start : null;
659
+ const { outChars, text } = textLengthOf(event.result);
660
+
661
+ let cmd: string | null = null;
662
+ let exit: number | null = null;
663
+ let ok: boolean | null = !event.isError;
664
+ if (event.toolName === "bash") {
665
+ cmd = meta?.cmd ?? null;
666
+ if (event.isError) {
667
+ exit = bashExitFromResult(text);
668
+ // isError is authoritative even when a provider omits the textual
669
+ // exit-code suffix. Unknown exit remains null, but the outcome must
670
+ // still count as a failure rather than silently disappearing from
671
+ // loop protection.
672
+ ok = false;
673
+ } else {
674
+ // Pi omits bash exit details on success; persist the known shell
675
+ // convention explicitly so the shared event format is unambiguous.
676
+ exit = 0;
677
+ ok = true;
678
+ }
679
+ }
680
+
681
+ withStoreLock(storeDir(ctx.cwd), () => {
682
+ if (loopLedgerMemo !== loopLedgerStamp(ctx.cwd)) rebuildLoopStateUnlocked(ctx.cwd);
683
+ appendJsonlLocked(
684
+ eventsPath(ctx.cwd),
685
+ toolResultEvent(session.id, event.toolName, { ok, ms, outChars, cmd, exit }),
686
+ );
687
+ // Incremental streak update (bash only). The rebuild above first
688
+ // incorporates external writers, then this result is applied once.
689
+ if (cmd !== null && ok !== null) recordLoopOutcome(cmd, ok, exit);
690
+ loopLedgerMemo = loopLedgerStamp(ctx.cwd);
691
+ });
692
+
693
+ renderWidgetNow(ctx);
694
+ return undefined;
695
+ }),
696
+ );
697
+
698
+ pi.on("turn_end", async (_event, ctx) =>
699
+ attempt(ctx, async () => {
700
+ // Context usage watermarks (goal #12)
701
+ try {
702
+ const usage = ctx.getContextUsage();
703
+ if (usage && usage.percent !== null) {
704
+ lastCtxPct = usage.percent;
705
+ const step = Math.floor(usage.percent / 10) * 10;
706
+ const contextDue =
707
+ step >= cfg.ctx_warn_pct &&
708
+ (step > state.notify_watermarks.ctx_pct ||
709
+ (cfg.ctx_warn_pct === 0 && state.counters.ctx_zero_notified !== 1));
710
+ if (contextDue) {
711
+ state.notify_watermarks.ctx_pct = step;
712
+ markZeroThreshold(cfg.ctx_warn_pct, "ctx_zero_notified");
713
+ saveState(ctx.cwd, state);
714
+ if (ctx.hasUI) {
715
+ ctx.ui.notify(
716
+ `guard: context at ${Math.round(usage.percent)}% — consider /compact to free budget.`,
717
+ "warning",
718
+ );
719
+ }
720
+ }
721
+ }
722
+ } catch {
723
+ // usage is best-effort
724
+ }
725
+
726
+ // Diff budget (goal #8)
727
+ const diff = await gatherDiff(ctx);
728
+ lastDiff = {
729
+ files: diff.files,
730
+ lines: diff.lines,
731
+ available: diff.available,
732
+ lowerBound: diff.scanLimited,
733
+ };
734
+ if (diff.repo && diff.available) {
735
+ const { files, lines } = diff;
736
+ const wm = state.notify_watermarks;
737
+ if (lines >= cfg.diff_hard_lines) {
738
+ if (thresholdDue(lines, cfg.diff_hard_lines, wm.diff_lines, "diff_hard_zero_notified")) {
739
+ wm.diff_lines = Math.max(wm.diff_lines, cfg.diff_hard_lines);
740
+ markZeroThreshold(cfg.diff_hard_lines, "diff_hard_zero_notified");
741
+ saveState(ctx.cwd, state);
742
+ if (ctx.hasUI) {
743
+ ctx.ui.notify(
744
+ `guard: diff reached ${lines} lines (hard limit ${cfg.diff_hard_lines}) — split before continuing.`,
745
+ "error",
746
+ );
747
+ }
748
+ }
749
+ } else if (thresholdDue(lines, cfg.diff_warn_lines, wm.diff_lines, "diff_warn_zero_notified")) {
750
+ wm.diff_lines = Math.max(wm.diff_lines, cfg.diff_warn_lines);
751
+ markZeroThreshold(cfg.diff_warn_lines, "diff_warn_zero_notified");
752
+ saveState(ctx.cwd, state);
753
+ if (ctx.hasUI) {
754
+ ctx.ui.notify(
755
+ `guard: diff reached ${lines} lines (warn ${cfg.diff_warn_lines}) — consider splitting.`,
756
+ "warning",
757
+ );
758
+ }
759
+ }
760
+ if (thresholdDue(files, cfg.diff_warn_files, wm.diff_files, "diff_files_zero_notified")) {
761
+ wm.diff_files = Math.max(wm.diff_files, cfg.diff_warn_files);
762
+ markZeroThreshold(cfg.diff_warn_files, "diff_files_zero_notified");
763
+ saveState(ctx.cwd, state);
764
+ if (ctx.hasUI) {
765
+ ctx.ui.notify(`guard: diff touched ${files} files (warn ${cfg.diff_warn_files}) — check scope.`, "warning");
766
+ }
767
+ }
768
+ }
769
+
770
+ renderWidgetNow(ctx);
771
+ return undefined;
772
+ }),
773
+ );
774
+
775
+ // Compaction removes the previously injected hidden brief from active
776
+ // context. Invalidate both digests so the next turn re-injects constraints,
777
+ // trusted journal entries and orientation even when their text is unchanged.
778
+ pi.on("session_compact", async (_event, ctx) =>
779
+ attempt(ctx, async () => {
780
+ state.brief_digest = null;
781
+ delete state.counters.orient_sha1;
782
+ saveState(ctx.cwd, state);
783
+ return undefined;
784
+ }),
785
+ );
786
+
787
+ pi.on("session_shutdown", async (_event, ctx) => {
788
+ // Idempotent persistence; no UI during shutdown.
789
+ try {
790
+ if (ctx?.cwd) saveState(ctx.cwd, state);
791
+ } catch {
792
+ // best-effort
793
+ }
794
+ startTimes.clear();
795
+ loopState.clear();
796
+ blockedToolCallIds.clear();
797
+ loopLedgerMemo = null;
798
+ evidenceMemo = null;
799
+ orientationText = null;
800
+ evidenceCount = 0;
801
+ lastDiff = { lines: 0, files: 0, available: false, lowerBound: false };
802
+ lastCtxPct = null;
803
+ return undefined;
804
+ });
805
+
806
+ // --- commands ----------------------------------------------------------
807
+
808
+ pi.registerCommand("guard-status", {
809
+ description: "protect-context: compact session status",
810
+ handler: async (_args, ctx) =>
811
+ attempt(ctx, async () => {
812
+ const text = buildStatus(await gatherStatus(ctx));
813
+ if (ctx.hasUI) ctx.ui.notify(text, "info");
814
+ return undefined;
815
+ }),
816
+ });
817
+
818
+ pi.registerCommand("guard-contract", {
819
+ description: "protect-context: contract list | add <text> | remove <id>",
820
+ handler: async (args, ctx) =>
821
+ attempt(ctx, async () => {
822
+ const trimmed = (args ?? "").trim();
823
+ const parts = trimmed.split(/\s+/);
824
+ const verb = parts[0] ?? "";
825
+ const rest = parts.slice(1).join(" ").trim();
826
+ if (verb === "" || verb === "list") {
827
+ const current = loadContract(ctx.cwd);
828
+ const lines = contractLines(current);
829
+ if (ctx.hasUI) {
830
+ ctx.ui.notify(
831
+ `contract (${current.constraints.length}):\n${lines.join("\n")}\nNudge: add missing constraints with /guard-contract add <text>.`,
832
+ "info",
833
+ );
834
+ }
835
+ return undefined;
836
+ }
837
+ if (verb === "add") {
838
+ if (rest === "") {
839
+ if (ctx.hasUI) ctx.ui.notify("usage: /guard-contract add <text>", "warning");
840
+ return undefined;
841
+ }
842
+ // Slash commands are direct owner interaction and therefore trusted
843
+ // by brief prioritization; model-callable writes use provenance tags.
844
+ const result = addConstraintWithStatus(ctx.cwd, rest, "user");
845
+ const c = result.constraint;
846
+ const total = result.total;
847
+ if (ctx.hasUI) {
848
+ // SPEC §5 caps the re-injected brief at 12 contract lines: a
849
+ // constraint past the cap would be silently DROPPED while the
850
+ // owner believes it is enforced — warn instead.
851
+ const over = total > BRIEF_MAX_CONTRACT_LINES;
852
+ const ack = !result.added
853
+ ? `constraint ${c.id} already exists\nNudge: no duplicate was written.`
854
+ : over
855
+ ? `added ${c.id}\nwarning: ${total} constraints exceed the ${BRIEF_MAX_CONTRACT_LINES}-line brief cap — ` +
856
+ `only the ${BRIEF_MAX_CONTRACT_LINES} highest-priority owner/seed constraints are re-injected.` +
857
+ `\nNudge: keep the contract tight — fewer, sharper constraints survive re-injection.`
858
+ : `added ${c.id}\nNudge: the brief re-injects on your next prompt.`;
859
+ ctx.ui.notify(ack, result.added && over ? "warning" : "info");
860
+ }
861
+ return undefined;
862
+ }
863
+ if (verb === "remove") {
864
+ const removed = rest !== "" && removeConstraint(ctx.cwd, rest);
865
+ const safeId = truncLine(rest, 80);
866
+ if (ctx.hasUI) {
867
+ ctx.ui.notify(
868
+ removed
869
+ ? `removed ${safeId}\nNudge: the contract re-injects on your next prompt.`
870
+ : `no constraint '${safeId}'\nNudge: run /guard-contract list first.`,
871
+ removed ? "info" : "warning",
872
+ );
873
+ }
874
+ return undefined;
875
+ }
876
+ if (ctx.hasUI) ctx.ui.notify("usage: /guard-contract list | add <text> | remove <id>", "warning");
877
+ return undefined;
878
+ }),
879
+ });
880
+
881
+ pi.registerCommand("guard-verify", {
882
+ description: "protect-context: run a command and record evidence",
883
+ handler: async (args, ctx) =>
884
+ attempt(ctx, async () => {
885
+ const command = (args ?? "").trim();
886
+ if (command === "") {
887
+ if (ctx.hasUI) ctx.ui.notify("usage: /guard-verify <command>", "warning");
888
+ return undefined;
889
+ }
890
+ const out = await runVerifyAndRecord(ctx, command, 180);
891
+ if (ctx.hasUI) ctx.ui.notify(out, "info");
892
+ return undefined;
893
+ }),
894
+ });
895
+
896
+ pi.registerCommand("guard-note", {
897
+ description: "protect-context: journal a decision|correction|risk|note",
898
+ handler: async (args, ctx) =>
899
+ attempt(ctx, async () => {
900
+ const m = (args ?? "").trim().match(/^(\S+)\s+([\s\S]+)$/);
901
+ const type = m && isJournalType(m[1]) ? m[1] : null;
902
+ if (!type || !m) {
903
+ if (ctx.hasUI) ctx.ui.notify("usage: /guard-note <decision|correction|risk|note> <text>", "warning");
904
+ return undefined;
905
+ }
906
+ appendJournal(ctx.cwd, { type, text: m[2].trim() });
907
+ if (ctx.hasUI) ctx.ui.notify(`journal: ${type} recorded\nNudge: check corrections before similar work.`, "info");
908
+ return undefined;
909
+ }),
910
+ });
911
+
912
+ pi.registerCommand("guard-report", {
913
+ description: "protect-context: full session summary",
914
+ handler: async (_args, ctx) =>
915
+ attempt(ctx, async () => {
916
+ const st = await gatherStatus(ctx);
917
+ const lines: string[] = [buildStatus(st), "---"];
918
+ lines.push(
919
+ `config: loop_block_after=${cfg.loop_block_after} diff_warn=${cfg.diff_warn_lines}L/${cfg.diff_warn_files}f ` +
920
+ `diff_hard=${cfg.diff_hard_lines}L ctx_warn=${cfg.ctx_warn_pct}% brief=${cfg.brief_mode} risk_confirm=${cfg.risk_confirm}`,
921
+ );
922
+ if (st.loops.length > 0) {
923
+ const visibleLoops = st.loops.slice(0, 5);
924
+ const hidden = st.loops.length - visibleLoops.length;
925
+ lines.push(
926
+ `loops: ${visibleLoops.map((l) => `'${truncLine(l.cmd, CMD_DISPLAY_MAX)}'x${l.failures}`).join(", ")}` +
927
+ (hidden > 0 ? `, +${hidden} more` : ""),
928
+ );
929
+ }
930
+ lines.push("Nudge: use /guard-verify for evidence and /guard-note to persist decisions.");
931
+ if (ctx.hasUI) ctx.ui.notify(lines.join("\n"), "info");
932
+ return undefined;
933
+ }),
934
+ });
935
+
936
+ // --- model-callable tools ---------------------------------------------
937
+
938
+ pi.registerTool({
939
+ name: "guard_verify",
940
+ label: "Guard Verify",
941
+ description:
942
+ "Run a verification command and record evidence (id, exit, digest) in .agent-guard/verify_runs.jsonl. " +
943
+ "Never raises on nonzero exit — it records the failure. Cite the evidence id when claiming success. " +
944
+ "timeout_s is clamped to [1, 900] so a runaway value cannot wedge the session.",
945
+ promptSnippet: "Run a verification command and record evidence",
946
+ promptGuidelines: [
947
+ "Use guard_verify to run verification commands; cite the returned evidence id when claiming success.",
948
+ ],
949
+ parameters: Type.Object({
950
+ command: Type.String({ description: "Shell command to run for verification" }),
951
+ timeout_s: Type.Optional(Type.Integer({ description: "Timeout in seconds (default 180, clamped to 1–900)" })),
952
+ }),
953
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
954
+ const out = await runVerifyAndRecord(ctx, params.command, params.timeout_s ?? 180, signal);
955
+ return { content: [{ type: "text", text: out }], details: {} };
956
+ },
957
+ });
958
+
959
+ pi.registerTool({
960
+ name: "guard_note",
961
+ label: "Guard Note",
962
+ description:
963
+ "Append a decision/correction/risk/note to the persistent journal (.agent-guard/journal.jsonl). " +
964
+ "Model-written entries are tagged agent and persisted for owner review; they are not trusted brief input. " +
965
+ "Only trusted slash-command/CLI decisions and corrections are re-injected.",
966
+ promptSnippet: "Record a durable decision or correction in the journal",
967
+ promptGuidelines: [
968
+ "Use guard_note to record durable decisions; when corrected, log a tagged correction with type=correction.",
969
+ ],
970
+ parameters: Type.Object({
971
+ type: StringEnum(["decision", "correction", "risk", "note"] as const),
972
+ text: Type.String({ description: "What happened / what was decided" }),
973
+ tags: Type.Optional(Type.Array(Type.String({ description: "Optional tags" }))),
974
+ }),
975
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
976
+ const entry = appendJournal(ctx.cwd, {
977
+ type: params.type,
978
+ text: params.text,
979
+ tags: tagsWithRequired(params.tags ?? [], "agent"),
980
+ });
981
+ return {
982
+ content: [
983
+ {
984
+ type: "text",
985
+ text: `journal: ${entry.type} recorded${entry.tags.length > 0 ? ` [${entry.tags.join(",")}]` : ""}\nNudge: check corrections before similar work.`,
986
+ },
987
+ ],
988
+ details: {},
989
+ };
990
+ },
991
+ });
992
+
993
+ pi.registerTool({
994
+ name: "guard_contract_check",
995
+ label: "Guard Contract Check",
996
+ description:
997
+ "Deterministic checklist: marks each contract constraint addressed/unclear based on keyword coverage of a change summary. No LLM involved.",
998
+ promptSnippet: "Check a change summary against the contract constraints",
999
+ promptGuidelines: [
1000
+ "Use guard_contract_check with a change summary before claiming a task satisfies the contract.",
1001
+ ],
1002
+ parameters: Type.Object({
1003
+ summary: Type.String({ description: "Summary of the change made" }),
1004
+ }),
1005
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1006
+ const contract = loadContract(ctx.cwd);
1007
+ const text = renderChecklist(checkContractSummary(params.summary, contract));
1008
+ return { content: [{ type: "text", text }], details: {} };
1009
+ },
1010
+ });
1011
+
1012
+ pi.registerTool({
1013
+ name: "guard_status",
1014
+ label: "Guard Status",
1015
+ description:
1016
+ "Compact guardrail dashboard: session, diff, loop streaks, evidence count, contract size, last verify.",
1017
+ promptSnippet: "Show the protect-context status dashboard",
1018
+ parameters: Type.Object({}),
1019
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1020
+ const text = buildStatus(await gatherStatus(ctx));
1021
+ return { content: [{ type: "text", text }], details: {} };
1022
+ },
1023
+ });
1024
+ }