greprag 5.46.0 → 5.48.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 (38) hide show
  1. package/dist/commands/arm-reminder.d.ts +25 -0
  2. package/dist/commands/arm-reminder.js +65 -0
  3. package/dist/commands/arm-reminder.js.map +1 -0
  4. package/dist/commands/assistant-reminder.d.ts +9 -0
  5. package/dist/commands/assistant-reminder.js +20 -0
  6. package/dist/commands/assistant-reminder.js.map +1 -0
  7. package/dist/commands/checkpoint-reminder.d.ts +14 -0
  8. package/dist/commands/checkpoint-reminder.js +50 -0
  9. package/dist/commands/checkpoint-reminder.js.map +1 -0
  10. package/dist/commands/friction-reminder.d.ts +92 -0
  11. package/dist/commands/friction-reminder.js +147 -0
  12. package/dist/commands/friction-reminder.js.map +1 -0
  13. package/dist/commands/frontdesk-reminder.d.ts +23 -0
  14. package/dist/commands/frontdesk-reminder.js +40 -0
  15. package/dist/commands/frontdesk-reminder.js.map +1 -0
  16. package/dist/commands/inbox-watch-supervisor.d.ts +25 -0
  17. package/dist/commands/inbox-watch-supervisor.js +112 -5
  18. package/dist/commands/inbox-watch-supervisor.js.map +1 -1
  19. package/dist/commands/init.js +31 -0
  20. package/dist/commands/init.js.map +1 -1
  21. package/dist/commands/memory-reflex.d.ts +115 -0
  22. package/dist/commands/memory-reflex.js +243 -0
  23. package/dist/commands/memory-reflex.js.map +1 -0
  24. package/dist/commands/reminder-registry.d.ts +24 -0
  25. package/dist/commands/reminder-registry.js +76 -0
  26. package/dist/commands/reminder-registry.js.map +1 -0
  27. package/dist/commands/reminder-types.d.ts +76 -0
  28. package/dist/commands/reminder-types.js +8 -0
  29. package/dist/commands/reminder-types.js.map +1 -0
  30. package/dist/commands/setup-reminder.d.ts +9 -0
  31. package/dist/commands/setup-reminder.js +20 -0
  32. package/dist/commands/setup-reminder.js.map +1 -0
  33. package/dist/hook.js +472 -120
  34. package/dist/hook.js.map +1 -1
  35. package/dist/opencode-plugin.js +68 -8
  36. package/dist/opencode-plugin.js.map +1 -1
  37. package/package.json +1 -1
  38. package/skill/templates/reflex-chip.md +58 -0
package/dist/hook.js CHANGED
@@ -71,6 +71,13 @@ const opencode_plugin_helpers_1 = require("./opencode-plugin-helpers");
71
71
  // Mechanic dispatcher (Chip B) — PreToolUse guard + matchset boot pull/refresh.
72
72
  // docs/mechanic-repairs.md D1/D2/D5/D8; docs/mechanic-engine-contracts.md.
73
73
  const guard_1 = require("./guard");
74
+ // Reminder-interrupt registry (docs/reminder-interrupt.md §Registry spec) — the
75
+ // detector model: each module detects a live deficiency, fires while it holds, and
76
+ // auto-silences when resolved (the arm-check pattern). The hook below assembles the
77
+ // live env (ALL I/O) and emits; the modules are pure.
78
+ const reminder_registry_1 = require("./commands/reminder-registry");
79
+ const friction_reminder_1 = require("./commands/friction-reminder");
80
+ const memory_reflex_1 = require("./commands/memory-reflex");
74
81
  const worktree_state_1 = require("./worktree-state");
75
82
  // Ingress-trigger bridge, Adapter A (LOCAL/state half) — the Stop hook computes
76
83
  // the deterministic state values (stress / turnCount / keyword-seen) from the
@@ -778,6 +785,37 @@ async function store(input, source = 'claude-code') {
778
785
  if (source === 'codex') {
779
786
  turn.toolCalls.push(...(0, codex_hook_events_1.readCodexSubagentToolCalls)(input));
780
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 */ }
781
819
  // Empty turn — nothing to capture.
782
820
  if (!turn.userPrompt && !turn.agentResponse && turn.toolCalls.length === 0)
783
821
  return;
@@ -814,6 +852,12 @@ async function store(input, source = 'claude-code') {
814
852
  status: turn.status,
815
853
  userPrompt,
816
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
+ : {}),
817
861
  toolCalls,
818
862
  filesTouched: turn.filesTouched,
819
863
  artifacts: artifactRefs,
@@ -822,6 +866,20 @@ async function store(input, source = 'claude-code') {
822
866
  provenance,
823
867
  });
