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
@@ -45,6 +45,20 @@ import { dream as runDream, formatFixed3, SUPERSEDED_HEADING } from './dream.js'
45
45
  const NAME_SHAPE = /^[a-z0-9][a-z0-9-]*$/;
46
46
  const isNameShaped = (query) => query.includes('-') && NAME_SHAPE.test(query);
47
47
  const missLine = (name) => `no fact named '${name}' in any layer bound here; matching by words instead:`;
48
+ /**
49
+ * The closing paragraph of a recall that left fact files out (job65/#119), or `''`: the
50
+ * store's own sentence for each, labelled by layer, so the person knows what to repair and
51
+ * that the rest was searched. `_skipped_note` in the reference.
52
+ */
53
+ function skippedNote(skipped) {
54
+ if (skipped.length === 0)
55
+ return '';
56
+ const count = skipped.length;
57
+ const noun = count === 1 ? 'fact file' : 'fact files';
58
+ return (`\n\n${count} ${noun} could not be read and ${count === 1 ? 'was' : 'were'} skipped; ` +
59
+ `every other fact was searched:\n` +
60
+ skipped.map((s) => `[${s.layer}] ${s.reason}`).join('\n'));
61
+ }
48
62
  /**
49
63
  * The model's spelling adapted to the store's contract: lowercase, `_`/space -> `-`.
50
64
  *
@@ -246,6 +260,17 @@ export class Memory {
246
260
  const normalizedLinks = (links ?? []).map((link) => normalizeName(link));
247
261
  let result;
248
262
  try {
263
+ // A SPELLING IS NOT A NAME (job65/#156): a name that had to be bent never lands ON
264
+ // ANOTHER FACT. "Deploy Command" used to replace `deploy-command` silently; now, when
265
+ // the bent name is a fact already, the answer is the duplicate nudge naming it and
266
+ // nothing is written. The exact slug is the one spelling that updates. See the reference.
267
+ if (typeof name === 'string' && name !== normalized && this.store.lookup(normalized, false) !== null) {
268
+ return {
269
+ reply: `similar memory '${normalized}' already exists — save under that SAME ` +
270
+ 'name to update it, or skip. Do not rename to force a copy.',
271
+ status: 'duplicate',
272
+ };
273
+ }
249
274
  result = this.store.save(type, normalized, description, body, normalizedLinks);
250
275
  }
251
276
  catch (e) {
@@ -360,13 +385,17 @@ export class Memory {
360
385
  let reached = 0;
361
386
  let unreadable = 0;
362
387
  let candidates = 0;
388
+ const skipped = [];
363
389
  for (const [label, store, writable] of this.layers) {
364
390
  if (picked.length >= budget)
365
391
  break; // budget spent: later layers are never even read
366
392
  reached += 1;
393
+ // ONE BAD FILE COSTS THAT FILE (job65/#119): the layer is scored over every fact it CAN
394
+ // read and the rest are named. Only a layer that cannot be listed is still the error.
395
+ const leftOut = [];
367
396
  let facts;
368
397
  try {
369
- facts = store.recall(query, budget, writable && stamp, minRatio);
398
+ facts = store.recall(query, budget, writable && stamp, minRatio, leftOut);
370
399
  }
371
400
  catch (e) {
372
401
  if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
@@ -377,6 +406,8 @@ export class Memory {
377
406
  unreadable += 1;
378
407
  continue; // a corrupt grant/profile layer must not take down recall
379
408
  }
409
+ for (const s of leftOut)
410
+ skipped.push({ layer: label, ...s });
380
411
  try {
381
412
  candidates += countFacts(store.root);
382
413
  }
@@ -404,16 +435,19 @@ export class Memory {
404
435
  candidates,
405
436
  unreadable,
406
437
  lookup,
438
+ skipped,
407
439
  };
408
440
  const lead = lookup === 'miss' ? `${missLine(name)}\n\n` : '';
441
+ const note = skippedNote(skipped);
409
442
  if (picked.length === 0) {
410
443
  const [status, reply] = this.nothingToReport();
411
- return { reply: lead + reply, status, source: null, ...counts };
444
+ return { reply: lead + reply + note, status, source: null, headers: [], ...counts };
412
445
  }
413
446
  return {
414
- reply: lead + picked.map(([label, fact]) => this.format(label, fact)).join('\n\n'),
447
+ reply: lead + picked.map(([label, fact]) => this.format(label, fact)).join('\n\n') + note,
415
448
  status: 'answered',
416
449
  source: picked[0][0].split(':', 1)[0],
450
+ headers: picked.map(([label, fact]) => this.header(label, fact)),
417
451
  ...counts,
418
452
  };
419
453
  }
@@ -431,11 +465,13 @@ export class Memory {
431
465
  let reached = 0;
432
466
  let unreadable = 0;
433
467
  let candidates = 0;
468
+ const skipped = [];
434
469
  for (const [label, store, writable] of this.layers) {
435
470
  reached += 1;
471
+ const leftOut = [];
436
472
  let fact;
437
473
  try {
438
- fact = store.lookup(name, writable && stamp);
474
+ fact = store.lookup(name, writable && stamp, leftOut);
439
475
  }
440
476
  catch (e) {
441
477
  if (!(e instanceof BantamError || e instanceof PyOSError || e instanceof PyUnicodeDecodeError)) {
@@ -446,6 +482,8 @@ export class Memory {
446
482
  unreadable += 1;
447
483
  continue;
448
484
  }
485
+ for (const s of leftOut)
486
+ skipped.push({ layer: label, ...s });
449
487
  try {
450
488
  candidates += countFacts(store.root);
451
489
  }
@@ -456,7 +494,7 @@ export class Memory {
456
494
  }
457
495
  if (fact !== null) {
458
496
  return {
459
- reply: this.format(label, fact),
497
+ reply: this.format(label, fact) + skippedNote(skipped),
460
498
  status: 'answered',
461
499
  source: label.split(':', 1)[0],
462
500
  budget,
@@ -466,6 +504,8 @@ export class Memory {
466
504
  candidates,
467
505
  unreadable,
468
506
  lookup: 'hit',
507
+ skipped,
508
+ headers: [this.header(label, fact)],
469
509
  };
470
510
  }
471
511
  }
@@ -856,11 +896,15 @@ export class Memory {
856
896
  }
857
897
  return `${where} ${remedy}; otherwise save a memory to start this one.`;
858
898
  }
859
- format(label, fact) {
899
+ /** `[layer] [name] (type) description`, from the parsed fields (job65/#155). */
900
+ header(label, fact) {
860
901
  // `pyText` for the three interpolated fields, not template-literal coercion: a
861
902
  // hand-edited `name:` with nothing after it is `None` in Python's sentence and would be
862
903
  // `null` in this one. The store already reproduces it in the index line and on disk.
863
904
  const tag = this.showLayers ? `[${label}] ` : '';
864
- return `${tag}[${pyText(fact.name)}] (${pyText(fact.type)}) ${pyText(fact.description)}\n${fact.body}`;
905
+ return `${tag}[${pyText(fact.name)}] (${pyText(fact.type)}) ${pyText(fact.description)}`;
906
+ }
907
+ format(label, fact) {
908
+ return `${this.header(label, fact)}\n${fact.body}`;
865
909
  }
