greprag 5.47.0 → 5.49.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.
Files changed (47) hide show
  1. package/dist/commands/assistant-reminder.d.ts +9 -0
  2. package/dist/commands/assistant-reminder.js +20 -0
  3. package/dist/commands/assistant-reminder.js.map +1 -0
  4. package/dist/commands/checkpoint-reminder.d.ts +14 -0
  5. package/dist/commands/checkpoint-reminder.js +50 -0
  6. package/dist/commands/checkpoint-reminder.js.map +1 -0
  7. package/dist/commands/crush.d.ts +9 -0
  8. package/dist/commands/crush.js +39 -0
  9. package/dist/commands/crush.js.map +1 -1
  10. package/dist/commands/frontdesk-reminder.d.ts +23 -0
  11. package/dist/commands/frontdesk-reminder.js +40 -0
  12. package/dist/commands/frontdesk-reminder.js.map +1 -0
  13. package/dist/commands/inbox-watch-supervisor.d.ts +25 -0
  14. package/dist/commands/inbox-watch-supervisor.js +112 -5
  15. package/dist/commands/inbox-watch-supervisor.js.map +1 -1
  16. package/dist/commands/init.js +47 -38
  17. package/dist/commands/init.js.map +1 -1
  18. package/dist/commands/memory-reflex.d.ts +115 -0
  19. package/dist/commands/memory-reflex.js +243 -0
  20. package/dist/commands/memory-reflex.js.map +1 -0
  21. package/dist/commands/opencode-relay.d.ts +19 -4
  22. package/dist/commands/opencode-relay.js +56 -5
  23. package/dist/commands/opencode-relay.js.map +1 -1
  24. package/dist/commands/reminder-registry.d.ts +6 -2
  25. package/dist/commands/reminder-registry.js +20 -3
  26. package/dist/commands/reminder-registry.js.map +1 -1
  27. package/dist/commands/reminder-types.d.ts +27 -0
  28. package/dist/commands/setup-reminder.d.ts +9 -0
  29. package/dist/commands/setup-reminder.js +20 -0
  30. package/dist/commands/setup-reminder.js.map +1 -0
  31. package/dist/commands/watcher-registry.js +4 -2
  32. package/dist/commands/watcher-registry.js.map +1 -1
  33. package/dist/hook.js +329 -109
  34. package/dist/hook.js.map +1 -1
  35. package/dist/index.js +4 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/opencode-plugin-crush.d.ts +88 -0
  38. package/dist/opencode-plugin-crush.js +193 -0
  39. package/dist/opencode-plugin-crush.js.map +1 -0
  40. package/dist/opencode-plugin.bundle.js +2132 -0
  41. package/dist/opencode-plugin.d.ts +6 -0
  42. package/dist/opencode-plugin.js +184 -51
  43. package/dist/opencode-plugin.js.map +1 -1
  44. package/dist/worktree-state.js +5 -5
  45. package/dist/worktree-state.js.map +1 -1
  46. package/package.json +3 -2
  47. package/scripts/bundle-opencode-plugin.mjs +54 -0
package/dist/hook.js CHANGED
@@ -77,6 +77,7 @@ const guard_1 = require("./guard");
77
77
  // live env (ALL I/O) and emits; the modules are pure.
78
78
  const reminder_registry_1 = require("./commands/reminder-registry");
79
79
  const friction_reminder_1 = require("./commands/friction-reminder");
80
+ const memory_reflex_1 = require("./commands/memory-reflex");
80
81
  const worktree_state_1 = require("./worktree-state");
81
82
  // Ingress-trigger bridge, Adapter A (LOCAL/state half) — the Stop hook computes
82
83
  // the deterministic state values (stress / turnCount / keyword-seen) from the
