@sharpee/chord 3.1.0 → 3.5.0

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 (68) hide show
  1. package/analyzer.d.ts +11 -3
  2. package/analyzer.d.ts.map +1 -1
  3. package/analyzer.js +1360 -33
  4. package/analyzer.js.map +1 -1
  5. package/ast.d.ts +480 -9
  6. package/ast.d.ts.map +1 -1
  7. package/catalog.d.ts +57 -1
  8. package/catalog.d.ts.map +1 -1
  9. package/catalog.js +91 -3
  10. package/catalog.js.map +1 -1
  11. package/diagnostics.d.ts +1 -1
  12. package/diagnostics.d.ts.map +1 -1
  13. package/index.d.ts +32 -13
  14. package/index.d.ts.map +1 -1
  15. package/index.js +112 -30
  16. package/index.js.map +1 -1
  17. package/ir.d.ts +361 -7
  18. package/ir.d.ts.map +1 -1
  19. package/lexer.d.ts +11 -3
  20. package/lexer.d.ts.map +1 -1
  21. package/lexer.js +36 -8
  22. package/lexer.js.map +1 -1
  23. package/manifests/combat.d.ts +16 -0
  24. package/manifests/combat.d.ts.map +1 -0
  25. package/manifests/combat.js +38 -0
  26. package/manifests/combat.js.map +1 -0
  27. package/manifests/hunger.d.ts +18 -0
  28. package/manifests/hunger.d.ts.map +1 -0
  29. package/manifests/hunger.js +8 -0
  30. package/manifests/hunger.js.map +1 -0
  31. package/manifests/index.d.ts +23 -0
  32. package/manifests/index.d.ts.map +1 -0
  33. package/manifests/index.js +52 -0
  34. package/manifests/index.js.map +1 -0
  35. package/manifests/npc.d.ts +19 -0
  36. package/manifests/npc.d.ts.map +1 -0
  37. package/manifests/npc.js +38 -0
  38. package/manifests/npc.js.map +1 -0
  39. package/manifests/scoring.d.ts +25 -0
  40. package/manifests/scoring.d.ts.map +1 -0
  41. package/manifests/scoring.js +8 -0
  42. package/manifests/scoring.js.map +1 -0
  43. package/manifests/state-machines.d.ts +17 -0
  44. package/manifests/state-machines.d.ts.map +1 -0
  45. package/manifests/state-machines.js +8 -0
  46. package/manifests/state-machines.js.map +1 -0
  47. package/manifests/types.d.ts +41 -0
  48. package/manifests/types.d.ts.map +1 -0
  49. package/manifests/types.js +18 -0
  50. package/manifests/types.js.map +1 -0
  51. package/message-alias-catalog.d.ts +22 -0
  52. package/message-alias-catalog.d.ts.map +1 -0
  53. package/message-alias-catalog.js +826 -0
  54. package/message-alias-catalog.js.map +1 -0
  55. package/package.json +1 -1
  56. package/parser.d.ts +2 -2
  57. package/parser.d.ts.map +1 -1
  58. package/parser.js +1644 -131
  59. package/parser.js.map +1 -1
  60. package/phrasebooks.d.ts +28 -0
  61. package/phrasebooks.d.ts.map +1 -0
  62. package/phrasebooks.js +6 -0
  63. package/phrasebooks.js.map +1 -0
  64. package/version.d.ts +38 -0
  65. package/version.d.ts.map +1 -0
  66. package/version.js +41 -0
  67. package/version.js.map +1 -0
  68. package/sharpee-chord-3.0.0.tgz +0 -0
package/parser.js CHANGED
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseStory = parseStory;
4
- const lexer_1 = require("./lexer");
5
- const span_1 = require("./span");
4
+ const catalog_js_1 = require("./catalog.js");
5
+ const lexer_js_1 = require("./lexer.js");
6
+ const span_js_1 = require("./span.js");
6
7
  const ARTICLES = new Set(['the', 'a', 'an']);
7
8
  const DIRECTIONS = new Set([
8
9
  'north', 'south', 'east', 'west',
@@ -19,7 +20,9 @@ const ORDINALS = {
19
20
  };
20
21
  /** Words that terminate a noun phrase in condition/statement positions. */
21
22
  const PHRASE_STOPS = new Set(['is', 'has', 'holds', 'wears', 'can', 'and', 'or', 'then', 'to', 'while', 'with']);
22
- const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'every']);
23
+ /** Stop words ending an emit-payload value expression (ADR-216). */
24
+ const EMIT_VALUE_STOPS = new Set(['and', 'when']);
25
+ const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'every', 'import', 'override']);
23
26
  /**
24
27
  * Parse `.story` source into an AST.
25
28
  * @param source full `.story` text
@@ -27,7 +30,7 @@ const TOP_KEYWORDS = new Set(['story', 'create', 'define', 'when', 'once', 'ever
27
30
  * a (possibly partial) StoryFile — callers gate on diagnostics.hasErrors()
28
31
  */
29
32
  function parseStory(source, diagnostics) {
30
- return new Parser((0, lexer_1.lex)(source, diagnostics), diagnostics).parseFile();
33
+ return new Parser((0, lexer_js_1.lex)(source, diagnostics), diagnostics).parseFile();
31
34
  }
32
35
  /** Cursor over one line's tokens. */
