bantamkit-mcp 0.35.7 → 0.35.9

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 (53) hide show
  1. package/README.md +24 -9
  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/assets.js +28 -4
  9. package/dist/cli.js +83 -24
  10. package/dist/contract.d.ts +0 -9
  11. package/dist/contract.js +51 -12
  12. package/dist/eventlog.d.ts +22 -2
  13. package/dist/eventlog.js +30 -3
  14. package/dist/hookadapter.js +263 -43
  15. package/dist/hostinstall.d.ts +2 -2
  16. package/dist/hostinstall.js +318 -84
  17. package/dist/index.d.ts +2 -2
  18. package/dist/index.js +1 -1
  19. package/dist/mcp/identity.js +27 -1
  20. package/dist/mcp/pyargs.d.ts +22 -6
  21. package/dist/mcp/pyargs.js +113 -39
  22. package/dist/mcp/server.js +177 -41
  23. package/dist/mcp/status.js +11 -4
  24. package/dist/mcp/transport.d.ts +51 -6
  25. package/dist/mcp/transport.js +217 -26
  26. package/dist/mcpreport.d.ts +12 -0
  27. package/dist/mcpreport.js +67 -15
  28. package/dist/memory/cli.js +18 -3
  29. package/dist/memory/component.d.ts +17 -0
  30. package/dist/memory/component.js +51 -7
  31. package/dist/memory/factfile.d.ts +21 -3
  32. package/dist/memory/factfile.js +351 -12
  33. package/dist/memory/layers.d.ts +19 -0
  34. package/dist/memory/layers.js +102 -2
  35. package/dist/memory/pyyaml.d.ts +6 -4
  36. package/dist/memory/pyyaml.js +6 -4
  37. package/dist/memory/store.d.ts +82 -7
  38. package/dist/memory/store.js +277 -29
  39. package/dist/npminstall.d.ts +18 -0
  40. package/dist/npminstall.js +26 -5
  41. package/dist/pyargparse.js +48 -2
  42. package/dist/pyjsonschema.d.ts +85 -4
  43. package/dist/pyjsonschema.js +1812 -181
  44. package/dist/selfupdate.d.ts +112 -32
  45. package/dist/selfupdate.js +221 -79
  46. package/dist/shiftwork.d.ts +46 -7
  47. package/dist/shiftwork.js +246 -42
  48. package/dist/tokenledger.js +20 -4
  49. package/dist/updatecheck.d.ts +15 -3
  50. package/dist/updatecheck.js +23 -8
  51. package/dist/workplan.d.ts +8 -3
  52. package/dist/workplan.js +62 -3
  53. package/package.json +2 -2
@@ -38,11 +38,13 @@
38
38
  * handed — the two branches Python takes when `_snapshot` is pinned have no reachable
39
39
  * caller here. Everything else about both functions is the reference's.
40
40
  */
41
+ import { randomBytes } from 'node:crypto';
42
+ import { writeFileSync } from 'node:fs';
41
43
  import { basename } from 'node:path';
42
44
  import { BantamError } from '../errors.js';
43
45
  import { formatFact, parseFrontmatter, pySplit, pyStrip, todayLocal } from './factfile.js';
44
46
  import { PyScalar } from './pyyaml.js';
45
- import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyParent, pyParents, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, PyOSError, sortedPathNames, } from './pyfs.js';
47
+ import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyIsDir, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyNewlineOut, pyParent, pyParents, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink, pyWriteText, PyOSError, PyUnicodeDecodeError, sortedPathNames, } from './pyfs.js';
46
48
  /**
47
49
  * The exact bytes written into a `.bantamkit/.gitignore` the first time such a directory is
48
50
  * brought into existence (user ruling 2026-09-15, `.shiftwork/notes-job51/P0-probes.md`).
@@ -162,6 +164,42 @@ export const DURABLE_TYPES = ['feedback', 'user'];
162
164
  */
163
165
  const NAME_RE = /^[a-z0-9][a-z0-9-]*\n?$/;
164
166
  const NAME_PATTERN = '^[a-z0-9][a-z0-9-]*$';