@@ -784,6 +785,37 @@ async function store(input, source = 'claude-code') {
784
785
  if (source === 'codex') {
785
786
  turn.toolCalls.push(...(0, codex_hook_events_1.readCodexSubagentToolCalls)(input));
786
787
  }
788
+ // Memory-reflex EFFICACY capture (adr/memory-reflex.md). If a memory injection was surfaced
789
+ // this turn: (1) score the FREE overlap proxy locally → bump the `used` numerator, and (2)
790
+ // carry the injection onto the turn POST so the server-side Flash-Lite judge (Chip A) can
791
+ // score it accurately. Consume-once: the stash is always cleared, so a turn with no injection
792
+ // never reuses a stale sample. adr: adr/memory-reflex.md
793
+ let memoryInjectionForStore = null;
794
+ try {
795
+ const effShort = (0, session_id_1.truncateSessionId)(input.session_id);
796
+ if (effShort) {
797
+ const pending = consumeMemoryInjection(effShort);
798
+ if (pending) {
799
+ memoryInjectionForStore = pending.hitText;
800
+ if ((0, memory_reflex_1.injectionWasUsed)(pending.hitText, turn.userPrompt, turn.agentResponse)) {
801
+ recordMemoryUsed(pending.projectId);
802
+ }
803
+ }
804
+ }
805
+ }
806
+ catch { /* efficacy capture is best-effort — never block the turn */ }
807
+ // Self-report scan (P1b, adr/memory-reflex.md). If the agent emitted a [[recall: X]] marker
808
+ // (taught by the memory announce) it's flagging its OWN context gap → stash the topic so the
809
+ // NEXT turn's notify pulls + injects it (the self-report trigger). Best-effort.
810
+ try {
811
+ const srShort = (0, session_id_1.truncateSessionId)(input.session_id);
812
+ if (srShort && anchor.projectId) {
813
+ const topic = (0, memory_reflex_1.extractRecallMarker)(turn.agentResponse);
814
+ if (topic)
815
+ stashSelfReport(srShort, anchor.projectId, topic);
816
+ }
817
+ }
818
+ catch { /* best-effort — never block the turn */ }
787
819
  // Empty turn — nothing to capture.
788
820
  if (!turn.userPrompt && !turn.agentResponse && turn.toolCalls.length === 0)
789
821
  return;
@@ -820,6 +852,12 @@ async function store(input, source = 'claude-code') {
820
852
  status: turn.status,
821
853
  userPrompt,
822
854
  agentResponse,
855
+ // The memory injection surfaced this turn (when any) — the SEAM the Flash-Lite efficacy
856
+ // judge (Chip A) consumes server-side at ingest to score used÷injected. Scrubbed + capped
857
+ // like the other text fields. Omitted entirely on the common no-injection turn.
858
+ ...(memoryInjectionForStore
859
+ ? { memoryInjection: capField((0, secret_scrubber_1.scrubString)(memoryInjectionForStore, redaction)) }
860
+ : {}),
823
861
  toolCalls,
824
862
  filesTouched: turn.filesTouched,
825
863
  artifacts: artifactRefs,
@@ -828,6 +866,20 @@ async function store(input, source = 'claude-code') {
828
866
  provenance,
829
867
  });
830
868
  }
