@sharpee/chord 3.0.0 → 3.3.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 (55) hide show
  1. package/analyzer.d.ts +11 -3
  2. package/analyzer.d.ts.map +1 -1
  3. package/analyzer.js +1217 -33
  4. package/analyzer.js.map +1 -1
  5. package/ast.d.ts +458 -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 +95 -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 +31 -13
  14. package/index.d.ts.map +1 -1
  15. package/index.js +110 -30
  16. package/index.js.map +1 -1
  17. package/ir.d.ts +316 -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/index.d.ts +21 -0
  28. package/manifests/index.d.ts.map +1 -0
  29. package/manifests/index.js +46 -0
  30. package/manifests/index.js.map +1 -0
  31. package/manifests/npc.d.ts +19 -0
  32. package/manifests/npc.d.ts.map +1 -0
  33. package/manifests/npc.js +38 -0
  34. package/manifests/npc.js.map +1 -0
  35. package/manifests/state-machines.d.ts +17 -0
  36. package/manifests/state-machines.d.ts.map +1 -0
  37. package/manifests/state-machines.js +8 -0
  38. package/manifests/state-machines.js.map +1 -0
  39. package/manifests/types.d.ts +41 -0
  40. package/manifests/types.d.ts.map +1 -0
  41. package/manifests/types.js +18 -0
  42. package/manifests/types.js.map +1 -0
  43. package/message-alias-catalog.d.ts +22 -0
  44. package/message-alias-catalog.d.ts.map +1 -0
  45. package/message-alias-catalog.js +830 -0
  46. package/message-alias-catalog.js.map +1 -0
  47. package/package.json +1 -1
  48. package/parser.d.ts +2 -2
  49. package/parser.d.ts.map +1 -1
  50. package/parser.js +1566 -148
  51. package/parser.js.map +1 -1
  52. package/phrasebooks.d.ts +28 -0
  53. package/phrasebooks.d.ts.map +1 -0
  54. package/phrasebooks.js +6 -0
  55. package/phrasebooks.js.map +1 -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,9 +82,18 @@ 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
- 'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose',
96
+ 'refuse', 'phrase', 'emit', 'set', 'change', 'move', 'remove', 'award', 'win', 'lose', 'kill',
85
97
  'if', 'select', 'each', 'end', 'else', 'or', 'when', 'at',
86
98
  ]);
87
99
  /**
@@ -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,12 @@ 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;
168
216
  case 'each':
169
217
  // Never top-level (given 9: all behavior is owned) — ratchet E3.
170
218
  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 +228,18 @@ class Parser {
180
228
  kind: 'story-file',
181
229
  header,
182
230
  declarations,
183
- span: last ? (0, span_1.mergeSpans)(start, lineSpan(last)) : start,
231
+ span: last ? (0, span_js_1.mergeSpans)(start, lineSpan(last)) : start,
184
232
  };
185
233
  }
234
+ /** ADR-249: the one inside-block comment diagnostic, with its fix-it. */
235
+ reportCommentInsideBlock(line) {
236
+ this.diagnostics.error('parse.comment-inside-block', 'Comments are only legal outside blocks, at the top level of the story file.', lineSpan(line));
237
+ }
238
+ /** Report an inside-block comment and consume the line (block parsing continues). */
239
+ skipCommentInsideBlock(line) {
240
+ this.reportCommentInsideBlock(line);
241
+ this.pos++;
242
+ }
186
243
  /** Skip lines until the next top-level keyword at indent 0. */
187
244
  recoverToTopLevel(consumeCurrent = false) {
188
245
  if (consumeCurrent)
@@ -223,10 +280,73 @@ class Parser {
223
280
  const states = [];
224
281
  let statesReversible = false;
225
282
  const scores = [];
283
+ const onClauses = [];
284
+ const uses = [];
285
+ const usePhrasebooks = [];
226
286
  let span = lineSpan(line);
227
287
  while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
288
+ const peeked = this.lines[this.pos];
289
+ const peekedWord = firstWord(peeked);
290
+ // `use <extension>` — a trusted platform extension (ADR-215): static,
291
+ // one name per line; the analyzer gates the name against the manifest
292
+ // registry.
293
+ if (peekedWord === 'use') {
294
+ const useLine = this.lines[this.pos++];
295
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(useLine));
296
+ const uc = new Cursor(useLine.tokens, useLine);
297
+ uc.matchWord('use');
298
+ // ADR-250 D2: exactly the word `phrasebook` selects the two-word
299
+ // form — `use phrasebook <name> [while <condition>]`, stackable.
300
+ // Plain `use <extension>` keeps its strict one-word grammar.
301
+ if (uc.isWord('phrasebook')) {
302
+ uc.next();
303
+ const bookTok = uc.next();
304
+ if (!bookTok || bookTok.kind !== 'word') {
305
+ this.diagnostics.error('parse.use-phrasebook', 'Expected `use phrasebook <name> [while <condition>]` — a single kebab-case book name.', uc.restSpan());
306
+ continue;
307
+ }
308
+ let bookCondition = null;
309
+ if (uc.isWord('while')) {
310
+ uc.next();
311
+ const condTokens = [];
312
+ while (!uc.atEnd())
313
+ condTokens.push(uc.next());
314
+ bookCondition = this.parseCondition(new Cursor(condTokens, useLine), useLine);
315
+ }
316
+ else if (!uc.atEnd()) {
317
+ this.diagnostics.error('parse.use-phrasebook', 'Unexpected text after the book name — only `while <condition>` may follow.', uc.restSpan());
318
+ continue;
319
+ }
320
+ usePhrasebooks.push({ name: bookTok.text, condition: bookCondition, span: lineSpan(useLine) });
321
+ continue;
322
+ }
323
+ const nameTok = uc.next();
324
+ if (!nameTok || nameTok.kind !== 'word' || !uc.atEnd()) {
325
+ this.diagnostics.error('parse.use', 'Expected `use <extension>` — one extension name per line.', lineSpan(useLine));
326
+ }
327
+ else {
328
+ uses.push({ name: nameTok.text, span: lineSpan(useLine) });
329
+ }
330
+ continue;
331
+ }
332
+ // `on every turn [while <cond>][, once]` — the story-owned daemon
333
+ // (ADR-236 D7, ratchet R4). The only clause form the header hosts;
334
+ // anything else keeps its owner-attached home.
335
+ if (peekedWord === 'on' || peekedWord === 'after') {
336
+ const clause = this.parseOnClause(peeked.indent, peekedWord);
337
+ span = (0, span_js_1.mergeSpans)(span, clause.span);
338
+ if (clause.clauseKind === 'on' && clause.binding === 'every-turn') {
339
+ onClauses.push(clause);
340
+ }
341
+ else if (!(clause.clauseKind === 'after' && clause.binding === 'every-turn')) {
342
+ // (`after every turn` already got its own parse error inside
343
+ // parseOnClause — don't stack a second diagnostic on it.)
344
+ 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);
345
+ }
346
+ continue;
347
+ }
228
348
  const fieldLine = this.lines[this.pos++];
229
- span = (0, span_1.mergeSpans)(span, lineSpan(fieldLine));
349
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(fieldLine));
230
350
  const key = firstWord(fieldLine);
231
351
  // `states[, reversible]: a, b` — story phases (ratchet D2/D4).
232
352
  if (key === 'states') {
@@ -254,7 +374,7 @@ class Parser {
254
374
  const colonAt = fieldLine.raw.indexOf(':');
255
375
  fields[key] = fieldLine.raw.slice(colonAt + 1).trim();
256
376
  }
257
- return { kind: 'story-header', title, author, fields, states, statesReversible, scores, span };
377
+ return { kind: 'story-header', title, author, fields, states, statesReversible, scores, onClauses, uses, usePhrasebooks, span };
258
378
  }