167
+ /**
168
+ * THE LONGEST NAME A SAVE ACCEPTS (job65/#156): `NAME_MAX_CHARS` in the reference, the same
169
+ * number. The fact lands as `<name>.md` through `<name>.md.<pid>.<8 hex>.tmp` (#161), so the
170
+ * name leaves room for that suffix under the 255-byte file-name limit. WAS: no cap, and a
171
+ * 301-character name came back as a raw `[Errno 63] File name too long: '<absolute path>'`.
172
+ * `NAME_RE` admits ASCII only, so characters and bytes are one count.
173
+ */
174
+ export const NAME_MAX_CHARS = 128;
175
+ /**
176
+ * ONE LINE MEANS ONE LINE (job65/#155): the reference's `_NOT_ONE_LINE`, the same ranges —
177
+ * every C0/C1 control plus U+2028/U+2029. Refused in a saved `description`; in a fact file on
178
+ * disk each run of them is READ as one space (see `facts()`), so `index.md` and the hook's
179
+ * injected header can never gain a line nobody saved.
180
+ */
181
+ const NOT_ONE_LINE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
182
+ /**
183
+ * A LONE SURROGATE IS NOT TEXT (job65/#160). JSON can carry `\ud800` on its own; no UTF-8
184
+ * file can hold it. This port used to write U+FFFD and say "saved"; the reference raised a
185
+ * `UnicodeEncodeError`. A JS string holds a pair as two code units, so a surrogate is lone
186
+ * when it is a high one not followed by a low one, or a low one not preceded by a high one.
187
+ */
188
+ const LONE_SURROGATE = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
189
+ function codepoint(unit) {
190
+ return `U+${unit.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`;
191
+ }
192
+ /** `U+XXXX` of the first lone surrogate in `text`, or `null` when it is all text. */
193
+ export function loneSurrogate(text) {
194
+ const found = LONE_SURROGATE.exec(text);
195
+ return found === null ? null : codepoint(found[0]);
196
+ }
197
+ function notOneLine(text) {
198
+ NOT_ONE_LINE.lastIndex = 0;
199
+ const found = NOT_ONE_LINE.exec(text);
200
+ NOT_ONE_LINE.lastIndex = 0;
201
+ return found === null ? null : codepoint(found[0]);
202
+ }
165
203
  export const DUPLICATE_JACCARD = 0.5;
166
204
  // ---- the precision gate (roadmap #6) ---------------------------------------------
167
205
  //
@@ -276,6 +314,13 @@ const FACT_UNREACHABLE = "a fact that could not be stat'd is not a fact that is
276
314
  'this path';
277
315
  const ARCHIVE_DESTINATION_UNREACHABLE = "a destination that could not be stat'd is not a name that is already archived, " +
278
316
  'and nothing has moved: the fact is still in facts/';
317
+ /**
318
+ * job65/#141, #123: the codes that say a directory EXISTS and this process may not write into
319
+ * it — `NOT_WRITABLE_ERRNOS` in the reference. EACCES is the chmod case everywhere, EPERM an
320
+ * immutable flag or a Windows ACL, EROFS a read-only mount. On the mkdir path it is only half
321
+ * the test (`ensureDirs` also needs the root to be there); see the reference's docstring.
322
+ */
323
+ const NOT_WRITABLE_CODES = new Set(['EACCES', 'EPERM', 'EROFS']);
279
324
  export class MemoryValidationError extends BantamError {
280
325
  }
