bantamkit-mcp 0.35.6 → 0.35.8

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 (44) hide show
  1. package/README.md +11 -7
  2. package/assets/tools/memory_compact.json +1 -2
  3. package/assets/tools/memory_recall.json +1 -3
  4. package/assets/tools/shiftwork_clock_in.json +1 -1
  5. package/assets/tools/shiftwork_clock_out.json +1 -1
  6. package/assets/tools/validate_json.json +1 -1
  7. package/assets/tools/work_plan.json +14 -8
  8. package/dist/cli.js +28 -0
  9. package/dist/contract.d.ts +0 -9
  10. package/dist/contract.js +51 -12
  11. package/dist/eventlog.d.ts +14 -1
  12. package/dist/eventlog.js +23 -3
  13. package/dist/hookadapter.js +260 -45
  14. package/dist/hostinstall.js +250 -79
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/mcp/pyargs.d.ts +22 -6
  18. package/dist/mcp/pyargs.js +113 -39
  19. package/dist/mcp/server.js +18 -27
  20. package/dist/mcp/status.js +11 -4
  21. package/dist/mcpreport.d.ts +12 -0
  22. package/dist/mcpreport.js +67 -15
  23. package/dist/memory/cli.js +18 -3
  24. package/dist/memory/component.d.ts +17 -0
  25. package/dist/memory/component.js +51 -7
  26. package/dist/memory/factfile.d.ts +21 -3
  27. package/dist/memory/factfile.js +351 -12
  28. package/dist/memory/layers.d.ts +19 -0
  29. package/dist/memory/layers.js +102 -2
  30. package/dist/memory/pyyaml.d.ts +6 -4
  31. package/dist/memory/pyyaml.js +6 -4
  32. package/dist/memory/store.d.ts +82 -7
  33. package/dist/memory/store.js +277 -29
  34. package/dist/npminstall.d.ts +18 -0
  35. package/dist/npminstall.js +26 -5
  36. package/dist/pyjsonschema.d.ts +85 -4
  37. package/dist/pyjsonschema.js +1812 -181
  38. package/dist/selfupdate.d.ts +112 -32
  39. package/dist/selfupdate.js +221 -79
  40. package/dist/shiftwork.d.ts +46 -7
  41. package/dist/shiftwork.js +246 -42
  42. package/dist/workplan.d.ts +8 -3
  43. package/dist/workplan.js +62 -3
  44. package/package.json +1 -1
@@ -49,7 +49,7 @@ import { fileURLToPath } from 'node:url';
49
49
  import { Memory } from './memory/component.js';
50
50
  import { discoverProjectStore, resolveProjectStore } from './memory/layers.js';
51
51
  import { DURABLE_TYPES, MemoryStore, RECALL_MIN_SCORE_RATIO, pyEqualValue, pyText, tokens } from './memory/store.js';
52
- import { keptManifest, keptPrefix } from './npminstall.js';
52
+ import { keptCli, keptManifest, keptPrefix } from './npminstall.js';
53
53
  import { decide as decideUpdate, KEY as UPDATE_KEY, loadRecord, PROGRAM as UPDATE_PROGRAM, recordPath, STATE_AVAILABLE, } from './updatecheck.js';