866
910
  }
@@ -27,7 +27,16 @@ export declare class FactParseError extends Error {
27
27
  * in `pyyaml.ts` for what lands in which arm, and `store.pyText` for how it is rendered.
28
28
  */
29
29
  export type FactValue = string | PyScalar | null;
30
- /** `store.Fact`. `created` is `null` only before a Fact's first write. */
30
+ /**
31
+ * `store.Fact`. `created` is `null` only before a Fact's first write.
32
+ *
33
+ * `source` is the file the Fact was READ FROM — set by `MemoryStore.facts` and by nothing
34
+ * else, absent on a Fact built in memory. `compact` moves THIS path (#122): before it moved
35
+ * `facts/<name>.md` derived from the frontmatter, so a hand-edited `stale.md` whose
36
+ * frontmatter said `name: fresh-one` archived the NEWEST fact and left the stale one live,
37
+ * and a `name: 7` raised for a `facts/7.md` that never existed. The reference's dataclass
38
+ * field is `compare=False`, so nothing here compares it either.
39
+ */
31
40
  export interface Fact {
32
41
  name: FactValue;
33
42
  description: FactValue;
@@ -36,6 +45,7 @@ export interface Fact {
36
45
  links: FactValue[];
37
46
  last_recalled: FactValue;
38
47
  created?: FactValue;
48
+ source?: string;
39
49
  }
40
50
  /** The frontmatter as it comes off disk — exactly the keys the file carries. */
41
51
  export type FactMeta = Record<string, FactValue | FactValue[]>;
@@ -102,8 +112,16 @@ export declare function pySplit(text: string, sep: string, maxsplit: number): st
102
112
  * RULING — SCOPE. This is not a YAML parser and does not try to be. It accepts a
103
113
  * root-level block mapping of plain keys whose values are plain, single-quoted or
104
114
  * double-quoted scalars (wrapped or not), `[]`, or a block sequence of such scalars at
105
- * column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings.
106
- * Anything else RAISES instead of guessing. The reason is the failure mode this whole unit
115
+ * column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings —
116
+ * plus, since #120, the two shapes a human writes by hand that the emitter never does:
117
+ * block scalars (`|`, `>`, every chomping and indentation indicator) and a one-line flow
118
+ * sequence of scalars (`[a, 'b c', 2]`), each read exactly as PyYAML reads it, and blank
119
+ * and comment lines between entries. Anything else RAISES instead of guessing: every
120
+ * indicator in `REFUSED_INDICATORS`, a nested or multi-line flow sequence, a tab in a
121
+ * plain scalar. Before #120 most of those fell through to the plain-scalar scanner, so
122
+ * `description: |` became the text `| hand edited block` and the first recall stamped
123
+ * that guess back to disk; the conformance suite now generates 1500 frontmatters a run and
124
+ * requires the port never to answer one differently from PyYAML. The reason is the failure mode this whole unit
107
125
  * exists for: a parser that silently produces something plausible for input it does not
108
126
  * understand turns a hand-edited fact file into a wrong `Fact`, and nothing anywhere
109
127
  * reports it. A raise is visible; `_facts` already has a sentence for it.
@@ -110,8 +110,16 @@ export function pySplit(text, sep, maxsplit) {
110
110
  * RULING — SCOPE. This is not a YAML parser and does not try to be. It accepts a
111
111
  * root-level block mapping of plain keys whose values are plain, single-quoted or
112
112
  * double-quoted scalars (wrapped or not), `[]`, or a block sequence of such scalars at
113
- * column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings.
114
- * Anything else RAISES instead of guessing. The reason is the failure mode this whole unit
113
+ * column 0 — which is exactly what PyYAML emits for a `Fact` at these dump settings —
114
+ * plus, since #120, the two shapes a human writes by hand that the emitter never does:
115
+ * block scalars (`|`, `>`, every chomping and indentation indicator) and a one-line flow
116
+ * sequence of scalars (`[a, 'b c', 2]`), each read exactly as PyYAML reads it, and blank
117
+ * and comment lines between entries. Anything else RAISES instead of guessing: every
118
+ * indicator in `REFUSED_INDICATORS`, a nested or multi-line flow sequence, a tab in a
119
+ * plain scalar. Before #120 most of those fell through to the plain-scalar scanner, so
120
+ * `description: |` became the text `| hand edited block` and the first recall stamped
121
+ * that guess back to disk; the conformance suite now generates 1500 frontmatters a run and
122
+ * requires the port never to answer one differently from PyYAML. The reason is the failure mode this whole unit
115
123
  * exists for: a parser that silently produces something plausible for input it does not
116
124
  * understand turns a hand-edited fact file into a wrong `Fact`, and nothing anywhere
117
125
  * reports it. A raise is visible; `_facts` already has a sentence for it.
@@ -128,6 +136,14 @@ export function parseFrontmatter(front) {
128
136
  const meta = {};
129
137
  let pos = 0;
130
138
  while (pos < front.length) {
139
+ // A blank line, or a line holding only a comment, is not an entry to PyYAML's scanner
140
+ // (`scan_to_next_token` steps over both), so it is not one here either. A block scalar's
141
+ // trailing `#` line at column 0 is the shape that needs this (#120).
142
+ const blank = /^ *(?:#[^\n]*)?(?:\n|$)/.exec(front.slice(pos));
143
+ if (blank && blank[0].length > 0) {
144
+ pos += blank[0].length;
145
+ continue;
146
+ }
131
147
  const key = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(front.slice(pos, front.indexOf('\n', pos) + 1 || undefined));
132
148
  if (!key) {
133
149
  throw new FactParseError(`frontmatter line is not a plain "key:" mapping entry: ${line(front, pos)}`);
@@ -146,6 +162,7 @@ export function parseFrontmatter(front) {
146
162
  else if (front[pos] === ' ') {
147
163
  pos += 1;
148
164
  [value, pos] = scanNode(front, pos);
165
+ pos = skipTrailingComment(front, pos);
149
166
  if (pos < front.length && front[pos] !== '\n') {
150
167
  throw new FactParseError(`trailing content after the value of "${key[1]}": ${line(front, pos)}`);
151
168
  }
@@ -162,22 +179,334 @@ function line(text, pos) {
162
179
  const end = text.indexOf('\n', pos);
163
180
  return JSON.stringify(text.slice(pos, end === -1 ? undefined : end));
164
181
  }
182
+ /**
183
+ * What a YAML indicator this codec does NOT implement means to PyYAML, keyed by the
184
+ * character that starts the value. RULING (#120): each of these RAISES a named
185
+ * `FactParseError` instead of falling through to `scanPlain`. Before #120 they fell through,
186
+ * so `description: &a x` was read as the string `&a x` where Python reads `x`, and the first
187
+ * recall stamped that guess back to disk quoted — the hand-edited YAML was gone for good.
188
+ * A refusal names the file and changes nothing; a guess rewrites it.
189
+ *
190
+ * `|` and `>` are not here: block scalars ARE implemented (`scanBlockScalar`). Nor is `[`:
191
+ * a one-line flow sequence of scalars is implemented (`scanFlowSequence`). Nor is `#`: a
192
+ * comment where the value should be leaves the value null, which is what PyYAML reads.
193
+ */
194
+ const REFUSED_INDICATORS = {
195
+ '&': 'an anchor',
196
+ '*': 'an alias',
197
+ '!': 'a tag',
198
+ '%': 'a directive',
199
+ '@': 'a reserved indicator',
200
+ '`': 'a reserved indicator',
201
+ '{': 'a flow mapping',
202
+ '}': 'a flow-mapping end outside a flow collection',
203
+ ']': 'a flow-sequence end outside a flow collection',
204
+ ',': 'a flow-entry separator outside a flow collection',
205
+ '\t': 'a tab, which cannot start a YAML token',
206
+ };
207
+ /** `'\0 \t\r\n\x85\u2028\u2029'` in PyYAML's scanner: end of input, whitespace or a break. */
208
+ function blankOrEnd(ch) {
209
+ return ch === undefined || ' \t\r\n\x85\u2028\u2029'.includes(ch);
210
+ }
211
+ function refuseIndicator(front, pos, what) {
212
+ throw new FactParseError(`${JSON.stringify(front[pos])} starts ${what} — YAML this codec does not implement, ` +
213
+ `so it is refused rather than read as plain text: ${line(front, pos)}`);
214
+ }
215
+ /**
216
+ * After a value: spaces, then an optional comment to end of line. PyYAML's
217
+ * `scan_to_next_token` skips exactly that (spaces, never tabs), so `'x' # note` and
218
+ * `[a]#note` are the value and nothing more. A plain scalar has already eaten its own
219
+ * ` #` comment, and a block scalar ends at a line break, so this is a no-op for both.
220
+ */
221
+ function skipTrailingComment(front, pos) {
222
+ let i = pos;
223
+ while (front[i] === ' ')
224
+ i += 1;
225
+ if (front[i] === '#') {
226
+ while (i < front.length && front[i] !== '\n')
227
+ i += 1;
228
+ return i;
229
+ }
230
+ return i === front.length || front[i] === '\n' ? i : pos;
231
+ }
165
232
  /** A single-line-or-wrapped scalar starting at `pos`. Returns the value and the index after it. */
166
233
  function scanNode(front, pos) {
234
+ // PyYAML steps over SPACES before a token (never tabs), so `description: 'x'` is `x`
235
+ // and not the plain text `'x'` it used to be here.
236
+ while (front[pos] === ' ')
237
+ pos += 1;
167
238
  const ch = front[pos];
239
+ if (ch === undefined || ch === '\n')
240
+ return [null, pos]; // `key: ` is null, as `key:` is
241
+ if (ch === '#') {
242
+ // A comment where the value should be: the value is empty, so null.
243
+ const eol = front.indexOf('\n', pos);
244
+ return [null, eol === -1 ? front.length : eol];
245
+ }
168
246
  if (ch === "'")
169
247
  return scanQuoted(front, pos, "'");
170
248
  if (ch === '"')
171
249
  return scanQuoted(front, pos, '"');
172
- if (ch === '[') {
173
- const m = /^\[\s*\]/.exec(front.slice(pos));
174
- if (!m) {
175
- throw new FactParseError(`only the empty flow sequence "[]" is in this codec's language: ${line(front, pos)}`);
176
- }
177
- return [[], pos + m[0].length];
250
+ if (ch === '[')
251
+ return scanFlowSequence(front, pos);
252
+ if (ch === '|' || ch === '>')
253
+ return scanBlockScalar(front, pos);
254
+ const refused = REFUSED_INDICATORS[ch];
255
+ if (refused !== undefined)
256
+ refuseIndicator(front, pos, refused);
257
+ if ((ch === '-' || ch === '?' || ch === ':') && blankOrEnd(front[pos + 1])) {
258
+ refuseIndicator(front, pos, ch === '-' ? 'a sequence entry' : ch === '?' ? 'a mapping key' : 'a mapping value');
178
259
  }
179
260
  return scanPlain(front, pos);
180
261
  }
262
+ /** The column of `pos`: characters since the last `\n`. PyYAML's `self.column`. */
263
+ function column(front, pos) {
264
+ return pos - (front.lastIndexOf('\n', pos - 1) + 1);
265
+ }
266
+ /**
267
+ * A block scalar, `|` (literal) or `>` (folded), with its chomping and indentation
268
+ * indicators. A port of PyYAML's `scan_block_scalar` and its three helpers, line for line,
269
+ * including the folding rule PyYAML picked where the spec is ambiguous (a more-indented
270
+ * line keeps its break). Implemented rather than refused (#120) because it is the shape a
271
+ * human reaches for when a description runs to two lines, and refusing it bricks the whole
272
+ * store for Node while Python serves it.
273
+ *
274
+ * The value is a mapping value or a column-0 sequence item, so PyYAML's `self.indent` is 0
275
+ * and `min_indent` is 1 in both places. Returns the value and the index of the `\n` that ends
276
+ * its last consumed line (or the end of input), the same contract `scanPlain` keeps.
277
+ *
278
+ * Line breaks other than `\n` (`\x85`, `\u2028`, `\u2029`, a stray `\r`) are breaks to
279
+ * PyYAML and not to this port, so a block scalar holding one is refused, not misread.
280
+ */
281
+ function scanBlockScalar(front, pos) {
282
+ const folded = front[pos] === '>';
283
+ const bad = (why) => new FactParseError(`while scanning a block scalar, ${why}: ${line(front, pos)}`);
284
+ const digit = (c) => c !== undefined && c >= '0' && c <= '9';
285
+ let i = pos + 1;
286
+ // scan_block_scalar_indicators
287
+ let chomping = null;
288
+ let increment = null;
289
+ if (front[i] === '+' || front[i] === '-') {
290
+ chomping = front[i] === '+';
291
+ i += 1;
292
+ if (digit(front[i])) {
293
+ increment = Number(front[i]);
294
+ if (increment === 0)
295
+ throw bad('expected indentation indicator in the range 1-9, but found 0');
296
+ i += 1;
297
+ }
298
+ }
299
+ else if (digit(front[i])) {
300
+ increment = Number(front[i]);
301
+ if (increment === 0)
302
+ throw bad('expected indentation indicator in the range 1-9, but found 0');
303
+ i += 1;
304
+ if (front[i] === '+' || front[i] === '-') {
305
+ chomping = front[i] === '+';
306
+ i += 1;
307
+ }
308
+ }
309
+ if (!(i === front.length || front[i] === ' ' || front[i] === '\n')) {
310
+ throw bad(`expected chomping or indentation indicators, but found ${JSON.stringify(front[i])}`);
311
+ }
312
+ // scan_block_scalar_ignored_line
313
+ while (front[i] === ' ')
314
+ i += 1;
315
+ if (front[i] === '#') {
316
+ while (i < front.length && front[i] !== '\n') {
317
+ if ('\r\x85\u2028\u2029'.includes(front[i]))
318
+ throw bad('a line break other than \\n');
319
+ i += 1;
320
+ }
321
+ }
322
+ if (i < front.length && front[i] !== '\n') {
323
+ throw bad(`expected a comment or a line break, but found ${JSON.stringify(front[i])}`);
324
+ }
325
+ if (i < front.length)
326
+ i += 1;
327
+ // scan_block_scalar_breaks
328
+ const scanBreaks = (indent) => {
329
+ const chunks = [];
330
+ while (column(front, i) < indent && front[i] === ' ')
331
+ i += 1;
332
+ while (front[i] === '\n') {
333
+ chunks.push('\n');
334
+ i += 1;
335
+ while (column(front, i) < indent && front[i] === ' ')
336
+ i += 1;
337
+ }
338
+ return chunks;
339
+ };
340
+ const minIndent = 1;
341
+ let indent;
342
+ let breaks;
343
+ if (increment === null) {
344
+ // scan_block_scalar_indentation
345
+ breaks = [];
346
+ let maxIndent = 0;
347
+ while (front[i] === ' ' || front[i] === '\n') {
348
+ if (front[i] === '\n') {
349
+ breaks.push('\n');
350
+ i += 1;
351
+ }
352
+ else {
353
+ i += 1;
354
+ maxIndent = Math.max(maxIndent, column(front, i));
355
+ }
356
+ }
357
+ indent = Math.max(minIndent, maxIndent);
358
+ }
359
+ else {
360
+ indent = minIndent + increment - 1;
361
+ breaks = scanBreaks(indent);
362
+ }
363
+ const chunks = [];
364
+ let lineBreak = '';
365
+ while (column(front, i) === indent && i < front.length) {
366
+ chunks.push(...breaks);
367
+ const leadingNonSpace = front[i] !== ' ' && front[i] !== '\t';
368
+ const eol = front.indexOf('\n', i);
369
+ const end = eol === -1 ? front.length : eol;
370
+ const text = front.slice(i, end);
371
+ if (/[\r\x85\u2028\u2029]/.test(text))
372
+ throw bad('a line break other than \\n');
373
+ chunks.push(text);
374
+ i = end;
375
+ lineBreak = i < front.length ? '\n' : '';
376
+ if (lineBreak)
377
+ i += 1;
378
+ breaks = scanBreaks(indent);
379
+ if (column(front, i) === indent && i < front.length) {
380
+ if (folded && lineBreak === '\n' && leadingNonSpace && front[i] !== ' ' && front[i] !== '\t') {
381
+ if (breaks.length === 0)
382
+ chunks.push(' ');
383
+ }
384
+ else {
385
+ chunks.push(lineBreak);
386
+ }
387
+ }
388
+ else {
389
+ break;
390
+ }
391
+ }
392
+ if (chomping !== false)
393
+ chunks.push(lineBreak);
394
+ if (chomping === true)
395
+ chunks.push(...breaks);
396
+ // What follows must be the next entry at column 0 (or nothing). A less-indented line that
397
+ // is not at column 0 is a parser error to PyYAML (`|\n a\n x` is measured), so it is one
398
+ // here; nothing after the scalar is read as part of it. The one exception is a comment,
399
+ // which PyYAML's `scan_to_next_token` steps over at any column.
400
+ if (i < front.length && column(front, i) !== 0 && front[i] === '#') {
401
+ const eol = front.indexOf('\n', i);
402
+ return [chunks.join(''), eol === -1 ? front.length : eol];
403
+ }
404
+ if (i < front.length && column(front, i) !== 0) {
405
+ throw new FactParseError(`content after a block scalar is not at column 0: ${line(front, i)}`);
406
+ }
407
+ return [chunks.join(''), i === front.length ? i : i - 1];
408
+ }
409
+ /**
410
+ * A flow sequence on ONE line, of plain, single- or double-quoted scalars: `[a, 'b c', 2]`.
411
+ * A port of how PyYAML's scanner reads a plain scalar at `flow_level > 0` (it ends at `,`,
412
+ * `?`, `[`, `]`, `{`, `}`, at `: ` and at ` #`), with each item resolved exactly as a
413
+ * mapping value is. A trailing comma is allowed, as PyYAML allows it.
414
+ *
415
+ * RULING (#120): the empty `[]` was all this accepted before, and `links: [a, b]` made Node
416
+ * refuse the whole store while Python served it. What is still REFUSED, each a named
417
+ * `FactParseError`: a nested collection (`[a, [b]]`, `[a, {b: c}]`), a single-pair mapping
418
+ * (`[a: b]`, `[? x]`), and a sequence that continues onto the next line. Python reads all
419
+ * three; registered in `docs/porting.md` with the other scanner shapes.
420
+ */
421
+ function scanFlowSequence(front, pos) {
422
+ const eol = front.indexOf('\n', pos);
423
+ const end = eol === -1 ? front.length : eol;
424
+ const refuse = (why) => {
425
+ throw new FactParseError(`a flow sequence ${why} is outside this codec's language: ${line(front, pos)}`);
426
+ };
427
+ const items = [];
428
+ let i = pos + 1;
429
+ const skipSpaces = () => {
430
+ while (front[i] === ' ')
431
+ i += 1;
432
+ };
433
+ skipSpaces();
434
+ if (front[i] === ']')
435
+ return [items, i + 1];
436
+ for (;;) {
437
+ if (i >= end)
438
+ refuse('that continues past its line');
439
+ const ch = front[i];
440
+ if (ch === "'" || ch === '"') {
441
+ const [value, next] = scanQuoted(front, i, ch);
442
+ if (next > end)
443
+ refuse('whose quoted item spans lines');
444
+ items.push(value);
445
+ i = next;
446
+ }
447
+ else if (ch === '[' || ch === '{') {
448
+ refuse('holding a nested collection');
449
+ }
450
+ else if (ch === '?' || ch === ':') {
451
+ refuse('holding a mapping entry');
452
+ }
453
+ else if (ch === '-' && blankOrEnd(front[i + 1])) {
454
+ refuse('holding a block sequence entry');
455
+ }
456
+ else if (REFUSED_INDICATORS[ch] !== undefined || ch === '#' || ch === '|' || ch === '>') {
457
+ refuseIndicator(front, i, ch === '#' ? 'a comment' : ch === '|' || ch === '>' ? 'a block scalar' : REFUSED_INDICATORS[ch]);
458
+ }
459
+ else {
460
+ // scan_plain at flow_level > 0, confined to this line.
461
+ let text = '';
462
+ let spaces = '';
463
+ for (;;) {
464
+ if (front[i] === '#')
465
+ break;
466
+ let length = 0;
467
+ for (;;) {
468
+ const c = front[i + length];
469
+ if (blankOrEnd(c))
470
+ break;
471
+ if (c === ':' && (blankOrEnd(front[i + length + 1]) || ',[]{}'.includes(front[i + length + 1])))
472
+ break;
473
+ if (',?[]{}'.includes(c))
474
+ break;
475
+ length += 1;
476
+ }
477
+ if (length === 0)
478
+ break;
479
+ text += spaces + front.slice(i, i + length);
480
+ i += length;
481
+ const from = i;
482
+ while (front[i] === ' ')
483
+ i += 1;
484
+ spaces = front.slice(from, i);
485
+ if (i >= end)
486
+ refuse('that continues past its line');
487
+ if (spaces === '' || front[i] === '#')
488
+ break;
489
+ }
490
+ items.push(constructPlain(text));
491
+ }
492
+ skipSpaces();
493
+ if (front[i] === ']')
494
+ return [items, i + 1];
495
+ if (front[i] !== ',') {
496
+ if (front[i] === ':' || front[i] === '?')
497
+ refuse('holding a mapping entry');
498
+ if (i >= end)
499
+ refuse('that continues past its line');
500
+ refuse(`with ${JSON.stringify(front[i])} where "," or "]" belongs`);
501
+ }
502
+ i += 1;
503
+ skipSpaces();
504
+ if (front[i] === ']')
505
+ return [items, i + 1];
506
+ if (front[i] === ',')
507
+ refuse('with an empty entry');
508
+ }
509
+ }
181
510
  /**
182
511
  * A plain scalar and its wrapped continuation lines.
183
512
  *
@@ -196,6 +525,12 @@ function scanPlain(front, pos) {
196
525
  // Python reads is the point.
197
526
  const hash = raw.indexOf(' #');
198
527
  const cut = hash === -1 ? raw : raw.slice(0, hash);
528
+ // A tab is not whitespace to PyYAML's plain-scalar scanner (`scan_plain_spaces` takes
529
+ // spaces only), and a tab cannot start a token, so `x\t` and `a\tb` are a ScannerError
530
+ // there. Trimming it here answered a value Python refuses (#120, measured).
531
+ if (cut.includes('\t')) {
532
+ throw new FactParseError(`a tab inside a plain scalar is not readable YAML — PyYAML rejects it too: ${line(front, i)}`);
533
+ }
199
534
  const piece = cut.replace(/[ \t]+$/, '').replace(/^[ \t]+/, '');
200
535
  if (pieces.length > 0 && piece === '') {
201
536
  throw new FactParseError('a blank line inside a plain scalar is outside this codec\'s language');
@@ -214,7 +549,10 @@ function scanPlain(front, pos) {
214
549
  i = next;
215
550
  }
216
551
  const value = pieces.join(' ');
217
- if (/(^|[ \t]):([ \t]|$)/.test(value)) {
552
+ // `:` followed by a blank ends a plain scalar in PyYAML's block context, whatever precedes
553
+ // it — `a: b` and `a:` are both `mapping values are not allowed here`. The old pattern
554
+ // demanded a blank BEFORE the colon too, so `a: b` was answered (#120, measured).
555
+ if (/:([ \t]|$)/.test(value)) {
218
556
  throw new FactParseError(`a plain scalar containing ": " is not readable YAML — PyYAML rejects it too: ${JSON.stringify(value)}`);
219
557
  }
220
558
  return [resolvePlain(value), i];
@@ -370,10 +708,11 @@ function scanBlockSequence(front, pos) {
370
708
  throw new FactParseError('a nested sequence item is not in this codec\'s language');
371
709
  }
372
710
  items.push(value);
373
- if (next < front.length && front[next] !== '\n') {
374
- throw new FactParseError(`trailing content after a sequence item: ${line(front, next)}`);
711
+ const after = skipTrailingComment(front, next);
712
+ if (after < front.length && front[after] !== '\n') {
713
+ throw new FactParseError(`trailing content after a sequence item: ${line(front, after)}`);
375
714
  }
376
- i = next + 1;
715
+ i = after + 1;
377
716
  }
378
717
  return [items, i];
379
718
  }
@@ -90,3 +90,22 @@ export declare function countFacts(root: string): number;
90
90
  * surface at construction, not silently drop. Returned grant paths are fully resolved.
91
91
  */
92
92
  export declare function loadGrants(projectStore: string): string[];
93
+ /**
94
+ * job65/#158 — the sentence both runtimes give for a tab PyYAML will not scan. Python
95
+ * rewrites PyYAML's `found character '\t' that cannot start any token` into exactly this,
96
+ * from the error's own mark; this port finds the same byte with `firstTokenTab`.
97
+ */
98
+ export declare function tabSentence(line: number, column: number): string;
99
+ /**
100
+ * The first tab PyYAML's scanner would refuse, as 1-based `[line, column]`, or `null`.
101
+ *
102
+ * MEASURED, not read off the spec: PyYAML 6.0.3's `scan_to_next_token` skips SPACES only, and
103
+ * a plain scalar stops at a tab, so every tab outside a quoted scalar and outside a comment
104
+ * is `found character '\t' that cannot start any token` — leading indentation, after `:`,
105
+ * after `-`, inside `[ ]`, between a value and its comment, trailing, alone on a line, and
106
+ * after `---`. A tab inside `'...'`/`"..."` or after a ` #` is accepted. The probe battery
107
+ * is in `.shiftwork/notes-job65/J65-10.md`. A quote OPENS a scalar only where a token can
108
+ * start (line start, or after a space, `[`, `{` or `,`) — `it's` is a plain scalar — and an
109
+ * open quote carries across lines, as a flow scalar does.
110
+ */
111
+ export declare function firstTokenTab(text: string): [number, number] | null;