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
@@ -184,8 +184,23 @@ export function loadGrants(projectStore) {
184
184
  throw new MemoryValidationError(`invalid memory config ${configPath}: not a file`);
185
185
  }
186
186
  let data;
187
+ let text;
187
188
  try {
188
- data = parseConfigDocument(pyReadText(configPath));
189
+ text = pyReadText(configPath);
190
+ }
191
+ catch (e) {
192
+ throw new MemoryValidationError(`invalid memory config ${configPath}: ${e instanceof Error ? e.message : String(e)}`);
193
+ }
194
+ // job65/#158: a tab where a YAML token would start is refused BEFORE the reader runs, in
195
+ // the sentence `load_grants` gives for PyYAML's own refusal of the same byte. The reader
196
+ // below accepted `[ \t]*` as indentation, so a tab-indented grant list was layered here
197
+ // and refused at startup by the reference: one config, two verdicts.
198
+ const tab = firstTokenTab(text);
199
+ if (tab !== null) {
200
+ throw new MemoryValidationError(`invalid memory config ${configPath}: ${tabSentence(tab[0], tab[1])}`);
201
+ }
202
+ try {
203
+ data = parseConfigDocument(text);
189
204
  }
190
205
  catch (e) {
191
206
  // `except (yaml.YAMLError, OSError, UnicodeDecodeError)`. `pyReadText` raises the last
@@ -206,12 +221,97 @@ export function loadGrants(projectStore) {
206
221
  for (const entry of raw.items) {
207
222
  const resolved = pyResolve(pyJoin(pyParent(configPath), entry.value));
208
223
  if (!pyIsDir(resolved)) {
209
- throw new MemoryValidationError(`granted store does not exist: ${resolved} (from ${configPath})`);
224
+ // job65/#184: a path that EXISTS but is not a directory is its own sentence; a regular
225
+ // file used to be called nonexistent. `pyExists` raises for EACCES exactly as `pyIsDir`.
226
+ const shape = pyExists(resolved) ? 'is not a directory' : 'does not exist';
227
+ throw new MemoryValidationError(`granted store ${shape}: ${resolved} (from ${configPath})`);
210
228
  }
211
229
  grants.push(resolved);
212
230
  }
213
231
  return grants;
214
232
  }
233
+ // ------------------------------------------------------------------------- the config
234
+ /**
235
+ * job65/#158 — the sentence both runtimes give for a tab PyYAML will not scan. Python
236
+ * rewrites PyYAML's `found character '\t' that cannot start any token` into exactly this,
237
+ * from the error's own mark; this port finds the same byte with `firstTokenTab`.
238
+ */
239
+ export function tabSentence(line, column) {
240
+ return `line ${line}, column ${column}: a tab cannot start a YAML token; indent with spaces`;
241
+ }
242
+ /**
243
+ * The first tab PyYAML's scanner would refuse, as 1-based `[line, column]`, or `null`.
244
+ *
245
+ * MEASURED, not read off the spec: PyYAML 6.0.3's `scan_to_next_token` skips SPACES only, and
246
+ * a plain scalar stops at a tab, so every tab outside a quoted scalar and outside a comment
247
+ * is `found character '\t' that cannot start any token` — leading indentation, after `:`,
248
+ * after `-`, inside `[ ]`, between a value and its comment, trailing, alone on a line, and
249
+ * after `---`. A tab inside `'...'`/`"..."` or after a ` #` is accepted. The probe battery
250
+ * is in `.shiftwork/notes-job65/J65-10.md`. A quote OPENS a scalar only where a token can
251
+ * start (line start, or after a space, `[`, `{` or `,`) — `it's` is a plain scalar — and an
252
+ * open quote carries across lines, as a flow scalar does.
253
+ */
254
+ export function firstTokenTab(text) {
255
+ const lines = text.split('\n');
256
+ let quote = null;
257
+ let flow = 0;
258
+ let plain = false; // inside a plain scalar, where a quote, `[` or ` ` is content
259
+ let plainIndent = 0; // the indentation of the line that plain scalar started on
260
+ for (let n = 0; n < lines.length; n += 1) {
261
+ const line = lines[n];
262
+ // A plain scalar continues onto a MORE-indented next line (a multi-line plain scalar);
263
+ // anything else starts a token. An approximation of PyYAML's indent rule.
264
+ const indent = line.length - line.replace(/^ +/, '').length;
265
+ if (plain && indent <= plainIndent)
266
+ plain = false;
267
+ for (let i = 0; i < line.length; i += 1) {
268
+ const ch = line[i];
269
+ if (quote !== null) {
270
+ if (quote === '"' && ch === '\\')
271
+ i += 1;
272
+ else if (quote === "'" && ch === "'" && line[i + 1] === "'")
273
+ i += 1;
274
+ else if (ch === quote)
275
+ quote = null;
276
+ continue;
277
+ }
278
+ if (ch === '\t')
279
+ return [n + 1, i + 1];
280
+ const next = line[i + 1] ?? ' ';
281
+ if (ch === ' ') {
282
+ if (plain && next === '#')
283
+ plain = false;
284
+ continue;
285
+ }
286
+ if (plain) {
287
+ if (ch === ':' && (next === ' ' || next === '\t' || (flow > 0 && ',[]{}'.includes(next))))
288
+ plain = false;
289
+ else if (flow > 0 && ',[]{}'.includes(ch))
290
+ plain = false;
291
+ else
292
+ continue;
293
+ }
294
+ // A token starts here.
295
+ if (ch === '#')
296
+ break;
297
+ if (ch === "'" || ch === '"')
298
+ quote = ch;
299
+ else if (ch === '[' || ch === '{')
300
+ flow += 1;
301
+ else if ((ch === ']' || ch === '}') && flow > 0)
302
+ flow -= 1;
303
+ else if (ch === ',' && flow > 0)
304
+ continue;
305
+ else if ((ch === '-' || ch === ':' || ch === '?') && (next === ' ' || next === '\t'))
306
+ continue;
307
+ else {
308
+ plain = true;
309
+ plainIndent = indent;
310
+ }
311
+ }
312
+ }
313
+ return null;
314
+ }
215
315
  class ConfigParseError extends Error {
216
316
  }
217
317
  function parseConfigDocument(text) {
@@ -147,9 +147,11 @@ export type PyOrder = {
147
147
  * carried as a must-differ case rather than claimed.
148
148
  * - `yaml` (`!`, `&`, `*`) RAISES, and CPython does NOT agree for all three: `k: !` is a
149
149
  * tag PROPERTY on an empty node in the real scanner and answers `None`, while `&` and
150
- * `*` are an anchor and an alias and answer with a `ScannerError`. This codec has no
151
- * scanner and sees three plain scalars. All three are must-differ cases; `!` is the one
152
- * where CPython answers and this port refuses, and it is written down as that rather
153
- * than filed with the other two.
150
+ * `*` are an anchor and an alias and answer with a `ScannerError`. All three are
151
+ * must-differ cases; `!` is the one where CPython answers and this port refuses, and it
152
+ * is written down as that rather than filed with the other two. Since #120 the fact
153
+ * codec refuses a value STARTING with any of the three before it reaches this function
154
+ * (`REFUSED_INDICATORS` in `factfile.ts`), so the raise a store sees is a
155
+ * `FactParseError` naming the indicator; this arm still answers for any other caller.
154
156
  */
155
157
  export declare function constructPlain(value: string): string | null | PyScalar;
@@ -832,10 +832,12 @@ function constructTimestamp(raw) {
832
832
  * carried as a must-differ case rather than claimed.
833
833
  * - `yaml` (`!`, `&`, `*`) RAISES, and CPython does NOT agree for all three: `k: !` is a
834
834
  * tag PROPERTY on an empty node in the real scanner and answers `None`, while `&` and
835
- * `*` are an anchor and an alias and answer with a `ScannerError`. This codec has no
836
- * scanner and sees three plain scalars. All three are must-differ cases; `!` is the one
837
- * where CPython answers and this port refuses, and it is written down as that rather
838
- * than filed with the other two.
835
+ * `*` are an anchor and an alias and answer with a `ScannerError`. All three are
836
+ * must-differ cases; `!` is the one where CPython answers and this port refuses, and it
837
+ * is written down as that rather than filed with the other two. Since #120 the fact
838
+ * codec refuses a value STARTING with any of the three before it reaches this function
839
+ * (`REFUSED_INDICATORS` in `factfile.ts`), so the raise a store sees is a
840
+ * `FactParseError` naming the indicator; this arm still answers for any other caller.
839
841
  */
840
842
  export function constructPlain(value) {
841
843
  const tag = resolveImplicitTag(value);
@@ -90,6 +90,21 @@ export declare const VALID_TYPES: readonly ["feedback", "project", "reference",
90
90
  * `store.DURABLE_TYPES`.
91
91
  */
92
92
  export declare const DURABLE_TYPES: readonly ["feedback", "user"];
93
+ /**
94
+ * THE LONGEST NAME A SAVE ACCEPTS (job65/#156): `NAME_MAX_CHARS` in the reference, the same
95
+ * number. The fact lands as `<name>.md` through `<name>.md.<pid>.<8 hex>.tmp` (#161), so the
96
+ * name leaves room for that suffix under the 255-byte file-name limit. WAS: no cap, and a
97
+ * 301-character name came back as a raw `[Errno 63] File name too long: '<absolute path>'`.
98
+ * `NAME_RE` admits ASCII only, so characters and bytes are one count.
99
+ */
100
+ export declare const NAME_MAX_CHARS = 128;
101
+ /** `U+XXXX` of the first lone surrogate in `text`, or `null` when it is all text. */
102
+ export declare function loneSurrogate(text: string): string | null;
103
+ /** One fact file left out of a tolerant read, and the store's own sentence for why (#119). */
104
+ export interface SkippedFact {
105
+ file: string;
106
+ reason: string;
107
+ }
93
108
  export declare const DUPLICATE_JACCARD = 0.5;
94
109
  export declare const RECALL_MIN_SCORE_RATIO = 0;
95
110
  export declare const MIN_RATIO_RANGE = "recall min-score ratio must be between 0.0 and 1.0";
@@ -318,8 +333,19 @@ export declare class MemoryStore {
318
333
  * the failing leaf, or that strerror, would be a divergence manufactured by mkdir's
319
334
  * internals. `this.root` is the path the caller named and both sides agree on it, which is
320
335
  * what lets one conformance case pin this line byte for byte.
336
+ *
337
+ * AMENDED job65/#141: THE SENTENCE ABOVE IS NOW THE "NOT THERE" ARM ONLY. A `chmod 000`
338
+ * store answered it too, about a directory that exists. The second arm is still an outcome:
339
+ * the mkdir failed with one of `NOT_WRITABLE_CODES` AND `this.root` is a directory that is
340
+ * there, and then the refusal is `memory store is not writable: <root>: <strerror>; the
341
+ * directory exists but cannot be written to, so nothing was written`. The cwd-`/` divergence
342
+ * measured above is about a root that is NOT there, so it never reaches this arm.
321
343
  */
322
344
  private ensureDirs;
345
+ /** `_present`: is `this.root` a directory that is there? "Not allowed to look" is "there". */
346
+ private present;
347
+ /** `_unwritable`: one sentence for a store that exists and refused a write (#141). */
348
+ private unwritable;
323
349
  /**
324
350
  * Write a fact, or refuse it as a near-duplicate of one already here.
325
351
  *
@@ -356,7 +382,7 @@ export declare class MemoryStore {
356
382
  * reference's `not 0.0 <= min_ratio <= 1.0` refuses it. That is a real mutant (M7b) and
357
383
  * it is killed on both sides.
358
384
  */
359
- recall(query: string, k?: number | null, stamp?: boolean, minRatio?: number): Fact[];
385
+ recall(query: string, k?: number | null, stamp?: boolean, minRatio?: number, skipped?: SkippedFact[]): Fact[];
360
386
  /**
361
387
  * The fact whose `name` field is exactly `name`, or `null`; no scoring, no ranking.
362
388
  *
@@ -373,7 +399,7 @@ export declare class MemoryStore {
373
399
  * order, which is the order `recall` breaks a tie in. `stamp` dates the hit through
374
400
  * `stamp()` exactly as `recall` dates its hits; a miss writes nothing.
375
401
  */
376
- lookup(name: string, stamp?: boolean): Fact | null;
402
+ lookup(name: string, stamp?: boolean, skipped?: SkippedFact[]): Fact | null;
377
403
  /**
378
404
  * `len(store._facts())` at the operator CLI, which is the only caller.
379
405
  *
@@ -610,6 +636,18 @@ export declare class MemoryStore {
610
636
  * bytes `dream` MEASURES and the bytes the store WRITES are already one function. That is
611
637
  * the code-shape difference the Python half had to manufacture, not a behaviour one.
612
638
  */
639
+ /**
640
+ * Every fact this store can read, and `{file, reason}` for each file it could not.
641
+ *
642
+ * THE DECLARED TOLERANT LISTING (job65/#119), `readable_facts` in the reference. One empty,
643
+ * truncated, non-UTF-8 or unreadable fact file costs that file, not the store: the hook's
644
+ * SessionStart index and native export read through this, as `Memory.recallOutcome` reads
645
+ * through `recall(..., skipped)`. A `facts/` that cannot be listed still throws.
646
+ */
647
+ readableFacts(): {
648
+ facts: Fact[];
649
+ skipped: SkippedFact[];
650
+ };
613
651
  internals(): StoreInternals;
614
652
  private factPath;
615
653
  private indexLine;
@@ -694,10 +732,11 @@ export declare class MemoryStore {
694
732
  /**
695
733
  * Every fact on disk, parsed.
696
734
  *
697
- * The `read_text` is OUTSIDE the try in the reference, so a fact file that is not UTF-8
698
- * raises a bare `UnicodeDecodeError` rather than a `malformed fact file …` sentence. That
699
- * ordering is reproduced: `pyReadText` raises `PyUnicodeDecodeError` with CPython's own
700
- * message and it is not caught here.
735
+ * AMENDED job65/#159: the `read_text` is now INSIDE the reference's try, so a fact file that
736
+ * is not UTF-8 is `malformed fact file <name>: 'utf-8' codec can't decode …` — naming the
737
+ * file — instead of the bare decoder message. `pyReadText` raises `PyUnicodeDecodeError`
738
+ * with CPython's own text and it is wrapped here; a `PyOSError` from the read still escapes
739
+ * as itself, as in the reference (`OSError` is not in its `except`).
701
740
  *
702
741
  * RULING — THE MESSAGE TEXT FOR A MALFORMED FILE. `_facts` interpolates the exception into
703
742
  * `malformed fact file {name}: {e}`, and that reaches the model. Three of the four shapes
@@ -712,7 +751,33 @@ export declare class MemoryStore {
712
751
  * carried as a must-differ `ruling` case in the conformance suite so it is re-measured on
713
752
  * every run instead of asserted once and forgotten.
714
753
  */
754
+ /**
755
+ * `_facts`: every fact on disk, in `factPaths` order, with two hand-edit shapes tolerated.
756
+ *
757
+ * AN UNQUOTED TIMESTAMP IS READ AS ITS `str()` (#121). `yaml.safe_load` hands the reference
758
+ * a `datetime.date` for `created: 2026-01-01` and a `datetime.datetime` for the longer
759
+ * forms, while every file the store writes carries the quoted string. `recall` and `save`
760
+ * never minded, but `compact` sorts `(last_recalled or created, name)` and the sort raised
761
+ * `'<' not supported between instances of 'datetime.date' and 'str'` the moment one
762
+ * hand-edited file sat beside a written one — the only remedy the budget refusal names,
763
+ * dead on the store that named it. The scalar's `text` IS `str(value)` on the reference
764
+ * (`isoformat()` for a date, `isoformat(' ')` for a datetime), so both runtimes read the
765
+ * same string and the next write quotes it. Only timestamps are touched: an `int` or `bool`
766
+ * in these fields is left exactly as the scalar cases in `suites/store.mjs` pin it.
767
+ *
768
+ * `findings`, when given, collects one sentence per shape `lint` should name — the coerced
769
+ * timestamp, and a file whose name is not the fact's (#122) — without throwing, because
770
+ * every other caller tolerates both. `lint` is the only caller that passes an array.
771
+ */
772
+ /**
773
+ * `skipped`, when given, makes ONE BAD FILE COST THAT FILE (job65/#119): a file that is
774
+ * malformed or cannot be read is pushed as `{file, reason}` (the sentence this would have
775
+ * thrown) and every other file is still read. Only `recall`, `lookup` and `readableFacts`
776
+ * pass it; a save, a compaction or a lint keeps the strict read.
777
+ */
715
778
  private facts;
779
+ /** One fact file, parsed, or the `malformed fact file` sentence for it (see `facts`). */
780
+ private readFact;
716
781
  /**
717
782
  * Date the recall.
718
783
  *
@@ -722,7 +787,17 @@ export declare class MemoryStore {
722
787
  * listing inside `_facts` has already run before any write.
723
788
  */
724
789
  private stamp;
725
- /** Write via `<name>.md.tmp` and `os.replace`, so a reader never sees a half-written fact. */
790
+ /**
791
+ * Write via a PER-WRITER temp and `os.replace`, so a reader never sees a half-written fact
792
+ * and two writers never share a temp (job65/#161).
793
+ *
794
+ * WAS `<name>.md.tmp`, one temp per FACT: two servers saving one name raced on it (the
795
+ * second `replace` found nothing — `[Errno 2] … 'shared-name.md.tmp'`), and a write that
796
+ * died half-way left the temp beside the facts. NOW the shape `clockOut` and the hook ledger
797
+ * use (J65-1, J65-4): `<name>.md.<pid>.<8 hex>.tmp`, created with `wx` (O_EXCL), renamed
798
+ * onto the fact, and unlinked on any failure before the error goes on up. `*.md` listings
799
+ * never see it.
800
+ */
726
801
  private writeFact;
727
802
  /**
728
803
  * Write `index.md` from the facts on disk. THE OP THE ORIGINAL DEFECT DESTROYED.