869
+ // ---------- Main ---------------------------------------------------------
870
+ // ---------- Recap (SessionStart) ------------------------------------------
871
+ // `ByPeriodRow`, `stripRecapNoise`, `fmtHoursAgo`, and the recap body
872
+ // renderer (`buildRecapBody`) are now imported from `./opencode-plugin-helpers`
873
+ // — see adr/opencode-monitor-relay.md 2026-06-06 (g). The recap content
874
+ // pushed to the system prompt is the same for Claude/Codex and the opencode
875
+ // plugin; per-harness wrappers (preamble, output mode, transform-hook push)
876
+ // stay local.
877
+ /** Fetch a slim summary of open checkpoints for the given project. Returns
878
+ * up to `limit` rows so the checkpoint module can compute the overflow
879
+ * (3-most-recent + "N more" hint). Returns [] on any error — the hook
880
+ * must never block on a checkpoint query failure. (OpenCheckpointSlim +
881
+ * renderCheckpointHint now live in commands/checkpoint-reminder.ts; the hook
882
+ * owns only this fetch.) */
831
883
  async function fetchOpenCheckpoints(apiUrl, apiKey, projectId, limit = 10) {
832
884
  try {
833
885
  const url = `${apiUrl}/v1/checkpoints/${encodeURIComponent(projectId)}`
@@ -844,33 +896,6 @@ async function fetchOpenCheckpoints(apiUrl, apiKey, projectId, limit = 10) {
844
896
  return [];
845
897
  }
846
898
  }
847
- /** Render a YYYY-MM-DD date in UTC for the session-start hint. */
848
- function fmtCheckpointDate(iso) {
849
- return iso.slice(0, 10);
850
- }
851
- /** Format the open-checkpoints hint block. Shape per design doc § Hint
852
- * placement: 3-most-recent rows + a single overflow line when there are
853
- * more. Returns null when there are zero open checkpoints — caller
854
- * treats it as silent. */
855
- function renderCheckpointHint(open, projectName) {
856
- if (open.length === 0)
857
- return null;
858
- // Server returns DESC by created_at; take the 3 most recent.
859
- const top = open.slice(0, 3);
860
- const overflow = open.length - top.length;
861
- const lines = [];
862
- lines.push(`[${open.length} checkpoint${open.length === 1 ? '' : 's'} open in ${projectName}:`);
863
- for (const cp of top) {
864
- lines.push(` · ${cp.nodeId} ${cp.title} (since ${fmtCheckpointDate(cp.createdAt)})`);
865
- }
866
- if (overflow > 0) {
867
- lines.push(` · ${overflow} more — greprag checkpoint list]`);
868
- }
869
- else {
870
- lines.push(` View one: greprag checkpoint show <id>]`);
871
- }
872
- return lines.join('\n');
873
- }
874
899
  /** Fetch unread inbox count for the given project context. Project-scoped
875
900
  * count includes tenant-level messages (mail addressed to the user, not
876
901
  * any specific project, still shows up). Returns 0 on any error so the
@@ -931,6 +956,44 @@ async function fetchSessionUnread(apiUrl, apiKey, short) {
931
956
  return 0;
932
957
  }
933
958
  }
959
+ /** Fetch top episodic-memory hits for a query (the memory-reflex inline path), project-
960
+ * scoped, with a HARD timeout so a slow search never blocks the turn. POSTs the same
961
+ * `/v1/memory/query` the CLI `memory search` uses, with the de-pollution `session-turn`
962
+ * provenance filter. Returns [] on any error/abort → the reflex stays silent. The
963
+ * confidence gate + framing live in buildMemoryInjection (commands/memory-reflex.ts).
964
+ * adr: adr/memory-reflex.md */
965
+ async function fetchMemoryHits(apiUrl, apiKey, projectId, query, limit = 3, timeoutMs = 2000) {
966
+ const ctl = new AbortController();
967
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
968
+ try {
969
+ const res = await fetch(`${apiUrl}/v1/memory/query`, {
970
+ method: 'POST',
971
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
972
+ body: JSON.stringify({ query, limit, projectId, filters: { provenance: 'session-turn' } }),
973
+ signal: ctl.signal,
974
+ });
975
+ if (!res.ok)
976
+ return { hits: [], quiet: false };
977
+ const data = await res.json();
978
+ // The server's Flash-Lite efficacy verdict rides the same response (adr/memory-reflex.md).
979
+ const quiet = data.memoryReflexQuiet === true;
980
+ if (!data.ok || !Array.isArray(data.nodes))
981
+ return { hits: [], quiet };
982
+ return {
983
+ hits: data.nodes.map(n => ({
984
+ content: n.content, score: n.score, confidence: n.confidence ?? null,
985
+ shape: n.shape ?? null, createdAt: n.createdAt ?? null, projectName: n.projectName ?? null,
986
+ })),
987
+ quiet,
988
+ };
989
+ }
990
+ catch {
991
+ return { hits: [], quiet: false };
992
+ }
993
+ finally {
994
+ clearTimeout(timer);
995
+ }
996
+ }
934
997
  /** Identity-drift warning rate limiter.
935
998
  *
936
999
  * The drift warning ("stored ID ≠ git-derived ID, run `greprag doctor`") used
@@ -1095,34 +1158,29 @@ async function recap(input, mode = 'plain') {
1095
1158
  // (and then read) another session's session-targeted DM. Live inbox now
1096
1159
  // belongs exclusively to Monitor watchers; the agent learns about its own
1097
1160
  // mail via the watcher's session-scoped stream.
1098
- // Open-checkpoints hint still fires — checkpoints are operator-triggered
1099
- // landmarks, not message-shape inbox traffic, no privacy issue.
1161
+ // Open-checkpoints — checkpoints are operator-triggered landmarks, not message-shape
1162
+ // inbox traffic, no privacy issue. The hook owns ONLY this fetch; the checkpoint module
1163
+ // (commands/checkpoint-reminder.ts) renders the list → hint block in its announce.
1100
1164
  const openCheckpoints = await fetchOpenCheckpoints(cfg.apiUrl, cfg.apiKey, anchor.projectId, 10);
1101
- const checkpointHint = renderCheckpointHint(openCheckpoints, anchor.projectName);
1102
- // Front-desk awareness a discreet count of cold opens + inbound email waiting
1103
- // at the shared tenant front desk, so a stranger's first contact isn't stranded
1104
- // until someone happens to run `greprag inbox`. Content is NEVER injected just
1105
- // the count + the door. Fires once per session (SessionStart), so it's naturally
1106
- // deduped. This is NOT the v5.6.1 eavesdrop vector — see fetchFrontDeskUnread.
1107
- // (Suppressing it when a live receptionist is attached is a clean follow-up.)
1108
- // adr: adr/address-grammar.md (2026-06-10), docs/front-desk-switchboard.md §5
1165
+ // Front-desk awareness a discreet count of cold opens + inbound email waiting at the
1166
+ // shared tenant front desk, so a stranger's first contact isn't stranded until someone
1167
+ // runs `greprag inbox`. Content is NEVER injected just the count + the door, formatted
1168
+ // by the front-desk module's announce. NOT the v5.6.1 eavesdrop vectorsee
1169
+ // fetchFrontDeskUnread. adr: adr/address-grammar.md (2026-06-10), docs/front-desk-switchboard.md §5
1109
1170
  const frontDeskUnread = await fetchFrontDeskUnread(cfg.apiUrl, cfg.apiKey);
1110
- const frontDeskHint = frontDeskUnread > 0
1111
- ? `[📬 ${frontDeskUnread} message${frontDeskUnread === 1 ? '' : 's'} at the front desk — \`greprag inbox --front-desk\` to read]`
1112
- : null;
1113
1171
  // Assistant doctrine auto-load — fires ONLY for the flagged assistant project
1114
- // (isAssistantProject), so a normal project sees zero change. Computed up here
1115
- // so it rides EVERY exit path below (recap body present, suppressed, or empty),
1116
- // guaranteeing the loop loads on every session start. adr: adr/assistant-role.md
1172
+ // (isAssistantProject), so a normal project sees zero change. The hook owns the
1173
+ // doctrine-file read; the assistant-doctrine module routes the text. adr: adr/assistant-role.md
1117
1174
  const assistantDoctrine = (0, project_anchor_1.isAssistantProject)(anchor)
1118
1175
  ? (0, assistant_doctrine_1.buildAssistantDoctrineContext)(cwd)
1119
1176
  : null;
1120
- // Reminder-interrupt announces (docs/reminder-interrupt.md §Registry spec) — the
1121
- // SessionStart half of each module's pair, loaded ONCE + re-fired on compact (recap
1122
- // matcher ''). watcher-arm teaches the urgent arm method; mechanic teaches the reflex
1123
- // chip. The Mechanic kill switch drops ONLY the mechanic announce; watcher-arm stays.
1124
- // Computed up here so it rides EVERY exit path below, like assistantDoctrine. Reuses
1125
- // the `mechanicAnnounce` name so the preamble push is unchanged.
1177
+ // ── THE single SessionStart announce assembly (docs/reminder-interrupt.md §Registry
1178
+ // spec) ──────────────────────────────────────────────────────────────────────────────
1179
+ // Every agent-facing announce setup-warning, front-desk, checkpoint, assistant-doctrine,
1180
+ // watcher-arm, mechanic is now a registry module. The hook did the I/O above; here it
1181
+ // stuffs the results into ONE env and collectAnnounces renders them in registry order.
1182
+ // Loaded once + re-fired on compact (recap matcher ''). The Mechanic kill switch drops
1183
+ // ONLY the mechanic announce; a missing session id drops ONLY watcher-arm.
1126
1184
  const announceShort = (0, session_id_1.truncateSessionId)(input.session_id) || '';
1127
1185
  const announceEnv = {
1128
1186
  short: announceShort, turnCount: 0, stress: 0, armed: false, sessionUnread: 0,
@@ -1134,25 +1192,17 @@ async function recap(input, mode = 'plain') {
1134
1192
  catch {
1135
1193
  return false;
1136
1194
  } })(),
1195
+ projectName: anchor.projectName,
1196
+ setupWarning,
1197
+ frontDeskUnread,
1198
+ openCheckpoints,
1199
+ assistantDoctrine,
1137
1200
  };
1138
1201
  let announceReg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
1139
1202
  if (!announceShort)
1140
1203
  announceReg = announceReg.filter(m => m.id !== 'watcher-arm');
1141
- const mechanicAnnounce = (0, reminder_registry_1.collectAnnounces)(announceEnv, announceReg).join('\n\n') || null;
1142
- const preamble = () => {
1143
- const lines = [];
1144
- if (setupWarning)
1145
- lines.push(setupWarning);
1146
- if (frontDeskHint)
1147
- lines.push(frontDeskHint);
1148
- if (checkpointHint)
1149
- lines.push(checkpointHint);
1150
- if (assistantDoctrine)
1151
- lines.push(assistantDoctrine);
1152
- if (mechanicAnnounce)
1153
- lines.push(mechanicAnnounce);
1154
- return lines.length ? lines.join('\n') + '\n' : '';
1155
- };
1204
+ const announceBlock = (0, reminder_registry_1.collectAnnounces)(announceEnv, announceReg).join('\n\n') || null;
1205
+ const preamble = () => (announceBlock ? announceBlock + '\n' : '');
1156
1206
  // Per-project opt-out — agent flips session_start_recap=false to suppress
1157
1207
  // the episodic injection for a specific project without disabling the
1158
1208
  // hook globally. Setup warning + checkpoint hint still fire above.
@@ -1180,24 +1230,8 @@ async function recap(input, mode = 'plain') {
1180
1230
  return;
1181
1231
  }
1182
1232
  const parts = [];
1183
- if (setupWarning) {
1184
- parts.push(setupWarning);
1185
- parts.push('');
1186
- }
1187
- if (frontDeskHint) {
1188
- parts.push(frontDeskHint);
1189
- parts.push('');
1190
- }
1191
- if (checkpointHint) {
1192
- parts.push(checkpointHint);
1193
- parts.push('');
1194
- }
1195
- if (assistantDoctrine) {
1196
- parts.push(assistantDoctrine);
1197
- parts.push('');
1198
- }
1199
- if (mechanicAnnounce) {
1200
- parts.push(mechanicAnnounce);
1233
+ if (announceBlock) {
1234
+ parts.push(announceBlock);
1201
1235
  parts.push('');
1202
1236
  }
1203
1237
  parts.push(body);
@@ -1336,13 +1370,168 @@ function recordFrictionFire(projectId, tier, turnCount) {
1336
1370
  }
1337
1371
  catch { /* counter is best-effort — never block the turn */ }
