bantamkit-mcp 0.35.7 → 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 +247 -41
  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
@@ -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.
@@ -2,6 +2,24 @@ import { type PyValue } from './pyjson.js';
2
2
  export declare class PyJsonSchemaUnsupported extends Error {
3
3
  constructor(message: string);
4
4
  }
5
+ /**
6
+ * A schema the validator will not resolve, answered — not raised. `contract.schemaError`
7
+ * returns the message as the feedback sentence, so `validate_json` says `{"valid": false,
8
+ * "feedback": …}` where it used to say `isError` (#125). The Python side prints the same
9
+ * sentence from `contract._unsupported_schema` and from the `referencing` exceptions.
10
+ */
11
+ export declare class PyJsonSchemaRefusal extends Error {
12
+ constructor(message: string);
13
+ }
14
+ /** The drafts this port validates. Draft 3 is looked up so it can be refused by name. */
15
+ export type Draft = 4 | 6 | 7 | 2019 | 2020;
16
+ /**
17
+ * `urllib.parse.urlsplit(uri).geturl()` — `_utils.URIDict.normalize`, the function every
18
+ * `$schema` goes through before `_META_SCHEMAS` is consulted. 3.12 strips leading C0/space,
19
+ * removes every tab/CR/LF, lowercases the scheme, and `urlunsplit` drops an empty query and
20
+ * an empty fragment. Measured in `tools/conformance/suites/validate.mjs` (`dialect/…`).
21
+ */
22
+ export declare function pyUrlNormalize(uri: string): string;
5
23
  /**
6
24
  * `jsonschema.exceptions.ValidationError`, narrowed to the fields the relevance heuristic
7
25
  * and `schema_error` read.
@@ -11,6 +29,9 @@ export declare class PyJsonSchemaUnsupported extends Error {
11
29
  * is what `schema_error` interpolates. Keeping the two apart is not pedantry: `best_match`
12
30
  * ranks context errors by their relative paths and then `schema_error` prints an absolute
13
31
  * one, and a port that conflated them would name the wrong field inside every `anyOf`.
32
+ *
33
+ * `draft` is the dialect the keyword ran under — `error._type_checker`, which
34
+ * `_matches_type` consults, and the one place the dialect reaches `best_match`.
14
35
  */