54
54
  /**
55
55
  * The directory `p` names, as the KERNEL names it. `realpathSync.native`, not `realpathSync`.
@@ -264,9 +264,71 @@ function readLedger(run, sessionId) {
264
264
  return { reads: {} };
265
265
  }
266
266
  }
267
+ /**
268
+ * Temp-and-rename, never a bare `writeFileSync` (job65, J65-4, #167): a reader that lands
269
+ * between a whole-file truncate and its write sees an empty ledger, which `readLedger` reads
270
+ * as "nothing seen". The rename is atomic on both platforms; the name carries the pid so two
271
+ * writers never share a temp file.
272
+ */
267
273
  function writeLedger(run, sessionId, ledger) {
268
274
  fs.mkdirSync(run.state, { recursive: true });
269
- fs.writeFileSync(ledgerPath(run, sessionId), JSON.stringify(ledger));
275
+ const file = ledgerPath(run, sessionId);
276
+ const tmp = `${file}.${process.pid}.tmp`;
277
+ fs.writeFileSync(tmp, JSON.stringify(ledger));
278
+ fs.renameSync(tmp, file);
279
+ }
280
+ /** How long a writer waits for the ledger lock before going ahead without it. */
281
+ const LEDGER_LOCK_WAIT_MS = 2000;
282
+ /** A lock file older than this was left by a hook process that died holding it. */
283
+ const LEDGER_LOCK_STALE_MS = 10_000;
284
+ /** The poll between two attempts at the lock. */
285
+ const LEDGER_LOCK_POLL_MS = 5;
286
+ /**
287
+ * One read-modify-write of the session ledger, under a lock (job65, J65-4, #167).
288
+ *
289
+ * THE RACE THIS CLOSES IS MEASURED, not supposed: the host fires one hook PROCESS per tool
290
+ * call and a parallel tool block fires them at once, so twenty Reads in one block are twenty
291
+ * processes each doing read → add one entry → write the whole file, and the last writer wins.
292
+ * Measured at 64ae11d over 20 distinct files: 6 of 20 entries survived on this side, 16 of 20
293
+ * on the reference (`.shiftwork/notes-job65/J65-4.md`). An atomic write alone cannot close it
294
+ * — the lost entries are lost between the read and the write, not inside the write — so the
295
+ * whole read-modify-write runs under `<ledger>.lock`, taken with `O_EXCL` (the one primitive
296
+ * both platforms and both runtimes share without a dependency). A lock older than
297
+ * `LEDGER_LOCK_STALE_MS` is a dead holder's and is removed; a writer that cannot take the
298
+ * lock within `LEDGER_LOCK_WAIT_MS` goes ahead unlocked, because a hook that fails the tool
299
+ * call over its own cache is worse than a lost entry. `update` returns whether the ledger
300
+ * changed; nothing is written when it did not.
301
+ */
302
+ function updateLedger(run, sessionId, update) {
303
+ fs.mkdirSync(run.state, { recursive: true });
304
+ const lock = `${ledgerPath(run, sessionId)}.lock`;
305
+ const deadline = Date.now() + LEDGER_LOCK_WAIT_MS;
306
+ let held = false;
307
+ while (!held && Date.now() < deadline) {
308
+ try {
309
+ fs.closeSync(fs.openSync(lock, 'wx'));
310
+ held = true;
311
+ }
312
+ catch {
313
+ try {
314
+ if (Date.now() - fs.statSync(lock).mtimeMs > LEDGER_LOCK_STALE_MS)
315
+ fs.rmSync(lock, { force: true });
316
+ }
317
+ catch {
318
+ /* the holder released it between the open and the stat: go round again */
319
+ }
320
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LEDGER_LOCK_POLL_MS);
321
+ }
322
+ }
323
+ try {
324
+ const ledger = readLedger(run, sessionId);
325
+ if (update(ledger))
326
+ writeLedger(run, sessionId, ledger);
327
+ }
328
+ finally {
329
+ if (held)
330
+ fs.rmSync(lock, { force: true });
331
+ }
270
332
  }
271
333
  // ------------------------------------- the host's auto-memory directory, and A's export
272
334
  //
@@ -494,6 +556,7 @@ function exportToNative(dir, store) {
494
556
  bytes: 0,
495
557
  indexBytes: 0,
496
558
  skipped: [],
559
+ unparsed: [],
497
560
  index: 'absent',
498
561
  };
499
562
  const indexFile = path.join(dir, NATIVE_INDEX);