824
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.) */
825
883
  async function fetchOpenCheckpoints(apiUrl, apiKey, projectId, limit = 10) {
826
884
  try {
827
885
  const url = `${apiUrl}/v1/checkpoints/${encodeURIComponent(projectId)}`
@@ -838,33 +896,6 @@ async function fetchOpenCheckpoints(apiUrl, apiKey, projectId, limit = 10) {
838
896
  return [];
839
897
  }
840
898
  }
841
- /** Render a YYYY-MM-DD date in UTC for the session-start hint. */
842
- function fmtCheckpointDate(iso) {
843
- return iso.slice(0, 10);
844
- }
845
- /** Format the open-checkpoints hint block. Shape per design doc § Hint
846
- * placement: 3-most-recent rows + a single overflow line when there are
847
- * more. Returns null when there are zero open checkpoints — caller
848
- * treats it as silent. */
849
- function renderCheckpointHint(open, projectName) {
850
- if (open.length === 0)
851
- return null;
852
- // Server returns DESC by created_at; take the 3 most recent.
853
- const top = open.slice(0, 3);
854
- const overflow = open.length - top.length;
855
- const lines = [];
856
- lines.push(`[${open.length} checkpoint${open.length === 1 ? '' : 's'} open in ${projectName}:`);
857
- for (const cp of top) {
858
- lines.push(` · ${cp.nodeId} ${cp.title} (since ${fmtCheckpointDate(cp.createdAt)})`);
859
- }
860
- if (overflow > 0) {
861
- lines.push(` · ${overflow} more — greprag checkpoint list]`);
862
- }
863
- else {
864
- lines.push(` View one: greprag checkpoint show <id>]`);
865
- }
866
- return lines.join('\n');
867
- }
868
899
  /** Fetch unread inbox count for the given project context. Project-scoped
869
900
  * count includes tenant-level messages (mail addressed to the user, not
870
901
  * any specific project, still shows up). Returns 0 on any error so the
@@ -906,6 +937,63 @@ async function fetchFrontDeskUnread(apiUrl, apiKey) {
906
937
  return 0;
907
938
  }
908
939
  }
940
+ /** Fetch this session's OWN unread count — messages addressed to `to_session_id =
941
+ * <short>` (mail a peer or the operator sent to THIS session). Safe + non-eavesdrop:
942
+ * reads only this session's mail, NOT the project-wide count the v5.6.1 removal retired
943
+ * (that count betrayed a sibling session's private DMs). Powers the watcher-arm module's
944
+ * grounded "a peer messaged you" nag. Returns 0 on any error. adr: adr/session-id-awareness.md */
945
+ async function fetchSessionUnread(apiUrl, apiKey, short) {
946
+ try {
947
+ const res = await fetch(`${apiUrl}/v1/inbox?count_only=1&session_id=${encodeURIComponent(short)}`, {
948
+ headers: { 'Authorization': `Bearer ${apiKey}` },
949
+ });
950
+ if (!res.ok)
951
+ return 0;
952
+ const data = await res.json();
953
+ return data.unread_count || 0;
954
+ }
955
+ catch {
956
+ return 0;
957
+ }
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
+ }
909
997
  /** Identity-drift warning rate limiter.
910
998
  *
911
999
  * The drift warning ("stored ID ≠ git-derived ID, run `greprag doctor`") used
@@ -1070,40 +1158,51 @@ async function recap(input, mode = 'plain') {
1070
1158
  // (and then read) another session's session-targeted DM. Live inbox now
1071
1159
  // belongs exclusively to Monitor watchers; the agent learns about its own
1072
1160
  // mail via the watcher's session-scoped stream.
1073
- // Open-checkpoints hint still fires — checkpoints are operator-triggered
1074
- // 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.
1075
1164
  const openCheckpoints = await fetchOpenCheckpoints(cfg.apiUrl, cfg.apiKey, anchor.projectId, 10);
1076
- const checkpointHint = renderCheckpointHint(openCheckpoints, anchor.projectName);
1077
- // Front-desk awareness a discreet count of cold opens + inbound email waiting
1078
- // at the shared tenant front desk, so a stranger's first contact isn't stranded
1079
- // until someone happens to run `greprag inbox`. Content is NEVER injected just
1080
- // the count + the door. Fires once per session (SessionStart), so it's naturally
1081
- // deduped. This is NOT the v5.6.1 eavesdrop vector — see fetchFrontDeskUnread.
1082
- // (Suppressing it when a live receptionist is attached is a clean follow-up.)
1083
- // 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
1084
1170
  const frontDeskUnread = await fetchFrontDeskUnread(cfg.apiUrl, cfg.apiKey);
1085
- const frontDeskHint = frontDeskUnread > 0
1086
- ? `[📬 ${frontDeskUnread} message${frontDeskUnread === 1 ? '' : 's'} at the front desk — \`greprag inbox --front-desk\` to read]`
1087
- : null;
1088
1171
  // Assistant doctrine auto-load — fires ONLY for the flagged assistant project
1089
- // (isAssistantProject), so a normal project sees zero change. Computed up here
1090
- // so it rides EVERY exit path below (recap body present, suppressed, or empty),
1091
- // 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
1092
1174
  const assistantDoctrine = (0, project_anchor_1.isAssistantProject)(anchor)
1093
1175
  ? (0, assistant_doctrine_1.buildAssistantDoctrineContext)(cwd)
1094
1176
  : null;
1095
- const preamble = () => {
1096
- const lines = [];
1097
- if (setupWarning)
1098
- lines.push(setupWarning);
1099
- if (frontDeskHint)
1100
- lines.push(frontDeskHint);
1101
- if (checkpointHint)
1102
- lines.push(checkpointHint);
1103
- if (assistantDoctrine)
1104
- lines.push(assistantDoctrine);
1105
- return lines.length ? lines.join('\n') + '\n' : '';
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.
1184
+ const announceShort = (0, session_id_1.truncateSessionId)(input.session_id) || '';
1185
+ const announceEnv = {
1186
+ short: announceShort, turnCount: 0, stress: 0, armed: false, sessionUnread: 0,
1187
+ ownerPid: announceShort ? (0, watcher_registry_1.resolveClaudeOwnerPid)(announceShort) : null,
1188
+ alias: (0, session_id_1.readIdentityAlias)(),
1189
+ assistant: (() => { try {
1190
+ return (0, project_anchor_1.isAssistantProject)(anchor);
1191
+ }
1192
+ catch {
1193
+ return false;
1194
+ } })(),
1195
+ projectName: anchor.projectName,
1196
+ setupWarning,
1197
+ frontDeskUnread,
1198
+ openCheckpoints,
1199
+ assistantDoctrine,
1106
1200
  };
1201
+ let announceReg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
1202
+ if (!announceShort)
1203
+ announceReg = announceReg.filter(m => m.id !== 'watcher-arm');
1204
+ const announceBlock = (0, reminder_registry_1.collectAnnounces)(announceEnv, announceReg).join('\n\n') || null;
1205
+ const preamble = () => (announceBlock ? announceBlock + '\n' : '');
1107
1206
  // Per-project opt-out — agent flips session_start_recap=false to suppress
1108
1207
  // the episodic injection for a specific project without disabling the
1109
1208
  // hook globally. Setup warning + checkpoint hint still fire above.
@@ -1131,20 +1230,8 @@ async function recap(input, mode = 'plain') {
1131
1230
  return;
1132
1231
  }
1133
1232
  const parts = [];
1134
- if (setupWarning) {
1135
- parts.push(setupWarning);
1136
- parts.push('');
1137
- }
1138
- if (frontDeskHint) {
1139
- parts.push(frontDeskHint);
1140
- parts.push('');
1141
- }
1142
- if (checkpointHint) {
1143
- parts.push(checkpointHint);
1144
- parts.push('');
1145
- }
1146
- if (assistantDoctrine) {
1147
- parts.push(assistantDoctrine);
1233
+ if (announceBlock) {
1234
+ parts.push(announceBlock);
1148
1235
  parts.push('');
1149
1236
  }
1150
1237
  parts.push(body);
@@ -1236,69 +1323,328 @@ async function notify(input, source = 'claude-code') {
1236
1323
  writeAdditionalContext('UserPromptSubmit', context);
1237
1324
  return;
1238
1325
  }
1239
- // Local-first arm detection. Ground truth is a live watcher PROCESS on THIS
1240
- // machine its consumer (the Monitor task) is local, so a live supervisor
1241
- // pidfile means a live, consumed watcher. This replaces the server
1242
- // `isSessionArmed` registry check, whose ghost-lease flaw made an orphan
1243
- // (consumer dead, socket still open) read as "armed" — which both suppressed
1244
- // re-arm when the real watcher was gone AND, once the lease expired, let
1245
- // re-arm stack a new watcher on the undead orphan. With EPIPE-terminal + the
1246
- // SessionStart reaper sweeping stale pidfiles, "no live pidfile" reliably means
1247
- // "needs arming", lag-free and with no cloud round-trip. adr: adr/monitor-resilience.md
1248
- if ((0, watcher_registry_1.isLocallyArmed)(short))
1249
- return; // live local watcher silent
1250
- // Resolve the owning claude.exe PID HERE this hook is a live, direct
1251
- // descendant of it, so its ancestry is intact (the watcher's is not). Stamped
1252
- // into the arm command so the reaper checks "owner alive?" not "ancestry
1253
- // reachable?". Only runs on unarmed turns (we returned above if armed).
1254
- // adr: adr/monitor-resilience.md
1255
- const ownerPid = (0, watcher_registry_1.resolveClaudeOwnerPid)(short);
1256
- // Assistant project → arm an elevated watcher (`--assistant`) so the session
1257
- // wakes on inbound tenant email, not just its own DMs. Scoped strictly to the
1258
- // flagged project; every other project arms a stock watcher. Fail-open: an
1259
- // anchor read failure just yields a normal arm. adr: adr/assistant-role.md
1260
- let assistant = false;
1326
+ // Reminder-interrupt registry container (docs/reminder-interrupt.md §Registry spec):
1327
+ // a detector loop over the watcher-arm + mechanic-friction modules. Runs HERE in the
1328
+ // proven-registered `notify` hook so arming never depends on a freshly-merged
1329
+ // subcommand reaching the operator's settings. Fires whatever's deficient (stacking),
1330
+ // and auto-silences each module the instant its deficiency resolves.
1331
+ await injectReminders(input, cfg, short);
1332
+ }
1333
+ /** Has the operator hit the Mechanic kill switch (`mechanic off` → the D8 panic
1334
+ * switch file the guard dispatcher also checks first)? When set, the active-
1335
+ * Mechanic pair (SessionStart announce + per-turn friction reminder) stays quiet,
1336
+ * so the same switch that quiets the dispatcher quiets this pair too. Fail-quiet:
1337
+ * any fs error reads as NOT killed (the pair keeps working). */
1338
+ function mechanicKilled() {
1261
1339
  try {
1262
- assistant = (0, project_anchor_1.isAssistantProject)((0, project_anchor_1.readAnchor)(input.cwd || process.cwd()));
1263
- }
1264
- catch { /* fail-open — normal arm */ }
1265
- writeAdditionalContext('UserPromptSubmit', (0, session_id_1.buildArmDirective)(short, (0, session_id_1.readIdentityAlias)(), ownerPid, assistant));
1266
- }
1267
- /** "You've got mail" turn hook (Chip B) — wire as a UserPromptSubmit hook.
1268
- * Each turn, ask the server for ENVELOPE-ONLY pending front-desk mail and
1269
- * inject a one-line "you've got mail from X" notice for any arrival not yet
1270
- * announced on this machine. It feeds the human sign-off gate — it NEVER
1271
- * ingests body (front-desk store contract invariant 2/3); the data path is
1272
- * body-free end to end (envelope type has no body field, server projection
1273
- * withholds it). Self-silences once each record is announced; the agent stops
1274
- * the loop by acting on the mail (`greprag email` marks it read).
1340
+ return fs.existsSync((0, guard_1.killSwitchPath)());
1341
+ }
1342
+ catch {
1343
+ return false;
1344
+ }
1345
+ }
1346
+ /** Local path for this project's friction-reminder fire-counter (Measurement,
1347
+ * docs/reminder-interrupt.md). Sits beside the matchset cache under the shared
1348
+ * `~/.greprag/state` dir. */
1349
+ function frictionStatsPath(projectId) {
1350
+ const home = process.env.HOME || process.env.USERPROFILE || '';
1351
+ return path.join(home, '.greprag', 'state', `friction-reminder-${projectId}.json`);
1352
+ }
1353
+ /** Fire-counter I/O (the adherence DENOMINATOR). Reads the prior stats, tallies one
1354
+ * fire via the PURE tallyFrictionFire, writes them back. Fully best-effort: a miss
1355
+ * never blocks the turn. The numerator (reflex chips actually spawned) lands via
1356
+ * tallyReflexSpawn from the spawn path — see the report's Measurement note. */
1357
+ function recordFrictionFire(projectId, tier, turnCount) {
1358
+ try {
1359
+ const file = frictionStatsPath(projectId);
1360
+ let prior = null;
1361
+ try {
1362
+ prior = JSON.parse(fs.readFileSync(file, 'utf-8'));
1363
+ }
1364
+ catch { /* none yet */ }
1365
+ const next = (0, friction_reminder_1.tallyFrictionFire)(prior, tier, turnCount, new Date().toISOString());
1366
+ const dir = path.dirname(file);
1367
+ if (!fs.existsSync(dir))
1368
+ fs.mkdirSync(dir, { recursive: true });
1369
+ fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n');
1370
+ }
1371
+ catch { /* counter is best-effort — never block the turn */ }
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
+ }
1524
+ /** The reminder-interrupt registry CONTAINER call (docs/reminder-interrupt.md §Registry
1525
+ * spec) — invoked from the proven-registered `notify` hook (claude-code path). Assembles
1526
+ * the live env (the ONLY i/o: armed via isLocallyArmed, this session's own unread, the
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. */
1535
+ async function injectReminders(input, cfg, short) {
1536
+ try {
1537
+ const cwd = input.cwd || process.cwd();
1538
+ const anchor = (0, project_anchor_1.readAnchor)(cwd);
1539
+ const armed = (0, watcher_registry_1.isLocallyArmed)(short);
1540
+ const sessionUnread = armed ? 0 : await fetchSessionUnread(cfg.apiUrl, cfg.apiKey, short);
1541
+ const ownerPid = armed ? null : (0, watcher_registry_1.resolveClaudeOwnerPid)(short);
1542
+ let assistant = false;
1543
+ try {
1544
+ assistant = (0, project_anchor_1.isAssistantProject)(anchor);
1545
+ }
1546
+ catch { /* fail-open */ }
1547
+ // Stress/turnCount from the Stop-stashed state bag (no LLM/network here).
1548
+ const state = anchor.projectId
1549
+ ? ((0, guard_1.readMatchsetCache)(anchor.projectId)?.stateValues || {})
1550
+ : {};
1551
+ const turnCount = typeof state.turnCount === 'number' ? state.turnCount : 0;
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
+ }
1593
+ const env = {
1594
+ short, turnCount, stress, armed, sessionUnread,
1595
+ ownerPid, alias: (0, session_id_1.readIdentityAlias)(), assistant,
1596
+ frontDeskNotice, memoryHit,
1597
+ };
1598
+ // Kill switch drops ONLY the mechanic module — watcher-arm keeps working.
1599
+ const reg = mechanicKilled() ? reminder_registry_1.REGISTRY.filter(m => m.id !== 'mechanic-friction') : reminder_registry_1.REGISTRY;
1600
+ const fired = (0, reminder_registry_1.collectReminders)(env, reg);
1601
+ if (!fired.length)
1602
+ return;
1603
+ // Authoring rule #4 — frame nag/nudge with the act-now directive envelope; ambient
1604
+ // stays a soft pointer. Emit ONCE (stacking via join) so multiple due modules ride a
1605
+ // single additionalContext.
1606
+ const parts = fired.map(f => f.tier === 'ambient' ? f.line : (0, guard_1.wrapDirective)(f.line));
1607
+ writeAdditionalContext('UserPromptSubmit', parts.join('\n\n'));
1608
+ // Fire-counter (adherence denominator) for the mechanic module — best-effort.
1609
+ const mf = fired.find(f => f.id === 'mechanic-friction');
1610
+ if (mf && anchor.projectId)
1611
+ recordFrictionFire(anchor.projectId, mf.tier, turnCount);
1612
+ }
1613
+ catch { /* fail-open — a reminder miss never breaks the turn */ }
1614
+ }
1615
+ /** RETIRED 2026-06-20 — the per-turn reminder injection moved into the registry
1616
+ * container (`injectReminders`, called from `notify`), so watcher-arm + mechanic-friction
1617
+ * fire together under one detector loop with no double-injection. Kept as a no-op for
1618
+ * hook-registration stability (a fleet `friction-reminder` entry is harmless).
1619
+ * docs/reminder-interrupt.md §Registry spec */
1620
+ async function frictionReminder(_input) {
1621
+ // intentionally empty — see injectReminders (runs from notify)
1622
+ }
1623
+ /** Front-desk turn hook — wire as a UserPromptSubmit hook.
1275
1624
  *
1276
- * Unconfigured / no API key silent. Tenant-scoped by the API key, so a
1277
- * 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.
1278
1629
  *
1279
- * Auto-save (Chip E): when a project opts in (`email_autosave=true` in
1280
- * .greprag/project.json, or env GREPRAG_EMAIL_AUTOSAVE), the hook also drains
1281
- * new attachments to the configured dir each turn and appends a one-line saved
1282
- * summary. Default OFF no opt-in, no pull. Best-effort: a drain failure never
1283
- * 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. */
1284
1637
  async function mail(input) {
1285
1638
  const cwd = input.cwd || process.cwd();
1286
1639
  const cfg = getConfig(cwd);
1287
1640
  if (!cfg.enabled || !cfg.apiKey)
1288
1641
  return; // unconfigured → silent
1289
- const parts = [];
1290
- const notice = await (0, front_desk_mail_1.buildFrontDeskNotice)(cfg.apiUrl, cfg.apiKey);
1291
- if (notice)
1292
- parts.push(notice);
1293
1642
  try {
1294
1643
  const auto = await maybeAutoSaveAttachments(cwd, cfg.apiUrl, cfg.apiKey);
1295
1644
  if (auto)
1296
- parts.push(auto);
1645
+ writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', auto);
1297
1646
  }
1298
1647
  catch { /* drain is best-effort — never block a turn */ }
1299
- if (parts.length) {
1300
- writeAdditionalContext(input.hook_event_name || 'UserPromptSubmit', parts.join('\n\n'));
1301
- }
1302
1648
  }