15
36
  export interface PyValidationError {
16
37
  message: string;
@@ -21,11 +42,69 @@ export interface PyValidationError {
21
42
  path: (string | number)[];
22
43
  context: PyValidationError[];
23
44
  parent: PyValidationError | null;
45
+ draft: Draft;
24
46
  }
25
47
  export declare function absolutePath(error: PyValidationError): (string | number)[];
26
48
  /** `jsonschema._utils.equal`: JSON equality that refuses `True == 1`. */
27
49
  export declare function pyEqual(a: PyValue, b: PyValue): boolean;
28
- declare function iterErrors(instance: PyValue, schema: PyValue | boolean): PyValidationError[];
50
+ /** What a keyword needs beyond its own value: the root resource and the dialect in force. */
51
+ interface Ctx {
52
+ root: PyValue;
53
+ draft: Draft;
54
+ /** The ROOT's dialect — anchors are found by the resource's specification, not the leaf's. */
55
+ rootDraft: Draft;
56
+ }
57
+ declare function iterErrors(instance: PyValue, schema: PyValue | boolean, ctx?: Ctx): PyValidationError[];
58
+ /**
59
+ * `re.search(pattern, string)` — the reference's dialect, translated, bounded (#131, #132).
60
+ *
61
+ * THE DIALECT IS PYTHON'S. Until job65 this was `new RegExp(pattern, 'u')` with a silent
62
+ * fallback to a non-`u` RegExp, so the two runtimes disagreed on 5 of 5 of #131's probes:
63
+ * `\d`/`\w` are Unicode-wide in `re` and ASCII in JS, `$` matches before a final newline in
64
+ * `re`, `(?P<x>…)` refused here, and the fallback read `\Z` as a literal `Z`. JSON Schema
65
+ * recommends ECMA-262, but the product's contract is the reference's verdict, so the pattern
66
+ * is parsed the way `re._parser` parses it and re-emitted as a `v`-mode RegExp whose every
67
+ * construct means what it means in `re`:
68
+ *
69
+ * - `\d` → `\p{Nd}`; `\w` → `[\p{L}\p{N}_]` (`str.isalnum() or "_"`); `\s` → the 29
70
+ * codepoints `str.isspace()` names (JS's `\s` adds U+FEFF and drops U+001C-1F, U+0085);
71
+ * `\b`/`\B` are look-arounds over that `\w`, and `\B` never matches the empty string
72
+ * (`SRE_AT_UNI_NON_BOUNDARY`, CPython 3.11-3.13). Measured over every codepoint: the
73
+ * three classes are equal except for the codepoints Unicode 16 assigned after the
74
+ * reference's Unicode 15 (5,004 for `\w`, 80 for `\d`, 0 for `\s`).
75
+ * - `.` → `[^\n]` (JS also stops at `\r`, U+2028, U+2029); `$` → `(?=\n?$)`; `\A` → `^`;
76
+ * `\Z` → `$`; `(?P<n>…)` → a group; `{,n}` → `{0,n}`; a `{` that is not a repeat is a
77
+ * literal; every literal is emitted as `\u{…}`, so no JS syntax rule can reach it.
78
+ *
79
+ * A pattern `re` itself refuses is a `PyReError` (what `re.error` is on the reference: the
80
+ * schema check raises it). Constructs with no faithful translation — inline flags,
81
+ * look-behind (fixed-width in `re`, any width in JS), backreferences (a JS backreference to
82
+ * a group that did not take part matches the empty string; `re`'s fails), atomic groups,
83
+ * possessive quantifiers, conditional groups and `\N{…}` (no Unicode name table here) — are
84
+ * parsed, so that a broken pattern is still an error, and then REFUSED on both runtimes with
85
+ * one sentence (`unsupportedPatternSentence`; `contract._pattern_unsupported` on the
86
+ * reference), never answered differently.
87
+ */
88
+ export declare class PyReError extends Error {
89
+ constructor(message: string);
90
+ }
91
+ interface PyReTranslation {
92
+ /** The `v`-mode source, or `null` when the pattern uses a construct refused above. */
93
+ source: string | null;
94
+ }
95
+ /** `re._parser.parse(pattern)` + `fix_flags`, re-emitted for a `v`-mode RegExp. */
96
+ export declare function translatePyPattern(pattern: string): PyReTranslation;
97
+ /** The sentence both runtimes answer for a pattern outside the shared dialect (#131). */
98
+ export declare function unsupportedPatternSentence(pattern: string): string;
99
+ /** The milliseconds a document's patterns may spend, in total, before the validation is refused (#132). */
100
+ export declare const PATTERN_BUDGET_MS = 500;
101
+ /** The sentence both runtimes answer when a document's patterns run past the budget (#132). */
102
+ export declare function patternBudgetSentence(pattern: string): string;
103
+ /**
104
+ * At most `limit` codepoints of `text`, then `…(N more)` (#153). Codepoints, not UTF-16
105
+ * units, because `len()` on the reference counts codepoints.
106
+ */
107
+ export declare function clipText(text: string, limit?: number): string;
29
108
  /**
30
109
  * `jsonschema.exceptions.best_match`.
31
110
  *
@@ -38,13 +117,15 @@ declare function iterErrors(instance: PyValue, schema: PyValue | boolean): PyVal
38
117
  * parent stands.
39
118
  */
40
119
  export declare function bestMatch(errors: PyValidationError[]): PyValidationError | null;
41
- export declare function checkSchema(schema: PyValue | boolean, where?: string): void;
120
+ export declare function checkSchema(schema: PyValue | boolean, where?: string, draft?: Draft): void;
42
121
  /**
43
122
  * `jsonschema.validate(instance, schema)` reduced to what `contract.schema_error` needs:
44
123
  * the single error `best_match` chooses, or `null`.
45
124
  *
46
- * `check_schema` runs FIRST, as it does in the reference. See `checkSchema` for what that
47
- * means here and for the five wrong answers it replaced.
125
+ * In the reference's order: `validator_for` (the dialect, which raises on a non-string
126
+ * `$schema`), then the one-resource pre-scan `contract.py` runs before `validate`, then
127
+ * `check_schema`, then the instance. A `PyJsonSchemaRefusal` out of here is an ANSWER —
128
+ * `contract.schemaError` returns its sentence — and a `PyJsonSchemaUnsupported` is a raise.
48
129
  */
49
130
  export declare function validate(instance: PyValue, schema: PyValue): PyValidationError | null;
50
131
  export { iterErrors };