259
379
  /**
260
380
  * Parse the tail of a `states` line after the `states` word:
@@ -313,11 +433,17 @@ class Parser {
313
433
  kind: 'create',
314
434
  name,
315
435
  aka: [],
436
+ pronouns: [],
316
437
  compositions: [],
438
+ startsStates: [],
317
439
  placement: null,
318
440
  wears: [],
441
+ carries: [],
442
+ containing: [],
319
443
  exits: [],
320
444
  blockedExits: [],
445
+ deadlyExits: [],
446
+ deadly: null,
321
447
  states: [],
322
448
  statesReversible: false,
323
449
  scores: [],
@@ -331,7 +457,7 @@ class Parser {
331
457
  while (this.pos < this.lines.length && this.lines[this.pos].indent > 0) {
332
458
  const line = this.lines[this.pos];
333
459
  sawBlank = sawBlank || line.afterBlank;
334
- decl.span = (0, span_1.mergeSpans)(decl.span, lineSpan(line));
460
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
335
461
  const word = firstWord(line);
336
462
  const cur = new Cursor(line.tokens, line);
337
463
  if (word === 'aka') {
@@ -339,6 +465,20 @@ class Parser {
339
465
  cur.matchWord('aka');
340
466
  decl.aka.push(...this.parseCommaWords(cur));
341
467
  }
468
+ else if (word === 'pronouns') {
469
+ // ADR-242 D5: `pronouns <word>` — one word (a standard set or a
470
+ // `define pronouns` name). Person-only legality, word resolution,
471
+ // and duplicate-line rejection are the analyzer's gates.
472
+ this.pos++;
473
+ cur.matchWord('pronouns');
474
+ const wordTok = cur.next();
475
+ if (!wordTok || wordTok.kind !== 'word' || !cur.atEnd()) {
476
+ this.diagnostics.error('parse.pronouns-word', 'Expected one pronoun-set word after `pronouns` (e.g. `pronouns she`).', lineSpan(line));
477
+ }
478
+ else {
479
+ decl.pronouns.push({ word: wordTok.text.toLowerCase(), span: lineSpan(line) });
480
+ }
481
+ }
342
482
  else if (word === 'states' && (line.tokens[1]?.kind === 'colon' || line.tokens[1]?.kind === 'comma')) {
343
483
  this.pos++;
344
484
  cur.next();
@@ -359,7 +499,27 @@ class Parser {
359
499
  cur.matchWord('wears');
360
500
  decl.wears.push(this.parseNameRef(cur, () => false));
361
501
  }
502
+ else if (word === 'carries') {
503
+ // ADR-230 Phase 6 (sketch ruling 7): start inventory, the missing
504
+ // half of `wears` — previously accepted silently and dropped.
505
+ this.pos++;
506
+ cur.matchWord('carries');
507
+ decl.carries.push(this.parseNameRef(cur, () => false));
508
+ }
509
+ else if (word === 'containing') {
510
+ // ADR-236 D2 (ratchet R2): region membership — `containing the
511
+ // Clearing, the Forest Path, and the Canyon View`. Additive across
512
+ // lines; region-block-only legality is the analyzer's gate.
513
+ this.pos++;
514
+ cur.matchWord('containing');
515
+ decl.containing.push(...this.parseNameRefList(cur, line));
516
+ }
362
517
  else if (word === 'starts' && cur.isWord('in', 1)) {
518
+ // The `starts` dispatch is one-token lookahead (ADR-231 D5a):
519
+ // `starts in <place>` is placement (here); `starts <known-state>` is
520
+ // an initializer clause, handled by the composition-line fallthrough
521
+ // below (parseCompositionLine's `starts` branch — same branch that
522
+ // rejects unknown words after `starts`).
363
523
  this.pos++;
364
524
  cur.next();
365
525
  cur.next();
@@ -416,8 +576,26 @@ class Parser {
416
576
  this.pos++;
417
577
  cur.next();
418
578
  cur.next();
419
- const to = this.parseNameRef(cur, () => false);
420
- decl.exits.push({ kind: 'exit', direction: word, to, span: lineSpan(line) });
579
+ // `through` is reserved on exit lines as the door tail (ADR-234 D1,
580
+ // ratchet R2) it stops the destination name.
581
+ const to = this.parseNameRef(cur, (t) => t.kind === 'word' && t.text === 'through');
582
+ let via = null;
583
+ if (cur.matchWord('through')) {
584
+ const doorRef = this.parseNameRef(cur, () => false);
585
+ if (doorRef.words.length === 0) {
586
+ this.diagnostics.error('parse.exit-through', 'Expected a door name after `through` (e.g. `north to the Hall through the oak door`).', lineSpan(line));
587
+ }
588
+ else {
589
+ via = doorRef;
590
+ }
591
+ }
592
+ // ADR-234 D4: `, one-way` is reserved (traversable in the written
593
+ // direction only) but NOT wired — a legible reservation error, not
594
+ // a generic parse failure, until its own ratchet entry lands.
595
+ if (cur.peek()?.kind === 'comma' && cur.isWord('one-way', 1)) {
596
+ 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));
597
+ }
598
+ decl.exits.push({ kind: 'exit', direction: word, to, via, span: lineSpan(line) });
421
599
  }
422
600
  else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('blocked', 2)) {
423
601
  this.pos++;
@@ -425,6 +603,29 @@ class Parser {
425
603
  if (blocked)
426
604
  decl.blockedExits.push(blocked);
427
605
  }
606
+ else if (word && DIRECTIONS.has(word) && cur.isWord('is', 1) && cur.isWord('deadly', 2)) {
607
+ this.pos++;
608
+ const deadly = this.parseDeadlyExit(word, line);
609
+ if (deadly)
610
+ decl.deadlyExits.push(deadly);
611
+ }
612
+ else if (word === 'deadly' && line.tokens[1]?.kind === 'colon') {
613
+ // `deadly: <phrase>` — the no-escape room marker (ADR-227).
614
+ this.pos++;
615
+ const dc = new Cursor(line.tokens, line);
616
+ dc.next(); // deadly
617
+ dc.next(); // colon
618
+ const key = dc.next();
619
+ if (!key || key.kind !== 'word') {
620
+ this.diagnostics.error('parse.deadly-room', 'Expected a phrase key after `deadly:`.', lineSpan(line));
621
+ }
622
+ else if (decl.deadly) {
623
+ this.diagnostics.error('parse.deadly-room', 'Duplicate `deadly:` marker in this `create` block.', lineSpan(line));
624
+ }
625
+ else {
626
+ decl.deadly = { kind: 'deadly-room', phraseKey: key.text, span: lineSpan(line) };
627
+ }
628
+ }
428
629
  else if (!sawBlank || !line.afterBlank) {
429
630
  if (sawBlank) {
430
631
  this.diagnostics.error('parse.create-property', `Unrecognized line in \`create\` block: \`${line.raw.trim()}\`.`, lineSpan(line));
@@ -432,7 +633,7 @@ class Parser {
432
633
  }
433
634
  else {
434
635
  this.pos++;
435
- decl.compositions.push(...this.parseCompositionLine(cur, line));
636
+ decl.compositions.push(...this.parseCompositionLine(cur, line, decl.startsStates));
436
637
  }
437
638
  }
438
639
  else {
@@ -444,7 +645,7 @@ class Parser {
444
645
  ...decl.description,
445
646
  text: `${decl.description.text}\n\n${prose.text}`,
446
647
  markers: [...decl.description.markers, ...prose.markers],
447
- span: (0, span_1.mergeSpans)(decl.description.span, prose.span),
648
+ span: (0, span_js_1.mergeSpans)(decl.description.span, prose.span),
448
649
  };
449
650
  }
450
651
  else {
@@ -480,17 +681,78 @@ class Parser {
480
681
  this.diagnostics.error('parse.blocked-exit', 'Expected `: <phrase-key>` after `is blocked`.', lineSpan(line));
481
682
  return null;
482
683
  }
483
- const key = c.next();
484
- if (!key || key.kind !== 'word') {
684
+ const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
685
+ if (!key) {
485
686
  this.diagnostics.error('parse.blocked-exit', 'Expected a phrase key after `is blocked:`.', lineSpan(line));
486
687
  return null;
487
688
  }
488
- return { kind: 'blocked-exit', direction, phraseKey: key.text, condition, span: lineSpan(line) };
689
+ return { kind: 'blocked-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
690
+ }
691
+ parseDeadlyExit(direction, line) {
692
+ // <direction> is deadly [while <condition>]: <phrase-key> (ADR-227) —
693
+ // mirrors the blocked-exit grammar exactly.
694
+ const c = new Cursor(line.tokens, line);
695
+ c.next(); // direction
696
+ c.next(); // is
697
+ c.next(); // deadly
698
+ let condition = null;
699
+ if (c.matchWord('while')) {
700
+ const condTokens = [];
701
+ while (!c.atEnd() && c.peek().kind !== 'colon')
702
+ condTokens.push(c.next());
703
+ condition = this.parseCondition(new Cursor(condTokens, line), line);
704
+ }
705
+ const colon = c.next();
706
+ if (!colon || colon.kind !== 'colon') {
707
+ this.diagnostics.error('parse.deadly-exit', 'Expected `: <phrase-key>` after `is deadly`.', lineSpan(line));
708
+ return null;
709
+ }
710
+ const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
711
+ if (!key) {
712
+ this.diagnostics.error('parse.deadly-exit', 'Expected a phrase key after `is deadly:`.', lineSpan(line));
713
+ return null;
714
+ }
715
+ return { kind: 'deadly-exit', direction, phraseKey: key, condition, span: lineSpan(line) };
489
716
  }
490
- parseCompositionLine(c, line) {
717
+ parseCompositionLine(c, line, startsStates) {
491
718
  const items = [];
492
719
  while (!c.atEnd()) {
493
720
  const startTok = c.peek();
721
+ // `starts <state>` initializer clause (ADR-231 D5a) — one-token
722
+ // lookahead on the `starts` word Chord already owns: a known state
723
+ // word (`locked`, `open`, …) initializes the paired trait's initial
724
+ // value; `in` is the placement line's spelling and can't ride a
725
+ // composition list; anything else is its own parse error, never a
726
+ // silent pass into trait-name resolution.
727
+ if (c.isWord('starts')) {
728
+ const startsTok = c.next();
729
+ const stateTok = c.peek();
730
+ if (stateTok && stateTok.kind === 'word' && catalog_js_1.STARTS_STATE_PAIRINGS.has(stateTok.text)) {
731
+ c.next();
732
+ startsStates.push({
733
+ kind: 'starts-state',
734
+ state: stateTok.text,
735
+ span: (0, span_js_1.mergeSpans)(startsTok.span, stateTok.span),
736
+ });
737
+ if (c.peek()?.kind === 'comma') {
738
+ c.next();
739
+ continue;
740
+ }
741
+ break;
742
+ }
743
+ if (stateTok && stateTok.kind === 'word' && stateTok.text === 'in') {
744
+ 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));
745
+ }
746
+ else {
747
+ const known = [...catalog_js_1.STARTS_STATE_PAIRINGS.keys()].join(', ');
748
+ this.diagnostics.error('parse.starts-state', stateTok
749
+ ? `\`${stateTok.text}\` is not a state \`starts\` can initialize — known states: ${known} (placement is \`starts in <place>\`).`
750
+ : `Expected a state after \`starts\` — known states: ${known} (placement is \`starts in <place>\`).`, stateTok ? stateTok.span : startsTok.span);
751
+ }
752
+ while (!c.atEnd())
753
+ c.next(); // resynchronize: one mistake, one diagnostic
754
+ break;
755
+ }
494
756
  let article = null;
495
757
  const first = c.peek();
496
758
  if (first && first.kind === 'word' && ARTICLES.has(first.text)) {
@@ -524,7 +786,7 @@ class Parser {
524
786
  words,
525
787
  config,
526
788
  condition,
527
- span: (0, span_1.mergeSpans)(startTok.span, endTok.span),
789
+ span: (0, span_js_1.mergeSpans)(startTok.span, endTok.span),
528
790
  });
529
791
  if (c.peek()?.kind === 'comma')
530
792
  c.next();
@@ -540,6 +802,33 @@ class Parser {
540
802
  const settings = [];
541
803
  for (;;) {
542
804
  const startTok = c.peek();
805
+ // Ratchet R3 (ADR-234 D6): an article directly after `with`/`and`
806
+ // starts the adjective's single-entity config value — no keyword
807
+ // (`lockable with the iron key`). Keyed named fields (`with food the
808
+ // handful of feed` on authored traits) still parse below: their key
809
+ // words come first, so the article is not in first position.
810
+ if (startTok && startTok.kind === 'word' && ARTICLES.has(startTok.text)) {
811
+ c.next(); // article
812
+ const nameWords = [];
813
+ let lastTok = startTok;
814
+ while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
815
+ lastTok = c.next();
816
+ nameWords.push(lastTok.text);
817
+ }
818
+ if (nameWords.length === 0) {
819
+ this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
820
+ break;
821
+ }
822
+ settings.push({
823
+ key: [],
824
+ value: nameWords.join(' '),
825
+ valueKind: 'name',
826
+ span: (0, span_js_1.mergeSpans)(startTok.span, lastTok.span),
827
+ });
828
+ if (!c.matchWord('and'))
829
+ break;
830
+ continue;
831
+ }
543
832
  const key = [];
544
833
  while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('while')) {
545
834
  const t = c.peek();
@@ -569,11 +858,65 @@ class Parser {
569
858
  this.diagnostics.error('parse.config-value', 'Expected an entity name after the article.', c.restSpan());
570
859
  break;
571
860
  }
861
+ // Ratchet R3 (ADR-234 D6): the `key`/`tool` config keywords are
862
+ // removed — the entity is written directly after `with`. One form
863
+ // per concept (Given 7); the fix-it names the replacement.
864
+ if (key.length === 1 && (key[0] === 'key' || key[0] === 'tool')) {
865
+ 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);
866
+ if (!c.matchWord('and'))
867
+ break;
868
+ continue;
869
+ }
572
870
  settings.push({
573
871
  key,
574
872
  value: nameWords.join(' '),
575
873
  valueKind: 'name',
576
- span: startTok ? (0, span_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span,
874
+ span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastTok.span) : lastTok.span,
875
+ });
876
+ if (!c.matchWord('and'))
877
+ break;
878
+ continue;
879
+ }
880
+ // `[ … ]` list value (ADR-215): a bracketed, comma-separated list of
881
+ // name references (`patrol route [Hall, Study, Hall]`). Narrower than
882
+ // ADR-216's statement-payload arrays — config lists hold names only.
883
+ const bracketTok = c.peek();
884
+ if (bracketTok && bracketTok.kind === 'lbracket' && key.length > 0) {
885
+ c.next(); // [
886
+ const listValues = [];
887
+ for (;;) {
888
+ if (c.peek()?.kind === 'rbracket')
889
+ break;
890
+ const ref = this.parseNameRef(c, () => false);
891
+ if (ref.words.length === 0) {
892
+ this.diagnostics.error('parse.config-list', 'Expected an entity name inside the `[ … ]` list.', c.restSpan());
893
+ while (!c.atEnd() && c.peek().kind !== 'rbracket')
894
+ c.next();
895
+ break;
896
+ }
897
+ listValues.push(ref);
898
+ if (c.peek()?.kind === 'comma')
899
+ c.next();
900
+ else
901
+ break;
902
+ }
903
+ const close = c.next();
904
+ let lastSpan = bracketTok.span;
905
+ if (!close || close.kind !== 'rbracket') {
906
+ this.diagnostics.error('parse.config-list', 'Expected `]` to close the list.', c.restSpan());
907
+ }
908
+ else {
909
+ lastSpan = close.span;
910
+ }
911
+ if (listValues.length === 0) {
912
+ this.diagnostics.error('parse.config-list', 'A `[ … ]` list needs at least one entry.', lastSpan);
913
+ }
914
+ settings.push({
915
+ key,
916
+ value: '',
917
+ valueKind: 'list',
918
+ listValues,
919
+ span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, lastSpan) : lastSpan,
577
920
  });
578
921
  if (!c.matchWord('and'))
579
922
  break;
@@ -589,7 +932,7 @@ class Parser {
589
932
  key,
590
933
  value: valueTok.text,
591
934
  valueKind: valueTok.kind,
592
- span: startTok ? (0, span_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
935
+ span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, valueTok.span) : valueTok.span,
593
936
  });
594
937
  if (!c.matchWord('and'))
595
938
  break;
@@ -599,7 +942,8 @@ class Parser {
599
942
  parsePhraseOverride(line) {
600
943
  const c = new Cursor(line.tokens, line);
601
944
  c.matchWord('phrase');
602
- const key = c.next(); // validated by caller
945
+ // Key word validated by the caller; phrase-key = WORD { "." WORD } (ADR-231 D1b).
946
+ const key = this.readLabelKey(c);
603
947
  // CP3: optional `, <strategy>` (the Z5 adverb set, retired fix-its
604
948
  // included) — `phrase present, cycling:`.
605
949
  let strategy = null;
@@ -654,11 +998,11 @@ class Parser {
654
998
  const last = variants[variants.length - 1];
655
999
  return {
656
1000
  kind: 'phrase-override',
657
- key: key.text,
1001
+ key,
658
1002
  strategy,
659
1003
  condition,
660
1004
  variants,
661
- span: (0, span_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
1005
+ span: (0, span_js_1.mergeSpans)(lineSpan(line), last?.span ?? lineSpan(line)),
662
1006
  };
663
1007
  }
664
1008
  /** Same-line phrase text (quoted or bare) was removed — grammar log 2026-07-10. */
@@ -683,6 +1027,33 @@ class Parser {
683
1027
  out.push(current.join(' '));
684
1028
  return out;
685
1029
  }
1030
+ /**
1031
+ * A comma-separated list of entity name references, Oxford-`and` welcome
1032
+ * (`the Clearing, the Forest Path, and the Canyon View`). Each reference
1033
+ * keeps its own article and span; an empty list is a parse error.
1034
+ */
1035
+ parseNameRefList(c, line) {
1036
+ const refs = [];
1037
+ for (;;) {
1038
+ const ref = this.parseNameRef(c, (t) => t.kind === 'word' && t.text === 'and');
1039
+ if (ref.words.length === 0) {
1040
+ this.diagnostics.error('parse.name-list', 'Expected an entity name.', c.atEnd() ? lineSpan(line) : c.restSpan());
1041
+ break;
1042
+ }
1043
+ refs.push(ref);
1044
+ if (c.peek()?.kind === 'comma') {
1045
+ c.next();
1046
+ c.matchWord('and'); // Oxford `and` after the comma
1047
+ }
1048
+ else if (!c.matchWord('and')) {
1049
+ break;
1050
+ }
1051
+ }
1052
+ if (!c.atEnd()) {
1053
+ this.diagnostics.error('parse.name-list', `Unexpected trailing text in name list: \`${c.peek().text}\`.`, c.restSpan());
1054
+ }
1055
+ return refs;
1056
+ }
686
1057
  parseStateList(c) {
687
1058
  const out = [];
688
1059
  while (!c.atEnd()) {
@@ -727,7 +1098,7 @@ class Parser {
727
1098
  const text = line.raw.trim();
728
1099
  this.extractMarkers(line, markers);
729
1100
  current.push(text);
730
- span = span ? (0, span_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
1101
+ span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
731
1102
  first = false;
732
1103
  }
733
1104
  if (current.length)
@@ -737,7 +1108,7 @@ class Parser {
737
1108
  form: 'prose',
738
1109
  text: paragraphs.map((p) => p.join(' ')).join('\n\n'),
739
1110
  markers,
740
- span: span ?? (0, span_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
1111
+ span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
741
1112
  };
742
1113
  }
743
1114
  /**
@@ -759,7 +1130,7 @@ class Parser {
759
1130
  break;
760
1131
  collected.push({ raw: line.raw, blankBefore: !first && line.afterBlank });
761
1132
  this.extractMarkers(line, markers);
762
- span = span ? (0, span_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
1133
+ span = span ? (0, span_js_1.mergeSpans)(span, lineSpan(line)) : lineSpan(line);
763
1134
  this.pos++;
764
1135
  first = false;
765
1136
  }
@@ -775,14 +1146,14 @@ class Parser {
775
1146
  form: 'verbatim',
776
1147
  text: lines.join('\n'),
777
1148
  markers,
778
- span: span ?? (0, span_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
1149
+ span: span ?? (0, span_js_1.spanOf)(this.lines[this.pos - 1]?.lineNo ?? 1, 1),
779
1150
  };
780
1151
  }
781
1152
  extractMarkers(line, into) {
782
1153
  const re = /\{([^}]*)\}/g;
783
1154
  let m;
784
1155
  while ((m = re.exec(line.raw)) !== null) {
785
- into.push({ content: m[1], span: (0, span_1.spanOf)(line.lineNo, m.index + 1, m[0].length) });
1156
+ into.push({ content: m[1], span: (0, span_js_1.spanOf)(line.lineNo, m.index + 1, m[0].length) });
786
1157
  }
787
1158
  }
788
1159
  /** Error-recovery only: a removed same-line quoted value, kept as prose text. */
@@ -791,7 +1162,7 @@ class Parser {
791
1162
  const re = /\{([^}]*)\}/g;
792
1163
  let m;
793
1164
  while ((m = re.exec(tok.text)) !== null) {
794
- markers.push({ content: m[1], span: (0, span_1.spanOf)(tok.span.line, tok.span.column + 1 + m.index, m[0].length) });
1165
+ markers.push({ content: m[1], span: (0, span_js_1.spanOf)(tok.span.line, tok.span.column + 1 + m.index, m[0].length) });
795
1166
  }
796
1167
  return { kind: 'text', form: 'prose', text: tok.text, markers, span: tok.span };
797
1168
  }
@@ -805,6 +1176,10 @@ class Parser {
805
1176
  return this.parseDefineCondition();
806
1177
  case 'phrase':
807
1178
  return this.parseDefinePhrase();
1179
+ case 'phrasebook':
1180
+ // ADR-245/250 D1 (David 2026-07-21) — named, predicated phrase
1181
+ // collections; entries reuse the phrase-override grammar.
1182
+ return this.parseDefinePhrasebook();
808
1183
  case 'phrases':
809
1184
  return this.parseDefinePhrases();
810
1185
  case 'verb':
@@ -820,8 +1195,15 @@ class Parser {
820
1195
  return this.parseDefineTrait();
821
1196
  case 'action':
822
1197
  return this.parseDefineAction();
1198
+ case 'chain':
1199
+ return this.parseDefineChainHatch();
823
1200
  case 'behavior':
824
- return this.parseDefineBehaviorHatch();
1201
+ // ADR-235 D2 (removal, 2026-07-18): the behavior hatch carried no
1202
+ // trait/action binding key, so it structurally could never fire —
1203
+ // removed rather than repaired.
1204
+ 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]));
1205
+ this.pos++;
1206
+ return null;
825
1207
  case 'score':
826
1208
  // Removed — ratchet D12/CP5 (2026-07-11).
827
1209
  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));