281
326
  export class MemoryBudgetExceeded extends BantamError {
@@ -632,6 +677,13 @@ export class MemoryStore {
632
677
  * the failing leaf, or that strerror, would be a divergence manufactured by mkdir's
633
678
  * internals. `this.root` is the path the caller named and both sides agree on it, which is
634
679
  * what lets one conformance case pin this line byte for byte.
680
+ *
681
+ * AMENDED job65/#141: THE SENTENCE ABOVE IS NOW THE "NOT THERE" ARM ONLY. A `chmod 000`
682
+ * store answered it too, about a directory that exists. The second arm is still an outcome:
683
+ * the mkdir failed with one of `NOT_WRITABLE_CODES` AND `this.root` is a directory that is
684
+ * there, and then the refusal is `memory store is not writable: <root>: <strerror>; the
685
+ * directory exists but cannot be written to, so nothing was written`. The cwd-`/` divergence
686
+ * measured above is about a root that is NOT there, so it never reaches this arm.
635
687
  */
636
688
  ensureDirs() {
637
689
  // Checked BEFORE the mkdir below, per user ruling #2: this is the one moment that can
@@ -651,12 +703,30 @@ export class MemoryStore {
651
703
  catch (e) {
652
704
  if (!(e instanceof PyOSError))
653
705
  throw e;
706
+ if (NOT_WRITABLE_CODES.has(e.code) && this.present())
707
+ throw this.unwritable(e);
654
708
  throw new MemoryValidationError(`memory store could not be created: ${this.root}; the directory is not` +
655
709
  ' there and this filesystem would not make it, so nothing was written');
656
710
  }
657
711
  if (bantamkitDir !== null)
658
712
  ensureBantamkitGitignore(bantamkitDir, !bantamkitDirExistedBefore);
659
713
  }
714
+ /** `_present`: is `this.root` a directory that is there? "Not allowed to look" is "there". */
715
+ present() {
716
+ try {
717
+ return pyIsDir(this.root);
718
+ }
719
+ catch (e) {
720
+ if (!(e instanceof PyOSError))
721
+ throw e;
722
+ return true;
723
+ }
724
+ }
725
+ /** `_unwritable`: one sentence for a store that exists and refused a write (#141). */
726
+ unwritable(error) {
727
+ return new MemoryValidationError(`memory store is not writable: ${this.root}: ${error.strerror}; the directory` +
728
+ ' exists but cannot be written to, so nothing was written');
729
+ }
660
730
  // ---- ops ----
661
731
  /**
662
732
  * Write a fact, or refuse it as a near-duplicate of one already here.
@@ -679,6 +749,24 @@ export class MemoryStore {
679
749
  if (pyStrip(description || '') === '') {
680
750
  throw new MemoryValidationError('description must be a non-empty line');
681
751
  }
752
+ if (name.length > NAME_MAX_CHARS) {
753
+ throw new MemoryValidationError(`invalid name: ${name.length} characters; must be at most ${NAME_MAX_CHARS}`);
754
+ }
755
+ const texts = [
756
+ ['description', description],
757
+ ['body', body],
758
+ ...links.map((link, i) => [`links[${i}]`, link]),
759
+ ];
760
+ for (const [label, value] of texts) {
761
+ const bad = typeof value === 'string' ? loneSurrogate(value) : null;
762
+ if (bad !== null) {
763
+ throw new MemoryValidationError(`${label} holds a lone surrogate (${bad}), which is not text and cannot be stored as UTF-8; nothing was saved`);
764
+ }
765
+ }
766
+ const broken = notOneLine(pyStrip(description));
767
+ if (broken !== null) {
768
+ throw new MemoryValidationError(`description must be one line; it holds the control character ${broken}`);
769
+ }
682
770
  const newTokens = tokens(`${name} ${description}`);
683
771
  let existing = null;
684
772
  for (const fact of this.facts()) {
@@ -703,7 +791,16 @@ export class MemoryStore {
703
791
  };
704
792
  const path = this.factPath(name);
705
793
  const existed = pyExists(path) ? pyReadText(path) : null;
706
- this.writeFact(fact);
794
+ // #141, the 555 shape: every directory was already there, so the fact write is the first
795
+ // syscall the mode refuses. Only the "exists, not writable" codes become the sentence.
796
+ try {
797
+ this.writeFact(fact);
798
+ }
799
+ catch (e) {
800
+ if (e instanceof PyOSError && NOT_WRITABLE_CODES.has(e.code))
801
+ throw this.unwritable(e);
802
+ throw e;
803
+ }
707
804
  try {
708
805
  this.checkIndexBudget();
709
806
  }
@@ -745,13 +842,13 @@ export class MemoryStore {
745
842
  * reference's `not 0.0 <= min_ratio <= 1.0` refuses it. That is a real mutant (M7b) and
746
843
  * it is killed on both sides.
747
844
  */
748
- recall(query, k = null, stamp = true, minRatio = RECALL_MIN_SCORE_RATIO) {
845
+ recall(query, k = null, stamp = true, minRatio = RECALL_MIN_SCORE_RATIO, skipped) {
749
846
  if (!(minRatio >= 0 && minRatio <= 1))
750
847
  throw new MemoryValidationError(MIN_RATIO_RANGE);
751
848
  const limit = k ?? this.k;
752
849
  const q = tokens(query);
753
850
  let scored = [];
754
- for (const fact of this.facts()) {
851
+ for (const fact of this.facts(undefined, skipped)) {
755
852
  let score = 0;
756
853
  for (const t of tokens(`${pyText(fact.name)} ${pyText(fact.description)}`))
757
854
  if (q.has(t))
@@ -798,8 +895,8 @@ export class MemoryStore {
798
895
  * order, which is the order `recall` breaks a tie in. `stamp` dates the hit through
799
896
  * `stamp()` exactly as `recall` dates its hits; a miss writes nothing.
800
897
  */
801
- lookup(name, stamp = true) {
802
- for (const fact of this.facts()) {
898
+ lookup(name, stamp = true, skipped) {
899
+ for (const fact of this.facts(undefined, skipped)) {
803
900
  if (typeof fact.name === 'string' && fact.name === name) {
804
901
  if (stamp)
805
902
  this.stamp(fact);
@@ -828,11 +925,19 @@ export class MemoryStore {
828
925
  * and the budget error to `lint: FAIL — …` on stderr with exit 1.
829
926
  */
830
927
  lint() {
831
- for (const fact of this.facts()) {
928
+ const findings = [];
929
+ for (const fact of this.facts(findings)) {
832
930
  if (!VALID_TYPES.includes(pyText(fact.type))) {
833
931
  throw new MemoryValidationError(`fact '${pyText(fact.name)}' has invalid type '${pyText(fact.type)}'`);
834
932
  }
835
933
  }
934
+ // The two hand-edit shapes every other op now TOLERATES (#121, #122) are named here,
935
+ // first one in file order, because lint is the one surface whose job is to say what an
936
+ // operator should fix: an unquoted timestamp that `facts` read as a string, and a file
937
+ // whose name is not the fact's. Neither is a reason for `recall` or `compact` to refuse
938
+ // the whole store, and both are a reason for `lint` to exit 1.
939
+ if (findings.length > 0)
940
+ throw new MemoryValidationError(findings[0]);
836
941
  this.checkIndexBudget();
837
942
  }
838
943
  /**
@@ -924,9 +1029,13 @@ export class MemoryStore {
924
1029
  // name, instead of surfacing as a `FileNotFoundError` out of the final write. `exist_ok`
925
1030
  // semantics make it free for every store that is already there.
926
1031
  this.ensureDirs();
1032
+ // Keyed by the FACT, not by `fact.name` (#122): two files can carry one name, and a map
1033
+ // keyed by name collapsed them — `indexBefore` reported 88 bytes for a 134-byte index, and
1034
+ // the second file's line was never subtracted. `byEviction` sorts a copy of this same
1035
+ // array, so the objects are the keys on both sides of the loop.
927
1036
  const sizes = new Map();
928
1037
  for (const fact of facts) {
929
- sizes.set(pyHashKey(fact.name), Buffer.byteLength(this.indexLine(fact), 'utf8'));
1038
+ sizes.set(fact, Buffer.byteLength(this.indexLine(fact), 'utf8'));
930
1039
  }
931
1040
  const all = [...sizes.values()];
932
1041
  if (reserve === null) {
@@ -952,9 +1061,14 @@ export class MemoryStore {
952
1061
  for (const fact of this.byEviction(facts)) {
953
1062
  if (size <= target)
954
1063
  break;
955
- const path = this.factPath(fact.name);
1064
+ // THE FILE THE FACT WAS READ FROM, not `facts/<name>.md` (#122). The two are the same
1065
+ // path for every fact this store wrote; they part on a hand-edited file whose
1066
+ // frontmatter names another fact, and there `factPath(fact.name)` moved the WRONG file
1067
+ // — the newest fact, under the name of the stalest — or raised for a name no file
1068
+ // carries. `lint` names the mismatch.
1069
+ const path = fact.source ?? this.factPath(fact.name);
956
1070
  pyReplace(path, pyJoin(this.root, 'archive', pyName(path)));
957
- const bytes = sizes.get(pyHashKey(fact.name));
1071
+ const bytes = sizes.get(fact);
958
1072
  size -= bytes;
959
1073
  archived.push({
960
1074
  name: fact.name,
@@ -1186,6 +1300,18 @@ export class MemoryStore {
1186
1300
  * bytes `dream` MEASURES and the bytes the store WRITES are already one function. That is
1187
1301
  * the code-shape difference the Python half had to manufacture, not a behaviour one.
1188
1302
  */
1303
+ /**
1304
+ * Every fact this store can read, and `{file, reason}` for each file it could not.
1305
+ *
1306
+ * THE DECLARED TOLERANT LISTING (job65/#119), `readable_facts` in the reference. One empty,
1307
+ * truncated, non-UTF-8 or unreadable fact file costs that file, not the store: the hook's
1308
+ * SessionStart index and native export read through this, as `Memory.recallOutcome` reads
1309
+ * through `recall(..., skipped)`. A `facts/` that cannot be listed still throws.
1310
+ */
1311
+ readableFacts() {
1312
+ const skipped = [];
1313
+ return { facts: this.facts(undefined, skipped), skipped };
1314
+ }
1189
1315
  internals() {
1190
1316
  return {
1191
1317
  facts: () => this.facts(),
@@ -1343,10 +1469,11 @@ export class MemoryStore {
1343
1469
  /**
1344
1470
  * Every fact on disk, parsed.
1345
1471
  *
1346
- * The `read_text` is OUTSIDE the try in the reference, so a fact file that is not UTF-8
1347
- * raises a bare `UnicodeDecodeError` rather than a `malformed fact file …` sentence. That
1348
- * ordering is reproduced: `pyReadText` raises `PyUnicodeDecodeError` with CPython's own
1349
- * message and it is not caught here.
1472
+ * AMENDED job65/#159: the `read_text` is now INSIDE the reference's try, so a fact file that
1473
+ * is not UTF-8 is `malformed fact file <name>: 'utf-8' codec can't decode …` — naming the
1474
+ * file — instead of the bare decoder message. `pyReadText` raises `PyUnicodeDecodeError`
1475
+ * with CPython's own text and it is wrapped here; a `PyOSError` from the read still escapes
1476
+ * as itself, as in the reference (`OSError` is not in its `except`).
1350
1477
  *
1351
1478
  * RULING — THE MESSAGE TEXT FOR A MALFORMED FILE. `_facts` interpolates the exception into
1352
1479
  * `malformed fact file {name}: {e}`, and that reaches the model. Three of the four shapes
@@ -1361,12 +1488,66 @@ export class MemoryStore {
1361
1488
  * carried as a must-differ `ruling` case in the conformance suite so it is re-measured on
1362
1489
  * every run instead of asserted once and forgotten.
1363
1490
  */
1364
- facts() {
1491
+ /**
1492
+ * `_facts`: every fact on disk, in `factPaths` order, with two hand-edit shapes tolerated.
1493
+ *
1494
+ * AN UNQUOTED TIMESTAMP IS READ AS ITS `str()` (#121). `yaml.safe_load` hands the reference
1495
+ * a `datetime.date` for `created: 2026-01-01` and a `datetime.datetime` for the longer
1496
+ * forms, while every file the store writes carries the quoted string. `recall` and `save`
1497
+ * never minded, but `compact` sorts `(last_recalled or created, name)` and the sort raised
1498
+ * `'<' not supported between instances of 'datetime.date' and 'str'` the moment one
1499
+ * hand-edited file sat beside a written one — the only remedy the budget refusal names,
1500
+ * dead on the store that named it. The scalar's `text` IS `str(value)` on the reference
1501
+ * (`isoformat()` for a date, `isoformat(' ')` for a datetime), so both runtimes read the
1502
+ * same string and the next write quotes it. Only timestamps are touched: an `int` or `bool`
1503
+ * in these fields is left exactly as the scalar cases in `suites/store.mjs` pin it.
1504
+ *
1505
+ * `findings`, when given, collects one sentence per shape `lint` should name — the coerced
1506
+ * timestamp, and a file whose name is not the fact's (#122) — without throwing, because
1507
+ * every other caller tolerates both. `lint` is the only caller that passes an array.
1508
+ */
1509
+ /**
1510
+ * `skipped`, when given, makes ONE BAD FILE COST THAT FILE (job65/#119): a file that is
1511
+ * malformed or cannot be read is pushed as `{file, reason}` (the sentence this would have
1512
+ * thrown) and every other file is still read. Only `recall`, `lookup` and `readableFacts`
1513
+ * pass it; a save, a compaction or a lint keeps the strict read.
1514
+ */
1515
+ facts(findings, skipped) {
1365
1516
  const out = [];
1366
1517
  for (const path of this.factPaths()) {
1367
- const text = pyReadText(path); // outside the try, exactly as in the reference
1518
+ if (skipped === undefined) {
1519
+ out.push(this.readFact(path, findings));
1520
+ continue;
1521
+ }
1522
+ try {
1523
+ out.push(this.readFact(path, findings));
1524
+ }
1525
+ catch (e) {
1526
+ if (!(e instanceof BantamError || e instanceof PyOSError))
1527
+ throw e;
1528
+ skipped.push({ file: basename(path), reason: e.message });
1529
+ }
1530
+ }
1531
+ return out;
1532
+ }
1533
+ /** One fact file, parsed, or the `malformed fact file` sentence for it (see `facts`). */
1534
+ readFact(path, findings) {
1535
+ const timestampText = (value) => value instanceof PyScalar && (value.ord.kind === 'date' || value.ord.kind === 'datetime')
1536
+ ? value.text
1537
+ : value;
1538
+ {
1368
1539
  const file = basename(path);
1369
1540
  const malformed = (reason) => new MemoryValidationError(`malformed fact file ${file}: ${reason}`);
1541
+ let text;
1542
+ try {
1543
+ text = pyReadText(path);
1544
+ }
1545
+ catch (e) {
1546
+ // #159: `UnicodeDecodeError` is a `ValueError`, which the reference's `except` names.
1547
+ if (e instanceof PyUnicodeDecodeError)
1548
+ throw malformed(e.message);
1549
+ throw e;
1550
+ }
1370
1551
  const parts = pySplit(text, '---\n', 2);
1371
1552
  if (parts.length !== 3) {
1372
1553
  throw malformed(`not enough values to unpack (expected 3, got ${parts.length})`);
@@ -1385,21 +1566,51 @@ export class MemoryStore {
1385
1566
  if (!(key in meta))
1386
1567
  throw malformed(`'${key}'`);
1387
1568
  }
1388
- const rawCreated = meta['created'];
1389
- out.push({
1390
- name: meta['name'],
1391
- description: meta['description'],
1569
+ const name = meta['name'];
1570
+ const stamps = {};
1571
+ for (const key of ['created', 'last_recalled']) {
1572
+ const raw = meta[key];
1573
+ const value = timestampText(raw);
1574
+ if (findings !== undefined && value !== raw) {
1575
+ findings.push(`fact '${pyText(name)}' in ${file}: ${key} is the unquoted timestamp ${value}, ` +
1576
+ `read as '${value}'; quote it`);
1577
+ }
1578
+ stamps[key] = value;
1579
+ }
1580
+ // `path.stem`, spelled the way `archived()` spells it.
1581
+ const suffix = pySuffix(file);
1582
+ const stem = suffix === '' ? file : file.slice(0, file.length - suffix.length);
1583
+ if (findings !== undefined && stem !== pyText(name)) {
1584
+ findings.push(`fact '${pyText(name)}' in ${file}: the file name does not match the fact name; ` +
1585
+ `rename the file to ${pyText(name)}.md or the fact to ${stem}`);
1586
+ }
1587
+ const rawCreated = stamps['created'];
1588
+ // #155: a description that is not one line is READ as one — each run of `NOT_ONE_LINE`
1589
+ // is one space — and `lint` names it, like the other hand-edit shapes.
1590
+ let description = meta['description'];
1591
+ const broken = typeof description === 'string' ? notOneLine(description) : null;
1592
+ if (broken !== null) {
1593
+ description = description.replace(NOT_ONE_LINE, ' ');
1594
+ if (findings !== undefined) {
1595
+ findings.push(`fact '${pyText(name)}' in ${file}: the description is not one line ` +
1596
+ `(it holds the control character ${broken}), read as '${description}'; make it one line`);
1597
+ }
1598
+ }
1599
+ return {
1600
+ name,
1601
+ description,
1392
1602
  type: meta['type'],
1393
1603
  body: pyStrip(parts[2]),
1394
1604
  links: pyList(meta['links']),
1395
- last_recalled: meta['last_recalled'] ?? null,
1605
+ last_recalled: stamps['last_recalled'] ?? null,
1396
1606
  // `or`, not `??`: every FALSY value falls back to the mtime, and that is now more
1397
1607
  // than the empty string — `created: 0`, `created: false` and `created: 0.0` are all
1398
- // falsy in Python too, while `created: 2026-08-23` is a truthy `date` and is kept.
1608
+ // falsy in Python too, while `created: 2026-08-23` is a truthy `date` (now its text)
1609
+ // and is kept.
1399
1610
  created: pyTruthy(rawCreated) ? rawCreated : pyMtimeDate(path),
1400
- });
1611
+ source: path,
1612
+ };
1401
1613
  }
1402
- return out;
1403
1614
  }
1404
1615
  /**
1405
1616
  * Date the recall.
@@ -1411,14 +1622,51 @@ export class MemoryStore {
1411
1622
  */
1412
1623
  stamp(fact) {
1413
1624
  fact.last_recalled = this.today();
1414
- this.writeFact(fact);
1625
+ // job65/#123: BEST-EFFORT. A read-only store (chmod 555, a read-only mount, a CI cache)
1626
+ // used to fail the whole recall here, after the hits were computed. A `PyOSError` from the
1627
+ // write now leaves the old date on disk and the recall answers; see the reference's
1628
+ // `_stamp` for why it is silent.
1629
+ try {
1630
+ this.writeFact(fact);
1631
+ }
1632
+ catch (e) {
1633
+ if (!(e instanceof PyOSError))
1634
+ throw e;
1635
+ }
1415
1636
  }
1416
- /** Write via `<name>.md.tmp` and `os.replace`, so a reader never sees a half-written fact. */
1637
+ /**
1638
+ * Write via a PER-WRITER temp and `os.replace`, so a reader never sees a half-written fact
1639
+ * and two writers never share a temp (job65/#161).
1640
+ *
1641
+ * WAS `<name>.md.tmp`, one temp per FACT: two servers saving one name raced on it (the
1642
+ * second `replace` found nothing — `[Errno 2] … 'shared-name.md.tmp'`), and a write that
1643
+ * died half-way left the temp beside the facts. NOW the shape `clockOut` and the hook ledger
1644
+ * use (J65-1, J65-4): `<name>.md.<pid>.<8 hex>.tmp`, created with `wx` (O_EXCL), renamed
1645
+ * onto the fact, and unlinked on any failure before the error goes on up. `*.md` listings
1646
+ * never see it.
1647
+ */
1417
1648
  writeFact(fact) {
1418
1649
  const path = this.factPath(fact.name);
1419
- const tmp = pyWithSuffix(path, '.md.tmp');
1420
- pyWriteText(tmp, formatFact(fact));
1421
- pyReplace(tmp, path);
1650
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
1651
+ try {
1652
+ try {
1653
+ writeFileSync(tmp, pyNewlineOut(formatFact(fact)), { encoding: 'utf8', flag: 'wx' });
1654
+ }
1655
+ catch (e) {
1656
+ throw asPyOSError(e, tmp, undefined, 'crt');
1657
+ }
1658
+ pyReplace(tmp, path);
1659
+ }
1660
+ catch (e) {
1661
+ try {
1662
+ pyUnlink(tmp);
1663
+ }
1664
+ catch (cleanup) {
1665
+ if (!(cleanup instanceof PyOSError))
1666
+ throw cleanup;
1667
+ }
1668
+ throw e;
1669
+ }
1422
1670
  }
1423
1671
  /**
1424
1672
  * Write `index.md` from the facts on disk. THE OP THE ORIGINAL DEFECT DESTROYED.
@@ -45,6 +45,24 @@ export declare function shlexJoin(command: readonly string[]): string;
45
45
  * batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
46
46
  */
47
47
  export declare function runInstaller(command: readonly string[]): [number, string];
48
+ /** What one spawned command came back with: both streams apart, the status, and any spawn error. */
49
+ export interface Spawned {
50
+ readonly status: number | null;
51
+ readonly stdout: string;
52
+ readonly stderr: string;
53
+ readonly error: (Error & {
54
+ code?: string;
55
+ }) | null;
56
+ }
57
+ /**
58
+ * One argv, spawned and captured, with the Windows shim handling `runInstaller` established
59
+ * (J65-19 moved it here so `--update`'s version check could spawn npm through the same rule).
60
+ *
61
+ * `timeoutMs` is `spawnSync`'s own deadline: the child is killed when it passes, and the result
62
+ * carries an `error` whose `code` is `ETIMEDOUT` — the one fact `selfupdate.fetchIndex` turns
63
+ * into its timeout refusal. No deadline when it is omitted, which is what an installer gets.
64
+ */
65
+ export declare function spawnCommand(command: readonly string[], timeoutMs?: number): Spawned;
48
66
  /**
49
67
  * -1 when the index is ahead, 0 when they agree, 1 when the installed version is ahead.
50
68
  *
@@ -76,17 +76,38 @@ export function shlexJoin(command) {
76
76
  * batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
77
77
  */
78
78
  export function runInstaller(command) {
79
- const [program, ...args] = command;
80
- const win = process.platform === 'win32';
81
- const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
82
- const done = spawnSync(program ?? '', quoted, { encoding: 'utf8', shell: win });
79
+ const done = spawnCommand(command);
83
80
  if (done.error) {
84
81
  // A missing `npm` is not a stack trace: it is a command that failed, and `COMMAND_FAILED`
85
82
  // is the sentence that says so and hands back what went wrong. 127 is the shell's own
86
83
  // code for "command not found", so the number means something to the person reading it.
87
84
  return [127, done.error.message];
88
85
  }
89
- return [done.status ?? 1, `${done.stdout ?? ''}${done.stderr ?? ''}`];
86
+ return [done.status ?? 1, `${done.stdout}${done.stderr}`];
87
+ }
88
+ /**
89
+ * One argv, spawned and captured, with the Windows shim handling `runInstaller` established
90
+ * (J65-19 moved it here so `--update`'s version check could spawn npm through the same rule).
91
+ *
92
+ * `timeoutMs` is `spawnSync`'s own deadline: the child is killed when it passes, and the result
93
+ * carries an `error` whose `code` is `ETIMEDOUT` — the one fact `selfupdate.fetchIndex` turns
94
+ * into its timeout refusal. No deadline when it is omitted, which is what an installer gets.
95
+ */
96
+ export function spawnCommand(command, timeoutMs) {
97
+ const [program, ...args] = command;
98
+ const win = process.platform === 'win32';
99
+ const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
100
+ const done = spawnSync(program ?? '', quoted, {
101
+ encoding: 'utf8',
102
+ shell: win,
103
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
104
+ });
105
+ return {
106
+ status: done.status,
107
+ stdout: done.stdout ?? '',
108
+ stderr: done.stderr ?? '',
109
+ error: done.error ?? null,
110
+ };
90
111
  }
91
112
  /**
92
113
  * A dotted version as something orderable, deterministically, without claiming PEP 440.
@@ -79,9 +79,55 @@ export class ArgumentTypeError extends Error {
79
79
  * a second hand-written copy of a conversion rule is the defect this module was built from.
80
80
  */
81
81
  export function pyIntStrict(text) {
82
- if (!/^\s*[+-]?\d+(?:_\d+)*\s*$/.test(text))
82
+ // job66/#174: CPython's `int(str)`, measured on 3.12 rather than recalled. It first maps
83
+ // every NON-ASCII character through `_PyUnicode_TransformDecimalAndSpaceToASCII`: a Unicode
84
+ // decimal digit (category Nd, any script, astral included) becomes its ASCII digit and a
85
+ // Unicode space becomes ' '. ASCII passes through untouched, so the only ASCII whitespace
86
+ // the parser then skips is ` \t\n\v\f\r` — `int('3\x1c')` is a ValueError although
87
+ // `'\x1c'.isspace()` is true — and U+FEFF is not a space at all. Single underscores may sit
88
+ // BETWEEN digits only, and more than 4300 digits is a ValueError (`sys.int_info
89
+ // .default_max_str_digits`). WAS: `/^\s*[+-]?\d+(?:_\d+)*\s*$/` without the `u` flag —
90
+ // ASCII digits only, so `--k ٣` was exit 2 here and served on the reference, while JS
91
+ // `\s` admitted U+FEFF and refused U+0085, both the opposite of CPython.
92
+ let ascii = '';
93
+ for (const ch of text) {
94
+ const cp = ch.codePointAt(0);
95
+ if (cp < 0x80)
96
+ ascii += ch;
97
+ else if (PY_UNICODE_SPACE.has(cp))
98
+ ascii += ' ';
99
+ else if (/\p{Nd}/u.test(ch))
100
+ ascii += String(ndValue(cp));
101
+ else
102
+ throw new TypeError('not an int');
103
+ }
104
+ const match = /^[ \t\n\v\f\r]*([+-]?)([0-9]+(?:_[0-9]+)*)[ \t\n\v\f\r]*$/.exec(ascii);
105
+ if (match === null)
83
106
  throw new TypeError('not an int');
84
- return Number(text.trim().replace(/_/g, ''));
107
+ const digits = match[2].replace(/_/g, '');
108
+ if (digits.length > PY_INT_MAX_STR_DIGITS)
109
+ throw new TypeError('not an int');
110
+ const value = Number(`${match[1]}${digits}`);
111
+ return value === 0 ? 0 : value; // `int('-0')` is 0, never JavaScript's -0
112
+ }
113
+ /** `sys.int_info.default_max_str_digits`: CPython refuses a longer decimal string. */
114
+ const PY_INT_MAX_STR_DIGITS = 4300;
115
+ /** The non-ASCII code points `str.isspace()` is true for (CPython 3.12, Unicode 15.0). */
116
+ const PY_UNICODE_SPACE = new Set([
117
+ 0x85, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008,
118
+ 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000,
119
+ ]);
120
+ /**
121
+ * `unicodedata.decimal(ch)` for a category-Nd code point. Unicode allocates every decimal
122
+ * digit set as ten contiguous code points from 0 to 9, and sets that touch are back to back
123
+ * (the five mathematical sets at U+1D7CE), so the value is the offset from the start of the
124
+ * contiguous Nd run, modulo ten.
125
+ */
126
+ function ndValue(cp) {
127
+ let start = cp;
128
+ while (start > 0 && /\p{Nd}/u.test(String.fromCodePoint(start - 1)))
129
+ start -= 1;
130
+ return (cp - start) % 10;
85
131
  }
86
132
  /** `_get_action_name`: the name argparse puts after `argument `. */
87
133
  const actionName = (action) => action.optionStrings.length > 0