1338
1372
  }
1373
+ /** Local path for this project's memory-reflex fire-counter — the data the /mechanic
1374
+ * excessiveness monitor reads (fires · injected · silent · per-trigger). Beside the
1375
+ * friction counter under ~/.greprag/state. */
1376
+ function memoryReflexStatsPath(projectId) {
1377
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1378
+ return path.join(home, '.greprag', 'state', `memory-reflex-${projectId}.json`);
1379
+ }
1380
+ /** Tally one memory-reflex fire (best-effort). `didInject` splits fire→injected vs silent
1381
+ * so the monitor can read the inject/fire ratio (firing without finding = noise). */
1382
+ function recordMemoryFire(projectId, trigger, didInject, turnCount) {
1383
+ try {
1384
+ const file = memoryReflexStatsPath(projectId);
1385
+ let prior = null;
1386
+ try {
1387
+ prior = JSON.parse(fs.readFileSync(file, 'utf-8'));
1388
+ }
1389
+ catch { /* none yet */ }
1390
+ const next = (0, memory_reflex_1.tallyMemoryFire)(prior, trigger, didInject, turnCount, new Date().toISOString());
1391
+ const dir = path.dirname(file);
1392
+ if (!fs.existsSync(dir))
1393
+ fs.mkdirSync(dir, { recursive: true });
1394
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
1395
+ }
1396
+ catch { /* counter is best-effort — never block the turn */ }
1397
+ }
1398
+ /** Stash for the memory-reflex efficacy capture — the raw hit content injected THIS turn, so
1399
+ * the Stop hook can score the response's overlap against it (the `used` numerator). Keyed by
1400
+ * session (Stop has the short id); carries projectId so Stop needn't re-derive it. Written at
1401
+ * inject-time, consumed + deleted at Stop. adr: adr/memory-reflex.md */
1402
+ function memoryReflexPendingPath(short) {
1403
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1404
+ return path.join(home, '.greprag', 'state', `memory-reflex-pending-${short}.json`);
1405
+ }
1406
+ function stashMemoryInjection(short, projectId, hitText, turnCount) {
1407
+ try {
1408
+ const file = memoryReflexPendingPath(short);
1409
+ const dir = path.dirname(file);
1410
+ if (!fs.existsSync(dir))
1411
+ fs.mkdirSync(dir, { recursive: true });
1412
+ fs.writeFileSync(file, JSON.stringify({ projectId, hitText, turnCount }) + '\n');
1413
+ }
1414
+ catch { /* best-effort — a missed stash just means no efficacy sample this turn */ }
1415
+ }
1416
+ /** Read + DELETE the pending injection stash (consume-once, so a turn with no injection never
1417
+ * reuses a stale sample). Returns null on the common no-stash path. */
1418
+ function consumeMemoryInjection(short) {
1419
+ try {
1420
+ const file = memoryReflexPendingPath(short);
1421
+ const raw = fs.readFileSync(file, 'utf-8');
1422
+ try {
1423
+ fs.unlinkSync(file);
1424
+ }
1425
+ catch { /* already gone — fine */ }
1426
+ const p = JSON.parse(raw);
1427
+ if (!p || typeof p.projectId !== 'string' || typeof p.hitText !== 'string')
1428
+ return null;
1429
+ return { projectId: p.projectId, hitText: p.hitText };
1430
+ }
1431
+ catch {
1432
+ return null; // no stash this turn (the common case)
1433
+ }
1434
+ }
1435
+ /** Bump the memory-reflex EFFICACY numerator (the response drew on the injection) — best-effort. */
1436
+ function recordMemoryUsed(projectId) {
1437
+ try {
1438
+ const file = memoryReflexStatsPath(projectId);
1439
+ let prior = null;
1440
+ try {
1441
+ prior = JSON.parse(fs.readFileSync(file, 'utf-8'));
1442
+ }
1443
+ catch { /* none yet */ }
1444
+ const next = (0, memory_reflex_1.tallyMemoryUsed)(prior);
1445
+ const dir = path.dirname(file);
1446
+ if (!fs.existsSync(dir))
1447
+ fs.mkdirSync(dir, { recursive: true });
1448
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
1449
+ }
1450
+ catch { /* counter is best-effort */ }
1451
+ }
1452
+ /** Self-report stash (P1b, adr/memory-reflex.md) — the topic the agent flagged via a
1453
+ * `[[recall: X]]` marker LAST turn, so the NEXT turn's notify pulls + injects it. Separate
1454
+ * from the efficacy-injection stash. Keyed by session; carries projectId. Written at Stop,
1455
+ * consumed-once at the next notify. */
1456
+ function selfReportPendingPath(short) {
1457
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1458
+ return path.join(home, '.greprag', 'state', `memory-reflex-selfreport-${short}.json`);
1459
+ }
1460
+ function stashSelfReport(short, projectId, topic) {
1461
+ try {
1462
+ const file = selfReportPendingPath(short);
1463
+ const dir = path.dirname(file);
1464
+ if (!fs.existsSync(dir))
1465
+ fs.mkdirSync(dir, { recursive: true });
1466
+ fs.writeFileSync(file, JSON.stringify({ projectId, topic }) + '\n');
1467
+ }
1468
+ catch { /* best-effort — a missed self-report just means no pull this round */ }
1469
+ }
1470
+ /** Read + DELETE the self-report stash (consume-once). Null on the common no-marker path. */
1471
+ function consumeSelfReport(short) {
1472
+ try {
1473
+ const file = selfReportPendingPath(short);
1474
+ const raw = fs.readFileSync(file, 'utf-8');
1475
+ try {
1476
+ fs.unlinkSync(file);
1477
+ }
1478
+ catch { /* already gone — fine */ }
1479
+ const p = JSON.parse(raw);
1480
+ if (!p || typeof p.projectId !== 'string' || typeof p.topic !== 'string' || !p.topic)
1481
+ return null;
1482
+ return { projectId: p.projectId, topic: p.topic };
1483
+ }
1484
+ catch {
1485
+ return null;
1486
+ }
1487
+ }
1488
+ /** Local cache of the SERVER's auto-quiet verdict for this project (the
1489
+ * mechanicKilled() analog for the memory-reflex). The Flash-Lite efficacy judge
1490
+ * decides the threshold server-side and ships the boolean on the /v1/memory/query
1491
+ * response; the hook caches it here and `memoryReflexQuieted` honors it with a
1492
+ * TTL (quietCacheSaysSilent). Beside the fire-counter under ~/.greprag/state. */
1493
+ function memoryReflexQuietPath(projectId) {
1494
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1495
+ return path.join(home, '.greprag', 'state', `memory-reflex-quiet-${projectId}.json`);
1496
+ }
1497
+ /** Has the server's efficacy judge QUIETED this project's memory-reflex (low
1498
+ * used÷injected over a meaningful sample)? Reads the local cache; honors it only
1499
+ * while fresh (TTL) so a recovered reflex re-probes instead of staying silenced.
1500
+ * Fail-quiet: any error reads as NOT quieted (the reflex keeps working) —
1501
+ * mirrors mechanicKilled()'s fail-open posture. */
1502
+ function memoryReflexQuieted(projectId) {
1503
+ try {
1504
+ const raw = fs.readFileSync(memoryReflexQuietPath(projectId), 'utf-8');
1505
+ return (0, memory_reflex_1.quietCacheSaysSilent)(JSON.parse(raw), Date.now());
1506
+ }
1507
+ catch {
1508
+ return false;
1509
+ }
1510
+ }
1511
+ /** Cache the server's auto-quiet verdict from a /v1/memory/query response — written
1512
+ * whenever the reflex actually probes (fired the search). Best-effort. */
1513
+ function cacheMemoryReflexQuiet(projectId, quiet) {
1514
+ try {
1515
+ const file = memoryReflexQuietPath(projectId);
1516
+ const dir = path.dirname(file);
1517
+ if (!fs.existsSync(dir))
1518
+ fs.mkdirSync(dir, { recursive: true });
1519
+ const payload = { quiet, cachedAt: new Date().toISOString() };
1520
+ fs.writeFileSync(file, JSON.stringify(payload) + '\n');
1521
+ }
1522
+ catch { /* best-effort — a missed cache just means the reflex re-probes next turn */ }
1523
+ }
1339
1524
  /** The reminder-interrupt registry CONTAINER call (docs/reminder-interrupt.md §Registry
1340
1525
  * spec) — invoked from the proven-registered `notify` hook (claude-code path). Assembles
1341
1526
  * the live env (the ONLY i/o: armed via isLocallyArmed, this session's own unread, the
1342
- * Stop-stashed stress/turnCount) and fires every module whose detector reports deficient:
1343
- * watcher-arm (unarmed → nag if a peer messaged you, else nudge; silent when armed) beside
1344
- * the stress-gated mechanic-friction. Stacking; emitted once. Fail-open any miss → silent.
1345
- * The Mechanic kill switch drops ONLY the mechanic module; watcher-arm keeps working. */
1527
+ * Stop-stashed stress/turnCount, the front-desk envelope notice) and fires every module
1528
+ * whose detector reports deficient: watcher-arm (unarmed → nag if a peer messaged you, else
1529
+ * nudge; silent when armed), the stress-gated mechanic-friction, front-desk (a soft
1530
+ * "you've got mail" when a new envelope landed), and memory-reflex (auto-surfaced episodic
1531
+ * memory when THIS prompt signals recall — an inline, time-capped, confidence-gated search).
1532
+ * The announce-only modules (setup-warning / checkpoint / assistant-doctrine) stay silent per
1533
+ * turn — their env signals are SessionStart-only. Stacking; emitted once. Fail-open — any miss
1534
+ * → silent. The Mechanic kill switch drops ONLY the mechanic module; the rest keep working. */
1346
1535
  async function injectReminders(input, cfg, short) {
1347
1536
  try {
1348
1537
  const cwd = input.cwd || process.cwd();
@@ -1361,9 +1550,50 @@ async function injectReminders(input, cfg, short) {
1361
1550
  : {};
1362
1551
  const turnCount = typeof state.turnCount === 'number' ? state.turnCount : 0;
1363
1552
  const stress = typeof state.stress === 'number' ? state.stress : 0;
1553
+ // Front-desk envelope notice — the per-turn half of the front-desk module's pair (the
1554
+ // SessionStart count is the announce half). Body-free, self-dedups per machine, returns
1555
+ // null when nothing new. This is the network call the retired `mail` hook used to make;
1556
+ // it moves here so the whole announce/reminder surface rides ONE detector loop.
1557
+ const frontDeskNotice = await (0, front_desk_mail_1.buildFrontDeskNotice)(cfg.apiUrl, cfg.apiKey);
1558
+ // Memory-reflex (keyword layer) — recall intent in THIS prompt fires an inline,
1559
+ // time-capped, project-scoped search; a hit above the confidence gate is surfaced
1560
+ // inline (top 1-2, capped) so the agent sees it AS it forms its reply. A miss stays
1561
+ // silent (the search result is its own precision filter — no banner-blindness). Every
1562
+ // fire is tallied for the /mechanic excessiveness monitor. Gated on recall-intent, so
1563
+ // most turns skip the search entirely. docs/reminder-interrupt.md (memory-reflex).
1564
+ let memoryHit = null;
1565
+ const prompt = typeof input.prompt === 'string' ? input.prompt : '';
1566
+ // Pick the memory query + trigger (the hybrid): a self-report marker the agent emitted LAST
1567
+ // turn ([[recall: X]], stashed at Stop) takes precedence over recall-intent in THIS prompt —
1568
+ // the agent explicitly flagged its own gap. One injection per turn.
1569
+ let memQuery = null;
1570
+ let memTrigger = 'keyword';
1571
+ const selfReport = anchor.projectId ? consumeSelfReport(short) : null;
1572
+ if (selfReport) {
1573
+ memQuery = selfReport.topic;
1574
+ memTrigger = 'selfReport';
1575
+ }
1576
+ else if ((0, memory_reflex_1.hasRecallIntent)(prompt)) {
1577
+ memQuery = prompt.slice(0, 500);
1578
+ memTrigger = 'keyword';
1579
+ }
1580
+ // Auto-quiet gate (adr/memory-reflex.md): if the server's Flash-Lite efficacy judge has
1581
+ // QUIETED this project's reflex (low used÷injected over a meaningful sample), skip the search
1582
+ // entirely — the cached verdict expires on a TTL so a recovered reflex re-probes. mechanicKilled() analog.
1583
+ if (anchor.projectId && memQuery && !memoryReflexQuieted(anchor.projectId)) {
1584
+ const { hits, quiet } = await fetchMemoryHits(cfg.apiUrl, cfg.apiKey, anchor.projectId, memQuery);
1585
+ cacheMemoryReflexQuiet(anchor.projectId, quiet); // refresh the verdict from this probe
1586
+ memoryHit = (0, memory_reflex_1.buildMemoryInjection)(hits);
1587
+ recordMemoryFire(anchor.projectId, memTrigger, !!memoryHit, turnCount);
1588
+ // Stash what was injected so the Stop hook can score whether the response used it
1589
+ // (the efficacy numerator). Only when something was actually surfaced.
1590
+ if (memoryHit)
1591
+ stashMemoryInjection(short, anchor.projectId, (0, memory_reflex_1.gatedHitText)(hits), turnCount);
1592
+ }
1364
1593
  const env = {
1365
1594
  short, turnCount, stress, armed, sessionUnread,
1366
1595
  ownerPid, alias: (0, session_id_1.readIdentityAlias)(), assistant,
1596
+ frontDeskNotice, memoryHit,
1367
1597
  };
1368
1598
  // Kill switch drops ONLY the mechanic module — watcher-arm keeps working.
1369
1599
  const reg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
@@ -1390,41 +1620,31 @@ async function injectReminders(input, cfg, short) {
1390
1620
  async function frictionReminder(_input) {
1391
1621
  // intentionally empty — see injectReminders (runs from notify)
1392
1622
  }
1393
- /** "You've got mail" turn hook (Chip B) — wire as a UserPromptSubmit hook.
1394
- * Each turn, ask the server for ENVELOPE-ONLY pending front-desk mail and
1395
- * inject a one-line "you've got mail from X" notice for any arrival not yet
1396
- * announced on this machine. It feeds the human sign-off gate — it NEVER
1397
- * ingests body (front-desk store contract invariant 2/3); the data path is
1398
- * body-free end to end (envelope type has no body field, server projection
1399
- * withholds it). Self-silences once each record is announced; the agent stops
1400
- * the loop by acting on the mail (`greprag email` marks it read).
1623
+ /** Front-desk turn hook — wire as a UserPromptSubmit hook.
1401
1624
  *
1402
- * Unconfigured / no API key silent. Tenant-scoped by the API key, so a
1403
- * session only ever sees its own tenant's front desk.
1625
+ * NOTICE ROLE RETIRED 2026-06-20 the envelope-only "you've got mail from X" notice
1626
+ * moved into the reminder-interrupt registry (front-desk module, fired per turn from
1627
+ * `injectReminders` → the same detector loop as watcher-arm + mechanic-friction), so the
1628
+ * whole announce/reminder surface has ONE assembly. docs/reminder-interrupt.md §Registry spec.
1404
1629
  *
1405
- * Auto-save (Chip E): when a project opts in (`email_autosave=true` in
1406
- * .greprag/project.json, or env GREPRAG_EMAIL_AUTOSAVE), the hook also drains
1407
- * new attachments to the configured dir each turn and appends a one-line saved
1408
- * summary. Default OFF no opt-in, no pull. Best-effort: a drain failure never
1409
- * blocks the turn or suppresses the envelope notice. */
1630
+ * What REMAINS here is the attachment auto-save DRAIN a deliberate side-effect (it WRITES
1631
+ * files to disk), kept OUT of the pure announce/reminder registry by design (the registry
1632
+ * modules are pure; all I/O + side-effects live in the hook). Auto-save (Chip E): when a
1633
+ * project opts in (`email_autosave=true` in .greprag/project.json, or env
1634
+ * GREPRAG_EMAIL_AUTOSAVE), drain new attachments to the configured dir each turn and emit a
1635
+ * one-line saved summary. Default OFF — no opt-in, no pull. Best-effort: a drain failure
1636
+ * never blocks the turn. Unconfigured / no API key → silent. */
1410
1637
  async function mail(input) {
1411
1638
  const cwd = input.cwd || process.cwd();
1412
1639
  const cfg = getConfig(cwd);
1413
1640
  if (!cfg.enabled || !cfg.apiKey)
1414
1641
  return; // unconfigured → silent
1415
- const parts = [];
1416
- const notice = await (0, front_desk_mail_1.buildFrontDeskNotice)(cfg.apiUrl, cfg.apiKey);
1417
- if (notice)
1418
- parts.push(notice);
1419
1642
  try {
1420
1643
  const auto = await maybeAutoSaveAttachments(cwd, cfg.apiUrl, cfg.apiKey);
1421
1644
  if (auto)
1422
- parts.push(auto);
1645
+ writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', auto);
1423
1646
  }
1424
1647
  catch { /* drain is best-effort — never block a turn */ }
1425
- if (parts.length) {
1426
- writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', parts.join('\n\n'));
1427
- }
1428
1648
  }
1429
1649
  /** Per-turn attachment auto-save. Returns a one-line summary of freshly-saved
1430
1650
  * files, or null when auto-save is off or nothing new landed. Gated on an