@@ -829,6 +1211,29 @@ class Parser {
829
1211
  return null;
830
1212
  case 'sequence':
831
1213
  return this.parseDefineSequence();
1214
+ case 'machine':
1215
+ // ADR-215 `use state-machines` depth (spelling A, 2026-07-18) —
1216
+ // the `use` requirement is the analyzer's gate.
1217
+ return this.parseDefineMachine();
1218
+ case 'sound':
1219
+ case 'image':
1220
+ case 'music':
1221
+ // ADR-216 declared assets — DATA references, never hatches.
1222
+ return this.parseDefineAsset(subWord);
1223
+ case 'ambient':
1224
+ case 'layer':
1225
+ // ADR-241 D2 — named family channels (an ambient bed / an image
1226
+ // layer), one-liners beside the asset declarations.
1227
+ return this.parseDefineFamilyChannel(subWord);
1228
+ case 'channel':
1229
+ // ADR-216 custom channels (spelling A, 2026-07-18) — data projections.
1230
+ return this.parseDefineChannel();
1231
+ case 'topics':
1232
+ // ADR-239 D3 (as amended) — the ask/tell topic table block.
1233
+ return this.parseDefineTopics();
1234
+ case 'pronouns':
1235
+ // ADR-242 D7 (ruled Q-1) — named pronoun set, five named rows.
1236
+ return this.parseDefinePronouns();
832
1237
  default:
833
1238
  this.diagnostics.error('parse.unknown-define', `Unknown declaration \`define ${subWord ?? ''}\`.`, lineSpan(line));
834
1239
  this.recoverToTopLevel(true);
@@ -858,11 +1263,27 @@ class Parser {
858
1263
  const c = new Cursor(headLine.tokens, headLine);
859
1264
  c.next();
860
1265
  c.next(); // define phrase
861
- const keyTok = c.next();
862
- const key = keyTok && keyTok.kind === 'word' ? keyTok.text : '';
1266
+ // EBNF: phrase-key = WORD { "." WORD } (ADR-230 D5). Previously the
1267
+ // parser silently registered only the first segment (`if.action.taking`
1268
+ // became `if`), which made story-wide overrides of platform message ids
1269
+ // impossible.
1270
+ const key = this.readLabelKey(c) ?? '';
863
1271
  if (!key) {
864
1272
  this.diagnostics.error('parse.phrase-key', 'Expected a phrase key after `define phrase`.', lineSpan(headLine));
1273
+ c.next(); // skip the offending token so header options still parse
865
1274
  }
1275
+ const body = this.readPhraseBody(c, headLine, 'phrase');
1276
+ return { kind: 'define-phrase', key, ...body };
1277
+ }
1278
+ /**
1279
+ * Read a phrase body from a header cursor positioned after the key/alias:
1280
+ * the optional `, <strategy>|verbatim` header modifier, an optional trailing
1281
+ * `while <condition>` gate, then the indented variant lines up to
1282
+ * `end <endWord>`. Shared by `define phrase` and ADR-255's `override message`
1283
+ * so the two constructs never drift (ADR-255 D1). Returns the body fields
1284
+ * (the caller supplies the `kind` and key/alias).
1285
+ */
1286
+ readPhraseBody(c, headLine, endWord) {
866
1287
  let strategy = null;
867
1288
  let verbatim = false;
868
1289
  if (c.peek()?.kind === 'comma') {
@@ -892,15 +1313,22 @@ class Parser {
892
1313
  }
893
1314
  const variants = [];
894
1315
  let span = lineSpan(headLine);
1316
+ let flaggedFlushLeft = false;
895
1317
  for (;;) {
896
1318
  const line = this.lines[this.pos];
897
1319
  if (!line) {
898
- this.diagnostics.error('parse.unterminated-block', 'Missing `end phrase`.', span);
1320
+ this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
899
1321
  break;
900
1322
  }
1323
+ if (line.comment) {
1324
+ // ADR-249 — indented `##` stays prose here; only a flagged
1325
+ // (indent-0) comment line is an inside-block error.
1326
+ this.skipCommentInsideBlock(line);
1327
+ continue;
1328
+ }
901
1329
  if (isEndLine(line)) {
902
- span = (0, span_1.mergeSpans)(span, lineSpan(line));
903
- this.consumeEnd('phrase', headLine);
1330
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
1331
+ this.consumeEnd(endWord, headLine);
904
1332
  break;
905
1333
  }
906
1334
  if (firstWord(line) === 'or' && line.tokens.length === 1) {
@@ -911,19 +1339,153 @@ class Parser {
911
1339
  continue;
912
1340
  }
913
1341
  if (line.indent === 0 && TOP_KEYWORDS.has(firstWord(line) ?? '')) {
914
- this.diagnostics.error('parse.unterminated-block', 'Missing `end phrase`.', span);
1342
+ this.diagnostics.error('parse.unterminated-block', `Missing \`end ${endWord}\`.`, span);
915
1343
  break;
916
1344
  }
1345
+ if (line.indent === 0) {
1346
+ // A flush-left non-keyword line makes zero progress in either
1347
+ // variant parser below (both require depth > 0) — without this
1348
+ // guard the loop appended empty variants until OOM. One diagnostic
1349
+ // for the first offending line; the rest of the flush-left run is
1350
+ // consumed so `end <endWord>` still terminates the block.
1351
+ if (!flaggedFlushLeft) {
1352
+ flaggedFlushLeft = true;
1353
+ this.diagnostics.error('parse.phrase-text-indent', `Text must be indented under \`${endWord === 'override' ? 'override message' : 'define phrase'}\`.`, lineSpan(line));
1354
+ }
1355
+ this.pos++;
1356
+ continue;
1357
+ }
917
1358
  const variant = verbatim ? this.parseVerbatimBlock() : this.parseProseParagraph(1, 0);
918
1359
  variants.push(variant);
919
- span = (0, span_1.mergeSpans)(span, variant.span);
1360
+ span = (0, span_js_1.mergeSpans)(span, variant.span);
1361
+ }
1362
+ return { strategy, verbatim, condition, variants, span };
1363
+ }
1364
+ /**
1365
+ * `define phrasebook <name> [while <condition>] … end phrasebook`
1366
+ * (ADR-250 D1). Entries are phrase-override-shaped lines
1367
+ * (`<key>[, strategy]:` + `or` variants) at one indent level; the
1368
+ * body carries the ADR-249 comment guard like every end-terminated
1369
+ * block. Entry-level `while` parses here (override grammar) and is
1370
+ * gated in the analyzer (`analysis.phrasebook-entry-gate`).
1371
+ */
1372
+ parseDefinePhrasebook() {
1373
+ const headLine = this.lines[this.pos++];
1374
+ const c = new Cursor(headLine.tokens, headLine);
1375
+ c.matchWord('define');
1376
+ c.matchWord('phrasebook');
1377
+ let name = '';
1378
+ const nameTok = c.next();
1379
+ if (!nameTok || nameTok.kind !== 'word') {
1380
+ this.diagnostics.error('parse.phrasebook-header', 'Expected `define phrasebook <name> [while <condition>]` — a single kebab-case book name.', c.restSpan());
1381
+ }
1382
+ else {
1383
+ name = nameTok.text;
1384
+ }
1385
+ let condition = null;
1386
+ if (c.isWord('while')) {
1387
+ c.next();
1388
+ const condTokens = [];
1389
+ while (!c.atEnd())
1390
+ condTokens.push(c.next());
1391
+ condition = this.parseCondition(new Cursor(condTokens, headLine), headLine);
1392
+ }
1393
+ else if (!c.atEnd()) {
1394
+ this.diagnostics.error('parse.phrasebook-header', 'Unexpected text after the book name — only `while <condition>` may follow.', c.restSpan());
1395
+ }
1396
+ const entries = [];
1397
+ let span = lineSpan(headLine);
1398
+ for (;;) {
1399
+ const line = this.lines[this.pos];
1400
+ if (!line) {
1401
+ this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
1402
+ break;
1403
+ }
1404
+ if (line.comment) {
1405
+ // ADR-249: `##` inside a block is never a comment.
1406
+ this.skipCommentInsideBlock(line);
1407
+ continue;
1408
+ }
1409
+ if (isEndLine(line)) {
1410
+ span = (0, span_js_1.mergeSpans)(span, this.consumeEnd('phrasebook', headLine));
1411
+ break;
1412
+ }
1413
+ if (line.indent === 0) {
1414
+ this.diagnostics.error('parse.phrasebook-end', 'Missing `end phrasebook`.', span);
1415
+ break;
1416
+ }
1417
+ if (line.tokens[0]?.kind !== 'word' || !line.tokens.some((t) => t.kind === 'colon')) {
1418
+ this.diagnostics.error('parse.phrasebook-entry', 'Expected `<key>[, strategy]:` to open a phrasebook entry.', lineSpan(line));
1419
+ this.pos++;
1420
+ continue;
1421
+ }
1422
+ this.pos++;
1423
+ const entry = this.parsePhraseOverride(line);
1424
+ entries.push(entry);
1425
+ span = (0, span_js_1.mergeSpans)(span, entry.span);
1426
+ }
1427
+ return { kind: 'define-phrasebook', name, condition, entries, span };
1428
+ }
1429
+ /**
1430
+ * `import "<file>"` (ADR-251 D1) — the single generalized import form.
1431
+ * The path is extension-free (the compiler appends `.chord` at resolve
1432
+ * time — D2); any string is accepted at parse time. A bare word after
1433
+ * `import` (the removed `phrasebook` sub-word, or anything else) or a
1434
+ * missing string is `parse.import-form`.
1435
+ */
1436
+ parseImport() {
1437
+ const line = this.lines[this.pos++];
1438
+ const c = new Cursor(line.tokens, line);
1439
+ c.matchWord('import');
1440
+ const pathTok = c.next();
1441
+ if (!pathTok || pathTok.kind !== 'string' || !c.atEnd()) {
1442
+ this.diagnostics.error('parse.import-form', 'Expected a quoted file name: `import "<file>"` (no extension — `.chord` is assumed).', c.restSpan());
1443
+ return null;
920
1444
  }
921
- return { kind: 'define-phrase', key, strategy, verbatim, condition, variants, span };
1445
+ return { kind: 'import', path: pathTok.text, span: lineSpan(line) };
922
1446
  }
923
1447
  parseDefinePhrases() {
924
1448
  const headLine = this.lines[this.pos++];
925
1449
  return this.parsePhrasesBlock(headLine, 2);
926
1450
  }
1451
+ /**
1452
+ * ADR-255: dispatch the two override forms off the word after `override`.
1453
+ * `override message <alias> … end override` (full phrase body) vs.
1454
+ * `override messages <locale>` (locale block of `alias: text` entries).
1455
+ */
1456
+ parseOverride() {
1457
+ const headLine = this.lines[this.pos];
1458
+ const kindTok = headLine.tokens[1];
1459
+ if (kindTok?.kind === 'word' && kindTok.text === 'messages') {
1460
+ return this.parseOverrideMessages();
1461
+ }
1462
+ if (kindTok?.kind === 'word' && kindTok.text === 'message') {
1463
+ return this.parseOverrideMessage();
1464
+ }
1465
+ this.diagnostics.error('parse.override', 'Expected `override message <alias>` or `override messages <locale>`.', lineSpan(headLine));
1466
+ this.pos++;
1467
+ return null;
1468
+ }
1469
+ /** `override message <alias> [, strategy] [while <cond>] … end override` (ADR-255 D1). */
1470
+ parseOverrideMessage() {
1471
+ const headLine = this.lines[this.pos++];
1472
+ const c = new Cursor(headLine.tokens, headLine);
1473
+ c.next(); // override
1474
+ c.next(); // message
1475
+ const alias = this.readLabelKey(c) ?? '';
1476
+ if (!alias) {
1477
+ this.diagnostics.error('parse.override-alias', 'Expected an override alias after `override message`.', lineSpan(headLine));
1478
+ c.next(); // skip the offending token so header options still parse
1479
+ }
1480
+ const body = this.readPhraseBody(c, headLine, 'override');
1481
+ return { kind: 'override-message', alias, ...body };
1482
+ }
1483
+ /** `override messages <locale>` — a locale block of `alias: text` entries (ADR-255 D1). */
1484
+ parseOverrideMessages() {
1485
+ const headLine = this.lines[this.pos++];
1486
+ const phrases = this.parsePhrasesBlock(headLine, 2);
1487
+ return { kind: 'override-messages', locale: phrases.locale, entries: phrases.entries, span: phrases.span };
1488
+ }
927
1489
  /**
928
1490
  * Parse a phrases block from its header line (`define phrases <locale>` at
929
1491
  * top level, or `phrases <locale>` inside a trait/action, Phase B). The
@@ -943,16 +1505,18 @@ class Parser {
943
1505
  let span = lineSpan(headLine);
944
1506
  while (this.pos < this.lines.length && this.lines[this.pos].indent > headLine.indent) {
945
1507
  const line = this.lines[this.pos];
946
- const key = line.tokens[0];
947
- const colon = line.tokens[1];
948
- if (!key || key.kind !== 'word' || !colon || colon.kind !== 'colon') {
1508
+ const ec = new Cursor(line.tokens, line);
1509
+ const key = this.readLabelKey(ec); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
1510
+ const colon = ec.peek();
1511
+ if (!key || !colon || colon.kind !== 'colon') {
949
1512
  this.diagnostics.error('parse.phrase-entry', 'Expected `key: <text>` in the phrases block.', lineSpan(line));
950
1513
  this.pos++;
951
1514
  continue;
952
1515
  }
1516
+ ec.next(); // colon
953
1517
  this.pos++;
954
1518
  let value;
955
- const inline = line.tokens[2];
1519
+ const inline = ec.peek();
956
1520
  if (inline && inline.kind === 'string') {
957
1521
  this.reportSameLineText(inline.span);
958
1522
  value = this.textFromString(inline); // recovery: keep the text so analysis continues
@@ -969,11 +1533,11 @@ class Parser {
969
1533
  else {
970
1534
  value = this.parseProseParagraph(line.indent + 1, line.indent);
971
1535
  if (value.text === '') {
972
- this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key.text}\` has no text.`, lineSpan(line));
1536
+ this.diagnostics.error('parse.phrase-entry-empty', `Phrase \`${key}\` has no text.`, lineSpan(line));
973
1537
  }
974
1538
  }
975
- entries.push({ key: key.text, value, span: (0, span_1.mergeSpans)(lineSpan(line), value.span) });
976
- span = (0, span_1.mergeSpans)(span, entries[entries.length - 1].span);
1539
+ entries.push({ key, value, span: (0, span_js_1.mergeSpans)(lineSpan(line), value.span) });
1540
+ span = (0, span_js_1.mergeSpans)(span, entries[entries.length - 1].span);
977
1541
  }
978
1542
  return { kind: 'define-phrases', locale, entries, span };
979
1543
  }
@@ -1007,7 +1571,7 @@ class Parser {
1007
1571
  this.diagnostics.error('parse.verb-slot', 'Expected `(something)` slot in the verb pattern.', t.span);
1008
1572
  return null;
1009
1573
  }
1010
- pattern.push({ kind: 'slot', word: slot.text, span: (0, span_1.mergeSpans)(t.span, close.span) });
1574
+ pattern.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, close.span) });
1011
1575
  }
1012
1576
  else if (t.kind === 'word') {
1013
1577
  pattern.push({ kind: 'word', word: t.text, span: t.span });
@@ -1062,6 +1626,10 @@ class Parser {
1062
1626
  });
1063
1627
  while (this.pos < this.lines.length) {
1064
1628
  const line = this.lines[this.pos];
1629
+ if (looksLikeComment(line)) {
1630
+ this.skipCommentInsideBlock(line);
1631
+ continue;
1632
+ }
1065
1633
  if (line.indent === 0) {
1066
1634
  if (isEndLine(line))
1067
1635
  break;
@@ -1106,13 +1674,17 @@ class Parser {
1106
1674
  }
1107
1675
  }
1108
1676
  const endSpan = this.consumeEnd('trait', headLine);
1109
- return build((0, span_1.mergeSpans)(lineSpan(headLine), endSpan));
1677
+ return build((0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan));
1110
1678
  }
1111
1679
  /** `data` block fields: `locked: flag`, `body part: optional name`, `kind: one of a, b`. */
1112
1680
  parseTraitFields(dataLine) {
1113
1681
  const fields = [];
1114
1682
  while (this.pos < this.lines.length && this.lines[this.pos].indent > dataLine.indent) {
1115
1683
  const line = this.lines[this.pos++];
1684
+ if (looksLikeComment(line)) {
1685
+ this.reportCommentInsideBlock(line);
1686
+ continue;
1687
+ }
1116
1688
  const c = new Cursor(line.tokens, line);
1117
1689
  const name = [];
1118
1690
  while (!c.atEnd() && c.peek().kind === 'word')
@@ -1209,6 +1781,10 @@ class Parser {
1209
1781
  const line = this.lines[this.pos];
1210
1782
  if (line.indent === 0)
1211
1783
  break; // dedent-terminated (no `end action`, design.md §2.3/§3.4)
1784
+ if (looksLikeComment(line)) {
1785
+ this.skipCommentInsideBlock(line);
1786
+ continue;
1787
+ }
1212
1788
  const word = firstWord(line);
1213
1789
  if (word === 'grammar' && line.tokens.length === 1) {
1214
1790
  this.pos++;
@@ -1249,9 +1825,9 @@ class Parser {
1249
1825
  this.diagnostics.error('parse.action-otherwise', 'Expected `otherwise refuse <phrase-key>`.', lineSpan(line));
1250
1826
  }
1251
1827
  else {
1252
- const key = oc.next();
1253
- if (key && key.kind === 'word')
1254
- otherwise = { phraseKey: key.text, span: lineSpan(line) };
1828
+ const key = this.readLabelKey(oc); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
1829
+ if (key)
1830
+ otherwise = { phraseKey: key, span: lineSpan(line) };
1255
1831
  else
1256
1832
  this.diagnostics.error('parse.action-otherwise', 'Expected a phrase key after `otherwise refuse`.', oc.restSpan());
1257
1833
  }
@@ -1265,7 +1841,7 @@ class Parser {
1265
1841
  if (stmt)
1266
1842
  body.push(stmt);
1267
1843
  }
1268
- span = (0, span_1.mergeSpans)(span, lineSpan(line));
1844
+ span = (0, span_js_1.mergeSpans)(span, lineSpan(line));
1269
1845
  }
1270
1846
  return { kind: 'define-action', name: nameTok.text, patterns, constraints, musts, refusals, otherwise, scores, phrases, body, span };
1271
1847
  }
@@ -1276,13 +1852,14 @@ class Parser {
1276
1852
  */
1277
1853
  parseMustLine(line) {
1278
1854
  const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
1279
- if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
1855
+ if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
1280
1856
  this.diagnostics.error('parse.must', 'Expected `<subject> must <predicate>: <phrase-key>`.', lineSpan(line));
1281
1857
  return null;
1282
1858
  }
1283
- const keyTok = line.tokens[colonIndex + 1];
1284
- if (keyTok.kind !== 'word') {
1285
- this.diagnostics.error('parse.must', 'Expected a phrase key after the colon in the `must` requirement.', keyTok.span);
1859
+ const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
1860
+ const phraseKey = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
1861
+ if (!phraseKey || !keyCursor.atEnd()) {
1862
+ this.diagnostics.error('parse.must', 'Expected a phrase key after the colon in the `must` requirement.', line.tokens[colonIndex + 1].span);
1286
1863
  return null;
1287
1864
  }
1288
1865
  const c = new Cursor(line.tokens.slice(0, colonIndex), line);
@@ -1300,7 +1877,7 @@ class Parser {
1300
1877
  const predicate = this.parseInfinitivePredicate(c, line);
1301
1878
  if (!predicate)
1302
1879
  return null;
1303
- return { kind: 'must', subject, predicate, phraseKey: keyTok.text, span: lineSpan(line) };
1880
+ return { kind: 'must', subject, predicate, phraseKey, span: lineSpan(line) };
1304
1881
  }
1305
1882
  /** Infinitive predicate after `must`: be / have / hold / wear / see / reach. */
1306
1883
  parseInfinitivePredicate(c, line) {
@@ -1330,7 +1907,7 @@ class Parser {
1330
1907
  if (nameTok && nameTok.kind === 'word' && this.isBareConditionRef(c, 1)) {
1331
1908
  const anyTok = c.next();
1332
1909
  c.next();
1333
- return { kind: 'is-any', condition: nameTok.text, span: (0, span_1.mergeSpans)(anyTok.span, nameTok.span) };
1910
+ return { kind: 'is-any', condition: nameTok.text, span: (0, span_js_1.mergeSpans)(anyTok.span, nameTok.span) };
1334
1911
  }
1335
1912
  }
1336
1913
  // `be no <name>` — a negated requirement in disguise (decision 6):
@@ -1360,7 +1937,7 @@ class Parser {
1360
1937
  case 'see':
1361
1938
  case 'reach': {
1362
1939
  const thing = this.parseNameRef(c, (tok) => tok.kind === 'word' && PHRASE_STOPS.has(tok.text));
1363
- return { kind: 'can', ability: t.text, thing, span: (0, span_1.mergeSpans)(t.span, thing.span) };
1940
+ return { kind: 'can', ability: t.text, thing, span: (0, span_js_1.mergeSpans)(t.span, thing.span) };
1364
1941
  }
1365
1942
  default:
1366
1943
  this.diagnostics.error('parse.must-predicate', `Unknown predicate \`${t.text}\` after \`must\` — expected be, have, hold, wear, see, or reach.`, t.span);
@@ -1386,7 +1963,7 @@ class Parser {
1386
1963
  if (t.kind === 'colon') {
1387
1964
  const slot = c.next();
1388
1965
  if (slot && slot.kind === 'word') {
1389
- parts.push({ kind: 'slot', word: slot.text, span: (0, span_1.mergeSpans)(t.span, slot.span) });
1966
+ parts.push({ kind: 'slot', word: slot.text, span: (0, span_js_1.mergeSpans)(t.span, slot.span) });
1390
1967
  }
1391
1968
  else {
1392
1969
  this.diagnostics.error('parse.action-slot', 'Expected a slot name after `:`.', t.span);
@@ -1435,43 +2012,49 @@ class Parser {
1435
2012
  if (form === 'without') {
1436
2013
  const slot = c.next();
1437
2014
  const colon = c.next();
1438
- const key = c.next();
1439
- if (!slot || slot.kind !== 'word' || !colon || colon.kind !== 'colon' || !key || key.kind !== 'word') {
2015
+ if (!slot || slot.kind !== 'word' || !colon || colon.kind !== 'colon') {
2016
+ this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
2017
+ return null;
2018
+ }
2019
+ const key = this.readLabelKey(c); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
2020
+ if (!key) {
1440
2021
  this.diagnostics.error('parse.action-refusal', 'Expected `refuse without <slot>: <phrase-key>`.', lineSpan(line));
1441
2022
  return null;
1442
2023
  }
1443
- return { kind: 'without', slot: slot.text, condition: null, phraseKey: key.text, span: lineSpan(line) };
2024
+ return { kind: 'without', slot: slot.text, condition: null, phraseKey: key, span: lineSpan(line) };
1444
2025
  }
1445
2026
  // when: condition runs to the LAST colon; the key follows it.
1446
2027
  const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
1447
- if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
2028
+ if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
1448
2029
  this.diagnostics.error('parse.action-refusal', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
1449
2030
  return null;
1450
2031
  }
1451
2032
  const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
1452
2033
  const condition = this.parseCondition(condCursor, line);
1453
- const key = line.tokens[colonIndex + 1];
1454
- if (!key || key.kind !== 'word') {
2034
+ const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
2035
+ const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
2036
+ if (!key || !keyCursor.atEnd()) {
1455
2037
  this.diagnostics.error('parse.action-refusal', 'Expected a phrase key after the colon.', lineSpan(line));
1456
2038
  return null;
1457
2039
  }
1458
- return { kind: 'when', slot: null, condition, phraseKey: key.text, span: lineSpan(line) };
2040
+ return { kind: 'when', slot: null, condition, phraseKey: key, span: lineSpan(line) };
1459
2041
  }
1460
- /** `define behavior <name> from "<module>"` — CapabilityBehavior hatch. */
1461
- parseDefineBehaviorHatch() {
2042
+ /** `define chain <name> from "<module>"` — a TS chain hatch (ADR-094): the
2043
+ * named stdlib event chain is replaced by the module's handler. */
2044
+ parseDefineChainHatch() {
1462
2045
  const line = this.lines[this.pos];
1463
2046
  const c = new Cursor(line.tokens, line);
1464
2047
  c.next();
1465
- c.next(); // define behavior
1466
- const nameTok = c.next();
1467
- if (!nameTok || nameTok.kind !== 'word') {
1468
- this.diagnostics.error('parse.behavior-name', 'Expected a behavior name after `define behavior`.', c.restSpan());
2048
+ c.next(); // define chain
2049
+ const name = this.readLabelKey(c); // curated chain alias, single kebab word (ADR-254)
2050
+ if (!name) {
2051
+ this.diagnostics.error('parse.chain-hatch-name', 'Expected a chain name after `define chain` (e.g. `define chain opened-revealed from "…"`).', c.restSpan());
1469
2052
  this.pos++;
1470
2053
  return null;
1471
2054
  }
1472
- return this.parseHatchTail(line, c, 'behavior', nameTok.text);
2055
+ return this.parseHatchTail(line, c, 'chain', name);
1473
2056
  }
1474
- /** Shared `from "<module>"` tail for action/behavior hatches. */
2057
+ /** Shared `from "<module>"` tail for action / chain hatches. */
1475
2058
  parseHatchTail(line, c, hatchKind, name) {
1476
2059
  this.pos++;
1477
2060
  if (!c.matchWord('from')) {
@@ -1502,6 +2085,10 @@ class Parser {
1502
2085
  const line = this.lines[this.pos];
1503
2086
  if (line.indent === 0)
1504
2087
  break;
2088
+ if (looksLikeComment(line)) {
2089
+ this.skipCommentInsideBlock(line);
2090
+ continue;
2091
+ }
1505
2092
  const sc = new Cursor(line.tokens, line);
1506
2093
  let timing = null;
1507
2094
  let turns = 0;
@@ -1548,43 +2135,174 @@ class Parser {
1548
2135
  steps.push({ kind: 'sequence-step', timing, turns, owner, state, body, span: lineSpan(line) });
1549
2136
  }
1550
2137
  const endSpan = this.consumeEnd('sequence', headLine);
1551
- return { kind: 'define-sequence', name, steps, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
2138
+ return { kind: 'define-sequence', name, steps, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
1552
2139
  }
1553
- // ------------------------------------------------------------- on clause
1554
2140
  /**
1555
- * `on <verb> it` (intercept) / `after <verb> it` (react, ratchet D3) /
1556
- * `on every turn` with `while <cond>` on any binding and the `, once`
1557
- * clause modifier (D5). Terminated by `end on` / `end after`.
2141
+ * `define topics for <entity> end topics` (ADR-239 D3 as amended)
2142
+ * the ask/tell topic table: `about` rows in two tiers (entity /
2143
+ * quoted free-text with comma-separated aliases), each answering with
2144
+ * a one-line statement or an indented statement body.
1558
2145
  */
1559
- parseOnClause(indent, clauseKind) {
2146
+ parseDefineTopics() {
1560
2147
  const headLine = this.lines[this.pos++];
1561
2148
  const c = new Cursor(headLine.tokens, headLine);
1562
- c.matchWord(clauseKind);
1563
- const action = c.next();
1564
- let actionWord = '';
1565
- if (action && action.kind === 'word') {
1566
- actionWord = action.text;
2149
+ c.next();
2150
+ c.next(); // define topics
2151
+ let owner;
2152
+ if (!c.matchWord('for')) {
2153
+ this.diagnostics.error('parse.topics-for', 'Expected `for <entity>` after `define topics`.', c.restSpan());
2154
+ owner = { kind: 'name', article: null, words: [], span: lineSpan(headLine) };
1567
2155
  }
1568
2156
  else {
1569
- this.diagnostics.error('parse.on-action', `Expected an action word after \`${clauseKind}\`.`, lineSpan(headLine));
1570
- }
1571
- let binding = 'it';
1572
- let role = null;
1573
- let condition = null;
1574
- let once = false;
1575
- let ordering = null;
1576
- if (actionWord === 'every' && c.isWord('turn')) {
1577
- // `on every turn [while <condition>] [, once]` (§3.3 + D5).
1578
- c.next();
1579
- binding = 'every-turn';
1580
- actionWord = 'every-turn';
1581
- if (clauseKind === 'after') {
1582
- this.diagnostics.error('parse.after-every-turn', '`after every turn` is not a form — every-turn clauses are `on every turn` (they are not reactions to an action).', lineSpan(headLine));
2157
+ owner = this.parseNameRef(c, () => false);
2158
+ if (owner.words.length === 0 || !c.atEnd()) {
2159
+ this.diagnostics.error('parse.topics-for', 'Expected `define topics for <entity>` — the owner name runs to the end of the line.', c.restSpan());
1583
2160
  }
1584
2161
  }
1585
- else if (c.matchWord('anything')) {
1586
- // `on <action> anything as the <role>` (design.md §2.2 role binding).
1587
- binding = 'role';
2162
+ const decl = { kind: 'define-topics', owner, rows: [], span: lineSpan(headLine) };
2163
+ while (this.pos < this.lines.length) {
2164
+ const line = this.lines[this.pos];
2165
+ const word = firstWord(line);
2166
+ const lc = new Cursor(line.tokens, line);
2167
+ if (word === 'end' && lc.isWord('topics', 1)) {
2168
+ this.pos++;
2169
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2170
+ if (decl.rows.length === 0) {
2171
+ this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
2172
+ }
2173
+ return decl;
2174
+ }
2175
+ if (looksLikeComment(line)) {
2176
+ this.skipCommentInsideBlock(line);
2177
+ continue;
2178
+ }
2179
+ if (line.indent === 0)
2180
+ break; // dedent without `end topics` — reported below
2181
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2182
+ if (word === 'about') {
2183
+ const row = this.parseTopicRow(line);
2184
+ if (row) {
2185
+ decl.rows.push(row);
2186
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, row.span);
2187
+ }
2188
+ continue;
2189
+ }
2190
+ this.diagnostics.error('parse.topics-row', `Unrecognized line in \`define topics\`: \`${line.raw.trim()}\` — expected an \`about …: <response>\` row or \`end topics\`.`, lineSpan(line));
2191
+ this.pos++;
2192
+ }
2193
+ this.diagnostics.error('parse.topics-end', 'Expected `end topics` to close the block.', decl.span);
2194
+ if (decl.rows.length === 0) {
2195
+ this.diagnostics.error('parse.topics-empty', 'This `define topics` block declares no rows — add `about …: <response>` rows, or remove the block.', decl.span);
2196
+ }
2197
+ return decl;
2198
+ }
2199
+ /**
2200
+ * One `about …: <response>` row. Entity tier: `about the <entity>:`.
2201
+ * Free-text tier: `about "<text>"[, "<text>" …]:` — comma-separated
2202
+ * quoted aliases (spelling ruled 2026-07-18). The response is either the
2203
+ * rest of the line (one statement) or an indented statement body.
2204
+ */
2205
+ parseTopicRow(headLine) {
2206
+ const c = new Cursor(headLine.tokens, headLine);
2207
+ c.next(); // about
2208
+ let filter;
2209
+ const first = c.peek();
2210
+ if (first && first.kind === 'string') {
2211
+ c.next();
2212
+ if (first.text.trim() === '') {
2213
+ this.diagnostics.error('parse.topics-row', 'A quoted topic cannot be empty.', first.span);
2214
+ this.pos++;
2215
+ return null;
2216
+ }
2217
+ const aliases = [];
2218
+ let span = first.span;
2219
+ while (c.peek()?.kind === 'comma') {
2220
+ c.next();
2221
+ const alias = c.next();
2222
+ if (!alias || alias.kind !== 'string' || alias.text.trim() === '') {
2223
+ 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());
2224
+ this.pos++;
2225
+ return null;
2226
+ }
2227
+ aliases.push(alias.text);
2228
+ span = (0, span_js_1.mergeSpans)(span, alias.span);
2229
+ }
2230
+ filter = { kind: 'text', primary: first.text, aliases, span };
2231
+ }
2232
+ else {
2233
+ const ref = this.parseNameRef(c, () => false);
2234
+ if (ref.words.length === 0) {
2235
+ this.diagnostics.error('parse.topics-row', 'Expected an entity name or a quoted topic after `about`.', c.restSpan());
2236
+ this.pos++;
2237
+ return null;
2238
+ }
2239
+ filter = { kind: 'entity', ref };
2240
+ }
2241
+ const colon = c.next();
2242
+ if (!colon || colon.kind !== 'colon') {
2243
+ this.diagnostics.error('parse.topics-colon', 'Expected `:` after the topic key.', colon?.span ?? c.restSpan());
2244
+ this.pos++;
2245
+ return null;
2246
+ }
2247
+ let body;
2248
+ let span = lineSpan(headLine);
2249
+ if (!c.atEnd()) {
2250
+ // One-line response: the rest of the line is a single statement.
2251
+ // parseStatement consumes this.lines[this.pos] (the row line itself).
2252
+ const rest = { ...headLine, tokens: headLine.tokens.slice(c.i) };
2253
+ const stmt = this.parseStatement(rest, 'topics');
2254
+ if (!stmt)
2255
+ return null; // the statement error is already reported
2256
+ body = [stmt];
2257
+ }
2258
+ else {
2259
+ // Indented statement body on the following lines.
2260
+ this.pos++;
2261
+ body = this.parseStatements(headLine.indent, 'topics');
2262
+ if (body.length > 0)
2263
+ span = (0, span_js_1.mergeSpans)(span, body[body.length - 1].span);
2264
+ }
2265
+ if (body.length === 0) {
2266
+ this.diagnostics.error('parse.topics-response', 'Expected a response — a one-line statement after the `:`, or an indented statement body.', lineSpan(headLine));
2267
+ return null;
2268
+ }
2269
+ return { kind: 'topic-row', filter, body, span };
2270
+ }
2271
+ // ------------------------------------------------------------- on clause
2272
+ /**
2273
+ * `on <verb> it` (intercept) / `after <verb> it` (react, ratchet D3) /
2274
+ * `on every turn` — with `while <cond>` on any binding and the `, once`
2275
+ * clause modifier (D5). Terminated by `end on` / `end after`.
2276
+ */
2277
+ parseOnClause(indent, clauseKind) {
2278
+ const headLine = this.lines[this.pos++];
2279
+ const c = new Cursor(headLine.tokens, headLine);
2280
+ c.matchWord(clauseKind);
2281
+ const action = c.next();
2282
+ let actionWord = '';
2283
+ if (action && action.kind === 'word') {
2284
+ actionWord = action.text;
2285
+ }
2286
+ else {
2287
+ this.diagnostics.error('parse.on-action', `Expected an action word after \`${clauseKind}\`.`, lineSpan(headLine));
2288
+ }
2289
+ let binding = 'it';
2290
+ let role = null;
2291
+ let condition = null;
2292
+ let once = false;
2293
+ let ordering = null;
2294
+ if (actionWord === 'every' && c.isWord('turn')) {
2295
+ // `on every turn [while <condition>] [, once]` (§3.3 + D5).
2296
+ c.next();
2297
+ binding = 'every-turn';
2298
+ actionWord = 'every-turn';
2299
+ if (clauseKind === 'after') {
2300
+ this.diagnostics.error('parse.after-every-turn', '`after every turn` is not a form — every-turn clauses are `on every turn` (they are not reactions to an action).', lineSpan(headLine));
2301
+ }
2302
+ }
2303
+ else if (c.matchWord('anything')) {
2304
+ // `on <action> anything as the <role>` (design.md §2.2 role binding).
2305
+ binding = 'role';
1588
2306
  if (!c.matchWord('as') || !c.matchWord('the')) {
1589
2307
  this.diagnostics.error('parse.on-role', 'Expected `as the <role>` after `anything`.', c.restSpan());
1590
2308
  }
@@ -1637,8 +2355,629 @@ class Parser {
1637
2355
  once,
1638
2356
  ordering,
1639
2357
  body,
1640
- span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan),
2358
+ span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan),
2359
+ };
2360
+ }
2361
+ /**
2362
+ * ADR-216 media sugar statements: `play sound|music|ambient <asset>
2363
+ * [looping]`, `stop music|ambient`, `show image <asset> [in <layer>]`,
2364
+ * `hide image`, `transition <kind>`, `clear` — each with the standard
2365
+ * `when` suffix. Asset resolution/kind-checking is the analyzer's.
2366
+ */
2367
+ parseMediaStatement(first, c, line) {
2368
+ c.next(); // the keyword
2369
+ const fail = (message) => {
2370
+ this.diagnostics.error('parse.media', message, lineSpan(line));
2371
+ return null;
1641
2372
  };
2373
+ let form;
2374
+ let asset = null;
2375
+ let layer = null;
2376
+ let channel = null;
2377
+ let looping = false;
2378
+ let transitionKind = null;
2379
+ /** ADR-241 D3: optional `in <channel-word>` tail on the ambient forms. */
2380
+ const parseChannelTail = () => {
2381
+ if (!c.matchWord('in'))
2382
+ return true;
2383
+ const channelTok = c.next();
2384
+ if (!channelTok || channelTok.kind !== 'word') {
2385
+ fail('Expected a channel word after `in`.');
2386
+ return false;
2387
+ }
2388
+ channel = channelTok.text;
2389
+ return true;
2390
+ };
2391
+ if (first === 'play') {
2392
+ const what = c.next();
2393
+ if (!what || what.kind !== 'word' || !['sound', 'music', 'ambient'].includes(what.text)) {
2394
+ return fail('Expected `play sound|music|ambient <asset>`.');
2395
+ }
2396
+ const assetTok = c.next();
2397
+ if (!assetTok || assetTok.kind !== 'word')
2398
+ return fail(`Expected a declared asset name after \`play ${what.text}\`.`);
2399
+ asset = assetTok.text;
2400
+ if (what.text === 'music' && c.isWord('looping')) {
2401
+ c.next();
2402
+ looping = true;
2403
+ }
2404
+ if (what.text === 'ambient' && !parseChannelTail())
2405
+ return null;
2406
+ form = `play-${what.text}`;
2407
+ }
2408
+ else if (first === 'stop') {
2409
+ const what = c.next();
2410
+ if (!what || what.kind !== 'word' || !['music', 'ambient'].includes(what.text)) {
2411
+ return fail('Expected `stop music` or `stop ambient`.');
2412
+ }
2413
+ if (what.text === 'ambient' && !parseChannelTail())
2414
+ return null;
2415
+ form = `stop-${what.text}`;
2416
+ }
2417
+ else if (first === 'show') {
2418
+ if (!c.matchWord('image'))
2419
+ return fail('Expected `show image <asset> [in <layer>]`.');
2420
+ const assetTok = c.next();
2421
+ if (!assetTok || assetTok.kind !== 'word')
2422
+ return fail('Expected a declared asset name after `show image`.');
2423
+ asset = assetTok.text;
2424
+ if (c.matchWord('in')) {
2425
+ const layerTok = c.next();
2426
+ if (!layerTok || layerTok.kind !== 'word')
2427
+ return fail('Expected a layer word after `in`.');
2428
+ layer = layerTok.text;
2429
+ }
2430
+ form = 'show-image';
2431
+ }
2432
+ else if (first === 'hide') {
2433
+ if (!c.matchWord('image'))
2434
+ return fail('Expected `hide image`.');
2435
+ form = 'hide-image';
2436
+ }
2437
+ else if (first === 'transition') {
2438
+ const kindTok = c.next();
2439
+ if (!kindTok || kindTok.kind !== 'word')
2440
+ return fail('Expected a transition kind after `transition` (e.g. `transition fade`).');
2441
+ transitionKind = kindTok.text;
2442
+ form = 'transition';
2443
+ }
2444
+ else {
2445
+ form = 'clear';
2446
+ }
2447
+ const stmtWhen = this.parseStatementWhen(c, line);
2448
+ if (!c.atEnd())
2449
+ return fail(`Unexpected trailing text: \`${c.peek().text}\`.`);
2450
+ return { kind: 'media', form, asset, layer, channel, looping, transitionKind, stmtWhen, span: lineSpan(line) };
2451
+ }
2452
+ /**
2453
+ * `define channel <name> … end channel` (ADR-216; spelling A): keyword
2454
+ * body lines `mode <word>`, `gated by <capability>`, `from event
2455
+ * <dotted.key>`, `take <field>, …`. Value validation (mode set,
2456
+ * capability flags, required lines) is the analyzer's.
2457
+ */
2458
+ parseDefineChannel() {
2459
+ const headLine = this.lines[this.pos++];
2460
+ const c = new Cursor(headLine.tokens, headLine);
2461
+ c.next();
2462
+ c.next(); // define channel
2463
+ const nameTok = c.next();
2464
+ if (!nameTok || nameTok.kind !== 'word') {
2465
+ this.diagnostics.error('parse.channel-name', 'Expected a channel name after `define channel`.', lineSpan(headLine));
2466
+ return null;
2467
+ }
2468
+ const decl = {
2469
+ kind: 'define-channel',
2470
+ name: nameTok.text,
2471
+ mode: null,
2472
+ gatedBy: null,
2473
+ fromEvent: null,
2474
+ returns: null,
2475
+ span: lineSpan(headLine),
2476
+ };
2477
+ while (this.pos < this.lines.length) {
2478
+ const line = this.lines[this.pos];
2479
+ const word = firstWord(line);
2480
+ const lc = new Cursor(line.tokens, line);
2481
+ if (word === 'end' && lc.isWord('channel', 1)) {
2482
+ this.pos++;
2483
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2484
+ return decl;
2485
+ }
2486
+ if (looksLikeComment(line)) {
2487
+ this.skipCommentInsideBlock(line);
2488
+ continue;
2489
+ }
2490
+ if (line.indent === 0)
2491
+ break;
2492
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2493
+ this.pos++;
2494
+ if (word === 'mode') {
2495
+ lc.next();
2496
+ const modeTok = lc.next();
2497
+ if (!modeTok || modeTok.kind !== 'word' || !lc.atEnd()) {
2498
+ this.diagnostics.error('parse.channel-mode', 'Expected `mode replace|append|event`.', lineSpan(line));
2499
+ }
2500
+ else {
2501
+ decl.mode = modeTok.text;
2502
+ }
2503
+ continue;
2504
+ }
2505
+ if (word === 'gated' && lc.isWord('by', 1)) {
2506
+ lc.next();
2507
+ lc.next();
2508
+ const capTok = lc.next();
2509
+ if (!capTok || capTok.kind !== 'word' || !lc.atEnd()) {
2510
+ this.diagnostics.error('parse.channel-gate', 'Expected `gated by <capability>`.', lineSpan(line));
2511
+ }
2512
+ else {
2513
+ decl.gatedBy = capTok.text;
2514
+ }
2515
+ continue;
2516
+ }
2517
+ if (word === 'return') {
2518
+ // ADR-253 D1: `return <field | "text" | phrase <key>> from <event>` —
2519
+ // the event rides this clause (the standalone `from event`/`take` lines
2520
+ // are removed below).
2521
+ lc.next(); // return
2522
+ const returns = this.parseChannelReturn(lc, line);
2523
+ if (!returns)
2524
+ continue; // error already reported
2525
+ if (!lc.matchWord('from')) {
2526
+ this.diagnostics.error('parse.channel-return', 'Expected `from <event>` after the returned construct — `return <construct> from <event>`.', lineSpan(line));
2527
+ continue;
2528
+ }
2529
+ const key = this.readLabelKey(lc); // ADR-256: dotless Chord event id; the loader translates to the platform id
2530
+ if (!key || !lc.atEnd()) {
2531
+ this.diagnostics.error('parse.channel-return', 'Expected a single event id after `from`.', lineSpan(line));
2532
+ continue;
2533
+ }
2534
+ decl.returns = returns;
2535
+ decl.fromEvent = key;
2536
+ continue;
2537
+ }
2538
+ if (word === 'from' && lc.isWord('event', 1)) {
2539
+ 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));
2540
+ continue;
2541
+ }
2542
+ if (word === 'take') {
2543
+ 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));
2544
+ continue;
2545
+ }
2546
+ 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));
2547
+ }
2548
+ this.diagnostics.error('parse.channel-end', 'Expected `end channel` to close the block.', decl.span);
2549
+ return decl;
2550
+ }
2551
+ /**
2552
+ * Parse the construct after `return` (ADR-253 D1), stopping before `from`:
2553
+ * a `"quoted"` text template, a `phrase <key>`, or a bare field word. On a
2554
+ * malformed construct reports `parse.channel-return` and returns null.
2555
+ */
2556
+ parseChannelReturn(lc, line) {
2557
+ const tok = lc.peek();
2558
+ if (!tok) {
2559
+ this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
2560
+ return null;
2561
+ }
2562
+ if (tok.kind === 'string') {
2563
+ lc.next();
2564
+ return { kind: 'text', text: tok.text };
2565
+ }
2566
+ if (tok.kind === 'word' && tok.text === 'phrase') {
2567
+ lc.next(); // phrase
2568
+ const key = this.readLabelKey(lc);
2569
+ if (!key) {
2570
+ this.diagnostics.error('parse.channel-return', 'Expected a phrase key after `return phrase`.', lineSpan(line));
2571
+ return null;
2572
+ }
2573
+ return { kind: 'phrase', phrase: key };
2574
+ }
2575
+ if (tok.kind === 'word') {
2576
+ const field = this.readLabelKey(lc);
2577
+ if (!field) {
2578
+ this.diagnostics.error('parse.channel-return', 'Expected a field name after `return`.', lineSpan(line));
2579
+ return null;
2580
+ }
2581
+ return { kind: 'field', field };
2582
+ }
2583
+ this.diagnostics.error('parse.channel-return', 'Expected a field, a "text" template, or `phrase <key>` after `return`.', lineSpan(line));
2584
+ return null;
2585
+ }
2586
+ /**
2587
+ * `define pronouns <name> … end pronouns` (ADR-242 D7, ruled Q-1):
2588
+ * five named rows, each `<case> <form>` — `subject`, `object`,
2589
+ * `possessive`, `possessive-pronoun`, `reflexive`. Row completeness,
2590
+ * duplicates, and standard-word shadowing are the analyzer's gates
2591
+ * (the parseDefineChannel split: parser collects, analyzer validates).
2592
+ */
2593
+ parseDefinePronouns() {
2594
+ const headLine = this.lines[this.pos++];
2595
+ const c = new Cursor(headLine.tokens, headLine);
2596
+ c.next();
2597
+ c.next(); // define pronouns
2598
+ const nameTok = c.next();
2599
+ if (!nameTok || nameTok.kind !== 'word' || !c.atEnd()) {
2600
+ this.diagnostics.error('parse.pronouns-name', 'Expected a set name after `define pronouns` (e.g. `define pronouns ze`).', lineSpan(headLine));
2601
+ return null;
2602
+ }
2603
+ const decl = {
2604
+ kind: 'define-pronouns',
2605
+ name: nameTok.text.toLowerCase(),
2606
+ rows: [],
2607
+ span: lineSpan(headLine),
2608
+ };
2609
+ while (this.pos < this.lines.length) {
2610
+ const line = this.lines[this.pos];
2611
+ const word = firstWord(line);
2612
+ const lc = new Cursor(line.tokens, line);
2613
+ if (word === 'end' && lc.isWord('pronouns', 1)) {
2614
+ this.pos++;
2615
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2616
+ return decl;
2617
+ }
2618
+ if (looksLikeComment(line)) {
2619
+ this.skipCommentInsideBlock(line);
2620
+ continue;
2621
+ }
2622
+ if (line.indent === 0)
2623
+ break;
2624
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2625
+ this.pos++;
2626
+ if (word && catalog_js_1.PRONOUN_CASES.includes(word)) {
2627
+ lc.next();
2628
+ const formTok = lc.next();
2629
+ if (!formTok || formTok.kind !== 'word' || !lc.atEnd()) {
2630
+ this.diagnostics.error('parse.pronouns-row', `Expected one form word after \`${word}\` (e.g. \`${word} zir\`).`, lineSpan(line));
2631
+ }
2632
+ else {
2633
+ decl.rows.push({ case: word, form: formTok.text, span: lineSpan(line) });
2634
+ }
2635
+ continue;
2636
+ }
2637
+ 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));
2638
+ }
2639
+ this.diagnostics.error('parse.pronouns-end', 'Expected `end pronouns` to close the block.', decl.span);
2640
+ return decl;
2641
+ }
2642
+ /** `define sound|image|music <name> from "<file>"` (ADR-216) — data asset. */
2643
+ parseDefineAsset(assetKind) {
2644
+ const line = this.lines[this.pos++];
2645
+ const c = new Cursor(line.tokens, line);
2646
+ c.next();
2647
+ c.next(); // define <kind>
2648
+ const nameTok = c.next();
2649
+ if (!nameTok || nameTok.kind !== 'word') {
2650
+ this.diagnostics.error('parse.asset-name', `Expected an asset name after \`define ${assetKind}\`.`, c.restSpan());
2651
+ return null;
2652
+ }
2653
+ if (!c.matchWord('from')) {
2654
+ this.diagnostics.error('parse.asset-from', `Expected \`from "<file>"\` in the ${assetKind} declaration.`, c.restSpan());
2655
+ return null;
2656
+ }
2657
+ const pathTok = c.next();
2658
+ if (!pathTok || pathTok.kind !== 'string') {
2659
+ this.diagnostics.error('parse.asset-path', 'Expected a quoted file path after `from`.', c.restSpan());
2660
+ return null;
2661
+ }
2662
+ return { kind: 'define-asset', assetKind, name: nameTok.text, path: pathTok.text, span: lineSpan(line) };
2663
+ }
2664
+ /**
2665
+ * `define ambient <word>` / `define layer <word>` (ADR-241 D2): a
2666
+ * one-line named family channel declaration. Exactly one word — the
2667
+ * bed/layer name; the registered id is the loader's business.
2668
+ */
2669
+ parseDefineFamilyChannel(family) {
2670
+ const line = this.lines[this.pos++];
2671
+ const c = new Cursor(line.tokens, line);
2672
+ c.next();
2673
+ c.next(); // define ambient|layer
2674
+ const nameTok = c.next();
2675
+ if (!nameTok || nameTok.kind !== 'word') {
2676
+ this.diagnostics.error('parse.channel-name', `Expected a ${family === 'ambient' ? 'bed' : 'layer'} name after \`define ${family}\`.`, c.restSpan());
2677
+ return null;
2678
+ }
2679
+ if (!c.atEnd()) {
2680
+ this.diagnostics.error('parse.channel-name', `Unexpected trailing text after \`define ${family} ${nameTok.text}\` — the declaration is one word.`, c.restSpan());
2681
+ return null;
2682
+ }
2683
+ return { kind: 'define-family-channel', family, name: nameTok.text, span: lineSpan(line) };
2684
+ }
2685
+ // ---------------------------------------------------------- emit payload
2686
+ /**
2687
+ * ADR-216 payload fields: `<field> <value>` separated by `and` at the
2688
+ * flat (statement) level, by commas inside `{ … }` objects. Values:
2689
+ * literals, `[ … ]` arrays, `{ … }` objects, or value expressions
2690
+ * (world-state reads).
2691
+ */
2692
+ parseEmitFields(c, line, mode) {
2693
+ const fields = [];
2694
+ for (;;) {
2695
+ if (mode === 'braced' && c.peek()?.kind === 'rbrace')
2696
+ break;
2697
+ if (mode === 'flat' && (c.atEnd() || c.isWord('when')))
2698
+ break;
2699
+ const startTok = c.peek();
2700
+ const key = [];
2701
+ while (!c.atEnd() && c.peek().kind === 'word' && !c.isWord('and') && !c.isWord('when')) {
2702
+ const t = c.peek();
2703
+ if (ARTICLES.has(t.text) && key.length > 0)
2704
+ break;
2705
+ const after = c.peek(1);
2706
+ const atValuePosition = !after ||
2707
+ after.kind === 'comma' ||
2708
+ after.kind === 'rbrace' ||
2709
+ (after.kind === 'word' && (after.text === 'and' || after.text === 'when'));
2710
+ if (atValuePosition && key.length > 0)
2711
+ break;
2712
+ key.push(t.text);
2713
+ c.next();
2714
+ }
2715
+ if (key.length === 0) {
2716
+ this.diagnostics.error('parse.emit-payload', 'Expected a payload field name.', c.restSpan());
2717
+ break;
2718
+ }
2719
+ const value = this.parseEmitValue(c, line);
2720
+ if (!value)
2721
+ break;
2722
+ fields.push({ key, value, span: startTok ? (0, span_js_1.mergeSpans)(startTok.span, value.span) : value.span });
2723
+ if (mode === 'flat') {
2724
+ if (!c.matchWord('and'))
2725
+ break;
2726
+ }
2727
+ else if (c.peek()?.kind === 'comma') {
2728
+ c.next();
2729
+ }
2730
+ else {
2731
+ break;
2732
+ }
2733
+ }
2734
+ return fields;
2735
+ }
2736
+ /** One ADR-216 payload value (recursive: literals, arrays, objects, value exprs). */
2737
+ parseEmitValue(c, line) {
2738
+ const t = c.peek();
2739
+ if (!t) {
2740
+ this.diagnostics.error('parse.emit-payload', 'Expected a payload value.', c.restSpan());
2741
+ return null;
2742
+ }
2743
+ if (t.kind === 'number' || t.kind === 'string') {
2744
+ c.next();
2745
+ return { kind: 'literal', value: t.text, literalKind: t.kind, span: t.span };
2746
+ }
2747
+ if (t.kind === 'lbracket') {
2748
+ c.next();
2749
+ const items = [];
2750
+ while (c.peek() && c.peek().kind !== 'rbracket') {
2751
+ const item = this.parseEmitValue(c, line);
2752
+ if (!item)
2753
+ break;
2754
+ items.push(item);
2755
+ if (c.peek()?.kind === 'comma')
2756
+ c.next();
2757
+ else
2758
+ break;
2759
+ }
2760
+ const close = c.next();
2761
+ let endSpan = t.span;
2762
+ if (!close || close.kind !== 'rbracket') {
2763
+ this.diagnostics.error('parse.emit-payload', 'Expected `]` to close the array.', c.restSpan());
2764
+ }
2765
+ else {
2766
+ endSpan = close.span;
2767
+ }
2768
+ return { kind: 'array', items, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
2769
+ }
2770
+ if (t.kind === 'lbrace') {
2771
+ c.next();
2772
+ const fields = this.parseEmitFields(c, line, 'braced');
2773
+ const close = c.next();
2774
+ let endSpan = t.span;
2775
+ if (!close || close.kind !== 'rbrace') {
2776
+ this.diagnostics.error('parse.emit-payload', 'Expected `}` to close the object.', c.restSpan());
2777
+ }
2778
+ else {
2779
+ endSpan = close.span;
2780
+ }
2781
+ return { kind: 'object', fields, span: (0, span_js_1.mergeSpans)(t.span, endSpan) };
2782
+ }
2783
+ const expr = this.parseValueExpr(c, line, EMIT_VALUE_STOPS);
2784
+ return { kind: 'expr', expr, span: expr.span };
2785
+ }
2786
+ // -------------------------------------------------------------- machines
2787
+ /**
2788
+ * `define machine <name> … end machine` (ADR-215 `use state-machines`
2789
+ * depth; spelling A ratified 2026-07-18): `role <name> is <entity>`
2790
+ * bindings, `starts <state>`, and `state <name>[, terminal]` blocks. The
2791
+ * `use` requirement is the analyzer's gate.
2792
+ */
2793
+ parseDefineMachine() {
2794
+ const headLine = this.lines[this.pos++];
2795
+ const c = new Cursor(headLine.tokens, headLine);
2796
+ c.next();
2797
+ c.next(); // define machine
2798
+ const name = [];
2799
+ while (!c.atEnd() && c.peek().kind === 'word')
2800
+ name.push(c.next().text);
2801
+ if (name.length === 0) {
2802
+ this.diagnostics.error('parse.machine-name', 'Expected a machine name after `define machine`.', lineSpan(headLine));
2803
+ }
2804
+ const decl = { kind: 'define-machine', name, roles: [], initialState: null, states: [], span: lineSpan(headLine) };
2805
+ while (this.pos < this.lines.length) {
2806
+ const line = this.lines[this.pos];
2807
+ const word = firstWord(line);
2808
+ const lc = new Cursor(line.tokens, line);
2809
+ if (word === 'end' && lc.isWord('machine', 1)) {
2810
+ this.pos++;
2811
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2812
+ return decl;
2813
+ }
2814
+ if (looksLikeComment(line)) {
2815
+ this.skipCommentInsideBlock(line);
2816
+ continue;
2817
+ }
2818
+ if (line.indent === 0)
2819
+ break; // dedent without `end machine` — reported below
2820
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, lineSpan(line));
2821
+ if (word === 'role') {
2822
+ this.pos++;
2823
+ lc.next();
2824
+ const roleTok = lc.next();
2825
+ if (!roleTok || roleTok.kind !== 'word' || !lc.matchWord('is')) {
2826
+ this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
2827
+ continue;
2828
+ }
2829
+ const entity = this.parseNameRef(lc, () => false);
2830
+ if (entity.words.length === 0 || !lc.atEnd()) {
2831
+ this.diagnostics.error('parse.machine-role', 'Expected `role <name> is <entity>`.', lineSpan(line));
2832
+ continue;
2833
+ }
2834
+ decl.roles.push({ name: roleTok.text, entity, span: lineSpan(line) });
2835
+ continue;
2836
+ }
2837
+ if (word === 'starts') {
2838
+ this.pos++;
2839
+ lc.next();
2840
+ const stateTok = lc.next();
2841
+ if (!stateTok || stateTok.kind !== 'word' || !lc.atEnd()) {
2842
+ this.diagnostics.error('parse.machine-starts', 'Expected `starts <state>`.', lineSpan(line));
2843
+ continue;
2844
+ }
2845
+ if (decl.initialState !== null) {
2846
+ this.diagnostics.error('parse.machine-starts', 'This machine already declared its `starts` state.', lineSpan(line));
2847
+ continue;
2848
+ }
2849
+ decl.initialState = stateTok.text;
2850
+ continue;
2851
+ }
2852
+ if (word === 'state') {
2853
+ const state = this.parseMachineState(line);
2854
+ if (state) {
2855
+ decl.states.push(state);
2856
+ decl.span = (0, span_js_1.mergeSpans)(decl.span, state.span);
2857
+ }
2858
+ continue;
2859
+ }
2860
+ this.diagnostics.error('parse.machine-body', `Unrecognized line in \`define machine\`: \`${line.raw.trim()}\` — expected \`role\`, \`starts\`, \`state\`, or \`end machine\`.`, lineSpan(line));
2861
+ this.pos++;
2862
+ }
2863
+ this.diagnostics.error('parse.machine-end', 'Expected `end machine` to close the block.', decl.span);
2864
+ return decl;
2865
+ }
2866
+ /** One `state <name>[, terminal]` block: transition lines + `on enter`/`on exit` bodies. */
2867
+ parseMachineState(headLine) {
2868
+ this.pos++;
2869
+ const c = new Cursor(headLine.tokens, headLine);
2870
+ c.next(); // state
2871
+ const nameTok = c.next();
2872
+ if (!nameTok || nameTok.kind !== 'word') {
2873
+ this.diagnostics.error('parse.machine-state', 'Expected a state name after `state`.', lineSpan(headLine));
2874
+ return null;
2875
+ }
2876
+ let terminal = false;
2877
+ if (c.peek()?.kind === 'comma') {
2878
+ c.next();
2879
+ if (c.matchWord('terminal'))
2880
+ terminal = true;
2881
+ else
2882
+ this.diagnostics.error('parse.machine-state', 'Only `, terminal` may follow the state name.', c.restSpan());
2883
+ }
2884
+ const state = { name: nameTok.text, terminal, transitions: [], onEnter: [], onExit: [], span: lineSpan(headLine) };
2885
+ while (this.pos < this.lines.length) {
2886
+ const line = this.lines[this.pos];
2887
+ if (line.indent <= headLine.indent)
2888
+ break; // next state / end machine
2889
+ state.span = (0, span_js_1.mergeSpans)(state.span, lineSpan(line));
2890
+ const word = firstWord(line);
2891
+ if (word === 'when') {
2892
+ const transition = this.parseMachineTransition(line);
2893
+ if (transition)
2894
+ state.transitions.push(transition);
2895
+ continue;
2896
+ }
2897
+ if (word === 'on') {
2898
+ const lc = new Cursor(line.tokens, line);
2899
+ lc.next();
2900
+ const which = lc.next();
2901
+ if (which && which.kind === 'word' && (which.text === 'enter' || which.text === 'exit') && lc.atEnd()) {
2902
+ this.pos++;
2903
+ const body = this.parseStatements(line.indent, 'on');
2904
+ const endSpan = this.consumeEnd('on', line);
2905
+ state.span = (0, span_js_1.mergeSpans)(state.span, endSpan);
2906
+ if (which.text === 'enter')
2907
+ state.onEnter.push(...body);
2908
+ else
2909
+ state.onExit.push(...body);
2910
+ continue;
2911
+ }
2912
+ this.diagnostics.error('parse.machine-on', 'Only `on enter` and `on exit` blocks live in a machine `state`.', lineSpan(line));
2913
+ this.pos++;
2914
+ continue;
2915
+ }
2916
+ 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));
2917
+ this.pos++;
2918
+ }
2919
+ return state;
2920
+ }
2921
+ /**
2922
+ * `when <trigger>[ while <condition>]: <target-state>`. Triggers:
2923
+ * `event <dotted.key>`, `<gerund> <entity name>` (action on a target),
2924
+ * a single bare word (analyzer resolves condition-vs-gerund), or a
2925
+ * condition.
2926
+ */
2927
+ parseMachineTransition(line) {
2928
+ this.pos++;
2929
+ const tokens = line.tokens;
2930
+ const colonIndex = tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
2931
+ if (colonIndex === -1 || colonIndex !== tokens.length - 2 || tokens[tokens.length - 1].kind !== 'word') {
2932
+ this.diagnostics.error('parse.machine-when', 'Expected `when <trigger>[ while <condition>]: <target-state>`.', lineSpan(line));
2933
+ return null;
2934
+ }
2935
+ const target = tokens[tokens.length - 1].text;
2936
+ let head = tokens.slice(1, colonIndex);
2937
+ let condition = null;
2938
+ const whileIndex = head.findIndex((t) => t.kind === 'word' && t.text === 'while');
2939
+ if (whileIndex !== -1) {
2940
+ condition = this.parseCondition(new Cursor(head.slice(whileIndex + 1), line), line);
2941
+ head = head.slice(0, whileIndex);
2942
+ }
2943
+ const hc = new Cursor(head, line);
2944
+ const first = hc.peek();
2945
+ if (!first) {
2946
+ this.diagnostics.error('parse.machine-when', 'Expected a trigger after `when`.', lineSpan(line));
2947
+ return null;
2948
+ }
2949
+ let trigger;
2950
+ if (first.kind === 'word' && first.text === 'event') {
2951
+ hc.next();
2952
+ const key = this.readLabelKey(hc); // ADR-256: dotless Chord event id; story-loader translates to the platform id
2953
+ if (!key || !hc.atEnd()) {
2954
+ this.diagnostics.error('parse.machine-when', 'Expected `event <event.key>`.', lineSpan(line));
2955
+ return null;
2956
+ }
2957
+ trigger = { kind: 'event', event: key };
2958
+ }
2959
+ else if (first.kind === 'word' && !ARTICLES.has(first.text) && head.length === 1) {
2960
+ trigger = { kind: 'word', word: first.text, span: first.span };
2961
+ }
2962
+ else if (first.kind === 'word' && !ARTICLES.has(first.text) && head[1]?.kind === 'word' && (ARTICLES.has(head[1].text) || head[1].text === 'it')) {
2963
+ const action = hc.next().text;
2964
+ if (hc.isWord('it')) {
2965
+ // Machines are story-owned — there is no `it` (mirror of the
2966
+ // story-clause rule); name the entity or role.
2967
+ this.diagnostics.error('parse.machine-when', '`it` is not bound in a machine — name the entity or a declared role.', lineSpan(line));
2968
+ return null;
2969
+ }
2970
+ const targetRef = this.parseNameRef(hc, () => false);
2971
+ if (targetRef.words.length === 0 || !hc.atEnd()) {
2972
+ this.diagnostics.error('parse.machine-when', `Expected an entity or role name after \`${action}\`.`, lineSpan(line));
2973
+ return null;
2974
+ }
2975
+ trigger = { kind: 'action', action, target: targetRef };
2976
+ }
2977
+ else {
2978
+ trigger = { kind: 'condition', condition: this.parseCondition(hc, line) };
2979
+ }
2980
+ return { trigger, condition, target, span: lineSpan(line) };
1642
2981
  }
1643
2982
  // ------------------------------------------------------------ statements
1644
2983
  /**
@@ -1653,6 +2992,10 @@ class Parser {
1653
2992
  break;
1654
2993
  if (isEndLine(line) || ((firstWord(line) === 'else' || firstWord(line) === 'or') && line.tokens.length === 1))
1655
2994
  break;
2995
+ if (looksLikeComment(line)) {
2996
+ this.skipCommentInsideBlock(line);
2997
+ continue;
2998
+ }
1656
2999
  const stmt = this.parseStatement(line, blockKeyword);
1657
3000
  if (stmt)
1658
3001
  body.push(stmt);
@@ -1688,7 +3031,7 @@ class Parser {
1688
3031
  }
1689
3032
  return this.parseRefuseWhenStatement(line);
1690
3033
  }
1691
- const key = this.readDottedKey(c);
3034
+ const key = this.readLabelKey(c);
1692
3035
  if (!key) {
1693
3036
  this.diagnostics.error('parse.phrase-ref', `Expected a phrase key after \`${word}\`.`, c.restSpan());
1694
3037
  return null;
@@ -1699,6 +3042,14 @@ class Parser {
1699
3042
  this.reportRefusalInAfter(line);
1700
3043
  return null;
1701
3044
  }
3045
+ // Misordered prohibition (platform-issue-sweep Phase 8 #15c):
3046
+ // `refuse <key> when <condition>` used to leave the `when …`
3047
+ // tokens unconsumed and silently compile as an UNCONDITIONAL
3048
+ // refuse. Error with a fix-it instead.
3049
+ if (c.isWord('when')) {
3050
+ this.diagnostics.error('parse.refuse-order', `The condition comes first — write \`refuse when <condition>: ${key}\`.`, c.restSpan());
3051
+ return null;
3052
+ }
1702
3053
  return { kind: 'refuse', phraseKey: key, params, span: lineSpan(line) };
1703
3054
  }
1704
3055
  const stmtWhen = this.parseStatementWhen(c, line);
@@ -1709,21 +3060,43 @@ class Parser {
1709
3060
  if (next && next.indent > line.indent && !isStatementLine(next)) {
1710
3061
  inlineText = this.parseProseParagraph(line.indent + 1, line.indent);
1711
3062
  }
1712
- const span = inlineText ? (0, span_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
3063
+ const span = inlineText ? (0, span_js_1.mergeSpans)(lineSpan(line), inlineText.span) : lineSpan(line);
1713
3064
  return { kind: 'phrase', phraseKey: key, params, inlineText, stmtWhen, span };
1714
3065
  }
1715
3066
  case 'emit': {
1716
3067
  this.pos++;
1717
3068
  c.next();
3069
+ // ADR-256: an event id is a single dotless Chord token (`media-sound-play`);
3070
+ // story-loader translates it to the platform's dotted id at the emit seam.
3071
+ // A `.` now raises `parse.dotted-key` (ADR-254 uniform).
1718
3072
  const event = [];
1719
- while (!c.atEnd() && !c.isWord('when'))
1720
- event.push(c.next().text);
3073
+ while (!c.atEnd() && !c.isWord('when') && !c.isWord('with')) {
3074
+ const segment = this.readLabelKey(c);
3075
+ if (!segment)
3076
+ break;
3077
+ event.push(segment);
3078
+ }
1721
3079
  if (event.length === 0) {
1722
3080
  this.diagnostics.error('parse.emit', 'Expected an event name after `emit`.', lineSpan(line));
1723
3081
  return null;
1724
3082
  }
3083
+ // ADR-216 payload: `with <field> <value> [and …]` — the create-data
3084
+ // grammar; bracketed/braced structures inside separate with commas.
3085
+ const payload = [];
3086
+ if (c.matchWord('with')) {
3087
+ payload.push(...this.parseEmitFields(c, line, 'flat'));
3088
+ }
1725
3089
  const stmtWhen = this.parseStatementWhen(c, line);
1726
- return { kind: 'emit', event, stmtWhen, span: lineSpan(line) };
3090
+ return { kind: 'emit', event, payload, stmtWhen, span: lineSpan(line) };
3091
+ }
3092
+ case 'play':
3093
+ case 'stop':
3094
+ case 'show':
3095
+ case 'hide':
3096
+ case 'transition':
3097
+ case 'clear': {
3098
+ this.pos++;
3099
+ return this.parseMediaStatement(word, c, line);
1727
3100
  }
1728
3101
  case 'set': {
1729
3102
  this.pos++;
@@ -1807,6 +3180,24 @@ class Parser {
1807
3180
  const stmtWhen = this.parseStatementWhen(c, line);
1808
3181
  return { kind: word, phraseKey, stmtWhen, span: lineSpan(line) };
1809
3182
  }
3183
+ case 'kill': {
3184
+ // `kill the player [<phrase-key>] [when <cond>]` (ADR-227) — peer to
3185
+ // win/lose; terminal death via the platform killPlayer sink.
3186
+ this.pos++;
3187
+ c.next();
3188
+ if (!c.matchWord('the') || !c.matchWord('player')) {
3189
+ this.diagnostics.error('parse.kill-statement', 'Expected `kill the player [<phrase-key>] [when <condition>]`.', lineSpan(line));
3190
+ return null;
3191
+ }
3192
+ const key = c.peek();
3193
+ let phraseKey = null;
3194
+ if (key && key.kind === 'word' && key.text !== 'when') {
3195
+ phraseKey = key.text;
3196
+ c.next();
3197
+ }
3198
+ const stmtWhen = this.parseStatementWhen(c, line);
3199
+ return { kind: 'kill', phraseKey, stmtWhen, span: lineSpan(line) };
3200
+ }
1810
3201
  case 'if':
1811
3202
  // Removed — given 4 amended (ratchet 2026-07-11).
1812
3203
  this.diagnostics.error('parse.removed-if', '`if` was removed (given 4 amended) — use a `must` requirement for guards (`it must be hungry: already-fed`), a statement `when` suffix for conditionals (`award X when <condition>`), or `select` for branching.', lineSpan(line));
@@ -1832,38 +3223,53 @@ class Parser {
1832
3223
  /** `refuse when <condition>: <key>` as a body statement (prohibition, D6). */
1833
3224
  parseRefuseWhenStatement(line) {
1834
3225
  const colonIndex = line.tokens.map((t) => t.kind === 'colon').lastIndexOf(true);
1835
- if (colonIndex === -1 || colonIndex !== line.tokens.length - 2) {
3226
+ if (colonIndex === -1 || colonIndex >= line.tokens.length - 1) {
1836
3227
  this.diagnostics.error('parse.refuse-when', 'Expected `refuse when <condition>: <phrase-key>`.', lineSpan(line));
1837
3228
  return null;
1838
3229
  }
1839
- const keyTok = line.tokens[colonIndex + 1];
1840
- if (keyTok.kind !== 'word') {
1841
- this.diagnostics.error('parse.refuse-when', 'Expected a phrase key after the colon.', keyTok.span);
3230
+ const keyCursor = new Cursor(line.tokens.slice(colonIndex + 1), line);
3231
+ const key = this.readLabelKey(keyCursor); // label-key = single WORD (ADR-254; readLabelKey rejects dots)
3232
+ if (!key || !keyCursor.atEnd()) {
3233
+ this.diagnostics.error('parse.refuse-when', 'Expected a phrase key after the colon.', line.tokens[colonIndex + 1].span);
1842
3234
  return null;
1843
3235
  }
1844
3236
  const condCursor = new Cursor(line.tokens.slice(2, colonIndex), line);
1845
3237
  const condition = this.parseCondition(condCursor, line);
1846
- return { kind: 'refuse-when', condition, phraseKey: keyTok.text, span: lineSpan(line) };
3238
+ return { kind: 'refuse-when', condition, phraseKey: key, span: lineSpan(line) };
1847
3239
  }
1848
3240
  /** `refuse`/`must` inside an `after` clause — reactions cannot refuse (D3). */
1849
3241
  reportRefusalInAfter(line) {
1850
3242
  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));
1851
3243
  }
1852
3244
  /**
1853
- * Read a phrase key: a word, optionally continued by `.`-joined words
1854
- * (`zoo.pa.closing-3`, design.md §3.3). Returns null when no word follows.
3245
+ * Read a label/key: a single kebab-case `WORD` (ADR-254). A `.` in any label
3246
+ * position author labels (phrase, exit, refusal keys) AND the event-type
3247
+ * sites (`emit`, channel `from event`, machine `when event`) — is a parse
3248
+ * error (`parse.dotted-key`). The ban is uniform: ADR-256 made the event-type
3249
+ * sites dotless too (the dotless Chord event id is translated to the platform's
3250
+ * dotted id in `@sharpee/story-loader`, so no dotted form ever appears in a
3251
+ * `.story`). Returns `null` when no word is present.
1855
3252
  */
1856
- readDottedKey(c) {
3253
+ readLabelKey(c) {
1857
3254
  const first = c.peek();
1858
3255
  if (!first || first.kind !== 'word')
1859
3256
  return null;
1860
3257
  c.next();
1861
- let key = first.text;
1862
- while (c.peek()?.kind === 'punct' && c.peek().text === '.' && c.peek(1)?.kind === 'word') {
3258
+ const key = first.text;
3259
+ const dotFollows = () => c.peek()?.kind === 'punct' && c.peek().text === '.' && c.peek(1)?.kind === 'word';
3260
+ if (!dotFollows())
3261
+ return key;
3262
+ // ADR-254/256: a `.` in a label is a parse error. Consume the dotted
3263
+ // segments (one clean error, no cascade), flag the first dot, and hand back
3264
+ // the kebab-cased form so downstream parsing does not spuriously break.
3265
+ const dot = c.peek();
3266
+ const segments = [key];
3267
+ while (dotFollows()) {
1863
3268
  c.next();
1864
- key += '.' + c.next().text;
3269
+ segments.push(c.next().text);
1865
3270
  }
1866
- return key;
3271
+ 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);
3272
+ return segments.join('-');
1867
3273
  }
1868
3274
  parseParams(c, line) {
1869
3275
  const params = [];
@@ -1879,7 +3285,7 @@ class Parser {
1879
3285
  break;
1880
3286
  }
1881
3287
  const value = this.parseValueExpr(c, line, new Set(['with']));
1882
- params.push({ param, value, span: start ? (0, span_1.mergeSpans)(start.span, value.span) : value.span });
3288
+ params.push({ param, value, span: start ? (0, span_js_1.mergeSpans)(start.span, value.span) : value.span });
1883
3289
  }
1884
3290
  return params;
1885
3291
  }
@@ -1897,7 +3303,7 @@ class Parser {
1897
3303
  const stmt = this.parseStatement(line, 'ordinal');
1898
3304
  if (stmt) {
1899
3305
  body.push(stmt);
1900
- span = (0, span_1.mergeSpans)(span, stmt.span);
3306
+ span = (0, span_js_1.mergeSpans)(span, stmt.span);
1901
3307
  }
1902
3308
  }
1903
3309
  return { kind: 'ordinal', ordinal: ORDINALS[word], ordinalWord: word, body, span };
@@ -1924,7 +3330,7 @@ class Parser {
1924
3330
  }
1925
3331
  const body = this.parseStatements(headLine.indent, blockKeyword);
1926
3332
  const endSpan = this.consumeEnd('each', headLine);
1927
- return { kind: 'each', condition: nameTok.text, body, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
3333
+ return { kind: 'each', condition: nameTok.text, body, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
1928
3334
  }
1929
3335
  parseSelect(headLine) {
1930
3336
  this.pos++;
@@ -1955,7 +3361,7 @@ class Parser {
1955
3361
  }
1956
3362
  }
1957
3363
  const endSpan = this.consumeEnd('select', headLine);
1958
- return { kind: 'select-on', subject, arms, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
3364
+ return { kind: 'select-on', subject, arms, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
1959
3365
  }
1960
3366
  const strategyTok = c.next();
1961
3367
  if (!strategyTok || strategyTok.kind !== 'word' || !STRATEGIES.has(strategyTok.text)) {
@@ -1981,7 +3387,7 @@ class Parser {
1981
3387
  break;
1982
3388
  }
1983
3389
  const endSpan = this.consumeEnd('select', headLine);
1984
- return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0, span_1.mergeSpans)(lineSpan(headLine), endSpan) };
3390
+ return { kind: 'select-strategy', strategy: strategyTok.text, alternatives, span: (0, span_js_1.mergeSpans)(lineSpan(headLine), endSpan) };
1985
3391
  }
1986
3392
  /** Consume the expected `end <keyword>` line; diagnose a mismatch or absence. */
1987
3393
  consumeEnd(keyword, openLine) {
@@ -2028,10 +3434,10 @@ class Parser {
2028
3434
  if (stop(t))
2029
3435
  break;
2030
3436
  words.push(t.text);
2031
- span = span ? (0, span_1.mergeSpans)(span, t.span) : t.span;
3437
+ span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
2032
3438
  c.next();
2033
3439
  }
2034
- return { kind: 'name', article, words, span: span ?? (0, span_1.spanOf)(c.line.lineNo, 1) };
3440
+ return { kind: 'name', article, words, span: span ?? (0, span_js_1.spanOf)(c.line.lineNo, 1) };
2035
3441
  }
2036
3442
  /**
2037
3443
  * Parse a value expression: literal, name reference, or possessive chain
@@ -2059,7 +3465,7 @@ class Parser {
2059
3465
  if (m && m.kind === 'word' && m.text === 'match' && matchStandsAlone) {
2060
3466
  c.next();
2061
3467
  c.next();
2062
- return { kind: 'match', span: (0, span_1.mergeSpans)(first.span, m.span) };
3468
+ return { kind: 'match', span: (0, span_js_1.mergeSpans)(first.span, m.span) };
2063
3469
  }
2064
3470
  }
2065
3471
  // `its <field>` — possessive on `it`.
@@ -2067,7 +3473,7 @@ class Parser {
2067
3473
  c.next();
2068
3474
  const field = this.collectWords(c, extraStops);
2069
3475
  const base = { kind: 'ref', ref: { kind: 'name', article: null, words: ['it'], span: first.span }, span: first.span };
2070
- return { kind: 'possessive', base, field: field.words, span: (0, span_1.mergeSpans)(first.span, field.span ?? first.span) };
3476
+ return { kind: 'possessive', base, field: field.words, span: (0, span_js_1.mergeSpans)(first.span, field.span ?? first.span) };
2071
3477
  }
2072
3478
  let article = null;
2073
3479
  let span = first.span;
@@ -2084,7 +3490,7 @@ class Parser {
2084
3490
  if (PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
2085
3491
  break;
2086
3492
  c.next();
2087
- span = (0, span_1.mergeSpans)(span, t.span);
3493
+ span = (0, span_js_1.mergeSpans)(span, t.span);
2088
3494
  const poss = /^(.*)'s$/.exec(t.text);
2089
3495
  if (poss) {
2090
3496
  words.push(poss[1]);
@@ -2104,7 +3510,7 @@ class Parser {
2104
3510
  };
2105
3511
  if (possessiveBase !== null) {
2106
3512
  const field = this.collectWords(c, extraStops);
2107
- return { kind: 'possessive', base: refExpr, field: field.words, span: (0, span_1.mergeSpans)(span, field.span ?? span) };
3513
+ return { kind: 'possessive', base: refExpr, field: field.words, span: (0, span_js_1.mergeSpans)(span, field.span ?? span) };
2108
3514
  }
2109
3515
  return refExpr;
2110
3516
  }
@@ -2116,7 +3522,7 @@ class Parser {
2116
3522
  if (t.kind !== 'word' || PHRASE_STOPS.has(t.text) || extraStops.has(t.text))
2117
3523
  break;
2118
3524
  words.push(t.text);
2119
- span = span ? (0, span_1.mergeSpans)(span, t.span) : t.span;
3525
+ span = span ? (0, span_js_1.mergeSpans)(span, t.span) : t.span;
2120
3526
  c.next();
2121
3527
  }
2122
3528
  return { words, span };
@@ -2132,7 +3538,7 @@ class Parser {
2132
3538
  while (c.matchWord('or')) {
2133
3539
  const right = this.parseConditionAnd(c, line);
2134
3540
  operands.push(right);
2135
- span = (0, span_1.mergeSpans)(span, right.span);
3541
+ span = (0, span_js_1.mergeSpans)(span, right.span);
2136
3542
  }
2137
3543
  return { kind: 'or', operands, span };
2138
3544
  }
@@ -2145,7 +3551,7 @@ class Parser {
2145
3551
  while (c.matchWord('and')) {
2146
3552
  const right = this.parseConditionUnary(c, line);
2147
3553
  operands.push(right);
2148
- span = (0, span_1.mergeSpans)(span, right.span);
3554
+ span = (0, span_js_1.mergeSpans)(span, right.span);
2149
3555
  }
2150
3556
  return { kind: 'and', operands, span };
2151
3557
  }
@@ -2158,7 +3564,19 @@ class Parser {
2158
3564
  if (t.kind === 'word' && t.text === 'not') {
2159
3565
  c.next();
2160
3566
  const operand = this.parseConditionUnary(c, line);
2161
- return { kind: 'not', operand, span: (0, span_1.mergeSpans)(t.span, operand.span) };
3567
+ return { kind: 'not', operand, span: (0, span_js_1.mergeSpans)(t.span, operand.span) };
3568
+ }
3569
+ // `client has <capability>` (ADR-216) — `client` is reserved in
3570
+ // condition-subject position; the capability word is the analyzer's gate.
3571
+ if (t.kind === 'word' && t.text === 'client' && c.isWord('has', 1)) {
3572
+ c.next();
3573
+ c.next(); // client has
3574
+ const capability = c.next();
3575
+ if (!capability || capability.kind !== 'word') {
3576
+ this.diagnostics.error('parse.client-has', 'Expected a capability word after `client has` (sound, images, …).', c.restSpan());
3577
+ return { kind: 'condition-ref', name: '', span: lineSpan(line) };
3578
+ }
3579
+ return { kind: 'client-has', capability: capability.text, span: (0, span_js_1.mergeSpans)(t.span, capability.span) };
2162
3580
  }
2163
3581
  if (t.kind === 'lparen') {
2164
3582
  c.next();
@@ -2179,7 +3597,7 @@ class Parser {
2179
3597
  this.diagnostics.error('parse.chance', 'Expected a number after `one chance in`.', n?.span ?? c.restSpan());
2180
3598
  return { kind: 'chance', n: 0, span: t.span };
2181
3599
  }
2182
- return { kind: 'chance', n: Number(n.text), span: (0, span_1.mergeSpans)(t.span, n.span) };
3600
+ return { kind: 'chance', n: Number(n.text), span: (0, span_js_1.mergeSpans)(t.span, n.span) };
2183
3601
  }
2184
3602
  // `any <name>` / `no <name>` — existential / negated existential over a
2185
3603
  // named open condition (ratchet E1/E2, 2026-07-12). Triggers only on the
@@ -2194,7 +3612,7 @@ class Parser {
2194
3612
  return {
2195
3613
  kind: t.text === 'any' ? 'any-of' : 'none-of',
2196
3614
  condition: nameTok.text,
2197
- span: (0, span_1.mergeSpans)(t.span, nameTok.span),
3615
+ span: (0, span_js_1.mergeSpans)(t.span, nameTok.span),
2198
3616
  };
2199
3617
  }
2200
3618
  }
@@ -2236,7 +3654,7 @@ class Parser {
2236
3654
  kind: 'predicate',
2237
3655
  subject,
2238
3656
  predicate: { kind: 'is-a', negated, classifier: cls.words, span: cls.span ?? t.span },
2239
- span: (0, span_1.mergeSpans)(subject.span, cls.span ?? t.span),
3657
+ span: (0, span_js_1.mergeSpans)(subject.span, cls.span ?? t.span),
2240
3658
  };
2241
3659
  }
2242
3660
  if (c.isWord('in')) {
@@ -2246,7 +3664,7 @@ class Parser {
2246
3664
  kind: 'predicate',
2247
3665
  subject,
2248
3666
  predicate: { kind: 'is-in', negated, place, span: place.span },
2249
- span: (0, span_1.mergeSpans)(subject.span, place.span),
3667
+ span: (0, span_js_1.mergeSpans)(subject.span, place.span),
2250
3668
  };
2251
3669
  }
2252
3670
  if (c.isWord('here')) {
@@ -2258,7 +3676,7 @@ class Parser {
2258
3676
  kind: 'predicate',
2259
3677
  subject,
2260
3678
  predicate: { kind: 'is-here', negated, span: hereTok.span },
2261
- span: (0, span_1.mergeSpans)(subject.span, hereTok.span),
3679
+ span: (0, span_js_1.mergeSpans)(subject.span, hereTok.span),
2262
3680
  };
2263
3681
  }
2264
3682
  const value = this.parseValueExpr(c, line, new Set());
@@ -2266,7 +3684,7 @@ class Parser {
2266
3684
  kind: 'predicate',
2267
3685
  subject,
2268
3686
  predicate: { kind: 'is', negated, value, span: value.span },
2269
- span: (0, span_1.mergeSpans)(subject.span, value.span),
3687
+ span: (0, span_js_1.mergeSpans)(subject.span, value.span),
2270
3688
  };
2271
3689
  }
2272
3690
  if (t.text === 'has' || t.text === 'holds' || t.text === 'wears') {
@@ -2276,7 +3694,7 @@ class Parser {
2276
3694
  kind: 'predicate',
2277
3695
  subject,
2278
3696
  predicate: { kind: t.text, thing, span: thing.span },
2279
- span: (0, span_1.mergeSpans)(subject.span, thing.span),
3697
+ span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
2280
3698
  };
2281
3699
  }
2282
3700
  if (t.text === 'can') {
@@ -2291,8 +3709,8 @@ class Parser {
2291
3709
  return {
2292
3710
  kind: 'predicate',
2293
3711
  subject,
2294
- predicate: { kind: 'can', ability: ability.text, thing, span: (0, span_1.mergeSpans)(ability.span, thing.span) },
2295
- span: (0, span_1.mergeSpans)(subject.span, thing.span),
3712
+ predicate: { kind: 'can', ability: ability.text, thing, span: (0, span_js_1.mergeSpans)(ability.span, thing.span) },
3713
+ span: (0, span_js_1.mergeSpans)(subject.span, thing.span),
2296
3714
  };
2297
3715
  }
2298
3716
  this.diagnostics.error('parse.predicate', `Unknown predicate \`${t.text}\` — expected is, is a, is in, has, holds, wears, or can see/reach.`, t.span);