1303
1649
  /** Per-turn attachment auto-save. Returns a one-line summary of freshly-saved
1304
1650
  * files, or null when auto-save is off or nothing new landed. Gated on an
@@ -1565,7 +1911,7 @@ async function guardFlush(input) {
1565
1911
  async function main() {
1566
1912
  const subcommand = process.argv[2];
1567
1913
  const validSubs = new Set([
1568
- 'store', 'recap', 'notify', 'mail', 'session-id', 'pre-spawn-check', 'armcheck',
1914
+ 'store', 'recap', 'notify', 'mail', 'friction-reminder', 'session-id', 'pre-spawn-check', 'armcheck',
1569
1915
  'collision-check', 'drain', 'coordinate-gate',
1570
1916
  'guard', 'guard-refresh', 'guard-flush',
1571
1917
  'context-probe', 'context-check', 'crush-wrap',
@@ -1574,7 +1920,7 @@ async function main() {
1574
1920
  'codex-permission-context', 'codex-subagent-start',
1575
1921
  ]);
1576
1922
  if (!validSubs.has(subcommand)) {
1577
- process.stderr.write(`Usage: greprag-hook <store|recap|notify|mail|session-id|pre-spawn-check|armcheck|collision-check|drain|coordinate-gate|guard|guard-refresh|guard-flush|context-probe|context-check|crush-wrap|pre-compact|archive-pointer|codex-store|codex-notify|codex-inbox|codex-recap|codex-permission-context|codex-subagent-start>\n`);
1923
+ process.stderr.write(`Usage: greprag-hook <store|recap|notify|mail|friction-reminder|session-id|pre-spawn-check|armcheck|collision-check|drain|coordinate-gate|guard|guard-refresh|guard-flush|context-probe|context-check|crush-wrap|pre-compact|archive-pointer|codex-store|codex-notify|codex-inbox|codex-recap|codex-permission-context|codex-subagent-start>\n`);
1578
1924
  process.exit(1);
1579
1925
  }
1580
1926
  let input = {};
@@ -1602,6 +1948,12 @@ async function main() {
1602
1948
  else if (subcommand === 'mail') {
1603
1949
  await mail(input);
1604
1950
  }
1951
+ else if (subcommand === 'friction-reminder') {
1952
+ // UserPromptSubmit — the active Mechanic's dynamic friction reminder (beside
1953
+ // the arm directive). Stress-driven tier: silent/ambient/nudge/nag. Reads the
1954
+ // Stop-stashed state bag; additive + fail-open. docs/reminder-interrupt.md
1955
+ await frictionReminder(input);
1956
+ }
1605
1957
  else if (subcommand === 'codex-notify') {
1606
1958
  await notify(input, 'codex');
1607
1959
  }