@@ -510,8 +573,11 @@ function exportToNative(dir, store) {
510
573
  let named = seeded;
511
574
  const appended = [];
512
575
  try {
513
- const { facts, indexLine } = new MemoryStore(store).internals();
514
- const ranked = rankBySessionDropRule(facts().map((fact, position) => ({
576
+ const source = new MemoryStore(store);
577
+ const { indexLine } = source.internals();
578
+ const { facts, skipped } = tolerantFacts(source);
579
+ result.unparsed = skipped;
580
+ const ranked = rankBySessionDropRule(facts.map((fact, position) => ({
515
581
  fact,
516
582
  position,
517
583
  line: indexLine(fact),
@@ -608,8 +674,10 @@ function rankBySessionDropRule(all) {
608
674
  return [...all].sort((a, b) => decays(a) - decays(b) || cmp(evidence(b), evidence(a)) || cmp(a.name, b.name) || a.position - b.position);
609
675
  }
610
676
  function cappedIndex(root, header, max) {
611
- const { facts, indexLine } = new MemoryStore(root).internals();
612
- const all = facts().map((fact, position) => ({
677
+ const store = new MemoryStore(root);
678
+ const { indexLine } = store.internals();
679
+ const { facts, skipped } = tolerantFacts(store);
680
+ const all = facts.map((fact, position) => ({
613
681
  fact,
614
682
  position,
615
683
  line: indexLine(fact),
@@ -631,13 +699,15 @@ function cappedIndex(root, header, max) {
631
699
  .map((f) => f.line)
632
700
  .join('')
633
701
  .replace(/\n$/, ''), max);
634
- const lines = [header(dropped.length ? `${shown.length} of ${all.length}` : String(all.length))];
702
+ const lines = [header(dropped.length ? `${shown.length} of ${all.length}` : String(all.length), all.length)];
635
703
  if (body)
636
704
  lines.push(body);
637
705
  if (dropped.length) {
638
706
  lines.push(`[${dropped.length} of ${all.length} not shown — the block is capped at ${max} bytes; kept by rule: ${SESSION_DROP_RULE}; ~/.bantamkit/hooks/hook-log.jsonl names the dropped; mcp__bantamkit__memory_recall reads any fact by name]`);
639
707
  }
640
- return { block: lines.join('\n'), total: all.length, injected: shown.length, dropped };
708
+ if (skipped.length)
709
+ lines.push(skippedLine(skipped));
710
+ return { block: lines.join('\n'), total: all.length, injected: shown.length, dropped, skipped };
641
711
  }
642
712
  function countFacts(store) {
643
713
  try {
@@ -647,6 +717,25 @@ function countFacts(store) {
647
717
  return 0;
648
718
  }
649
719
  }
720
+ /**
721
+ * Every fact `store` can read, and the files it could not: `MemoryStore.readableFacts`. A
722
+ * store whose `facts/` cannot be LISTED still throws, exactly as before: that is a layer that
723
+ * could not be read, not a fact.
724
+ */
725
+ function tolerantFacts(store) {
726
+ return store.readableFacts();
727
+ }
728
+ /** "1 fact" / "N facts" (#188). The noun follows the block's TOTAL, the last number shown. */
729
+ function factNoun(total) {
730
+ return total === 1 ? 'fact' : 'facts';
731
+ }
732
+ /** The block's footer when files were left out: their names, never their contents. */
733
+ function skippedLine(skipped) {
734
+ const n = skipped.length;
735
+ return capBytes(`[${n} fact ${n === 1 ? 'file' : 'files'} not read — not a valid fact: ${skipped.map((s) => s.file).join(', ')}; ~/.bantamkit/hooks/hook-log.jsonl has the reason]`, SKIPPED_LINE_MAX);
736
+ }
737
+ /** A footer naming many broken files is still bounded, like everything else this file injects. */
738
+ const SKIPPED_LINE_MAX = 500;
650
739
  // ------------------------------------------------------- the stale-install signal
651
740
  // Reader 2 and Writer 1 of `docs/superpowers/specs/2026-09-19-stale-version-signal-design.md`.
652
741
  // One line at SessionStart when the install this hook can SEE is older than what the package
@@ -705,8 +794,15 @@ const UPDATE_LINE_MAX = 500;
705
794
  * "then reconnect the host" is measured reason 1 in `selfupdate.py:13-20`: a running server
706
795
  * keeps serving the code it loaded at startup, so an updated install does nothing for THIS
707
796
  * session.
797
+ *
798
+ * THE COMMAND IS THE KEPT INSTALL'S OWN `cli.js`, RUN WITH NODE — not the bare `{program}`
799
+ * (2026-09-26). `--install` puts `bantamkit-mcp` in `<prefix>/node_modules/.bin`, which is on
800
+ * nobody's PATH, so the bare word this line used to print answered `command not found` on the
801
+ * machine it was measured on. `node "<cli>"` is what a host is already told to run
802
+ * (`npminstall.keptCli`), needs no PATH and no `.cmd` shim on Windows, and the double quotes
803
+ * hold a home directory with a space in it in zsh, bash, cmd and PowerShell alike.
708
804
  */
709
- const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `{program} --update`, then reconnect the host.';
805
+ const UPDATE_LINE = '[bantamkit] {program} {installed} at {path} is running; the package index has {latest} — run `node "{cli}" --update`, then reconnect the host.';
710
806
  /** `{name}` substitution. A missing key is left alone rather than rendered `undefined`. */
711
807
  function fillLine(template, values) {
712
808
  return template.replace(/\{([a-z]+)\}/g, (whole, name) => values[name] ?? whole);
@@ -726,11 +822,12 @@ function fillLine(template, values) {
726
822
  * second thing to keep in step with two runtimes. There is no comparator in this file.
727
823
  */
728
824
  function keptInstall(home) {
729
- const manifest = keptManifest(keptPrefix(home));
825
+ const prefix = keptPrefix(home);
826
+ const manifest = keptManifest(prefix);
730
827
  try {
731
828
  const parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
732
829
  const version = parsed && typeof parsed.version === 'string' ? parsed.version.trim() : '';
733
- return version === '' ? null : { path: manifest, version };
830
+ return version === '' ? null : { path: manifest, cli: keptCli(prefix), version };
734
831
  }
735
832
  catch {
736
833
  return null;
@@ -760,6 +857,7 @@ function updateSignal(run) {
760
857
  program: UPDATE_PROGRAM,
761
858
  installed: kept.version,
762
859
  path: kept.path,
860
+ cli: kept.cli,
763
861
  latest,
764
862
  }), UPDATE_LINE_MAX);
765
863
  }
@@ -808,10 +906,19 @@ function sessionStart(run, input) {
808
906
  const cwd = input.cwd || process.cwd();
809
907
  const parts = [];
810
908
  const profileFacts = countFacts(run.profile);
811
- let profile = { block: '', total: 0, injected: 0, dropped: [] };
909
+ let profile = { block: '', total: 0, injected: 0, dropped: [], skipped: [] };
910
+ // IN ITS OWN TRY (job65, #119): this block used to sit outside every try, so a profile store
911
+ // the hook could not read at all threw past the arm and the session got NO context — not the
912
+ // project block, not the toolbox line. One unreadable FILE is handled inside `cappedIndex`;
913
+ // this catch is for a store that cannot even be listed, and it costs the profile block only.
812
914
  if (profileFacts > 0) {
813
- profile = cappedIndex(run.profile, (count) => `[bantamkit profile memory — ${count} facts learned across projects]`, SESSION_INJECT_MAX);
814
- parts.push(profile.block);
915
+ try {
916
+ profile = cappedIndex(run.profile, (count, total) => `[bantamkit profile memory — ${count} ${factNoun(total)} learned across projects]`, SESSION_INJECT_MAX);
917
+ parts.push(profile.block);
918
+ }
919
+ catch (e) {
920
+ log(run, { event: 'SessionStart', warn: `profile: ${message(e)}` });
921
+ }
815
922
  }
816
923
  // WHERE THE HOST'S OWN AUTO-MEMORY DIRECTORY IS, resolved ONCE per session and used twice:
817
924
  // it decides whether the project index has to be injected at all, and it is where A exports.
@@ -834,7 +941,7 @@ function sessionStart(run, input) {
834
941
  const store = discoverProjectStore(cwd);
835
942
  if (path.resolve(store) !== path.resolve(run.profile) && countFacts(store) > 0) {
836
943
  if (native.dir === null) {
837
- project = cappedIndex(store, (count) => `[bantamkit project memory — ${count} facts]`, SESSION_INJECT_MAX);
944
+ project = cappedIndex(store, (count, total) => `[bantamkit project memory — ${count} ${factNoun(total)}]`, SESSION_INJECT_MAX);
838
945
  project.facts = countFacts(store);
839
946
  parts.push(project.block);
840
947
  }
@@ -897,8 +1004,16 @@ function sessionStart(run, input) {
897
1004
  profileFacts,
898
1005
  profileInjected: profile.injected,
899
1006
  profileDropped: profile.dropped,
1007
+ // #119: the files left out, by name and the store's reason — present only when non-empty,
1008
+ // so a healthy session's record is the shape it always was.
1009
+ ...(profile.skipped.length ? { profileSkipped: profile.skipped } : {}),
900
1010
  ...(project
901
- ? { projectFacts: project.facts, projectInjected: project.injected, projectDropped: project.dropped }
1011
+ ? {
1012
+ projectFacts: project.facts,
1013
+ projectInjected: project.injected,
1014
+ projectDropped: project.dropped,
1015
+ ...(project.skipped.length ? { projectSkipped: project.skipped } : {}),
1016
+ }
902
1017
  : {}),
903
1018
  dropRule: SESSION_DROP_RULE,
904
1019
  // A — the host's auto-memory directory. `nativeBranch` / `nativeTried` are the one line
@@ -916,6 +1031,7 @@ function sessionStart(run, input) {
916
1031
  nativeBytes: exported.bytes,
917
1032
  nativeIndexBytes: exported.indexBytes,
918
1033
  nativeSkipped: exported.skipped,
1034
+ ...(exported.unparsed.length ? { nativeUnparsed: exported.unparsed } : {}),
919
1035
  nativeIndex: exported.index,
920
1036
  ...(exported.error ? { nativeError: exported.error } : {}),
921
1037
  }
@@ -1000,17 +1116,26 @@ function userPromptSubmit(run, input) {
1000
1116
  // prompt, for up to three files — and the rules keyed on that date (compaction's
1001
1117
  // stalest-first, the SessionStart drop rule, the Stop dream's store fingerprint) were
1002
1118
  // reading this arm's traffic. An explicit `memory_recall` still stamps.
1003
- const o = m.recallOutcome(prompt, 3, RECALL_MIN_SCORE_RATIO, false);
1119
+ const ask = () => m.recallOutcome(prompt, 3, RECALL_MIN_SCORE_RATIO, false);
1120
+ // ONE UNREADABLE FACT COSTS THAT FACT (job65, #119): the recall reads every layer past the
1121
+ // files it cannot read and names them in `o.skipped`; one recall, healthy or not. A layer
1122
+ // that cannot be LISTED fails exactly as it did.
1123
+ const o = ask();
1124
+ const skipped = o.skipped.map((s) => ({ ...s }));
1125
+ const degraded = skipped.length ? { skipped } : {};
1004
1126
  if (o.status !== 'answered') {
1005
- log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates });
1127
+ log(run, { event: 'UserPromptSubmit', action: 'none', status: o.status, candidates: o.candidates, ...degraded });
1006
1128
  return;
1007
1129
  }
1008
1130
  // Only the HEADER line of each hit — `[layer] [name] (type) description`. The body costs
1009
1131
  // ~1.5 KB a fact and would be re-sent on every later call; the header is ~150 B and
1010
1132
  // tells the model exactly which name to pass to memory_recall if it wants the body.
1011
- const heads = o.reply.split('\n').filter((l) => RECALL_HEADER.test(l));
1133
+ // Read off `o.headers`, built from the parsed fields (job65, #155), never by filtering the
1134
+ // reply's lines: a fact BODY line shaped like `[profile] [x] (feedback) ...` used to pass
1135
+ // for a header and be injected as a memory nobody saved.
1136
+ const heads = o.headers.filter((l) => RECALL_HEADER.test(l));
1012
1137
  if (heads.length === 0) {
1013
- log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers' });
1138
+ log(run, { event: 'UserPromptSubmit', action: 'none', reason: 'no-headers', ...degraded });
1014
1139
  return;
1015
1140
  }
1016
1141
  // WHAT THIS CONTEXT HAS ALREADY BEEN SHOWN IS NOT SHOWN AGAIN (job64, J64-2). Measured
@@ -1047,6 +1172,7 @@ function userPromptSubmit(run, input) {
1047
1172
  session: input.session_id ?? null,
1048
1173
  prompt: promptFingerprint(prompt),
1049
1174
  suppressed,
1175
+ ...degraded,
1050
1176
  });
1051
1177
  return;
1052
1178
  }
@@ -1085,6 +1211,7 @@ function userPromptSubmit(run, input) {
1085
1211
  injected,
1086
1212
  dropped: fresh.length - injected.length,
1087
1213
  suppressed,
1214
+ ...degraded,
1088
1215
  });
1089
1216
  emit({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx } });
1090
1217
  }
@@ -1094,7 +1221,15 @@ function userPromptSubmit(run, input) {
1094
1221
  // because they never pass through bantamkit's Agent. A PreToolUse hook is the one place
1095
1222
  // that does see them. Keyed by transcript (a subagent has its own transcript and has NOT
1096
1223
  // seen the parent's read), by path+offset+limit, and by mtime+size so an edit re-arms it.
1097
- // REFUSES ONCE: the second identical call goes through, so nothing can be hard-blocked.
1224
+ // REFUSES ONCE: the second identical call goes through, so nothing can be hard-blocked —
1225
+ // and ONLY once (job65, J65-4, #166): the allowed retry used to write `refused: false`
1226
+ // back, which re-armed the gate and refused every other read (allow, deny, allow, deny, …)
1227
+ // of a file the docs promise is "refused once". The record now keeps `refused: true` until
1228
+ // the signature changes, so the sequence is allow, deny, allow, allow, … and an edit re-arms.
1229
+ // ONLY REGULAR FILES (#190): a directory stats fine, so it was ledgered and its retry refused
1230
+ // with "use the content from that earlier read" — content the host never produced, because
1231
+ // the first Read failed with EISDIR. Anything that is not a regular file is treated like a
1232
+ // missing path: not ledgered, never refused, Read produces its own error.
1098
1233
  function preToolUseRead(run, input) {
1099
1234
  const ti = input.tool_input ?? {};
1100
1235
  const file = typeof ti['file_path'] === 'string' ? ti['file_path'] : '';
@@ -1107,6 +1242,22 @@ function preToolUseRead(run, input) {
1107
1242
  catch {
1108
1243
  return; // missing file: let Read produce its own error
1109
1244
  }
1245
+ if (!st.isFile())
1246
+ return; // a directory/device read has no file content to reuse
1247
+ // NOR A FILE THIS PROCESS MAY NOT READ (job65, #192). `isFile` closed the directory case of
1248
+ // #190 but the ledger still recorded on PreToolUse, before the host's Read could fail: a
1249
+ // `chmod 000` file was ledgered and its retry refused with "use the content from that
1250
+ // earlier read" — content the host never produced. The hook runs as the same user as the
1251
+ // host's Read, so `R_OK` here is the host's answer; a file that fails it is treated like a
1252
+ // missing path: not ledgered, never refused. Chosen over moving the record to
1253
+ // PostToolUse[Read] because it is one check in the arm that already decides, on both
1254
+ // runtimes, and needs no second event to agree with the first (docs/hooks.md, PreToolUse).
1255
+ try {
1256
+ fs.accessSync(file, fs.constants.R_OK);
1257
+ }
1258
+ catch {
1259
+ return;
1260
+ }
1110
1261
  const offset = ti['offset'];
1111
1262
  const limit = ti['limit'];
1112
1263
  // The transcript is the CONTEXT dimension: a subagent has its own transcript and has not
@@ -1114,14 +1265,39 @@ function preToolUseRead(run, input) {
1114
1265
  // `preCompact` must filter on it and a path may itself contain the key's delimiter.
1115
1266
  const transcript = String(input.transcript_path || input.session_id || '');
1116
1267
  const key = `${transcript}|${file}|${offset ?? ''}|${limit ?? ''}`;
1117
- const ledger = readLedger(run, input.session_id);
1118
- const prev = ledger.reads[key];
1119
1268
  const sig = `${st.mtimeMs}|${st.size}`;
1120
- if (prev && prev.sig === sig && !prev.refused) {
1121
- prev.refused = true;
1122
- prev.count += 1;
1123
- writeLedger(run, input.session_id, ledger);
1124
- const reason = `bantamkit filegraph: ${file}${offset != null ? ` (offset ${offset}${limit != null ? `, limit ${limit}` : ''})` : ''} was already read in this context at ${prev.at} and is unchanged on disk (same mtime and size). Use the content from that earlier read. If you genuinely need it again, repeat the exact same call — this refusal fires only once per unchanged file.`;
1269
+ const decision = {
1270
+ action: 'record',
1271
+ refusedAt: '',
1272
+ };
1273
+ // The whole read-modify-write under the ledger lock (#167); the decision is emitted after.
1274
+ updateLedger(run, input.session_id, (ledger) => {
1275
+ const prev = ledger.reads[key];
1276
+ if (prev && prev.sig === sig) {
1277
+ prev.count += 1;
1278
+ if (!prev.refused) {
1279
+ prev.refused = true;
1280
+ decision.action = 'refuse';
1281
+ decision.refusedAt = prev.at;
1282
+ }
1283
+ else {
1284
+ decision.action = 'allow-after-refuse-or-change'; // refused once already; every later repeat goes through
1285
+ }
1286
+ return true;
1287
+ }
1288
+ ledger.reads[key] = {
1289
+ sig,
1290
+ at: new Date().toISOString(),
1291
+ count: (prev ? prev.count : 0) + 1,
1292
+ refused: false,
1293
+ transcript,
1294
+ file,
1295
+ };
1296
+ decision.action = prev ? 'allow-after-refuse-or-change' : 'record';
1297
+ return true;
1298
+ });
1299
+ if (decision.action === 'refuse') {
1300
+ const reason = `bantamkit filegraph: ${file}${offset != null ? ` (offset ${offset}${limit != null ? `, limit ${limit}` : ''})` : ''} was already read in this context at ${decision.refusedAt} and is unchanged on disk (same mtime and size). Use the content from that earlier read. If you genuinely need it again, repeat the exact same call — this refusal fires only once per unchanged file.`;
1125
1301
  log(run, { event: 'PreToolUse', action: 'refuse', file, size: st.size });
1126
1302
  emit({
1127
1303
  hookSpecificOutput: {
@@ -1132,21 +1308,7 @@ function preToolUseRead(run, input) {
1132
1308
  });
1133
1309
  return;
1134
1310
  }
1135
- ledger.reads[key] = {
1136
- sig,
1137
- at: new Date().toISOString(),
1138
- count: (prev ? prev.count : 0) + 1,
1139
- refused: false,
1140
- transcript,
1141
- file,
1142
- };
1143
- writeLedger(run, input.session_id, ledger);
1144
- log(run, {
1145
- event: 'PreToolUse',
1146
- action: prev ? 'allow-after-refuse-or-change' : 'record',
1147
- file,
1148
- size: st.size,
1149
- });
1311
+ log(run, { event: 'PreToolUse', action: decision.action, file, size: st.size });
1150
1312
  }
1151
1313
  // ---------------------------------------------- PostToolUse → the usage events log
1152
1314
  // One line per tool call, for `tools/ledger/tool-usage.mjs` to read when a transcript is gone.
@@ -2061,7 +2223,7 @@ function stop(run, input) {
2061
2223
  const toolUses = (text.match(/"type":\s*"tool_use"/g) || []).length;
2062
2224
  const saved = (ledger.saved || 0) > 0 ||
2063
2225
  /"name":\s*"mcp__bantamkit__memory_save"/.test(text) ||
2064
- /"file_path":"[^"]*[\\/]memory[\\/][^"]*\.md"/.test(text);
2226
+ transcriptWroteMemory(text);
2065
2227
  if (toolUses < STOP_NUDGE_MIN_TOOL_CALLS || saved) {
2066
2228
  log(run, { event: 'Stop', action: 'pass', toolUses, saved });
2067
2229
  return;
@@ -2074,6 +2236,51 @@ function stop(run, input) {
2074
2236
  reason: `bantamkit: this session made ${toolUses} tool calls and saved no memory. Before stopping, decide whether anything durable was learned that is NOT derivable from the repo, git history, or docs — a correction or preference the user stated (feedback), a fact about ongoing work or a decision (project), a URL/ticket/dashboard (reference). If so, call mcp__bantamkit__memory_save for each (at most 3, description written as the words a future query would use). If nothing qualifies, stop with one line saying so. This nudge fires once per session.`,
2075
2237
  });
2076
2238
  }
2239
+ /** The native write tools whose `file_path` can put a fact into a `memory/` directory. */
2240
+ const SAVE_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit']);
2241
+ /** The path shape of a native memory write: a `.md` under any `memory/` directory. */
2242
+ const SAVE_WRITE_PATH = /[\\/]memory[\\/][\s\S]*\.md$/;
2243
+ /** A line can only hold a native write if it names one of the tools; the rest are never parsed. */
2244
+ const SAVE_LINE_HINT = /"name":\s*"(?:Write|Edit|MultiEdit)"/;
2245
+ /**
2246
+ * Did the transcript WRITE a native memory file (job65, #169)? Only a `tool_use` counts: a
2247
+ * Write/Edit/MultiEdit whose `file_path` is a `.md` under a `memory/` directory. Before this a
2248
+ * regex over the whole text took ANY `"file_path"` under `memory/` — a Read of
2249
+ * `docs/memory/notes.md` included — as a save, and silenced the nudge. (A `memory_save` call
2250
+ * is still recognised by its tool name, as it always was; that half was never the defect.)
2251
+ *
2252
+ * Lines are prefiltered on the tool names and only those are parsed, so a long transcript
2253
+ * costs one regex per line and a `JSON.parse` only where a write could be. A line that does
2254
+ * not parse is not a save.
2255
+ */
2256
+ function transcriptWroteMemory(text) {
2257
+ for (const line of text.split('\n')) {
2258
+ if (!SAVE_LINE_HINT.test(line))
2259
+ continue;
2260
+ let record;
2261
+ try {
2262
+ record = JSON.parse(line);
2263
+ }
2264
+ catch {
2265
+ continue;
2266
+ }
2267
+ const msg = record && typeof record === 'object' ? record.message : undefined;
2268
+ const content = msg && typeof msg === 'object' ? msg.content : undefined;
2269
+ if (!Array.isArray(content))
2270
+ continue;
2271
+ for (const block of content) {
2272
+ if (!block || typeof block !== 'object')
2273
+ continue;
2274
+ const b = block;
2275
+ if (b.type !== 'tool_use' || typeof b.name !== 'string' || !SAVE_WRITE_TOOLS.has(b.name))
2276
+ continue;
2277
+ const fp = b.input && typeof b.input === 'object' ? b.input.file_path : undefined;
2278
+ if (typeof fp === 'string' && SAVE_WRITE_PATH.test(fp))
2279
+ return true;
2280
+ }
2281
+ }
2282
+ return false;
2283
+ }
2077
2284
  // ---------------------------------------------------------------------- dispatch
2078
2285
  /** Read the whole of stdin. One JSON object is the entire input contract. */
2079
2286
  async function readStdin() {
@@ -2100,7 +2307,15 @@ export async function runHook() {
2100
2307
  input = parsed;
2101
2308
  }
2102
2309
  catch {
2103
- log(run, { event: 'parse-error', raw: raw.slice(0, 200) });
2310
+ // THE INPUT IS NOT LOGGED, AT ANY LENGTH (job65, #168): a truncated UserPromptSubmit event
2311
+ // carries the user's prompt, and this line used to keep its first 200 characters. Its
2312
+ // size and a SHA-256 digest are kept instead, the same fingerprint `promptFingerprint`
2313
+ // gives a prompt that did parse, so a bad input can still be recognised twice.
2314
+ log(run, {
2315
+ event: 'parse-error',
2316
+ bytes: Buffer.byteLength(raw),
2317
+ sha256: createHash('sha256').update(raw, 'utf8').digest('hex'),
2318
+ });
2104
2319
  return;
2105
2320
  }
2106
2321
  const ev = input.hook_event_name;