33
36
  class Cursor {
@@ -62,14 +65,14 @@ class Cursor {
62
65
  restSpan() {
63
66
  const t = this.peek();
64
67
  if (t)
65
- return (0, span_1.mergeSpans)(t.span, this.tokens[this.tokens.length - 1].span);
68
+ return (0, span_js_1.mergeSpans)(t.span, this.tokens[this.tokens.length - 1].span);
66
69
  return lineSpan(this.line);
67
70
  }
68
71
  }
69
72
  function lineSpan(line) {
70
73
  if (line.tokens.length === 0)
71
- return (0, span_1.spanOf)(line.lineNo, line.indent + 1, Math.max(1, line.raw.trim().length));
72
- return (0, span_1.mergeSpans)(line.tokens[0].span, line.tokens[line.tokens.length - 1].span);
74
+ return (0, span_js_1.spanOf)(line.lineNo, line.indent + 1, Math.max(1, line.raw.trim().length));
75
+ return (0, span_js_1.mergeSpans)(line.tokens[0].span, line.tokens[line.tokens.length - 1].span);
73
76
  }
74
77
  function firstWord(line) {
75
78
  const t = line.tokens[0];
@@ -79,6 +82,15 @@ function firstWord(line) {
79
82
  function isEndLine(line) {
80
83
  return firstWord(line) === 'end';
81
84
  }
85
+ /**
86
+ * True for any `##`-prefixed line at any indent (ADR-249). In code
87
+ * positions this is always an error — comments are only legal flagged
88
+ * (indent 0) and between top-level constructs. Prose positions never
89
+ * call this: an indented `##` in prose renders verbatim (§5.2 opacity).
90
+ */
91
+ function looksLikeComment(line) {
92
+ return line.raw.trimStart().startsWith('##');
93
+ }
82
94
  /** Words that open a statement or block boundary inside behavior bodies. */
83
95
  const STATEMENT_OPENERS = new Set([
84
96
  'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose', 'kill',
@@ -123,9 +135,24 @@ class Parser {
123
135
  parseFile() {
124
136
  let header = null;
125
137
  const declarations = [];
126
- const start = this.lines[0] ? lineSpan(this.lines[0]) : (0, span_1.spanOf)(1, 1);
138
+ const start = this.lines[0] ? lineSpan(this.lines[0]) : (0, span_js_1.spanOf)(1, 1);
127
139
  while (this.pos < this.lines.length) {
128
140
  const line = this.lines[this.pos];
141
+ if (line.comment) {
142
+ // ADR-249: comments are legal exactly between top-level
143
+ // constructs. A following indented line means this comment sits
144
+ // between a construct's header and its body — inside the block.
145
+ const next = this.lines[this.pos + 1];
146
+ if (next && next.indent > 0) {
147
+ this.reportCommentInsideBlock(line);
148
+ this.pos++;
149
+ this.recoverToTopLevel();
150
+ }
151
+ else {
152
+ this.pos++;
153
+ }
154
+ continue;
155
+ }
129
156
  if (line.indent !== 0) {
130
157
  this.diagnostics.error('parse.unexpected-indent', 'Unexpected indentation — expected a top-level declaration.', lineSpan(line));
131
158
  this.recoverToTopLevel();
@@ -151,6 +178,21 @@ class Parser {
151
178
  declarations.push(d);
152
179
  break;
153
180
  }
181
+ case 'import': {
182
+ // ADR-251: `import "<file>"` — the single generalized form.
183
+ // Position IS the spliced content's arbitration position (D4).
184
+ const d = this.parseImport();
185
+ if (d)
186
+ declarations.push(d);
187
+ break;
188
+ }
189
+ case 'override': {
190
+ // ADR-255: `override message <alias>` / `override messages <locale>`.
191
+ const d = this.parseOverride();
192
+ if (d)
193
+ declarations.push(d);
194
+ break;
195
+ }
154
196
  // Ownership-package removals (ratchet 2026-07-11): floating rules
155
197
  // are gone — each gets a fix-it naming its owner-attached form.
156
198
  case 'when':
@@ -165,6 +207,18 @@ class Parser {
165
207
  this.diagnostics.error('parse.removed-every', 'Top-level `every N turns` rules were removed (ownership package, 2026-07-11) — use a story-owned `define sequence`, or an every-turn clause on the owner.', lineSpan(line));
166
208
  this.recoverToTopLevel(true);
167
209
  break;
210
+ case 'use':
211
+ // ADR-215: `use` lives in the story header's indented body, not
212
+ // at the top level — a pointed fix-it, never a generic error.
213
+ this.diagnostics.error('parse.use-top-level', '`use <extension>` goes inside the story header\'s indented body (with `states:`, scores, …), not at the top level.', lineSpan(line));
214
+ this.recoverToTopLevel(true);
215
+ break;
216
+ case 'rank':
217
+ // ADR-261 D2: a rung lives in the `use scoring` body, so that the
218
+ // ladder cannot drift away from the line that enables it.
219
+ this.diagnostics.error('parse.rank-outside-scoring', '`rank "<name>" at <n>` goes in the indented body of the story header\'s `use scoring` line, not at the top level.', lineSpan(line));
220
+ this.recoverToTopLevel(true);
221
+ break;
168
222
  case 'each':
169
223
  // Never top-level (given 9: all behavior is owned) — ratchet E3.
170
224
  this.diagnostics.error('parse.each-top-level', '`each` blocks run inside an owner\'s behavior — place the block in an `on`/`after` clause body, an action body, a trait clause body, or a sequence step (never top-level).', lineSpan(line));
@@ -180,9 +234,18 @@ class Parser {
180
234
  kind: 'story-file',
181
235
  header,
182
236
  declarations,
183
- span: last ? (0, span_1.mergeSpans)(start, lineSpan(last)) : start,
237
+ span: last ? (0, span_js_1.mergeSpans)(start, lineSpan(last)) : start,
184
238
  };
185
239
  }
240
+ /** ADR-249: the one inside-block comment diagnostic, with its fix-it. */
241
+ reportCommentInsideBlock(line) {
242
+ this.diagnostics.error('parse.comment-inside-block', 'Comments are only legal outside blocks, at the top level of the story file.', lineSpan(line));
243
+ }
244
+ /** Report an inside-block comment and consume the line (block parsing continues). */
245
+ skipCommentInsideBlock(line) {
246
+ this.reportCommentInsideBlock(line);
247
+ this.pos++;
248
+ }
186
249
  /** Skip lines until the next top-level keyword at indent 0. */
187
250
  recoverToTopLevel(consumeCurrent = false) {
188
251
  if (consumeCurrent)
@@ -223,10 +286,121 @@ class Parser {
223
286
  const states = [];
224
287
  let statesReversible = false;
225
288
  const scores = [];
289
+ const onClauses = [];
290
+ const uses = [];
291
+ const usePhrasebooks = [];
292
+ const ranks = [];
293
+ let hunger;
226
294
  let span = lineSpan(line);
227
295
  while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
296
+ const peeked = this.lines[this.pos];
297
+ const peekedWord = firstWord(peeked);
298
+ // `use <extension>` — a trusted platform extension (ADR-215): static,
299
+ // one name per line; the analyzer gates the name against the manifest
300
+ // registry.
301
+ if (peekedWord === 'use') {
302
+ const useLine = this.lines[this.pos++];
303
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(useLine));
304
+ const uc = new Cursor(useLine.tokens, useLine);
305
+ uc.matchWord('use');
306
+ // ADR-250 D2: exactly the word `phrasebook` selects the two-word
307
+ // form — `use phrasebook <name> [while <condition>]`, stackable.
308
+ // Plain `use <extension>` keeps its strict one-word grammar.
309
+ if (uc.isWord('phrasebook')) {
310
+ uc.next();
311
+ const bookTok = uc.next();
312
+ if (!bookTok || bookTok.kind !== 'word') {
313
+ this.diagnostics.error('parse.use-phrasebook', 'Expected `use phrasebook <name> [while <condition>]` — a single kebab-case book name.', uc.restSpan());
314
+ continue;
315
+ }
316
+ let bookCondition = null;
317
+ if (uc.isWord('while')) {
318
+ uc.next();
319
+ const condTokens = [];
320
+ while (!uc.atEnd())
321
+ condTokens.push(uc.next());
322
+ bookCondition = this.parseCondition(new Cursor(condTokens, useLine), useLine);
323
+ }
324
+ else if (!uc.atEnd()) {
325
+ this.diagnostics.error('parse.use-phrasebook', 'Unexpected text after the book name — only `while <condition>` may follow.', uc.restSpan());
326
+ continue;
327
+ }
328
+ usePhrasebooks.push({ name: bookTok.text, condition: bookCondition, span: lineSpan(useLine) });
329
+ continue;
330
+ }
331
+ const nameTok = uc.next();
332
+ if (!nameTok || nameTok.kind !== 'word') {
333
+ this.diagnostics.error('parse.use', 'Expected `use <extension>` — one extension name per line.', lineSpan(useLine));
334
+ continue;
335
+ }
336
+ // Optional `, announce <mode>` (ADR-262 D3): how a metering extension's
337
+ // band crossings narrate. The analyzer validates the mode word.
338
+ let announce;
339
+ if (uc.peek()?.kind === 'comma') {
340
+ uc.next(); // consume the comma
341
+ if (!uc.isWord('announce')) {
342
+ this.diagnostics.error('parse.use-announce', 'Expected `announce <mode>` after the comma in a `use` line.', uc.restSpan());
343
+ continue;
344
+ }
345
+ uc.next(); // consume `announce`
346
+ const modeTok = uc.next();
347
+ if (!modeTok || modeTok.kind !== 'word') {
348
+ this.diagnostics.error('parse.use-announce', 'Expected an announce mode (all, collapsed, combined, silent) after `announce`.', uc.restSpan());
349
+ continue;
350
+ }
351
+ announce = modeTok.text;
352
+ }
353
+ if (!uc.atEnd()) {
354
+ this.diagnostics.error('parse.use', 'Expected `use <extension>[, announce <mode>]` — one extension name per line.', lineSpan(useLine));
355
+ continue;
356
+ }
357
+ uses.push(announce !== undefined
358
+ ? { name: nameTok.text, announce, span: lineSpan(useLine) }
359
+ : { name: nameTok.text, span: lineSpan(useLine) });
360
+ // ADR-261 D2 / ADR-263 D1: `use scoring` (rank ladder) and `use hunger`
361
+ // (satiety meter) are the `use` lines that take an indented body.
362
+ // `use combat` and `use state-machines` take none.
363
+ const body = [];
364
+ while (this.pos < this.lines.length && this.lines[this.pos].indent > useLine.indent) {
365
+ body.push(this.lines[this.pos++]);
366
+ }
367
+ if (body.length === 0)
368
+ continue;
369
+ if (nameTok.text === 'scoring') {
370
+ for (const bodyLine of body) {
371
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(bodyLine));
372
+ const rung = this.parseRankLine(bodyLine);
373
+ if (rung)
374
+ ranks.push(rung);
375
+ }
376
+ }
377
+ else if (nameTok.text === 'hunger') {
378
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(body[body.length - 1]));
379
+ hunger = this.parseHungerBody(body);
380
+ }
381
+ else {
382
+ this.diagnostics.error('parse.use-body', `\`use ${nameTok.text}\` takes no indented body — only \`use scoring\` and \`use hunger\` declare one.`, lineSpan(body[0]));
383
+ }
384
+ continue;
385
+ }
386
+ // `on every turn [while <cond>][, once]` — the story-owned daemon
387
+ // (ADR-236 D7, ratchet R4). The only clause form the header hosts;
388
+ // anything else keeps its owner-attached home.
389
+ if (peekedWord === 'on' || peekedWord === 'after') {
390
+ const clause = this.parseOnClause(peeked.indent, peekedWord);
391
+ span = (0, span_js_1.mergeSpans)(span, clause.span);
392
+ if (clause.clauseKind === 'on' && clause.binding === 'every-turn') {
393
+ onClauses.push(clause);
394
+ }
395
+ else if (!(clause.clauseKind === 'after' && clause.binding === 'every-turn')) {
396
+ // (`after every turn` already got its own parse error inside
397
+ // parseOnClause — don't stack a second diagnostic on it.)
398
+ this.diagnostics.error('parse.story-clause', 'The story header hosts only `on every turn` clauses (ADR-236 D7) — action and event clauses belong to the entity they are about.', clause.span);
399
+ }
400
+ continue;
401
+ }
228
402
  const fieldLine = this.lines[this.pos++];
229
- span = (0, span_1.mergeSpans)(span, lineSpan(fieldLine));
403
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(fieldLine));
230
404
  const key = firstWord(fieldLine);
231
405
  // `states[, reversible]: a, b` — story phases (ratchet D2/D4).
232
406
  if (key === 'states') {
@@ -246,6 +420,13 @@ class Parser {
246
420
  scores.push(s);
247
421
  continue;
248
422
  }
423
+ // A rung outside a `use scoring` body (ADR-261 D2). Placing the ladder
424
+ // under the line that enables it is what stops a ladder silently doing
425
+ // nothing, so a stray rung is an error rather than a floating field.
426
+ if (key === 'rank') {
427
+ this.diagnostics.error('parse.rank-outside-scoring', '`rank "<name>" at <n>` belongs in the indented body of a `use scoring` line.', lineSpan(fieldLine));
428
+ continue;
429
+ }
249
430
  const colon = fieldLine.tokens[1];
250
431
  if (!key || !colon || colon.kind !== 'colon') {
251
432
  this.diagnostics.error('parse.header-field', 'Expected `key: value` in the story header.', lineSpan(fieldLine));
@@ -254,7 +435,126 @@ class Parser {
254
435
  const colonAt = fieldLine.raw.indexOf(':');
255
436
  fields[key] = fieldLine.raw.slice(colonAt + 1).trim();
256
437
  }
257
- return { kind: 'story-header', title, author, fields, states, statesReversible, scores, span };
438
+ return { kind: 'story-header', title, author, fields, states, statesReversible, scores, onClauses, uses, usePhrasebooks, ranks, ...(hunger !== undefined ? { hunger } : {}), span };
439
+ }
440
+ /**
441
+ * The indented `use hunger` body (ADR-263 D1): `grows N each turn`,
442
+ * `<band> at <n> [says <key>]` rungs, and `fatal at N`.
443
+ */
444
+ parseHungerBody(body) {
445
+ let grows;
446
+ let fatal;
447
+ const rungs = [];
448
+ for (const line of body) {
449
+ const first = firstWord(line);
450
+ if (first === 'grows') {
451
+ const c = new Cursor(line.tokens, line);
452
+ c.matchWord('grows');
453
+ const nTok = c.next();
454
+ if (!nTok || nTok.kind !== 'number' || !c.matchWord('each') || !c.matchWord('turn') || !c.atEnd()) {
455
+ this.diagnostics.error('parse.hunger-grows', 'Expected `grows <number> each turn`.', lineSpan(line));
456
+ continue;
457
+ }
458
+ grows = Number(nTok.text);
459
+ }
460
+ else if (first === 'fatal') {
461
+ const c = new Cursor(line.tokens, line);
462
+ c.matchWord('fatal');
463
+ if (!c.matchWord('at')) {
464
+ this.diagnostics.error('parse.hunger-fatal', 'Expected `fatal at <number>`.', lineSpan(line));
465
+ continue;
466
+ }
467
+ const nTok = c.next();
468
+ if (!nTok || nTok.kind !== 'number' || !c.atEnd()) {
469
+ this.diagnostics.error('parse.hunger-fatal', 'Expected `fatal at <number>`.', lineSpan(line));
470
+ continue;
471
+ }
472
+ fatal = Number(nTok.text);
473
+ }
474
+ else {
475
+ const rung = this.parseMeterRungLine(line);
476
+ if (rung)
477
+ rungs.push(rung);
478
+ }
479
+ }
480
+ return { grows, fatal, rungs, span: lineSpan(body[0]) };
481
+ }
482
+ /**
483
+ * `<band> at <n> [says <key>]` — one rung of a metering body (ADR-263 D1).
484
+ * The band is a bareword and doubles as the band id.
485
+ */
486
+ parseMeterRungLine(line) {
487
+ const c = new Cursor(line.tokens, line);
488
+ const bandTok = c.next();
489
+ if (!bandTok || bandTok.kind !== 'word') {
490
+ this.diagnostics.error('parse.hunger-body', 'The `use hunger` body holds `grows N each turn`, `<band> at <n> [says <key>]` rungs, and `fatal at N`.', lineSpan(line));
491
+ return null;
492
+ }
493
+ if (!c.matchWord('at')) {
494
+ this.diagnostics.error('parse.meter-threshold', 'Expected `at <number>` after the band name.', c.restSpan());
495
+ return null;
496
+ }
497
+ const thresholdTok = c.next();
498
+ if (!thresholdTok || thresholdTok.kind !== 'number') {
499
+ this.diagnostics.error('parse.meter-threshold', 'Expected a number after `at`.', c.restSpan());
500
+ return null;
501
+ }
502
+ let phraseKey;
503
+ if (c.isWord('says')) {
504
+ c.next();
505
+ const keyTok = c.next();
506
+ if (!keyTok || keyTok.kind !== 'word') {
507
+ this.diagnostics.error('parse.meter-says', 'Expected a phrase key after `says`.', c.restSpan());
508
+ return null;
509
+ }
510
+ phraseKey = keyTok.text;
511
+ }
512
+ if (!c.atEnd()) {
513
+ this.diagnostics.error('parse.meter-extra', 'Unexpected text after the rung — the line is `<band> at <n> [says <key>]`.', c.restSpan());
514
+ return null;
515
+ }
516
+ return { kind: 'meter-rung', band: bandTok.text, threshold: Number(thresholdTok.text), phraseKey, span: lineSpan(line) };
517
+ }
518
+ /**
519
+ * `rank "<name>" at <n> [says <key>]` — one rung of the ladder, from the
520
+ * indented `use scoring` body (ADR-261 D2/D7).
521
+ */
522
+ parseRankLine(line) {
523
+ const c = new Cursor(line.tokens, line);
524
+ if (!c.matchWord('rank')) {
525
+ this.diagnostics.error('parse.use-scoring-body', 'The `use scoring` body holds only `rank "<name>" at <n> [says <key>]` lines.', lineSpan(line));
526
+ return null;
527
+ }
528
+ const nameTok = c.peek();
529
+ if (!nameTok || nameTok.kind !== 'string') {
530
+ this.diagnostics.error('parse.rank-name', 'Expected a quoted rank name after `rank` — rank prose is author content, written the way entity names are.', c.restSpan());
531
+ return null;
532
+ }
533
+ c.next();
534
+ if (!c.matchWord('at')) {
535
+ this.diagnostics.error('parse.rank-threshold', 'Expected `at <number>` after the rank name — a threshold is absolute points, never a percentage of max.', c.restSpan());
536
+ return null;
537
+ }
538
+ const thresholdTok = c.next();
539
+ if (!thresholdTok || thresholdTok.kind !== 'number') {
540
+ this.diagnostics.error('parse.rank-threshold', 'Expected a number after `at`.', c.restSpan());
541
+ return null;
542
+ }
543
+ let phraseKey;
544
+ if (c.isWord('says')) {
545
+ c.next();
546
+ const keyTok = c.next();
547
+ if (!keyTok || keyTok.kind !== 'word') {
548
+ this.diagnostics.error('parse.rank-says', 'Expected a phrase key after `says` — a kebab-case key in the story\'s own phrase namespace.', c.restSpan());
549
+ return null;
550
+ }
551
+ phraseKey = keyTok.text;
552
+ }
553
+ if (!c.atEnd()) {
554
+ this.diagnostics.error('parse.rank-extra', 'Unexpected text after the rung — the line is `rank "<name>" at <n> [says <key>]`.', c.restSpan());
555
+ return null;
556
+ }
557
+ return { kind: 'rank', name: nameTok.text, threshold: Number(thresholdTok.text), phraseKey, span: lineSpan(line) };
258
558
  }
259
559
  /**
260
560
  * Parse the tail of a `states` line after the `states` word:
@@ -313,10 +613,13 @@ class Parser {
313
613
  kind: 'create',
314
614
  name,
315
615
  aka: [],
616
+ pronouns: [],
316
617
  compositions: [],
618
+ startsStates: [],
317
619
  placement: null,
318
620
  wears: [],
319
621
  carries: [],
622
+ containing: [],
320
623
  exits: [],
321
624
  blockedExits: [],
322
625
  deadlyExits: [],
@@ -334,7 +637,7 @@ class Parser {
334
637
  while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
335
638
  const line = this.lines[this.pos];
336
639
  sawBlank = sawBlank || line.afterBlank;
337
- decl.span = (0, span_1.mergeSpans)(decl.span, lineSpan(line));
640
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
338
641
  const word = firstWord(line);
339
642
  const cur = new Cursor(line.tokens, line);
340
643
  if (word === 'aka') {
@@ -342,6 +645,20 @@ class Parser {
342
645
  cur.matchWord('aka');
343
646
  decl.aka.push(...this.parseCommaWords(cur));
344
647
  }
648
+ else if (word === 'pronouns') {
649
+ // ADR-242 D5: `pronouns <word>` — one word (a standard set or a
650
+ // `define pronouns` name). Person-only legality, word resolution,
651
+ // and duplicate-line rejection are the analyzer's gates.
652
+ this.pos++;
653
+ cur.matchWord('pronouns');
654
+ const wordTok = cur.next();
655
+ if (!wordTok || wordTok.kind !== 'word' || !cur.atEnd()) {
656
+ this.diagnostics.error('parse.pronouns-word', 'Expected one pronoun-set word after `pronouns` (e.g. `pronouns she`).', lineSpan(line));
657
+ }
658
+ else {
659
+ decl.pronouns.push({ word: wordTok.text.toLowerCase(), span: lineSpan(line) });
660
+ }
661
+ }
345
662
  else if (word === 'states' && (line.tokens[1]?.kind === 'colon' || line.tokens[1]?.kind === 'comma')) {
346
663
  this.pos++;
347
664
  cur.next();
@@ -369,7 +686,20 @@ class Parser {
369
686
  cur.matchWord('carries');
370
687
  decl.carries.push(this.parseNameRef(cur, () => false));
371
688
  }
689
+ else if (word === 'containing') {
690
+ // ADR-236 D2 (ratchet R2): region membership — `containing the
691
+ // Clearing, the Forest Path, and the Canyon View`. Additive across
692
+ // lines; region-block-only legality is the analyzer's gate.
693
+ this.pos++;
694
+ cur.matchWord('containing');
695
+ decl.containing.push(...this.parseNameRefList(cur, line));
696
+ }
372
697
  else if (word === 'starts' && cur.isWord('in', 1)) {
698
+ // The `starts` dispatch is one-token lookahead (ADR-231 D5a):
699
+ // `starts in <place>` is placement (here); `starts <known-state>` is
700
+ // an initializer clause, handled by the composition-line fallthrough
701
+ // below (parseCompositionLine's `starts` branch — same branch that
702
+ // rejects unknown words after `starts`).
373
703
  this.pos++;
374
704
  cur.next();
375
705
  cur.next();
@@ -426,8 +756,26 @@ class Parser {
426
756
  this.pos++;
427
757
  cur.next();
428
758
  cur.next();
429
- const to = this.parseNameRef(cur, () => false);
430
- decl.exits.push({ kind: 'exit', direction: word, to, span: lineSpan(line) });
759
+ // `through` is reserved on exit lines as the door tail (ADR-234 D1,
760
+ // ratchet R2) it stops the destination name.
761
+ const to = this.parseNameRef(cur, (t) => t.kind === 'word' && t.text === 'through');
762
+ let via = null;
763
+ if (cur.matchWord('through')) {
764
+ const doorRef = this.parseNameRef(cur, () => false);
765
+ if (doorRef.words.length === 0) {
766
+ this.diagnostics.error('parse.exit-through', 'Expected a door name after `through` (e.g. `north to the Hall through the oak door`).', lineSpan(line));
767
+ }
768
+ else {
769
+ via = doorRef;
770
+ }
771
+ }
772
+ // ADR-234 D4: `, one-way` is reserved (traversable in the written
773
+ // direction only) but NOT wired — a legible reservation error, not
774
+ // a generic parse failure, until its own ratchet entry lands.
775
+ if (cur.peek()?.kind === 'comma' && cur.isWord('one-way', 1)) {
776
+ this.diagnostics.error('parse.exit-one-way-reserved', '`, one-way` is reserved but not yet wired — exits and doors are bidirectional for now.', lineSpan(line));
777
+ }
778
+ decl.exits.push({ kind: 'exit', direction: word, to, via, span: lineSpan(line) });
431
779
  }
432
780
  else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('blocked', 2)) {
433
781
  this.pos++;
@@ -465,7 +813,7 @@ class Parser {
465
813
  }
466
814
  else {
467
815
  this.pos++;
468
- decl.compositions.push(...this.parseCompositionLine(cur, line));
816
+ decl.compositions.push(...this.parseCompositionLine(cur, line, decl.startsStates));
469
817
  }
470
818
  }
471
819
  else {
@@ -477,7 +825,7 @@ class Parser {
477
825
  ...decl.description,
478
826
  text: `${decl.description.text}\n\n${prose.text}`,
479
827
  markers: [...decl.description.markers, ...prose.markers],
480
- span: (0, span_1.mergeSpans)(decl.description.span, prose.span),
828
+ span: (0, span_js_1.mergeSpans)(decl.description.span, prose.span),
481
829
  };
482
830
  }
483
831
  else {
@@ -513,12 +861,12 @@ class Parser {
513
861
  this.diagnostics.error('parse.blocked-exit', 'Expected `: <phrase-key>` after `is blocked`.', lineSpan(line));
514
862
  return null;
515
863
  }
516
- const key = c.next();
517
- if (!key || key.kind !== 'word') {
864
+ const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
865
+ if (!key) {
518
866
  this.diagnostics.error('parse.blocked-exit', 'Expected a phrase key after `is blocked:`.', lineSpan(line));
519
867
  return null;
520
868
  }
521
- return { kind: 'blocked-exit', direction, phraseKey: key.text, condition, span: lineSpan(line) };
869
+ return { kind: 'blocked-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
522
870
  }
523
871
  parseDeadlyExit(direction, line) {
524
872
  // <direction> is deadly [while <condition>]: <phrase-key> (ADR-227) —
@@ -539,17 +887,52 @@ class Parser {
539
887
  this.diagnostics.error('parse.deadly-exit', 'Expected `: <phrase-key>` after `is deadly`.', lineSpan(line));
540
888
  return null;
541
889
  }
542
- const key = c.next();
543
- if (!key || key.kind !== 'word') {
890
+ const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
891
+ if (!key) {
544
892
  this.diagnostics.error('parse.deadly-exit', 'Expected a phrase key after `is deadly:`.', lineSpan(line));
545
893
  return null;
546
894
  }
547
- return { kind: 'deadly-exit', direction, phraseKey: key.text, condition, span: lineSpan(line) };
895
+ return { kind: 'deadly-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
548
896
  }
549
- parseCompositionLine(c, line) {
897
+ parseCompositionLine(c, line, startsStates) {
550
898
  const items = [];
551
899
  while (!c.atEnd()) {
552
900
  const startTok = c.peek();
901
+ // `starts <state>` initializer clause (ADR-231 D5a) — one-token
902
+ // lookahead on the `starts` word Chord already owns: a known state
903
+ // word (`locked`, `open`, …) initializes the paired trait's initial
904
+ // value; `in` is the placement line's spelling and can't ride a
905
+ // composition list; anything else is its own parse error, never a
906
+ // silent pass into trait-name resolution.
907
+ if (c.isWord('starts')) {
908
+ const startsTok = c.next();
909
+ const stateTok = c.peek();
910
+ if (stateTok && stateTok.kind === 'word' && catalog_js_1.STARTS_STATE_PAIRINGS.has(stateTok.text)) {
911
+ c.next();
912
+ startsStates.push({
913
+ kind: 'starts-state',
914
+ state: stateTok.text,
915
+ span: (0, span_js_1.mergeSpans)(startsTok.span, stateTok.span),
916
+ });
917
+ if (c.peek()?.kind === 'comma') {
918
+ c.next();
919
+ continue;
920
+ }
921
+ break;
922
+ }
923
+ if (stateTok && stateTok.kind === 'word' && stateTok.text === 'in') {
924
+ this.diagnostics.error('parse.starts-state', '`starts in <place>` is a placement line of its own — it cannot ride a composition list.', (0, span_js_1.mergeSpans)(startsTok.span, stateTok.span));
925
+ }
926
+ else {
927
+ const known = [...catalog_js_1.STARTS_STATE_PAIRINGS.keys()].join(', ');
928
+ this.diagnostics.error('parse.starts-state', stateTok
929
+ ? `\`${stateTok.text}\` is not a state \`starts\` can initialize — known states: ${known} (placement is \`starts in <place>\`).`
930
+ : `Expected a state after \`starts\` — known states: ${known} (placement is \`starts in <place>\`).`, stateTok ? stateTok.span : startsTok.span);
931
+ }
932
+ while (!c.atEnd())
933
+ c.next(); // resynchronize: one mistake, one diagnostic
934
+ break;
935
+ }
553
936
  let article = null;
554
937
  const first = c.peek();
555
938
  if (first && first.kind === 'word' && ARTICLES.has(first.text)) {
@@ -583,7 +966,7 @@ class Parser {
583
966
  words,
584
967
  config,
585
968
  condition,
586
- span: (0, span_1.mergeSpans)(startTok.span, endTok.span),
969
+ span: (0, span_js_1.mergeSpans)(startTok.span, endTok.span),
587
970
  });
588
971
  if (c.peek()?.kind === 'comma')
589
972
  c.next();
@@ -599,6 +982,33 @@ class Parser {
599
982
  const settings = [];
600
983
  for (;;) {
601
984
  const startTok = c.peek();
985
+ // Ratchet R3 (ADR-234 D6): an article directly after `with`/`and`
986
+ // starts the adjective's single-entity config value — no keyword
987
+ // (`lockable with the iron key`). Keyed named fields (`with food the
988
+ // handful of feed` on authored traits) still parse below: their key
989
+ // words come first, so the article is not in first position.
990
+ if (startTok && startTok.kind === 'word' && ARTICLES.has(startTok.text)) {
991
+ c.next(); // article
992
+ const nameWords = [];
993
+ let lastTok = startTok;
994
+ while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
995
+ lastTok = c.next();
996
+ nameWords.push(lastTok.text);
997
+ }
998
+ if (nameWords.length === 0) {
999
+ this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
1000
+ break;
1001
+ }
1002
+ settings.push({
1003
+ key: [],
1004
+ value: nameWords.join(' '),
1005
+ valueKind: 'name',
1006
+ span: (0, span_js_1.mergeSpans)(startTok.span, lastTok.span),
1007
+ });
1008
+ if (!c.matchWord('and'))
1009
+ break;
1010
+ continue;
1011
+ }
602
1012
  const key = [];
603
1013
  while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
604
1014
  const t = c.peek();
@@ -628,11 +1038,65 @@ class Parser {
628
1038
  this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
629
1039
  break;
630
1040
  }
1041
+ // Ratchet R3 (ADR-234 D6): the `key`/`tool` config keywords are
1042
+ // removed — the entity is written directly after `with`. One form
1043
+ // per concept (Given 7); the fix-it names the replacement.
1044
+ if (key.length === 1 && (key[0] === 'key' || key[0] === 'tool')) {
1045
+ this.diagnostics.error('parse.removed-config-keyword', `\`with ${key[0]} the …\` was removed (ratchet R3) — write the entity directly: \`with the ${nameWords.join(' ')}\`.`, startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span);
1046
+ if (!c.matchWord('and'))
1047
+ break;
1048
+ continue;
1049
+ }
631
1050
  settings.push({
632
1051
  key,
633
1052
  value: nameWords.join(' '),
634
1053
  valueKind: 'name',
635
- span: startTok ? (0, span_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span,
1054
+ span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span,
1055
+ });
1056
+ if (!c.matchWord('and'))
1057
+ break;
1058
+ continue;
1059
+ }
1060
+ // `[ … ]` list value (ADR-215): a bracketed, comma-separated list of
1061
+ // name references (`patrol route [Hall, Study, Hall]`). Narrower than
1062
+ // ADR-216's statement-payload arrays — config lists hold names only.
1063
+ const bracketTok = c.peek();
1064
+ if (bracketTok && bracketTok.kind === 'lbracket' && key.length > 0) {
1065
+ c.next(); // [
1066
+ const listValues = [];
1067
+ for (;;) {
1068
+ if (c.peek()?.kind === 'rbracket')
1069
+ break;
1070
+ const ref = this.parseNameRef(c, () => false);
1071
+ if (ref.words.length === 0) {
1072
+ this.diagnostics.error('parse.config-list', 'Expected an entity name inside the `[ … ]` list.', c.restSpan());
1073
+ while (!c.atEnd() && c.peek().kind !== 'rbracket')
1074
+ c.next();
1075
+ break;
1076
+ }
1077
+ listValues.push(ref);
1078
+ if (c.peek()?.kind === 'comma')
1079
+ c.next();
1080
+ else
1081
+ break;
1082
+ }
1083
+ const close = c.next();
1084
+ let lastSpan = bracketTok.span;
1085
+ if (!close || close.kind !== 'rbracket') {
1086
+ this.diagnostics.error('parse.config-list', 'Expected `]` to close the list.', c.restSpan());
1087
+ }
1088
+ else {
1089
+ lastSpan = close.span;
1090
+ }
1091
+ if (listValues.length === 0) {
1092
+ this.diagnostics.error('parse.config-list', 'A `[ … ]` list needs at least one entry.', lastSpan);
1093
+ }
1094
+ settings.push({
1095
+ key,
1096
+ value: '',
1097
+ valueKind: 'list',
1098
+ listValues,
1099
+ span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastSpan) : lastSpan,
636
1100
  });
637
1101
  if (!c.matchWord('and'))
638
1102
  break;
@@ -648,7 +1112,7 @@ class Parser {
648
1112
  key,
649
1113
  value: valueTok.text,
650
1114
  valueKind: valueTok.kind,
651
- span: startTok ? (0, span_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
1115
+ span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
652
1116
  });
653
1117
  if (!c.matchWord('and'))
654
1118
  break;
@@ -658,7 +1122,8 @@ class Parser {
658
1122
  parsePhraseOverride(line) {
659
1123
  const c = new Cursor(line.tokens, line);
660
1124
  c.matchWord('phrase');
661
- const key = c.next(); // validated by caller
1125
+ // Key word validated by the caller; phrase-key = WORD { "." WORD } (ADR-231 D1b).
1126
+ const key = this.readLabelKey(c);
662
1127
  // CP3: optional `, <strategy>` (the Z5 adverb set, retired fix-its
663
1128
  // included) — `phrase present, cycling:`.
664
1129
  let strategy = null;
@@ -713,11 +1178,11 @@ class Parser {
713
1178
  const last = variants[variants.length - 1];
714
1179
  return {
715
1180
  kind: 'phrase-override',
716
- key: key.text,
1181
+ key,
717
1182
  strategy,
718
1183
  condition,
719
1184
  variants,
720
- span: (0, span_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
1185
+ span: (0, span_js_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
721
1186
  };
722
1187
  }
723
1188
  /** Same-line phrase text (quoted or bare) was removed — grammar log 2026-07-10. */
@@ -742,6 +1207,33 @@ class Parser {
742
1207
  out.push(current.join(' '));
743
1208
  return out;
744
1209
  }
1210
+ /**
1211
+ * A comma-separated list of entity name references, Oxford-`and` welcome
1212
+ * (`the Clearing, the Forest Path, and the Canyon View`). Each reference
1213
+ * keeps its own article and span; an empty list is a parse error.
1214
+ */
1215
+ parseNameRefList(c, line) {
1216
+ const refs = [];
1217
+ for (;;) {
1218
+ const ref = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'and');
1219
+ if (ref.words.length === 0) {
1220
+ this.diagnostics.error('parse.name-list', 'Expected an entity name.', c.atEnd() ? lineSpan(line) : c.restSpan());
1221
+ break;
1222
+ }
1223
+ refs.push(ref);
1224
+ if (c.peek()?.kind === 'comma') {
1225
+ c.next();
1226
+ c.matchWord('and'); // Oxford `and` after the comma
1227
+ }
1228
+ else if (!c.matchWord('and')) {
1229
+ break;
1230
+ }
1231
+ }
1232
+ if (!c.atEnd()) {
1233
+ this.diagnostics.error('parse.name-list', `Unexpected trailing text in name list: \`${c.peek().text}\`.`, c.restSpan());
1234
+ }
1235
+ return refs;
1236
+ }
745
1237
  parseStateList(c) {
746
1238
  const out = [];
747
1239
  while (!c.atEnd()) {
@@ -786,7 +1278,7 @@ class Parser {
786
1278
  const text = line.raw.trim();
787
1279
  this.extractMarkers(line, markers);
788
1280
  current.push(text);
789
- span = span ? (0, span_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
1281
+ span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
790
1282
  first = false;
791
1283
  }
792
1284
  if (current.length)
@@ -796,7 +1288,7 @@ class Parser {
796
1288
  form: 'prose',
797
1289
  text: paragraphs.map((p) => p.join(' ')).join('\n\n'),
798
1290
  markers,
799
- span: span ?? (0, span_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
1291
+ span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
800
1292
  };
801
1293
  }
802
1294
  /**
@@ -818,7 +1310,7 @@ class Parser {
818
1310
  break;
819
1311
  collected.push({ raw: line.raw, blankBefore: !first && line.afterBlank });
820
1312
  this.extractMarkers(line, markers);
821
- span = span ? (0, span_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
1313
+ span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
822
1314
  this.pos++;
823
1315
  first = false;
824
1316
  }
@@ -834,14 +1326,14 @@ class Parser {
834
1326
  form: 'verbatim',
835
1327
  text: lines.join('\n'),
836
1328
  markers,
837
- span: span ?? (0, span_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
1329
+ span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
838
1330
  };
839
1331
  }
840
1332
  extractMarkers(line, into) {
841
1333
  const re = /\{([^}]*)\}/g;
842
1334
  let m;
843
1335
  while ((m = re.exec(line.raw)) !== null) {
844
- into.push({ content: m[1], span: (0, span_1.spanOf)(line.lineNo, m.index + 1, m[0].length) });
1336
+ into.push({ content: m[1], span: (0, span_js_1.spanOf)(line.lineNo, m.index + 1, m[0].length) });
845
1337
  }
846
1338
  }
847
1339
  /** Error-recovery only: a removed same-line quoted value, kept as prose text. */
@@ -850,7 +1342,7 @@ class Parser {
850
1342
  const re = /\{([^}]*)\}/g;
851
1343
  let m;
852
1344
  while ((m = re.exec(tok.text)) !== null) {
853
- markers.push({ content: m[1], span: (0, span_1.spanOf)(tok.span.line, tok.span.column + 1 + m.index, m[0].length) });
1345
+ markers.push({ content: m[1], span: (0, span_js_1.spanOf)(tok.span.line, tok.span.column + 1 + m.index, m[0].length) });
854
1346
  }
855
1347
  return { kind: 'text', form: 'prose', text: tok.text, markers, span: tok.span };
856
1348
  }
@@ -864,6 +1356,10 @@ class Parser {
864
1356
  return this.parseDefineCondition();
865
1357
  case 'phrase':
866
1358
  return this.parseDefinePhrase();
1359
+ case 'phrasebook':
1360
+ // ADR-245/250 D1 (David 2026-07-21) — named, predicated phrase
1361
+ // collections; entries reuse the phrase-override grammar.
1362
+ return this.parseDefinePhrasebook();
867
1363
  case 'phrases':
868
1364
  return this.parseDefinePhrases();
869
1365
  case 'verb':
@@ -879,8 +1375,15 @@ class Parser {
879
1375
  return this.parseDefineTrait();
880
1376
  case 'action':
881
1377
  return this.parseDefineAction();
1378
+ case 'chain':
1379
+ return this.parseDefineChainHatch();
882
1380
  case 'behavior':
883
- return this.parseDefineBehaviorHatch();
1381
+ // ADR-235 D2 (removal, 2026-07-18): the behavior hatch carried no
1382
+ // trait/action binding key, so it structurally could never fire —
1383
+ // removed rather than repaired.
1384
+ this.diagnostics.error('parse.removed-behavior-hatch', '`define behavior … from` was removed (ADR-235 D2) — it had no binding key and could never fire. Author the behavior in-language (`define trait <name>` with `on <verb> it` clauses, composed on the entity), or ship a full action with `define action <name> from "<module>"`.', lineSpan(this.lines[this.pos]));
1385
+ this.pos++;
1386
+ return null;
884
1387
  case 'score':
885
1388
  // Removed — ratchet D12/CP5 (2026-07-11).
886
1389
  this.diagnostics.error('parse.removed-score', '`define score` was removed (ownership package) — attach the score to its earning owner: `score <name> worth N` in the owner\'s create/trait/action block or the story header.', lineSpan(line));
@@ -888,6 +1391,29 @@ class Parser {
888
1391
  return null;
889
1392
  case 'sequence':
890
1393
  return this.parseDefineSequence();
1394
+ case 'machine':
1395
+ // ADR-215 `use state-machines` depth (spelling A, 2026-07-18) —
1396
+ // the `use` requirement is the analyzer's gate.
1397
+ return this.parseDefineMachine();
1398
+ case 'sound':
1399
+ case 'image':
1400
+ case 'music':
1401
+ // ADR-216 declared assets — DATA references, never hatches.
1402
+ return this.parseDefineAsset(subWord);
1403
+ case 'ambient':
1404
+ case 'layer':
1405
+ // ADR-241 D2 — named family channels (an ambient bed / an image
1406
+ // layer), one-liners beside the asset declarations.
1407
+ return this.parseDefineFamilyChannel(subWord);
1408
+ case 'channel':
1409
+ // ADR-216 custom channels (spelling A, 2026-07-18) — data projections.
1410
+ return this.parseDefineChannel();
1411
+ case 'topics':
1412
+ // ADR-239 D3 (as amended) — the ask/tell topic table block.
1413
+ return this.parseDefineTopics();
1414
+ case 'pronouns':
1415
+ // ADR-242 D7 (ruled Q-1) — named pronoun set, five named rows.
1416
+ return this.parseDefinePronouns();
891
1417
  default:
892
1418
  this.diagnostics.error('parse.unknown-define', `Unknown declaration \`define ${subWord ?? ''}\`.`, lineSpan(line));
893
1419
  this.recoverToTopLevel(true);
@@ -917,19 +1443,27 @@ class Parser {
917
1443
  const c = new Cursor(headLine.tokens, headLine);
918
1444
  c.next();
919
1445
  c.next(); // define phrase
920
- const keyTok = c.next();
921
- let key = keyTok && keyTok.kind === 'word' ? keyTok.text : '';
1446
+ // EBNF: phrase-key = WORD { "." WORD } (ADR-230 D5). Previously the
1447
+ // parser silently registered only the first segment (`if.action.taking`
1448
+ // became `if`), which made story-wide overrides of platform message ids
1449
+ // impossible.
1450
+ const key = this.readLabelKey(c) ?? '';
922
1451
  if (!key) {
923
1452
  this.diagnostics.error('parse.phrase-key', 'Expected a phrase key after `define phrase`.', lineSpan(headLine));
1453
+ c.next(); // skip the offending token so header options still parse
924
1454
  }
925
- // EBNF: phrase-key = WORD { "." WORD } (ADR-230 D5). The lexer splits at
926
- // dots, so consume `.WORD` segments — previously the parser silently
927
- // registered only the first segment (`if.action.taking` became `if`),
928
- // which made story-wide overrides of platform message ids impossible.
929
- while (key && c.peek()?.kind === 'punct' && c.peek()?.text === '.' && c.peek(1)?.kind === 'word') {
930
- c.next(); // consume '.'
931
- key += '.' + c.next().text;
932
- }
1455
+ const body = this.readPhraseBody(c, headLine, 'phrase');
1456
+ return { kind: 'define-phrase', key, ...body };
1457
+ }
1458
+ /**
1459
+ * Read a phrase body from a header cursor positioned after the key/alias:
1460
+ * the optional `, <strategy>|verbatim` header modifier, an optional trailing
1461
+ * `while <condition>` gate, then the indented variant lines up to
1462
+ * `end <endWord>`. Shared by `define phrase` and ADR-255's `override message`
1463
+ * so the two constructs never drift (ADR-255 D1). Returns the body fields
1464
+ * (the caller supplies the `kind` and key/alias).
1465
+ */
1466
+ readPhraseBody(c, headLine, endWord) {
933
1467
  let strategy = null;
934
1468
  let verbatim = false;
935
1469
  if (c.peek()?.kind === 'comma') {
@@ -959,15 +1493,22 @@ class Parser {
959
1493
  }
960
1494
  const variants = [];
961
1495
  let span = lineSpan(headLine);
1496
+ let flaggedFlushLeft = false;
962
1497
  for (;;) {
963
1498
  const line = this.lines[this.pos];
964
1499
  if (!line) {
965
- this.diagnostics.error('parse.unterminated-block', 'Missing `end phrase`.', span);
1500
+ this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
966
1501
  break;
967
1502
  }
1503
+ if (line.comment) {
1504
+ // ADR-249 — indented `##` stays prose here; only a flagged
1505
+ // (indent-0) comment line is an inside-block error.
1506
+ this.skipCommentInsideBlock(line);
1507
+ continue;
1508
+ }
968
1509
  if (isEndLine(line)) {
969
- span = (0, span_1.mergeSpans)(span, lineSpan(line));
970
- this.consumeEnd('phrase', headLine);
1510
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
1511
+ this.consumeEnd(endWord, headLine);
971
1512
  break;
972
1513
  }
973
1514
  if (firstWord(line) === 'or' && line.tokens.length === 1) {
@@ -978,19 +1519,153 @@ class Parser {
978
1519
  continue;
979
1520
  }
980
1521
  if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? '')) {
981
- this.diagnostics.error('parse.unterminated-block', 'Missing `end phrase`.', span);
1522
+ this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
982
1523
  break;
983
1524
  }
1525
+ if (line.indent === 0) {
1526
+ // A flush-left non-keyword line makes zero progress in either
1527
+ // variant parser below (both require depth > 0) — without this
1528
+ // guard the loop appended empty variants until OOM. One diagnostic
1529
+ // for the first offending line; the rest of the flush-left run is
1530
+ // consumed so `end <endWord>` still terminates the block.
1531
+ if (!flaggedFlushLeft) {
1532
+ flaggedFlushLeft = true;
1533
+ this.diagnostics.error('parse.phrase-text-indent', `Text must be indented under \`${endWord === 'override' ? 'override message' : 'define phrase'}\`.`, lineSpan(line));
1534
+ }
1535
+ this.pos++;
1536
+ continue;
1537
+ }
984
1538
  const variant = verbatim ? this.parseVerbatimBlock() : this.parseProseParagraph(1, 0);
985
1539
  variants.push(variant);
986
- span = (0, span_1.mergeSpans)(span, variant.span);
1540
+ span = (0, span_js_1.mergeSpans)(span, variant.span);
1541
+ }
1542
+ return { strategy, verbatim, condition, variants, span };
1543
+ }
1544
+ /**
1545
+ * `define phrasebook <name> [while <condition>] … end phrasebook`
1546
+ * (ADR-250 D1). Entries are phrase-override-shaped lines
1547
+ * (`<key>[, strategy]:` + `or` variants) at one indent level; the
1548
+ * body carries the ADR-249 comment guard like every end-terminated
1549
+ * block. Entry-level `while` parses here (override grammar) and is
1550
+ * gated in the analyzer (`analysis.phrasebook-entry-gate`).
1551
+ */
1552
+ parseDefinePhrasebook() {
1553
+ const headLine = this.lines[this.pos++];
1554
+ const c = new Cursor(headLine.tokens, headLine);
1555
+ c.matchWord('define');
1556
+ c.matchWord('phrasebook');
1557
+ let name = '';
1558
+ const nameTok = c.next();
1559
+ if (!nameTok || nameTok.kind !== 'word') {
1560
+ this.diagnostics.error('parse.phrasebook-header', 'Expected `define phrasebook <name> [while <condition>]` — a single kebab-case book name.', c.restSpan());
1561
+ }
1562
+ else {
1563
+ name = nameTok.text;
1564
+ }
1565
+ let condition = null;
1566
+ if (c.isWord('while')) {
1567
+ c.next();
1568
+ const condTokens = [];
1569
+ while (!c.atEnd())
1570
+ condTokens.push(c.next());
1571
+ condition = this.parseCondition(new Cursor(condTokens, headLine), headLine);
1572
+ }
1573
+ else if (!c.atEnd()) {
1574
+ this.diagnostics.error('parse.phrasebook-header', 'Unexpected text after the book name — only `while <condition>` may follow.', c.restSpan());
1575
+ }
1576
+ const entries = [];
1577
+ let span = lineSpan(headLine);
1578
+ for (;;) {
1579
+ const line = this.lines[this.pos];
1580
+ if (!line) {
1581
+ this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
1582
+ break;
1583
+ }
1584
+ if (line.comment) {
1585
+ // ADR-249: `##` inside a block is never a comment.
1586
+ this.skipCommentInsideBlock(line);
1587
+ continue;
1588
+ }
1589
+ if (isEndLine(line)) {
1590
+ span = (0, span_js_1.mergeSpans)(span, this.consumeEnd('phrasebook', headLine));
1591
+ break;
1592
+ }
1593
+ if (line.indent === 0) {
1594
+ this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
1595
+ break;
1596
+ }
1597
+ if (line.tokens[0]?.kind !== 'word' || !line.tokens.some((t) => t.kind === 'colon')) {
1598
+ this.diagnostics.error('parse.phrasebook-entry', 'Expected `<key>[, strategy]:` to open a phrasebook entry.', lineSpan(line));
1599
+ this.pos++;
1600
+ continue;
1601
+ }
1602
+ this.pos++;
1603
+ const entry = this.parsePhraseOverride(line);
1604
+ entries.push(entry);
1605
+ span = (0, span_js_1.mergeSpans)(span, entry.span);
1606
+ }
1607
+ return { kind: 'define-phrasebook', name, condition, entries, span };
1608
+ }
1609
+ /**
1610
+ * `import "<file>"` (ADR-251 D1) — the single generalized import form.
1611
+ * The path is extension-free (the compiler appends `.chord` at resolve
1612
+ * time — D2); any string is accepted at parse time. A bare word after
1613
+ * `import` (the removed `phrasebook` sub-word, or anything else) or a
1614
+ * missing string is `parse.import-form`.
1615
+ */
1616
+ parseImport() {
1617
+ const line = this.lines[this.pos++];
1618
+ const c = new Cursor(line.tokens, line);
1619
+ c.matchWord('import');
1620
+ const pathTok = c.next();
1621
+ if (!pathTok || pathTok.kind !== 'string' || !c.atEnd()) {
1622
+ this.diagnostics.error('parse.import-form', 'Expected a quoted file name: `import "<file>"` (no extension — `.chord` is assumed).', c.restSpan());
1623
+ return null;
987
1624
  }
988
- return { kind: 'define-phrase', key, strategy, verbatim, condition, variants, span };
1625
+ return { kind: 'import', path: pathTok.text, span: lineSpan(line) };
989
1626
  }
990
1627
  parseDefinePhrases() {
991
1628
  const headLine = this.lines[this.pos++];
992
1629
  return this.parsePhrasesBlock(headLine, 2);
993
1630
  }
1631
+ /**
1632
+ * ADR-255: dispatch the two override forms off the word after `override`.
1633
+ * `override message <alias> … end override` (full phrase body) vs.
1634
+ * `override messages <locale>` (locale block of `alias: text` entries).
1635
+ */
1636
+ parseOverride() {
1637
+ const headLine = this.lines[this.pos];
1638
+ const kindTok = headLine.tokens[1];
1639
+ if (kindTok?.kind === 'word' && kindTok.text === 'messages') {
1640
+ return this.parseOverrideMessages();
1641
+ }
1642
+ if (kindTok?.kind === 'word' && kindTok.text === 'message') {
1643
+ return this.parseOverrideMessage();
1644
+ }
1645
+ this.diagnostics.error('parse.override', 'Expected `override message <alias>` or `override messages <locale>`.', lineSpan(headLine));
1646
+ this.pos++;
1647
+ return null;
1648
+ }
1649
+ /** `override message <alias> [, strategy] [while <cond>] … end override` (ADR-255 D1). */
1650
+ parseOverrideMessage() {
1651
+ const headLine = this.lines[this.pos++];
1652
+ const c = new Cursor(headLine.tokens, headLine);
1653
+ c.next(); // override
1654
+ c.next(); // message
1655
+ const alias = this.readLabelKey(c) ?? '';
1656
+ if (!alias) {
1657
+ this.diagnostics.error('parse.override-alias', 'Expected an override alias after `override message`.', lineSpan(headLine));
1658
+ c.next(); // skip the offending token so header options still parse
1659
+ }
1660
+ const body = this.readPhraseBody(c, headLine, 'override');
1661
+ return { kind: 'override-message', alias, ...body };
1662
+ }
1663
+ /** `override messages <locale>` — a locale block of `alias: text` entries (ADR-255 D1). */
1664
+ parseOverrideMessages() {
1665
+ const headLine = this.lines[this.pos++];
1666
+ const phrases = this.parsePhrasesBlock(headLine, 2);
1667
+ return { kind: 'override-messages', locale: phrases.locale, entries: phrases.entries, span: phrases.span };
1668
+ }
994
1669
  /**
995
1670
  * Parse a phrases block from its header line (`define phrases <locale>` at
996
1671
  * top level, or `phrases <locale>` inside a trait/action, Phase B). The
@@ -1010,16 +1685,18 @@ class Parser {
1010
1685
  let span = lineSpan(headLine);
1011
1686
  while (this.pos < this.lines.length && this.lines[this.pos].indent > headLine.indent) {
1012
1687
  const line = this.lines[this.pos];
1013
- const key = line.tokens[0];
1014
- const colon = line.tokens[1];
1015
- if (!key || key.kind !== 'word' || !colon || colon.kind !== 'colon') {
1688
+ const ec = new Cursor(line.tokens, line);
1689
+ const key = this.readLabelKey(ec); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
1690
+ const colon = ec.peek();
1691
+ if (!key || !colon || colon.kind !== 'colon') {
1016
1692
  this.diagnostics.error('parse.phrase-entry', 'Expected `key: <text>` in the phrases block.', lineSpan(line));
1017
1693
  this.pos++;
1018
1694
  continue;
1019
1695
  }
1696
+ ec.next(); // colon
1020
1697
  this.pos++;
1021
1698
  let value;
1022
- const inline = line.tokens[2];
1699
+ const inline = ec.peek();
1023
1700
  if (inline && inline.kind === 'string') {
1024
1701
  this.reportSameLineText(inline.span);
1025
1702
  value = this.textFromString(inline); // recovery: keep the text so analysis continues
@@ -1036,11 +1713,11 @@ class Parser {
1036
1713
  else {
1037
1714
  value = this.parseProseParagraph(line.indent + 1, line.indent);
1038
1715
  if (value.text === '') {
1039
- this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key.text}\` has no text.`, lineSpan(line));
1716
+ this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key}\` has no text.`, lineSpan(line));
1040
1717
  }
1041
1718
  }
1042
- entries.push({ key: key.text, value, span: (0, span_1.mergeSpans)(lineSpan(line), value.span) });
1043
- span = (0, span_1.mergeSpans)(span, entries[entries.length - 1].span);
1719
+ entries.push({ key, value, span: (0, span_js_1.mergeSpans)(lineSpan(line), value.span) });
1720
+ span = (0, span_js_1.mergeSpans)(span, entries[entries.length - 1].span);
1044
1721
  }
1045
1722
  return { kind: 'define-phrases', locale, entries, span };
1046
1723
  }
@@ -1074,7 +1751,7 @@ class Parser {
1074
1751
  this.diagnostics.error('parse.verb-slot', 'Expected `(something)` slot in the verb pattern.', t.span);
1075
1752
  return null;
1076
1753
  }
1077
- pattern.push({ kind: 'slot', word: slot.text, span: (0, span_1.mergeSpans)(t.span, close.span) });
1754
+ pattern.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, close.span) });
1078
1755
  }
1079
1756
  else if (t.kind === 'word') {
1080
1757
  pattern.push({ kind: 'word', word: t.text, span: t.span });
@@ -1129,6 +1806,10 @@ class Parser {
1129
1806
  });
1130
1807
  while (this.pos < this.lines.length) {
1131
1808
  const line = this.lines[this.pos];
1809
+ if (looksLikeComment(line)) {
1810
+ this.skipCommentInsideBlock(line);
1811
+ continue;
1812
+ }
1132
1813
  if (line.indent === 0) {
1133
1814
  if (isEndLine(line))
1134
1815
  break;
@@ -1173,13 +1854,17 @@ class Parser {
1173
1854
  }
1174
1855
  }
1175
1856
  const endSpan = this.consumeEnd('trait', headLine);
1176
- return build((0, span_1.mergeSpans)(lineSpan(headLine), endSpan));
1857
+ return build((0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan));
1177
1858
  }
1178
1859
  /** `data` block fields: `locked: flag`, `body part: optional name`, `kind: one of a, b`. */
1179
1860
  parseTraitFields(dataLine) {
1180
1861
  const fields = [];
1181
1862
  while (this.pos < this.lines.length && this.lines[this.pos].indent > dataLine.indent) {
1182
1863
  const line = this.lines[this.pos++];
1864
+ if (looksLikeComment(line)) {
1865
+ this.reportCommentInsideBlock(line);
1866
+ continue;
1867
+ }
1183
1868
  const c = new Cursor(line.tokens, line);
1184
1869
  const name = [];
1185
1870
  while (!c.atEnd() && c.peek().kind === 'word')
@@ -1276,6 +1961,10 @@ class Parser {
1276
1961
  const line = this.lines[this.pos];
1277
1962
  if (line.indent === 0)
1278
1963
  break; // dedent-terminated (no `end action`, design.md §2.3/§3.4)
1964
+ if (looksLikeComment(line)) {
1965
+ this.skipCommentInsideBlock(line);
1966
+ continue;
1967
+ }
1279
1968
  const word = firstWord(line);
1280
1969
  if (word === 'grammar' && line.tokens.length === 1) {
1281
1970
  this.pos++;
@@ -1316,9 +2005,9 @@ class Parser {
1316
2005
  this.diagnostics.error('parse.action-otherwise', 'Expected `otherwise refuse <phrase-key>`.', lineSpan(line));
1317
2006
  }
1318
2007
  else {
1319
- const key = oc.next();
1320
- if (key && key.kind === 'word')
1321
- otherwise = { phraseKey: key.text, span: lineSpan(line) };
2008
+ const key = this.readLabelKey(oc); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
2009
+ if (key)
2010
+ otherwise = { phraseKey: key, span: lineSpan(line) };
1322
2011
  else
1323
2012
  this.diagnostics.error('parse.action-otherwise', 'Expected a phrase key after `otherwise refuse`.', oc.restSpan());
1324
2013
  }
@@ -1332,7 +2021,7 @@ class Parser {
1332
2021
  if (stmt)
1333
2022
  body.push(stmt);
1334
2023
  }
1335
- span = (0, span_1.mergeSpans)(span, lineSpan(line));
2024
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
1336
2025
  }
1337
2026
  return { kind: 'define-action', name: nameTok.text, patterns, constraints, musts, refusals, otherwise, scores, phrases, body, span };
1338
2027
  }
@@ -1343,13 +2032,14 @@ class Parser {
1343
2032
  */
1344
2033
  parseMustLine(line) {
1345
2034
  const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
1346
- if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
2035
+ if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
1347
2036
  this.diagnostics.error('parse.must', 'Expected `<subject> must <predicate>: <phrase-key>`.', lineSpan(line));
1348
2037
  return null;
1349
2038
  }
1350
- const keyTok = line.tokens[colonIndex + 1];
1351
- if (keyTok.kind !== 'word') {
1352
- this.diagnostics.error('parse.must', 'Expected a phrase key after the colon in the `must` requirement.', keyTok.span);
2039
+ const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
2040
+ const phraseKey = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
2041
+ if (!phraseKey || !keyCursor.atEnd()) {
2042
+ this.diagnostics.error('parse.must', 'Expected a phrase key after the colon in the `must` requirement.', line.tokens[colonIndex + 1].span);
1353
2043
  return null;
1354
2044
  }
1355
2045
  const c = new Cursor(line.tokens.slice(0, colonIndex), line);
@@ -1367,7 +2057,7 @@ class Parser {
1367
2057
  const predicate = this.parseInfinitivePredicate(c, line);
1368
2058
  if (!predicate)
1369
2059
  return null;
1370
- return { kind: 'must', subject, predicate, phraseKey: keyTok.text, span: lineSpan(line) };
2060
+ return { kind: 'must', subject, predicate, phraseKey, span: lineSpan(line) };
1371
2061
  }
1372
2062
  /** Infinitive predicate after `must`: be / have / hold / wear / see / reach. */
1373
2063
  parseInfinitivePredicate(c, line) {
@@ -1397,7 +2087,7 @@ class Parser {
1397
2087
  if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
1398
2088
  const anyTok = c.next();
1399
2089
  c.next();
1400
- return { kind: 'is-any', condition: nameTok.text, span: (0, span_1.mergeSpans)(anyTok.span, nameTok.span) };
2090
+ return { kind: 'is-any', condition: nameTok.text, span: (0, span_js_1.mergeSpans)(anyTok.span, nameTok.span) };
1401
2091
  }
1402
2092
  }
1403
2093
  // `be no <name>` — a negated requirement in disguise (decision 6):
@@ -1427,7 +2117,7 @@ class Parser {
1427
2117
  case 'see':
1428
2118
  case 'reach': {
1429
2119
  const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
1430
- return { kind: 'can', ability: t.text, thing, span: (0, span_1.mergeSpans)(t.span, thing.span) };
2120
+ return { kind: 'can', ability: t.text, thing, span: (0, span_js_1.mergeSpans)(t.span, thing.span) };
1431
2121
  }
1432
2122
  default:
1433
2123
  this.diagnostics.error('parse.must-predicate', `Unknown predicate \`${t.text}\` after \`must\` — expected be, have, hold, wear, see, or reach.`, t.span);
@@ -1453,7 +2143,7 @@ class Parser {
1453
2143
  if (t.kind === 'colon') {
1454
2144
  const slot = c.next();
1455
2145
  if (slot && slot.kind === 'word') {
1456
- parts.push({ kind: 'slot', word: slot.text, span: (0, span_1.mergeSpans)(t.span, slot.span) });
2146
+ parts.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, slot.span) });
1457
2147
  }
1458
2148
  else {
1459
2149
  this.diagnostics.error('parse.action-slot', 'Expected a slot name after `:`.', t.span);
@@ -1502,43 +2192,49 @@ class Parser {
1502
2192
  if (form === 'without') {
1503
2193
  const slot = c.next();
1504
2194
  const colon = c.next();
1505
- const key = c.next();
1506
- if (!slot || slot.kind !== 'word' || !colon || colon.kind !== 'colon' || !key || key.kind !== 'word') {
2195
+ if (!slot || slot.kind !== 'word' || !colon || colon.kind !== 'colon') {
2196
+ this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
2197
+ return null;
2198
+ }
2199
+ const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
2200
+ if (!key) {
1507
2201
  this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
1508
2202
  return null;
1509
2203
  }
1510
- return { kind: 'without', slot: slot.text, condition: null, phraseKey: key.text, span: lineSpan(line) };
2204
+ return { kind: 'without', slot: slot.text, condition: null, phraseKey: key, span: lineSpan(line) };
1511
2205
  }
1512
2206
  // when: condition runs to the LAST colon; the key follows it.
1513
2207
  const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
1514
- if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
2208
+ if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
1515
2209
  this.diagnostics.error('parse.action-refusal', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
1516
2210
  return null;
1517
2211
  }
1518
2212
  const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
1519
2213
  const condition = this.parseCondition(condCursor, line);
1520
- const key = line.tokens[colonIndex + 1];
1521
- if (!key || key.kind !== 'word') {
2214
+ const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
2215
+ const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
2216
+ if (!key || !keyCursor.atEnd()) {
1522
2217
  this.diagnostics.error('parse.action-refusal', 'Expected a phrase key after the colon.', lineSpan(line));
1523
2218
  return null;
1524
2219
  }
1525
- return { kind: 'when', slot: null, condition, phraseKey: key.text, span: lineSpan(line) };
2220
+ return { kind: 'when', slot: null, condition, phraseKey: key, span: lineSpan(line) };
1526
2221
  }
1527
- /** `define behavior <name> from "<module>"` — CapabilityBehavior hatch. */
1528
- parseDefineBehaviorHatch() {
2222
+ /** `define chain <name> from "<module>"` — a TS chain hatch (ADR-094): the
2223
+ * named stdlib event chain is replaced by the module's handler. */
2224
+ parseDefineChainHatch() {
1529
2225
  const line = this.lines[this.pos];
1530
2226
  const c = new Cursor(line.tokens, line);
1531
2227
  c.next();
1532
- c.next(); // define behavior
1533
- const nameTok = c.next();
1534
- if (!nameTok || nameTok.kind !== 'word') {
1535
- this.diagnostics.error('parse.behavior-name', 'Expected a behavior name after `define behavior`.', c.restSpan());
2228
+ c.next(); // define chain
2229
+ const name = this.readLabelKey(c); // curated chain alias, single kebab word (ADR-254)
2230
+ if (!name) {
2231
+ this.diagnostics.error('parse.chain-hatch-name', 'Expected a chain name after `define chain` (e.g. `define chain opened-revealed from "…"`).', c.restSpan());
1536
2232
  this.pos++;
1537
2233
  return null;
1538
2234
  }
1539
- return this.parseHatchTail(line, c, 'behavior', nameTok.text);
2235
+ return this.parseHatchTail(line, c, 'chain', name);
1540
2236
  }
1541
- /** Shared `from "<module>"` tail for action/behavior hatches. */
2237
+ /** Shared `from "<module>"` tail for action / chain hatches. */
1542
2238
  parseHatchTail(line, c, hatchKind, name) {
1543
2239
  this.pos++;
1544
2240
  if (!c.matchWord('from')) {
@@ -1569,6 +2265,10 @@ class Parser {
1569
2265
  const line = this.lines[this.pos];
1570
2266
  if (line.indent === 0)
1571
2267
  break;
2268
+ if (looksLikeComment(line)) {
2269
+ this.skipCommentInsideBlock(line);
2270
+ continue;
2271
+ }
1572
2272
  const sc = new Cursor(line.tokens, line);
1573
2273
  let timing = null;
1574
2274
  let turns = 0;
@@ -1615,7 +2315,138 @@ class Parser {
1615
2315
  steps.push({ kind: 'sequence-step', timing, turns, owner, state, body, span: lineSpan(line) });
1616
2316
  }
1617
2317
  const endSpan = this.consumeEnd('sequence', headLine);
1618
- return { kind: 'define-sequence', name, steps, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
2318
+ return { kind: 'define-sequence', name, steps, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
2319
+ }
2320
+ /**
2321
+ * `define topics for <entity> … end topics` (ADR-239 D3 as amended) —
2322
+ * the ask/tell topic table: `about` rows in two tiers (entity /
2323
+ * quoted free-text with comma-separated aliases), each answering with
2324
+ * a one-line statement or an indented statement body.
2325
+ */
2326
+ parseDefineTopics() {
2327
+ const headLine = this.lines[this.pos++];
2328
+ const c = new Cursor(headLine.tokens, headLine);
2329
+ c.next();
2330
+ c.next(); // define topics
2331
+ let owner;
2332
+ if (!c.matchWord('for')) {
2333
+ this.diagnostics.error('parse.topics-for', 'Expected `for <entity>` after `define topics`.', c.restSpan());
2334
+ owner = { kind: 'name', article: null, words: [], span: lineSpan(headLine) };
2335
+ }
2336
+ else {
2337
+ owner = this.parseNameRef(c, () => false);
2338
+ if (owner.words.length === 0 || !c.atEnd()) {
2339
+ this.diagnostics.error('parse.topics-for', 'Expected `define topics for <entity>` — the owner name runs to the end of the line.', c.restSpan());
2340
+ }
2341
+ }
2342
+ const decl = { kind: 'define-topics', owner, rows: [], span: lineSpan(headLine) };
2343
+ while (this.pos < this.lines.length) {
2344
+ const line = this.lines[this.pos];
2345
+ const word = firstWord(line);
2346
+ const lc = new Cursor(line.tokens, line);
2347
+ if (word === 'end' && lc.isWord('topics', 1)) {
2348
+ this.pos++;
2349
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2350
+ if (decl.rows.length === 0) {
2351
+ this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
2352
+ }
2353
+ return decl;
2354
+ }
2355
+ if (looksLikeComment(line)) {
2356
+ this.skipCommentInsideBlock(line);
2357
+ continue;
2358
+ }
2359
+ if (line.indent === 0)
2360
+ break; // dedent without `end topics` — reported below
2361
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2362
+ if (word === 'about') {
2363
+ const row = this.parseTopicRow(line);
2364
+ if (row) {
2365
+ decl.rows.push(row);
2366
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, row.span);
2367
+ }
2368
+ continue;
2369
+ }
2370
+ this.diagnostics.error('parse.topics-row', `Unrecognized line in \`define topics\`: \`${line.raw.trim()}\` — expected an \`about …: <response>\` row or \`end topics\`.`, lineSpan(line));
2371
+ this.pos++;
2372
+ }
2373
+ this.diagnostics.error('parse.topics-end', 'Expected `end topics` to close the block.', decl.span);
2374
+ if (decl.rows.length === 0) {
2375
+ this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
2376
+ }
2377
+ return decl;
2378
+ }
2379
+ /**
2380
+ * One `about …: <response>` row. Entity tier: `about the <entity>:`.
2381
+ * Free-text tier: `about "<text>"[, "<text>" …]:` — comma-separated
2382
+ * quoted aliases (spelling ruled 2026-07-18). The response is either the
2383
+ * rest of the line (one statement) or an indented statement body.
2384
+ */
2385
+ parseTopicRow(headLine) {
2386
+ const c = new Cursor(headLine.tokens, headLine);
2387
+ c.next(); // about
2388
+ let filter;
2389
+ const first = c.peek();
2390
+ if (first && first.kind === 'string') {
2391
+ c.next();
2392
+ if (first.text.trim() === '') {
2393
+ this.diagnostics.error('parse.topics-row', 'A quoted topic cannot be empty.', first.span);
2394
+ this.pos++;
2395
+ return null;
2396
+ }
2397
+ const aliases = [];
2398
+ let span = first.span;
2399
+ while (c.peek()?.kind === 'comma') {
2400
+ c.next();
2401
+ const alias = c.next();
2402
+ if (!alias || alias.kind !== 'string' || alias.text.trim() === '') {
2403
+ this.diagnostics.error('parse.topics-alias', 'Expected a quoted alias after the comma — aliases are declared quoted spellings: `about "treasure", "the hoard": …`.', alias?.span ?? c.restSpan());
2404
+ this.pos++;
2405
+ return null;
2406
+ }
2407
+ aliases.push(alias.text);
2408
+ span = (0, span_js_1.mergeSpans)(span, alias.span);
2409
+ }
2410
+ filter = { kind: 'text', primary: first.text, aliases, span };
2411
+ }
2412
+ else {
2413
+ const ref = this.parseNameRef(c, () => false);
2414
+ if (ref.words.length === 0) {
2415
+ this.diagnostics.error('parse.topics-row', 'Expected an entity name or a quoted topic after `about`.', c.restSpan());
2416
+ this.pos++;
2417
+ return null;
2418
+ }
2419
+ filter = { kind: 'entity', ref };
2420
+ }
2421
+ const colon = c.next();
2422
+ if (!colon || colon.kind !== 'colon') {
2423
+ this.diagnostics.error('parse.topics-colon', 'Expected `:` after the topic key.', colon?.span ?? c.restSpan());
2424
+ this.pos++;
2425
+ return null;
2426
+ }
2427
+ let body;
2428
+ let span = lineSpan(headLine);
2429
+ if (!c.atEnd()) {
2430
+ // One-line response: the rest of the line is a single statement.
2431
+ // parseStatement consumes this.lines[this.pos] (the row line itself).
2432
+ const rest = { ...headLine, tokens: headLine.tokens.slice(c.i) };
2433
+ const stmt = this.parseStatement(rest, 'topics');
2434
+ if (!stmt)
2435
+ return null; // the statement error is already reported
2436
+ body = [stmt];
2437
+ }
2438
+ else {
2439
+ // Indented statement body on the following lines.
2440
+ this.pos++;
2441
+ body = this.parseStatements(headLine.indent, 'topics');
2442
+ if (body.length > 0)
2443
+ span = (0, span_js_1.mergeSpans)(span, body[body.length - 1].span);
2444
+ }
2445
+ if (body.length === 0) {
2446
+ this.diagnostics.error('parse.topics-response', 'Expected a response — a one-line statement after the `:`, or an indented statement body.', lineSpan(headLine));
2447
+ return null;
2448
+ }
2449
+ return { kind: 'topic-row', filter, body, span };
1619
2450
  }
1620
2451
  // ------------------------------------------------------------- on clause
1621
2452
  /**
@@ -1704,8 +2535,629 @@ class Parser {
1704
2535
  once,
1705
2536
  ordering,
1706
2537
  body,
1707
- span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan),
2538
+ span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan),
2539
+ };
2540
+ }
2541
+ /**
2542
+ * ADR-216 media sugar statements: `play sound|music|ambient <asset>
2543
+ * [looping]`, `stop music|ambient`, `show image <asset> [in <layer>]`,
2544
+ * `hide image`, `transition <kind>`, `clear` — each with the standard
2545
+ * `when` suffix. Asset resolution/kind-checking is the analyzer's.
2546
+ */
2547
+ parseMediaStatement(first, c, line) {
2548
+ c.next(); // the keyword
2549
+ const fail = (message) => {
2550
+ this.diagnostics.error('parse.media', message, lineSpan(line));
2551
+ return null;
1708
2552
  };
2553
+ let form;
2554
+ let asset = null;
2555
+ let layer = null;
2556
+ let channel = null;
2557
+ let looping = false;
2558
+ let transitionKind = null;
2559
+ /** ADR-241 D3: optional `in <channel-word>` tail on the ambient forms. */
2560
+ const parseChannelTail = () => {
2561
+ if (!c.matchWord('in'))
2562
+ return true;
2563
+ const channelTok = c.next();
2564
+ if (!channelTok || channelTok.kind !== 'word') {
2565
+ fail('Expected a channel word after `in`.');
2566
+ return false;
2567
+ }
2568
+ channel = channelTok.text;
2569
+ return true;
2570
+ };
2571
+ if (first === 'play') {
2572
+ const what = c.next();
2573
+ if (!what || what.kind !== 'word' || !['sound', 'music', 'ambient'].includes(what.text)) {
2574
+ return fail('Expected `play sound|music|ambient <asset>`.');
2575
+ }
2576
+ const assetTok = c.next();
2577
+ if (!assetTok || assetTok.kind !== 'word')
2578
+ return fail(`Expected a declared asset name after \`play ${what.text}\`.`);
2579
+ asset = assetTok.text;
2580
+ if (what.text === 'music' && c.isWord('looping')) {
2581
+ c.next();
2582
+ looping = true;
2583
+ }
2584
+ if (what.text === 'ambient' && !parseChannelTail())
2585
+ return null;
2586
+ form = `play-${what.text}`;
2587
+ }
2588
+ else if (first === 'stop') {
2589
+ const what = c.next();
2590
+ if (!what || what.kind !== 'word' || !['music', 'ambient'].includes(what.text)) {
2591
+ return fail('Expected `stop music` or `stop ambient`.');
2592
+ }
2593
+ if (what.text === 'ambient' && !parseChannelTail())
2594
+ return null;
2595
+ form = `stop-${what.text}`;
2596
+ }
2597
+ else if (first === 'show') {
2598
+ if (!c.matchWord('image'))
2599
+ return fail('Expected `show image <asset> [in <layer>]`.');
2600
+ const assetTok = c.next();
2601
+ if (!assetTok || assetTok.kind !== 'word')
2602
+ return fail('Expected a declared asset name after `show image`.');
2603
+ asset = assetTok.text;
2604
+ if (c.matchWord('in')) {
2605
+ const layerTok = c.next();
2606
+ if (!layerTok || layerTok.kind !== 'word')
2607
+ return fail('Expected a layer word after `in`.');
2608
+ layer = layerTok.text;
2609
+ }
2610
+ form = 'show-image';
2611
+ }
2612
+ else if (first === 'hide') {
2613
+ if (!c.matchWord('image'))
2614
+ return fail('Expected `hide image`.');
2615
+ form = 'hide-image';
2616
+ }
2617
+ else if (first === 'transition') {
2618
+ const kindTok = c.next();
2619
+ if (!kindTok || kindTok.kind !== 'word')
2620
+ return fail('Expected a transition kind after `transition` (e.g. `transition fade`).');
2621
+ transitionKind = kindTok.text;
2622
+ form = 'transition';
2623
+ }
2624
+ else {
2625
+ form = 'clear';
2626
+ }
2627
+ const stmtWhen = this.parseStatementWhen(c, line);
2628
+ if (!c.atEnd())
2629
+ return fail(`Unexpected trailing text: \`${c.peek().text}\`.`);
2630
+ return { kind: 'media', form, asset, layer, channel, looping, transitionKind, stmtWhen, span: lineSpan(line) };
2631
+ }
2632
+ /**
2633
+ * `define channel <name> … end channel` (ADR-216; spelling A): keyword
2634
+ * body lines `mode <word>`, `gated by <capability>`, `from event
2635
+ * <dotted.key>`, `take <field>, …`. Value validation (mode set,
2636
+ * capability flags, required lines) is the analyzer's.
2637
+ */
2638
+ parseDefineChannel() {
2639
+ const headLine = this.lines[this.pos++];
2640
+ const c = new Cursor(headLine.tokens, headLine);
2641
+ c.next();
2642
+ c.next(); // define channel
2643
+ const nameTok = c.next();
2644
+ if (!nameTok || nameTok.kind !== 'word') {
2645
+ this.diagnostics.error('parse.channel-name', 'Expected a channel name after `define channel`.', lineSpan(headLine));
2646
+ return null;
2647
+ }
2648
+ const decl = {
2649
+ kind: 'define-channel',
2650
+ name: nameTok.text,
2651
+ mode: null,
2652
+ gatedBy: null,
2653
+ fromEvent: null,
2654
+ returns: null,
2655
+ span: lineSpan(headLine),
2656
+ };
2657
+ while (this.pos < this.lines.length) {
2658
+ const line = this.lines[this.pos];
2659
+ const word = firstWord(line);
2660
+ const lc = new Cursor(line.tokens, line);
2661
+ if (word === 'end' && lc.isWord('channel', 1)) {
2662
+ this.pos++;
2663
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2664
+ return decl;
2665
+ }
2666
+ if (looksLikeComment(line)) {
2667
+ this.skipCommentInsideBlock(line);
2668
+ continue;
2669
+ }
2670
+ if (line.indent === 0)
2671
+ break;
2672
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2673
+ this.pos++;
2674
+ if (word === 'mode') {
2675
+ lc.next();
2676
+ const modeTok = lc.next();
2677
+ if (!modeTok || modeTok.kind !== 'word' || !lc.atEnd()) {
2678
+ this.diagnostics.error('parse.channel-mode', 'Expected `mode replace|append|event`.', lineSpan(line));
2679
+ }
2680
+ else {
2681
+ decl.mode = modeTok.text;
2682
+ }
2683
+ continue;
2684
+ }
2685
+ if (word === 'gated' && lc.isWord('by', 1)) {
2686
+ lc.next();
2687
+ lc.next();
2688
+ const capTok = lc.next();
2689
+ if (!capTok || capTok.kind !== 'word' || !lc.atEnd()) {
2690
+ this.diagnostics.error('parse.channel-gate', 'Expected `gated by <capability>`.', lineSpan(line));
2691
+ }
2692
+ else {
2693
+ decl.gatedBy = capTok.text;
2694
+ }
2695
+ continue;
2696
+ }
2697
+ if (word === 'return') {
2698
+ // ADR-253 D1: `return <field | "text" | phrase <key>> from <event>` —
2699
+ // the event rides this clause (the standalone `from event`/`take` lines
2700
+ // are removed below).
2701
+ lc.next(); // return
2702
+ const returns = this.parseChannelReturn(lc, line);
2703
+ if (!returns)
2704
+ continue; // error already reported
2705
+ if (!lc.matchWord('from')) {
2706
+ this.diagnostics.error('parse.channel-return', 'Expected `from <event>` after the returned construct — `return <construct> from <event>`.', lineSpan(line));
2707
+ continue;
2708
+ }
2709
+ const key = this.readLabelKey(lc); // ADR-256: dotless Chord event id; the loader translates to the platform id
2710
+ if (!key || !lc.atEnd()) {
2711
+ this.diagnostics.error('parse.channel-return', 'Expected a single event id after `from`.', lineSpan(line));
2712
+ continue;
2713
+ }
2714
+ decl.returns = returns;
2715
+ decl.fromEvent = key;
2716
+ continue;
2717
+ }
2718
+ if (word === 'from' && lc.isWord('event', 1)) {
2719
+ this.diagnostics.error('parse.channel-from-removed', '`from event` was removed (ADR-253) — the event rides the `return … from <event>` clause: `return <construct> from <event>`.', lineSpan(line));
2720
+ continue;
2721
+ }
2722
+ if (word === 'take') {
2723
+ this.diagnostics.error('parse.channel-take-removed', '`take` was removed (ADR-253) — a channel `return`s a construct: `return <field> from <event>`, `return "text (slot)" from <event>`, or `return phrase <key> from <event>`.', lineSpan(line));
2724
+ continue;
2725
+ }
2726
+ this.diagnostics.error('parse.channel-body', `Unrecognized line in \`define channel\`: \`${line.raw.trim()}\` — expected \`mode\`, \`gated by\`, \`return … from <event>\`, or \`end channel\`.`, lineSpan(line));
2727
+ }
2728
+ this.diagnostics.error('parse.channel-end', 'Expected `end channel` to close the block.', decl.span);
2729
+ return decl;
2730
+ }
2731
+ /**
2732
+ * Parse the construct after `return` (ADR-253 D1), stopping before `from`:
2733
+ * a `"quoted"` text template, a `phrase <key>`, or a bare field word. On a
2734
+ * malformed construct reports `parse.channel-return` and returns null.
2735
+ */
2736
+ parseChannelReturn(lc, line) {
2737
+ const tok = lc.peek();
2738
+ if (!tok) {
2739
+ this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
2740
+ return null;
2741
+ }
2742
+ if (tok.kind === 'string') {
2743
+ lc.next();
2744
+ return { kind: 'text', text: tok.text };
2745
+ }
2746
+ if (tok.kind === 'word' && tok.text === 'phrase') {
2747
+ lc.next(); // phrase
2748
+ const key = this.readLabelKey(lc);
2749
+ if (!key) {
2750
+ this.diagnostics.error('parse.channel-return', 'Expected a phrase key after `return phrase`.', lineSpan(line));
2751
+ return null;
2752
+ }
2753
+ return { kind: 'phrase', phrase: key };
2754
+ }
2755
+ if (tok.kind === 'word') {
2756
+ const field = this.readLabelKey(lc);
2757
+ if (!field) {
2758
+ this.diagnostics.error('parse.channel-return', 'Expected a field name after `return`.', lineSpan(line));
2759
+ return null;
2760
+ }
2761
+ return { kind: 'field', field };
2762
+ }
2763
+ this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
2764
+ return null;
2765
+ }
2766
+ /**
2767
+ * `define pronouns <name> … end pronouns` (ADR-242 D7, ruled Q-1):
2768
+ * five named rows, each `<case> <form>` — `subject`, `object`,
2769
+ * `possessive`, `possessive-pronoun`, `reflexive`. Row completeness,
2770
+ * duplicates, and standard-word shadowing are the analyzer's gates
2771
+ * (the parseDefineChannel split: parser collects, analyzer validates).
2772
+ */
2773
+ parseDefinePronouns() {
2774
+ const headLine = this.lines[this.pos++];
2775
+ const c = new Cursor(headLine.tokens, headLine);
2776
+ c.next();
2777
+ c.next(); // define pronouns
2778
+ const nameTok = c.next();
2779
+ if (!nameTok || nameTok.kind !== 'word' || !c.atEnd()) {
2780
+ this.diagnostics.error('parse.pronouns-name', 'Expected a set name after `define pronouns` (e.g. `define pronouns ze`).', lineSpan(headLine));
2781
+ return null;
2782
+ }
2783
+ const decl = {
2784
+ kind: 'define-pronouns',
2785
+ name: nameTok.text.toLowerCase(),
2786
+ rows: [],
2787
+ span: lineSpan(headLine),
2788
+ };
2789
+ while (this.pos < this.lines.length) {
2790
+ const line = this.lines[this.pos];
2791
+ const word = firstWord(line);
2792
+ const lc = new Cursor(line.tokens, line);
2793
+ if (word === 'end' && lc.isWord('pronouns', 1)) {
2794
+ this.pos++;
2795
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2796
+ return decl;
2797
+ }
2798
+ if (looksLikeComment(line)) {
2799
+ this.skipCommentInsideBlock(line);
2800
+ continue;
2801
+ }
2802
+ if (line.indent === 0)
2803
+ break;
2804
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2805
+ this.pos++;
2806
+ if (word && catalog_js_1.PRONOUN_CASES.includes(word)) {
2807
+ lc.next();
2808
+ const formTok = lc.next();
2809
+ if (!formTok || formTok.kind !== 'word' || !lc.atEnd()) {
2810
+ this.diagnostics.error('parse.pronouns-row', `Expected one form word after \`${word}\` (e.g. \`${word} zir\`).`, lineSpan(line));
2811
+ }
2812
+ else {
2813
+ decl.rows.push({ case: word, form: formTok.text, span: lineSpan(line) });
2814
+ }
2815
+ continue;
2816
+ }
2817
+ this.diagnostics.error('parse.pronouns-body', `Unrecognized line in \`define pronouns\`: \`${line.raw.trim()}\` — expected \`${catalog_js_1.PRONOUN_CASES.join('`, `')}\`, or \`end pronouns\`.`, lineSpan(line));
2818
+ }
2819
+ this.diagnostics.error('parse.pronouns-end', 'Expected `end pronouns` to close the block.', decl.span);
2820
+ return decl;
2821
+ }
2822
+ /** `define sound|image|music <name> from "<file>"` (ADR-216) — data asset. */
2823
+ parseDefineAsset(assetKind) {
2824
+ const line = this.lines[this.pos++];
2825
+ const c = new Cursor(line.tokens, line);
2826
+ c.next();
2827
+ c.next(); // define <kind>
2828
+ const nameTok = c.next();
2829
+ if (!nameTok || nameTok.kind !== 'word') {
2830
+ this.diagnostics.error('parse.asset-name', `Expected an asset name after \`define ${assetKind}\`.`, c.restSpan());
2831
+ return null;
2832
+ }
2833
+ if (!c.matchWord('from')) {
2834
+ this.diagnostics.error('parse.asset-from', `Expected \`from "<file>"\` in the ${assetKind} declaration.`, c.restSpan());
2835
+ return null;
2836
+ }
2837
+ const pathTok = c.next();
2838
+ if (!pathTok || pathTok.kind !== 'string') {
2839
+ this.diagnostics.error('parse.asset-path', 'Expected a quoted file path after `from`.', c.restSpan());
2840
+ return null;
2841
+ }
2842
+ return { kind: 'define-asset', assetKind, name: nameTok.text, path: pathTok.text, span: lineSpan(line) };
2843
+ }
2844
+ /**
2845
+ * `define ambient <word>` / `define layer <word>` (ADR-241 D2): a
2846
+ * one-line named family channel declaration. Exactly one word — the
2847
+ * bed/layer name; the registered id is the loader's business.
2848
+ */
2849
+ parseDefineFamilyChannel(family) {
2850
+ const line = this.lines[this.pos++];
2851
+ const c = new Cursor(line.tokens, line);
2852
+ c.next();
2853
+ c.next(); // define ambient|layer
2854
+ const nameTok = c.next();
2855
+ if (!nameTok || nameTok.kind !== 'word') {
2856
+ this.diagnostics.error('parse.channel-name', `Expected a ${family === 'ambient' ? 'bed' : 'layer'} name after \`define ${family}\`.`, c.restSpan());
2857
+ return null;
2858
+ }
2859
+ if (!c.atEnd()) {
2860
+ this.diagnostics.error('parse.channel-name', `Unexpected trailing text after \`define ${family} ${nameTok.text}\` — the declaration is one word.`, c.restSpan());
2861
+ return null;
2862
+ }
2863
+ return { kind: 'define-family-channel', family, name: nameTok.text, span: lineSpan(line) };
2864
+ }
2865
+ // ---------------------------------------------------------- emit payload
2866
+ /**
2867
+ * ADR-216 payload fields: `<field> <value>` separated by `and` at the
2868
+ * flat (statement) level, by commas inside `{ … }` objects. Values:
2869
+ * literals, `[ … ]` arrays, `{ … }` objects, or value expressions
2870
+ * (world-state reads).
2871
+ */
2872
+ parseEmitFields(c, line, mode) {
2873
+ const fields = [];
2874
+ for (;;) {
2875
+ if (mode === 'braced' && c.peek()?.kind === 'rbrace')
2876
+ break;
2877
+ if (mode === 'flat' && (c.atEnd() || c.isWord('when')))
2878
+ break;
2879
+ const startTok = c.peek();
2880
+ const key = [];
2881
+ while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('when')) {
2882
+ const t = c.peek();
2883
+ if (ARTICLES.has(t.text) && key.length > 0)
2884
+ break;
2885
+ const after = c.peek(1);
2886
+ const atValuePosition = !after ||
2887
+ after.kind === 'comma' ||
2888
+ after.kind === 'rbrace' ||
2889
+ (after.kind === 'word' && (after.text === 'and' || after.text === 'when'));
2890
+ if (atValuePosition && key.length > 0)
2891
+ break;
2892
+ key.push(t.text);
2893
+ c.next();
2894
+ }
2895
+ if (key.length === 0) {
2896
+ this.diagnostics.error('parse.emit-payload', 'Expected a payload field name.', c.restSpan());
2897
+ break;
2898
+ }
2899
+ const value = this.parseEmitValue(c, line);
2900
+ if (!value)
2901
+ break;
2902
+ fields.push({ key, value, span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, value.span) : value.span });
2903
+ if (mode === 'flat') {
2904
+ if (!c.matchWord('and'))
2905
+ break;
2906
+ }
2907
+ else if (c.peek()?.kind === 'comma') {
2908
+ c.next();
2909
+ }
2910
+ else {
2911
+ break;
2912
+ }
2913
+ }
2914
+ return fields;
2915
+ }
2916
+ /** One ADR-216 payload value (recursive: literals, arrays, objects, value exprs). */
2917
+ parseEmitValue(c, line) {
2918
+ const t = c.peek();
2919
+ if (!t) {
2920
+ this.diagnostics.error('parse.emit-payload', 'Expected a payload value.', c.restSpan());
2921
+ return null;
2922
+ }
2923
+ if (t.kind === 'number' || t.kind === 'string') {
2924
+ c.next();
2925
+ return { kind: 'literal', value: t.text, literalKind: t.kind, span: t.span };
2926
+ }
2927
+ if (t.kind === 'lbracket') {
2928
+ c.next();
2929
+ const items = [];
2930
+ while (c.peek() && c.peek().kind !== 'rbracket') {
2931
+ const item = this.parseEmitValue(c, line);
2932
+ if (!item)
2933
+ break;
2934
+ items.push(item);
2935
+ if (c.peek()?.kind === 'comma')
2936
+ c.next();
2937
+ else
2938
+ break;
2939
+ }
2940
+ const close = c.next();
2941
+ let endSpan = t.span;
2942
+ if (!close || close.kind !== 'rbracket') {
2943
+ this.diagnostics.error('parse.emit-payload', 'Expected `]` to close the array.', c.restSpan());
2944
+ }
2945
+ else {
2946
+ endSpan = close.span;
2947
+ }
2948
+ return { kind: 'array', items, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
2949
+ }
2950
+ if (t.kind === 'lbrace') {
2951
+ c.next();
2952
+ const fields = this.parseEmitFields(c, line, 'braced');
2953
+ const close = c.next();
2954
+ let endSpan = t.span;
2955
+ if (!close || close.kind !== 'rbrace') {
2956
+ this.diagnostics.error('parse.emit-payload', 'Expected `}` to close the object.', c.restSpan());
2957
+ }
2958
+ else {
2959
+ endSpan = close.span;
2960
+ }
2961
+ return { kind: 'object', fields, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
2962
+ }
2963
+ const expr = this.parseValueExpr(c, line, EMIT_VALUE_STOPS);
2964
+ return { kind: 'expr', expr, span: expr.span };
2965
+ }
2966
+ // -------------------------------------------------------------- machines
2967
+ /**
2968
+ * `define machine <name> … end machine` (ADR-215 `use state-machines`
2969
+ * depth; spelling A ratified 2026-07-18): `role <name> is <entity>`
2970
+ * bindings, `starts <state>`, and `state <name>[, terminal]` blocks. The
2971
+ * `use` requirement is the analyzer's gate.
2972
+ */
2973
+ parseDefineMachine() {
2974
+ const headLine = this.lines[this.pos++];
2975
+ const c = new Cursor(headLine.tokens, headLine);
2976
+ c.next();
2977
+ c.next(); // define machine
2978
+ const name = [];
2979
+ while (!c.atEnd() && c.peek().kind === 'word')
2980
+ name.push(c.next().text);
2981
+ if (name.length === 0) {
2982
+ this.diagnostics.error('parse.machine-name', 'Expected a machine name after `define machine`.', lineSpan(headLine));
2983
+ }
2984
+ const decl = { kind: 'define-machine', name, roles: [], initialState: null, states: [], span: lineSpan(headLine) };
2985
+ while (this.pos < this.lines.length) {
2986
+ const line = this.lines[this.pos];
2987
+ const word = firstWord(line);
2988
+ const lc = new Cursor(line.tokens, line);
2989
+ if (word === 'end' && lc.isWord('machine', 1)) {
2990
+ this.pos++;
2991
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2992
+ return decl;
2993
+ }
2994
+ if (looksLikeComment(line)) {
2995
+ this.skipCommentInsideBlock(line);
2996
+ continue;
2997
+ }
2998
+ if (line.indent === 0)
2999
+ break; // dedent without `end machine` — reported below
3000
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
3001
+ if (word === 'role') {
3002
+ this.pos++;
3003
+ lc.next();
3004
+ const roleTok = lc.next();
3005
+ if (!roleTok || roleTok.kind !== 'word' || !lc.matchWord('is')) {
3006
+ this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
3007
+ continue;
3008
+ }
3009
+ const entity = this.parseNameRef(lc, () => false);
3010
+ if (entity.words.length === 0 || !lc.atEnd()) {
3011
+ this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
3012
+ continue;
3013
+ }
3014
+ decl.roles.push({ name: roleTok.text, entity, span: lineSpan(line) });
3015
+ continue;
3016
+ }
3017
+ if (word === 'starts') {
3018
+ this.pos++;
3019
+ lc.next();
3020
+ const stateTok = lc.next();
3021
+ if (!stateTok || stateTok.kind !== 'word' || !lc.atEnd()) {
3022
+ this.diagnostics.error('parse.machine-starts', 'Expected `starts <state>`.', lineSpan(line));
3023
+ continue;
3024
+ }
3025
+ if (decl.initialState !== null) {
3026
+ this.diagnostics.error('parse.machine-starts', 'This machine already declared its `starts` state.', lineSpan(line));
3027
+ continue;
3028
+ }
3029
+ decl.initialState = stateTok.text;
3030
+ continue;
3031
+ }
3032
+ if (word === 'state') {
3033
+ const state = this.parseMachineState(line);
3034
+ if (state) {
3035
+ decl.states.push(state);
3036
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, state.span);
3037
+ }
3038
+ continue;
3039
+ }
3040
+ this.diagnostics.error('parse.machine-body', `Unrecognized line in \`define machine\`: \`${line.raw.trim()}\` — expected \`role\`, \`starts\`, \`state\`, or \`end machine\`.`, lineSpan(line));
3041
+ this.pos++;
3042
+ }
3043
+ this.diagnostics.error('parse.machine-end', 'Expected `end machine` to close the block.', decl.span);
3044
+ return decl;
3045
+ }
3046
+ /** One `state <name>[, terminal]` block: transition lines + `on enter`/`on exit` bodies. */
3047
+ parseMachineState(headLine) {
3048
+ this.pos++;
3049
+ const c = new Cursor(headLine.tokens, headLine);
3050
+ c.next(); // state
3051
+ const nameTok = c.next();
3052
+ if (!nameTok || nameTok.kind !== 'word') {
3053
+ this.diagnostics.error('parse.machine-state', 'Expected a state name after `state`.', lineSpan(headLine));
3054
+ return null;
3055
+ }
3056
+ let terminal = false;
3057
+ if (c.peek()?.kind === 'comma') {
3058
+ c.next();
3059
+ if (c.matchWord('terminal'))
3060
+ terminal = true;
3061
+ else
3062
+ this.diagnostics.error('parse.machine-state', 'Only `, terminal` may follow the state name.', c.restSpan());
3063
+ }
3064
+ const state = { name: nameTok.text, terminal, transitions: [], onEnter: [], onExit: [], span: lineSpan(headLine) };
3065
+ while (this.pos < this.lines.length) {
3066
+ const line = this.lines[this.pos];
3067
+ if (line.indent <= headLine.indent)
3068
+ break; // next state / end machine
3069
+ state.span = (0, span_js_1.mergeSpans)(state.span, lineSpan(line));
3070
+ const word = firstWord(line);
3071
+ if (word === 'when') {
3072
+ const transition = this.parseMachineTransition(line);
3073
+ if (transition)
3074
+ state.transitions.push(transition);
3075
+ continue;
3076
+ }
3077
+ if (word === 'on') {
3078
+ const lc = new Cursor(line.tokens, line);
3079
+ lc.next();
3080
+ const which = lc.next();
3081
+ if (which && which.kind === 'word' && (which.text === 'enter' || which.text === 'exit') && lc.atEnd()) {
3082
+ this.pos++;
3083
+ const body = this.parseStatements(line.indent, 'on');
3084
+ const endSpan = this.consumeEnd('on', line);
3085
+ state.span = (0, span_js_1.mergeSpans)(state.span, endSpan);
3086
+ if (which.text === 'enter')
3087
+ state.onEnter.push(...body);
3088
+ else
3089
+ state.onExit.push(...body);
3090
+ continue;
3091
+ }
3092
+ this.diagnostics.error('parse.machine-on', 'Only `on enter` and `on exit` blocks live in a machine `state`.', lineSpan(line));
3093
+ this.pos++;
3094
+ continue;
3095
+ }
3096
+ this.diagnostics.error('parse.machine-state-body', `Unrecognized line in a machine \`state\` block: \`${line.raw.trim()}\` — expected \`when …: <state>\`, \`on enter\`, or \`on exit\`.`, lineSpan(line));
3097
+ this.pos++;
3098
+ }
3099
+ return state;
3100
+ }
3101
+ /**
3102
+ * `when <trigger>[ while <condition>]: <target-state>`. Triggers:
3103
+ * `event <dotted.key>`, `<gerund> <entity name>` (action on a target),
3104
+ * a single bare word (analyzer resolves condition-vs-gerund), or a
3105
+ * condition.
3106
+ */
3107
+ parseMachineTransition(line) {
3108
+ this.pos++;
3109
+ const tokens = line.tokens;
3110
+ const colonIndex = tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
3111
+ if (colonIndex === -1 || colonIndex !== tokens.length - 2 || tokens[tokens.length - 1].kind !== 'word') {
3112
+ this.diagnostics.error('parse.machine-when', 'Expected `when <trigger>[ while <condition>]: <target-state>`.', lineSpan(line));
3113
+ return null;
3114
+ }
3115
+ const target = tokens[tokens.length - 1].text;
3116
+ let head = tokens.slice(1, colonIndex);
3117
+ let condition = null;
3118
+ const whileIndex = head.findIndex((t) => t.kind === 'word' && t.text === 'while');
3119
+ if (whileIndex !== -1) {
3120
+ condition = this.parseCondition(new Cursor(head.slice(whileIndex + 1), line), line);
3121
+ head = head.slice(0, whileIndex);
3122
+ }
3123
+ const hc = new Cursor(head, line);
3124
+ const first = hc.peek();
3125
+ if (!first) {
3126
+ this.diagnostics.error('parse.machine-when', 'Expected a trigger after `when`.', lineSpan(line));
3127
+ return null;
3128
+ }
3129
+ let trigger;
3130
+ if (first.kind === 'word' && first.text === 'event') {
3131
+ hc.next();
3132
+ const key = this.readLabelKey(hc); // ADR-256: dotless Chord event id; story-loader translates to the platform id
3133
+ if (!key || !hc.atEnd()) {
3134
+ this.diagnostics.error('parse.machine-when', 'Expected `event <event.key>`.', lineSpan(line));
3135
+ return null;
3136
+ }
3137
+ trigger = { kind: 'event', event: key };
3138
+ }
3139
+ else if (first.kind === 'word' && !ARTICLES.has(first.text) && head.length === 1) {
3140
+ trigger = { kind: 'word', word: first.text, span: first.span };
3141
+ }
3142
+ else if (first.kind === 'word' && !ARTICLES.has(first.text) && head[1]?.kind === 'word' && (ARTICLES.has(head[1].text) || head[1].text === 'it')) {
3143
+ const action = hc.next().text;
3144
+ if (hc.isWord('it')) {
3145
+ // Machines are story-owned — there is no `it` (mirror of the
3146
+ // story-clause rule); name the entity or role.
3147
+ this.diagnostics.error('parse.machine-when', '`it` is not bound in a machine — name the entity or a declared role.', lineSpan(line));
3148
+ return null;
3149
+ }
3150
+ const targetRef = this.parseNameRef(hc, () => false);
3151
+ if (targetRef.words.length === 0 || !hc.atEnd()) {
3152
+ this.diagnostics.error('parse.machine-when', `Expected an entity or role name after \`${action}\`.`, lineSpan(line));
3153
+ return null;
3154
+ }
3155
+ trigger = { kind: 'action', action, target: targetRef };
3156
+ }
3157
+ else {
3158
+ trigger = { kind: 'condition', condition: this.parseCondition(hc, line) };
3159
+ }
3160
+ return { trigger, condition, target, span: lineSpan(line) };
1709
3161
  }
1710
3162
  // ------------------------------------------------------------ statements
1711
3163
  /**
@@ -1720,6 +3172,10 @@ class Parser {
1720
3172
  break;
1721
3173
  if (isEndLine(line) || ((firstWord(line) === 'else' || firstWord(line) === 'or') && line.tokens.length === 1))
1722
3174
  break;
3175
+ if (looksLikeComment(line)) {
3176
+ this.skipCommentInsideBlock(line);
3177
+ continue;
3178
+ }
1723
3179
  const stmt = this.parseStatement(line, blockKeyword);
1724
3180
  if (stmt)
1725
3181
  body.push(stmt);
@@ -1755,7 +3211,7 @@ class Parser {
1755
3211
  }
1756
3212
  return this.parseRefuseWhenStatement(line);
1757
3213
  }
1758
- const key = this.readDottedKey(c);
3214
+ const key = this.readLabelKey(c);
1759
3215
  if (!key) {
1760
3216
  this.diagnostics.error('parse.phrase-ref', `Expected a phrase key after \`${word}\`.`, c.restSpan());
1761
3217
  return null;
@@ -1766,6 +3222,14 @@ class Parser {
1766
3222
  this.reportRefusalInAfter(line);
1767
3223
  return null;
1768
3224
  }
3225
+ // Misordered prohibition (platform-issue-sweep Phase 8 #15c):
3226
+ // `refuse <key> when <condition>` used to leave the `when …`
3227
+ // tokens unconsumed and silently compile as an UNCONDITIONAL
3228
+ // refuse. Error with a fix-it instead.
3229
+ if (c.isWord('when')) {
3230
+ this.diagnostics.error('parse.refuse-order', `The condition comes first — write \`refuse when <condition>: ${key}\`.`, c.restSpan());
3231
+ return null;
3232
+ }
1769
3233
  return { kind: 'refuse', phraseKey: key, params, span: lineSpan(line) };
1770
3234
  }
1771
3235
  const stmtWhen = this.parseStatementWhen(c, line);
@@ -1776,21 +3240,43 @@ class Parser {
1776
3240
  if (next && next.indent > line.indent && !isStatementLine(next)) {
1777
3241
  inlineText = this.parseProseParagraph(line.indent + 1, line.indent);
1778
3242
  }
1779
- const span = inlineText ? (0, span_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
3243
+ const span = inlineText ? (0, span_js_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
1780
3244
  return { kind: 'phrase', phraseKey: key, params, inlineText, stmtWhen, span };
1781
3245
  }
1782
3246
  case 'emit': {
1783
3247
  this.pos++;
1784
3248
  c.next();
3249
+ // ADR-256: an event id is a single dotless Chord token (`media-sound-play`);
3250
+ // story-loader translates it to the platform's dotted id at the emit seam.
3251
+ // A `.` now raises `parse.dotted-key` (ADR-254 uniform).
1785
3252
  const event = [];
1786
- while (!c.atEnd() && !c.isWord('when'))
1787
- event.push(c.next().text);
3253
+ while (!c.atEnd() && !c.isWord('when') && !c.isWord('with')) {
3254
+ const segment = this.readLabelKey(c);
3255
+ if (!segment)
3256
+ break;
3257
+ event.push(segment);
3258
+ }
1788
3259
  if (event.length === 0) {
1789
3260
  this.diagnostics.error('parse.emit', 'Expected an event name after `emit`.', lineSpan(line));
1790
3261
  return null;
1791
3262
  }
3263
+ // ADR-216 payload: `with <field> <value> [and …]` — the create-data
3264
+ // grammar; bracketed/braced structures inside separate with commas.
3265
+ const payload = [];
3266
+ if (c.matchWord('with')) {
3267
+ payload.push(...this.parseEmitFields(c, line, 'flat'));
3268
+ }
1792
3269
  const stmtWhen = this.parseStatementWhen(c, line);
1793
- return { kind: 'emit', event, stmtWhen, span: lineSpan(line) };
3270
+ return { kind: 'emit', event, payload, stmtWhen, span: lineSpan(line) };
3271
+ }
3272
+ case 'play':
3273
+ case 'stop':
3274
+ case 'show':
3275
+ case 'hide':
3276
+ case 'transition':
3277
+ case 'clear': {
3278
+ this.pos++;
3279
+ return this.parseMediaStatement(word, c, line);
1794
3280
  }
1795
3281
  case 'set': {
1796
3282
  this.pos++;
@@ -1917,38 +3403,53 @@ class Parser {
1917
3403
  /** `refuse when <condition>: <key>` as a body statement (prohibition, D6). */
1918
3404
  parseRefuseWhenStatement(line) {
1919
3405
  const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
1920
- if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
3406
+ if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
1921
3407
  this.diagnostics.error('parse.refuse-when', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
1922
3408
  return null;
1923
3409
  }
1924
- const keyTok = line.tokens[colonIndex + 1];
1925
- if (keyTok.kind !== 'word') {
1926
- this.diagnostics.error('parse.refuse-when', 'Expected a phrase key after the colon.', keyTok.span);
3410
+ const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
3411
+ const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
3412
+ if (!key || !keyCursor.atEnd()) {
3413
+ this.diagnostics.error('parse.refuse-when', 'Expected a phrase key after the colon.', line.tokens[colonIndex + 1].span);
1927
3414
  return null;
1928
3415
  }
1929
3416
  const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
1930
3417
  const condition = this.parseCondition(condCursor, line);
1931
- return { kind: 'refuse-when', condition, phraseKey: keyTok.text, span: lineSpan(line) };
3418
+ return { kind: 'refuse-when', condition, phraseKey: key, span: lineSpan(line) };
1932
3419
  }
1933
3420
  /** `refuse`/`must` inside an `after` clause — reactions cannot refuse (D3). */
1934
3421
  reportRefusalInAfter(line) {
1935
3422
  this.diagnostics.error('parse.react-refusal', 'Refusals (`refuse`, `must`) cannot appear in an `after` clause — reactions run after the action succeeded. Use an `on` clause to intercept.', lineSpan(line));
1936
3423
  }
1937
3424
  /**
1938
- * Read a phrase key: a word, optionally continued by `.`-joined words
1939
- * (`zoo.pa.closing-3`, design.md §3.3). Returns null when no word follows.
3425
+ * Read a label/key: a single kebab-case `WORD` (ADR-254). A `.` in any label
3426
+ * position author labels (phrase, exit, refusal keys) AND the event-type
3427
+ * sites (`emit`, channel `from event`, machine `when event`) — is a parse
3428
+ * error (`parse.dotted-key`). The ban is uniform: ADR-256 made the event-type
3429
+ * sites dotless too (the dotless Chord event id is translated to the platform's
3430
+ * dotted id in `@sharpee/story-loader`, so no dotted form ever appears in a
3431
+ * `.story`). Returns `null` when no word is present.
1940
3432
  */
1941
- readDottedKey(c) {
3433
+ readLabelKey(c) {
1942
3434
  const first = c.peek();
1943
3435
  if (!first || first.kind !== 'word')
1944
3436
  return null;
1945
3437
  c.next();
1946
- let key = first.text;
1947
- while (c.peek()?.kind === 'punct' && c.peek().text === '.' && c.peek(1)?.kind === 'word') {
3438
+ const key = first.text;
3439
+ const dotFollows = () => c.peek()?.kind === 'punct' && c.peek().text === '.' && c.peek(1)?.kind === 'word';
3440
+ if (!dotFollows())
3441
+ return key;
3442
+ // ADR-254/256: a `.` in a label is a parse error. Consume the dotted
3443
+ // segments (one clean error, no cascade), flag the first dot, and hand back
3444
+ // the kebab-cased form so downstream parsing does not spuriously break.
3445
+ const dot = c.peek();
3446
+ const segments = [key];
3447
+ while (dotFollows()) {
1948
3448
  c.next();
1949
- key += '.' + c.next().text;
3449
+ segments.push(c.next().text);
1950
3450
  }
1951
- return key;
3451
+ this.diagnostics.error('parse.dotted-key', `Labels are single words; "." is not allowed in "${segments.join('.')}" — use kebab-case, e.g. "${segments.join('-')}".`, dot.span);
3452
+ return segments.join('-');
1952
3453
  }
1953
3454
  parseParams(c, line) {
1954
3455
  const params = [];
@@ -1964,7 +3465,7 @@ class Parser {
1964
3465
  break;
1965
3466
  }
1966
3467
  const value = this.parseValueExpr(c, line, new Set(['with']));
1967
- params.push({ param, value, span: start ? (0, span_1.mergeSpans)(start.span, value.span) : value.span });
3468
+ params.push({ param, value, span: start ? (0, span_js_1.mergeSpans)(start.span, value.span) : value.span });
1968
3469
  }
1969
3470
  return params;
1970
3471
  }
@@ -1982,7 +3483,7 @@ class Parser {
1982
3483
  const stmt = this.parseStatement(line, 'ordinal');
1983
3484
  if (stmt) {
1984
3485
  body.push(stmt);
1985
- span = (0, span_1.mergeSpans)(span, stmt.span);
3486
+ span = (0, span_js_1.mergeSpans)(span, stmt.span);
1986
3487
  }
1987
3488
  }
1988
3489
  return { kind: 'ordinal', ordinal: ORDINALS[word], ordinalWord: word, body, span };
@@ -2009,7 +3510,7 @@ class Parser {
2009
3510
  }
2010
3511
  const body = this.parseStatements(headLine.indent, blockKeyword);
2011
3512
  const endSpan = this.consumeEnd('each', headLine);
2012
- return { kind: 'each', condition: nameTok.text, body, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
3513
+ return { kind: 'each', condition: nameTok.text, body, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
2013
3514
  }
2014
3515
  parseSelect(headLine) {
2015
3516
  this.pos++;
@@ -2040,7 +3541,7 @@ class Parser {
2040
3541
  }
2041
3542
  }
2042
3543
  const endSpan = this.consumeEnd('select', headLine);
2043
- return { kind: 'select-on', subject, arms, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
3544
+ return { kind: 'select-on', subject, arms, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
2044
3545
  }
2045
3546
  const strategyTok = c.next();
2046
3547
  if (!strategyTok || strategyTok.kind !== 'word' || !STRATEGIES.has(strategyTok.text)) {
@@ -2066,7 +3567,7 @@ class Parser {
2066
3567
  break;
2067
3568
  }
2068
3569
  const endSpan = this.consumeEnd('select', headLine);
2069
- return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
3570
+ return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
2070
3571
  }
2071
3572
  /** Consume the expected `end <keyword>` line; diagnose a mismatch or absence. */
2072
3573
  consumeEnd(keyword, openLine) {
@@ -2113,10 +3614,10 @@ class Parser {
2113
3614
  if (stop(t))
2114
3615
  break;
2115
3616
  words.push(t.text);
2116
- span = span ? (0, span_1.mergeSpans)(span, t.span) : t.span;
3617
+ span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
2117
3618
  c.next();
2118
3619
  }
2119
- return { kind: 'name', article, words, span: span ?? (0, span_1.spanOf)(c.line.lineNo, 1) };
3620
+ return { kind: 'name', article, words, span: span ?? (0, span_js_1.spanOf)(c.line.lineNo, 1) };
2120
3621
  }
2121
3622
  /**
2122
3623
  * Parse a value expression: literal, name reference, or possessive chain
@@ -2144,7 +3645,7 @@ class Parser {
2144
3645
  if (m && m.kind === 'word' && m.text === 'match' && matchStandsAlone) {
2145
3646
  c.next();
2146
3647
  c.next();
2147
- return { kind: 'match', span: (0, span_1.mergeSpans)(first.span, m.span) };
3648
+ return { kind: 'match', span: (0, span_js_1.mergeSpans)(first.span, m.span) };
2148
3649
  }
2149
3650
  }
2150
3651
  // `its <field>` — possessive on `it`.
@@ -2152,7 +3653,7 @@ class Parser {
2152
3653
  c.next();
2153
3654
  const field = this.collectWords(c, extraStops);
2154
3655
  const base = { kind: 'ref', ref: { kind: 'name', article: null, words: ['it'], span: first.span }, span: first.span };
2155
- return { kind: 'possessive', base, field: field.words, span: (0, span_1.mergeSpans)(first.span, field.span ?? first.span) };
3656
+ return { kind: 'possessive', base, field: field.words, span: (0, span_js_1.mergeSpans)(first.span, field.span ?? first.span) };
2156
3657
  }
2157
3658
  let article = null;
2158
3659
  let span = first.span;
@@ -2169,7 +3670,7 @@ class Parser {
2169
3670
  if (PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
2170
3671
  break;
2171
3672
  c.next();
2172
- span = (0, span_1.mergeSpans)(span, t.span);
3673
+ span = (0, span_js_1.mergeSpans)(span, t.span);
2173
3674
  const poss = /^(.*)'s$/.exec(t.text);
2174
3675
  if (poss) {
2175
3676
  words.push(poss[1]);
@@ -2189,7 +3690,7 @@ class Parser {
2189
3690
  };
2190
3691
  if (possessiveBase !== null) {
2191
3692
  const field = this.collectWords(c, extraStops);
2192
- return { kind: 'possessive', base: refExpr, field: field.words, span: (0, span_1.mergeSpans)(span, field.span ?? span) };
3693
+ return { kind: 'possessive', base: refExpr, field: field.words, span: (0, span_js_1.mergeSpans)(span, field.span ?? span) };
2193
3694
  }
2194
3695
  return refExpr;
2195
3696
  }
@@ -2201,7 +3702,7 @@ class Parser {
2201
3702
  if (t.kind !== 'word' || PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
2202
3703
  break;
2203
3704
  words.push(t.text);
2204
- span = span ? (0, span_1.mergeSpans)(span, t.span) : t.span;
3705
+ span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
2205
3706
  c.next();
2206
3707
  }
2207
3708
  return { words, span };
@@ -2217,7 +3718,7 @@ class Parser {
2217
3718
  while (c.matchWord('or')) {
2218
3719
  const right = this.parseConditionAnd(c, line);
2219
3720
  operands.push(right);
2220
- span = (0, span_1.mergeSpans)(span, right.span);
3721
+ span = (0, span_js_1.mergeSpans)(span, right.span);
2221
3722
  }
2222
3723
  return { kind: 'or', operands, span };
2223
3724
  }
@@ -2230,7 +3731,7 @@ class Parser {
2230
3731
  while (c.matchWord('and')) {
2231
3732
  const right = this.parseConditionUnary(c, line);
2232
3733
  operands.push(right);
2233
- span = (0, span_1.mergeSpans)(span, right.span);
3734
+ span = (0, span_js_1.mergeSpans)(span, right.span);
2234
3735
  }
2235
3736
  return { kind: 'and', operands, span };
2236
3737
  }
@@ -2243,7 +3744,19 @@ class Parser {
2243
3744
  if (t.kind === 'word' && t.text === 'not') {
2244
3745
  c.next();
2245
3746
  const operand = this.parseConditionUnary(c, line);
2246
- return { kind: 'not', operand, span: (0, span_1.mergeSpans)(t.span, operand.span) };
3747
+ return { kind: 'not', operand, span: (0, span_js_1.mergeSpans)(t.span, operand.span) };
3748
+ }
3749
+ // `client has <capability>` (ADR-216) — `client` is reserved in
3750
+ // condition-subject position; the capability word is the analyzer's gate.
3751
+ if (t.kind === 'word' && t.text === 'client' && c.isWord('has', 1)) {
3752
+ c.next();
3753
+ c.next(); // client has
3754
+ const capability = c.next();
3755
+ if (!capability || capability.kind !== 'word') {
3756
+ this.diagnostics.error('parse.client-has', 'Expected a capability word after `client has` (sound, images, …).', c.restSpan());
3757
+ return { kind: 'condition-ref', name: '', span: lineSpan(line) };
3758
+ }
3759
+ return { kind: 'client-has', capability: capability.text, span: (0, span_js_1.mergeSpans)(t.span, capability.span) };
2247
3760
  }
2248
3761
  if (t.kind === 'lparen') {
2249
3762
  c.next();
@@ -2264,7 +3777,7 @@ class Parser {
2264
3777
  this.diagnostics.error('parse.chance', 'Expected a number after `one chance in`.', n?.span ?? c.restSpan());
2265
3778
  return { kind: 'chance', n: 0, span: t.span };
2266
3779
  }
2267
- return { kind: 'chance', n: Number(n.text), span: (0, span_1.mergeSpans)(t.span, n.span) };
3780
+ return { kind: 'chance', n: Number(n.text), span: (0, span_js_1.mergeSpans)(t.span, n.span) };
2268
3781
  }
2269
3782
  // `any <name>` / `no <name>` — existential / negated existential over a
2270
3783
  // named open condition (ratchet E1/E2, 2026-07-12). Triggers only on the
@@ -2279,7 +3792,7 @@ class Parser {
2279
3792
  return {
2280
3793
  kind: t.text === 'any' ? 'any-of' : 'none-of',
2281
3794
  condition: nameTok.text,
2282
- span: (0, span_1.mergeSpans)(t.span, nameTok.span),
3795
+ span: (0, span_js_1.mergeSpans)(t.span, nameTok.span),
2283
3796
  };
2284
3797
  }
2285
3798
  }
@@ -2321,7 +3834,7 @@ class Parser {
2321
3834
  kind: 'predicate',
2322
3835
  subject,
2323
3836
  predicate: { kind: 'is-a', negated, classifier: cls.words, span: cls.span ?? t.span },
2324
- span: (0, span_1.mergeSpans)(subject.span, cls.span ?? t.span),
3837
+ span: (0, span_js_1.mergeSpans)(subject.span, cls.span ?? t.span),
2325
3838
  };
2326
3839
  }
2327
3840
  if (c.isWord('in')) {
@@ -2331,7 +3844,7 @@ class Parser {
2331
3844
  kind: 'predicate',
2332
3845
  subject,
2333
3846
  predicate: { kind: 'is-in', negated, place, span: place.span },
2334
- span: (0, span_1.mergeSpans)(subject.span, place.span),
3847
+ span: (0, span_js_1.mergeSpans)(subject.span, place.span),
2335
3848
  };
2336
3849
  }
2337
3850
  if (c.isWord('here')) {
@@ -2343,7 +3856,7 @@ class Parser {
2343
3856
  kind: 'predicate',
2344
3857
  subject,
2345
3858
  predicate: { kind: 'is-here', negated, span: hereTok.span },
2346
- span: (0, span_1.mergeSpans)(subject.span, hereTok.span),
3859
+ span: (0, span_js_1.mergeSpans)(subject.span, hereTok.span),
2347
3860
  };
2348
3861
  }
2349
3862
  const value = this.parseValueExpr(c, line, new Set());
@@ -2351,7 +3864,7 @@ class Parser {
2351
3864
  kind: 'predicate',
2352
3865
  subject,
2353
3866
  predicate: { kind: 'is', negated, value, span: value.span },
2354
- span: (0, span_1.mergeSpans)(subject.span, value.span),
3867
+ span: (0, span_js_1.mergeSpans)(subject.span, value.span),
2355
3868
  };
2356
3869
  }
2357
3870
  if (t.text === 'has' || t.text === 'holds' || t.text === 'wears') {
@@ -2361,7 +3874,7 @@ class Parser {
2361
3874
  kind: 'predicate',
2362
3875
  subject,
2363
3876
  predicate: { kind: t.text, thing, span: thing.span },
2364
- span: (0, span_1.mergeSpans)(subject.span, thing.span),
3877
+ span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
2365
3878
  };
2366
3879
  }
2367
3880
  if (t.text === 'can') {
@@ -2376,8 +3889,8 @@ class Parser {
2376
3889
  return {
2377
3890
  kind: 'predicate',
2378
3891
  subject,
2379
- predicate: { kind: 'can', ability: ability.text, thing, span: (0, span_1.mergeSpans)(ability.span, thing.span) },
2380
- span: (0, span_1.mergeSpans)(subject.span, thing.span),
3892
+ predicate: { kind: 'can', ability: ability.text, thing, span: (0, span_js_1.mergeSpans)(ability.span, thing.span) },
3893
+ span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
2381
3894
  };
2382
3895
  }
2383
3896
  this.diagnostics.error('parse.predicate', `Unknown predicate \`${t.text}\` — expected is, is a, is in, has, holds, wears, or can see/reach.`, t.span);