agentopology 0.2.5 → 0.4.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 (162) hide show
  1. package/README.md +121 -7
  2. package/dist/analyzer/__tests__/analyzer.test.js +36 -9
  3. package/dist/analyzer/__tests__/analyzer.test.js.map +1 -1
  4. package/dist/analyzer/__tests__/observability-gap.test.js +40 -10
  5. package/dist/analyzer/__tests__/observability-gap.test.js.map +1 -1
  6. package/dist/analyzer/index.d.ts +2 -1
  7. package/dist/analyzer/index.d.ts.map +1 -1
  8. package/dist/analyzer/index.js +1 -1
  9. package/dist/analyzer/index.js.map +1 -1
  10. package/dist/bindings/__tests__/bindings.test.js +20 -5
  11. package/dist/bindings/__tests__/bindings.test.js.map +1 -1
  12. package/dist/bindings/__tests__/claude-workflow.test.js +12 -3
  13. package/dist/bindings/__tests__/claude-workflow.test.js.map +1 -1
  14. package/dist/bindings/__tests__/defaults-agreement.test.d.ts +16 -0
  15. package/dist/bindings/__tests__/defaults-agreement.test.d.ts.map +1 -0
  16. package/dist/bindings/__tests__/defaults-agreement.test.js +80 -0
  17. package/dist/bindings/__tests__/defaults-agreement.test.js.map +1 -0
  18. package/dist/bindings/__tests__/observability.test.js +8 -2
  19. package/dist/bindings/__tests__/observability.test.js.map +1 -1
  20. package/dist/bindings/__tests__/stub-generation.test.js +8 -2
  21. package/dist/bindings/__tests__/stub-generation.test.js.map +1 -1
  22. package/dist/bindings/claude-code.d.ts.map +1 -1
  23. package/dist/bindings/claude-code.js +102 -3
  24. package/dist/bindings/claude-code.js.map +1 -1
  25. package/dist/bindings/claude-workflow.d.ts.map +1 -1
  26. package/dist/bindings/claude-workflow.js +2 -1
  27. package/dist/bindings/claude-workflow.js.map +1 -1
  28. package/dist/bindings/codex.d.ts.map +1 -1
  29. package/dist/bindings/codex.js +6 -2
  30. package/dist/bindings/codex.js.map +1 -1
  31. package/dist/bindings/gemini-cli.d.ts.map +1 -1
  32. package/dist/bindings/gemini-cli.js +2 -1
  33. package/dist/bindings/gemini-cli.js.map +1 -1
  34. package/dist/bindings/kiro.d.ts.map +1 -1
  35. package/dist/bindings/kiro.js +2 -1
  36. package/dist/bindings/kiro.js.map +1 -1
  37. package/dist/bindings/openclaw.d.ts.map +1 -1
  38. package/dist/bindings/openclaw.js +2 -1
  39. package/dist/bindings/openclaw.js.map +1 -1
  40. package/dist/cli/__tests__/cli.test.d.ts +12 -0
  41. package/dist/cli/__tests__/cli.test.d.ts.map +1 -0
  42. package/dist/cli/__tests__/cli.test.js +171 -0
  43. package/dist/cli/__tests__/cli.test.js.map +1 -0
  44. package/dist/cli/index.d.ts +1 -0
  45. package/dist/cli/index.d.ts.map +1 -1
  46. package/dist/cli/index.js +344 -5
  47. package/dist/cli/index.js.map +1 -1
  48. package/dist/docs/content.d.ts.map +1 -1
  49. package/dist/docs/content.js +112 -0
  50. package/dist/docs/content.js.map +1 -1
  51. package/dist/exporters/ascii.d.ts +13 -0
  52. package/dist/exporters/ascii.d.ts.map +1 -0
  53. package/dist/exporters/ascii.js +26 -0
  54. package/dist/exporters/ascii.js.map +1 -0
  55. package/dist/exporters/brief.d.ts +14 -0
  56. package/dist/exporters/brief.d.ts.map +1 -0
  57. package/dist/exporters/brief.js +37 -0
  58. package/dist/exporters/brief.js.map +1 -0
  59. package/dist/exporters/index.d.ts +2 -0
  60. package/dist/exporters/index.d.ts.map +1 -1
  61. package/dist/exporters/index.js +6 -0
  62. package/dist/exporters/index.js.map +1 -1
  63. package/dist/exporters/markdown.d.ts.map +1 -1
  64. package/dist/exporters/markdown.js +2 -1
  65. package/dist/exporters/markdown.js.map +1 -1
  66. package/dist/exporters/mermaid.d.ts.map +1 -1
  67. package/dist/exporters/mermaid.js +2 -1
  68. package/dist/exporters/mermaid.js.map +1 -1
  69. package/dist/import/__tests__/import.test.js +8 -2
  70. package/dist/import/__tests__/import.test.js.map +1 -1
  71. package/dist/parser/__tests__/parser.test.js +1553 -207
  72. package/dist/parser/__tests__/parser.test.js.map +1 -1
  73. package/dist/parser/__tests__/spec-sync.test.d.ts +10 -0
  74. package/dist/parser/__tests__/spec-sync.test.d.ts.map +1 -0
  75. package/dist/parser/__tests__/spec-sync.test.js +60 -0
  76. package/dist/parser/__tests__/spec-sync.test.js.map +1 -0
  77. package/dist/parser/ast.d.ts +39 -0
  78. package/dist/parser/ast.d.ts.map +1 -1
  79. package/dist/parser/index.d.ts +50 -6
  80. package/dist/parser/index.d.ts.map +1 -1
  81. package/dist/parser/index.js +241 -30
  82. package/dist/parser/index.js.map +1 -1
  83. package/dist/parser/validator.d.ts +7 -1
  84. package/dist/parser/validator.d.ts.map +1 -1
  85. package/dist/parser/validator.js +143 -5
  86. package/dist/parser/validator.js.map +1 -1
  87. package/dist/plan/__tests__/brief.test.d.ts +2 -0
  88. package/dist/plan/__tests__/brief.test.d.ts.map +1 -0
  89. package/dist/plan/__tests__/brief.test.js +442 -0
  90. package/dist/plan/__tests__/brief.test.js.map +1 -0
  91. package/dist/plan/brief.d.ts +207 -0
  92. package/dist/plan/brief.d.ts.map +1 -0
  93. package/dist/plan/brief.js +342 -0
  94. package/dist/plan/brief.js.map +1 -0
  95. package/dist/plan/render.d.ts +20 -0
  96. package/dist/plan/render.d.ts.map +1 -0
  97. package/dist/plan/render.js +425 -0
  98. package/dist/plan/render.js.map +1 -0
  99. package/dist/render/__tests__/ascii.test.d.ts +2 -0
  100. package/dist/render/__tests__/ascii.test.d.ts.map +1 -0
  101. package/dist/render/__tests__/ascii.test.js +171 -0
  102. package/dist/render/__tests__/ascii.test.js.map +1 -0
  103. package/dist/render/ascii.d.ts +23 -0
  104. package/dist/render/ascii.d.ts.map +1 -0
  105. package/dist/render/ascii.js +178 -0
  106. package/dist/render/ascii.js.map +1 -0
  107. package/dist/resolve/__tests__/defaults.test.d.ts +2 -0
  108. package/dist/resolve/__tests__/defaults.test.d.ts.map +1 -0
  109. package/dist/resolve/__tests__/defaults.test.js +109 -0
  110. package/dist/resolve/__tests__/defaults.test.js.map +1 -0
  111. package/dist/resolve/__tests__/order.test.d.ts +2 -0
  112. package/dist/resolve/__tests__/order.test.d.ts.map +1 -0
  113. package/dist/resolve/__tests__/order.test.js +238 -0
  114. package/dist/resolve/__tests__/order.test.js.map +1 -0
  115. package/dist/resolve/__tests__/phase.test.d.ts +12 -0
  116. package/dist/resolve/__tests__/phase.test.d.ts.map +1 -0
  117. package/dist/resolve/__tests__/phase.test.js +81 -0
  118. package/dist/resolve/__tests__/phase.test.js.map +1 -0
  119. package/dist/resolve/defaults.d.ts +51 -0
  120. package/dist/resolve/defaults.d.ts.map +1 -0
  121. package/dist/resolve/defaults.js +109 -0
  122. package/dist/resolve/defaults.js.map +1 -0
  123. package/dist/resolve/order.d.ts +73 -0
  124. package/dist/resolve/order.d.ts.map +1 -0
  125. package/dist/resolve/order.js +208 -0
  126. package/dist/resolve/order.js.map +1 -0
  127. package/dist/resolve/phase.d.ts +57 -0
  128. package/dist/resolve/phase.d.ts.map +1 -0
  129. package/dist/resolve/phase.js +60 -0
  130. package/dist/resolve/phase.js.map +1 -0
  131. package/dist/scaffold/__tests__/scaffold.test.js +8 -2
  132. package/dist/scaffold/__tests__/scaffold.test.js.map +1 -1
  133. package/dist/scaffold/__tests__/settings-merge.test.js +95 -0
  134. package/dist/scaffold/__tests__/settings-merge.test.js.map +1 -1
  135. package/dist/scaffold/incremental.d.ts.map +1 -1
  136. package/dist/scaffold/incremental.js +13 -2
  137. package/dist/scaffold/incremental.js.map +1 -1
  138. package/dist/visualizer/__tests__/brain.test.d.ts +2 -0
  139. package/dist/visualizer/__tests__/brain.test.d.ts.map +1 -0
  140. package/dist/visualizer/__tests__/brain.test.js +138 -0
  141. package/dist/visualizer/__tests__/brain.test.js.map +1 -0
  142. package/dist/visualizer/__tests__/topology-brain-link.test.d.ts +2 -0
  143. package/dist/visualizer/__tests__/topology-brain-link.test.d.ts.map +1 -0
  144. package/dist/visualizer/__tests__/topology-brain-link.test.js +56 -0
  145. package/dist/visualizer/__tests__/topology-brain-link.test.js.map +1 -0
  146. package/dist/visualizer/brain.d.ts +97 -0
  147. package/dist/visualizer/brain.d.ts.map +1 -0
  148. package/dist/visualizer/brain.js +473 -0
  149. package/dist/visualizer/brain.js.map +1 -0
  150. package/dist/visualizer/index.d.ts +19 -1
  151. package/dist/visualizer/index.d.ts.map +1 -1
  152. package/dist/visualizer/index.js +26 -11
  153. package/dist/visualizer/index.js.map +1 -1
  154. package/examples/code-review.at +32 -4
  155. package/examples/company-brain-team.at +241 -0
  156. package/examples/company-brain.at +104 -0
  157. package/examples/data-processing.at +33 -4
  158. package/examples/logos/gmail.svg +1 -0
  159. package/examples/logos/slack.svg +1 -0
  160. package/package.json +1 -1
  161. package/spec/grammar.md +55 -3
  162. package/spec/validation.md +103 -35
@@ -16,6 +16,78 @@ import { stripComments, extractBlock, extractAllBlocks, parseKV, parseList, pars
16
16
  // ---------------------------------------------------------------------------
17
17
  // Helpers
18
18
  // ---------------------------------------------------------------------------
19
+ /**
20
+ * V90: detect a field value that swallowed the next field.
21
+ *
22
+ * `parseFields` is line-based, and the grammar never says so. Writing two
23
+ * fields on one line — `agent a { model: sonnet retry: 3 }` — makes the first
24
+ * key take the whole rest of the line as its value (`"sonnet retry: 3"`), and
25
+ * nothing catches it. The value is then silently wrong: a model name that does
26
+ * not exist, a version string carrying a description, an action `kind` that
27
+ * fails its enum for a confusing reason.
28
+ *
29
+ * Detection: an UNQUOTED value containing whitespace followed by
30
+ * `identifier:` is almost certainly a swallowed field. Quoted values are
31
+ * exempt (a description may legitimately contain a colon), and a leading
32
+ * scheme like `https://` is not matched because it has no preceding
33
+ * whitespace.
34
+ */
35
+ function detectSwallowedFields(fields, nodeId, out) {
36
+ for (const [key, raw] of Object.entries(fields)) {
37
+ if (typeof raw !== "string")
38
+ continue;
39
+ const value = raw.trim();
40
+ // Free-prose fields whose quotes have already been stripped are exempt: a
41
+ // colon inside a description is ordinary English ("Demo: agent pipeline"),
42
+ // and once the quotes are gone there is no way to tell prose from a
43
+ // swallow. Where quotes SURVIVE parsing — every block except `meta` — the
44
+ // check below still catches `description: "k" model: sonnet`.
45
+ const PROSE_FIELDS = new Set(["description", "role", "generates", "termination"]);
46
+ if (PROSE_FIELDS.has(key) && !value.startsWith('"'))
47
+ continue;
48
+ // A balanced brace block is a NESTED OBJECT, not a swallow. The single-line
49
+ // form is spec-legal and parses correctly — `spec/grammar.md:117` gives
50
+ // `outputs: { depth: 1 | 2 | 3 }` verbatim. Flagging it rejected valid
51
+ // topologies, which is worse than missing one.
52
+ if (value.startsWith("{") && value.endsWith("}"))
53
+ continue;
54
+ // Same for a list.
55
+ if (value.startsWith("[") && value.endsWith("]"))
56
+ continue;
57
+ // A WELL-FORMED quoted string is exempt — a description may contain a
58
+ // colon. `"1.0.0" description: "x"` is NOT well-formed: it is one quoted
59
+ // token followed by more content, i.e. a swallowed field.
60
+ if (/^"(?:[^"\\]|\\.)*"$/.test(value))
61
+ continue;
62
+ const search = value.startsWith('"')
63
+ ? value.replace(/^"(?:[^"\\]|\\.)*"/, "")
64
+ : value;
65
+ const m = /\s([a-zA-Z][a-zA-Z0-9_-]*):/.exec(search.startsWith(" ") ? search : " " + search);
66
+ if (!m)
67
+ continue;
68
+ out.push({
69
+ rule: "V90",
70
+ level: "error",
71
+ message: `"${nodeId}" field \`${key}\` has the value \`${value}\`, which looks like it swallowed the ` +
72
+ `field \`${m[1]}\` — fields must be one per line`,
73
+ node: nodeId,
74
+ });
75
+ }
76
+ }
77
+ /**
78
+ * Placeholders the parser injects for REQUIRED fields that were not declared.
79
+ *
80
+ * The parser cannot leave them undefined without changing non-optional AST
81
+ * types, so it substitutes a sentinel — and that sentinel then satisfied every
82
+ * "is it present?" check downstream. V91 recognises them and reports the field
83
+ * as missing, which is what the spec says it is.
84
+ */
85
+ export const MISSING = {
86
+ /** `orchestrator.model` — spec/grammar.md, orchestrator field table. */
87
+ model: "unknown",
88
+ /** `meta.version` — spec/grammar.md:282, Required: yes. */
89
+ version: "0.0.0",
90
+ };
19
91
  /** Convert a kebab-case or snake_case identifier to a Title Case label. */
20
92
  function toLabel(id) {
21
93
  return id
@@ -203,8 +275,11 @@ export function parseMeta(body) {
203
275
  /**
204
276
  * Parse the `orchestrator { ... }` block into an {@link OrchestratorNode}.
205
277
  */
206
- export function parseOrchestrator(body) {
278
+ export function parseOrchestrator(body, _blockFieldMisuseWarnings) {
207
279
  const fields = parseFields(body);
280
+ if (_blockFieldMisuseWarnings) {
281
+ detectSwallowedFields(fields, "orchestrator", _blockFieldMisuseWarnings);
282
+ }
208
283
  const handles = parseMultilineList(body, "handles");
209
284
  const outputs = parseOutputsBlock(body);
210
285
  const node = {
@@ -247,8 +322,11 @@ export function parseRoles(body) {
247
322
  /**
248
323
  * Parse an `action <id> { ... }` block into an {@link ActionNode}.
249
324
  */
250
- export function parseAction(id, body) {
325
+ export function parseAction(id, body, _blockFieldMisuseWarnings) {
251
326
  const fields = parseFields(body);
327
+ if (_blockFieldMisuseWarnings) {
328
+ detectSwallowedFields(fields, id, _blockFieldMisuseWarnings);
329
+ }
252
330
  const commands = parseMultilineList(body, "commands");
253
331
  const node = {
254
332
  id,
@@ -279,7 +357,7 @@ export function parseAction(id, body) {
279
357
  * @param roles - Role descriptions from the `roles` block, used to attach a
280
358
  * role description to matching agents.
281
359
  */
282
- export function parseAgent(id, body, roles) {
360
+ export function parseAgent(id, body, roles, _blockFieldMisuseWarnings) {
283
361
  const fields = parseFields(body);
284
362
  const tools = parseMultilineList(body, "tools");
285
363
  const skills = parseMultilineList(body, "skills");
@@ -298,7 +376,24 @@ export function parseAgent(id, body, roles) {
298
376
  node.model = fields.model;
299
377
  if (fields.permissions)
300
378
  node.permissions = fields.permissions;
301
- // Extract prompt from prompt {} block (not KV pair)
379
+ // Extract prompt from prompt {} block (not KV pair).
380
+ //
381
+ // V89: `agent.prompt` is a BLOCK per spec/grammar.md:350. The string form
382
+ // (`prompt: "path.md"`) belongs to `skill`, a different node type
383
+ // (spec/grammar.md:1377). A KV `prompt` on an agent is silently unparseable,
384
+ // so flag it rather than dropping the author's instructions on the floor.
385
+ if (fields.prompt) {
386
+ _blockFieldMisuseWarnings?.push({
387
+ rule: "V89",
388
+ level: "error",
389
+ message: `agent "${id}" uses \`prompt:\` as a key-value field, but agent prompts must be a \`prompt { }\` block — ` +
390
+ `this value is ignored and the agent will have no instructions`,
391
+ node: id,
392
+ });
393
+ }
394
+ if (_blockFieldMisuseWarnings) {
395
+ detectSwallowedFields(fields, id, _blockFieldMisuseWarnings);
396
+ }
302
397
  const promptBlock = extractBlock(body, "prompt");
303
398
  if (promptBlock) {
304
399
  node.prompt = dedentBlock(promptBlock.body);
@@ -407,6 +502,24 @@ export function parseAgent(id, body, roles) {
407
502
  }
408
503
  if (fields.retrieval)
409
504
  node.retrieval = fields.retrieval;
505
+ // Custody: `custodian-of: [store, ...]` with optional `{ does: [verb, ...] }`.
506
+ // The store-list appears before an optional brace block. Match the list first,
507
+ // then look inside the block (if any) for `does`.
508
+ const custodyMatch = /custodian-of\s*:\s*\[([^\]]*)\]/.exec(body);
509
+ if (custodyMatch) {
510
+ const stores = parseList(custodyMatch[1]);
511
+ if (stores.length)
512
+ node.custodianOf = stores;
513
+ const custodyBlock = extractBlock(body, "custodian-of");
514
+ if (custodyBlock) {
515
+ const does = parseMultilineList(custodyBlock.body, "does");
516
+ const doesInline = parseFields(custodyBlock.body).does;
517
+ if (does.length)
518
+ node.custodianDoes = does;
519
+ else if (doesInline)
520
+ node.custodianDoes = parseList(doesInline);
521
+ }
522
+ }
410
523
  if (outputs)
411
524
  node.outputs = outputs;
412
525
  // Parse scale sub-block
@@ -537,8 +650,11 @@ export function parseAgent(id, body, roles) {
537
650
  /**
538
651
  * Parse a `gate <id> { ... }` block into a {@link GateNode}.
539
652
  */
540
- export function parseGate(id, body) {
653
+ export function parseGate(id, body, _blockFieldMisuseWarnings) {
541
654
  const fields = parseFields(body);
655
+ if (_blockFieldMisuseWarnings) {
656
+ detectSwallowedFields(fields, id, _blockFieldMisuseWarnings);
657
+ }
542
658
  const checks = parseMultilineList(body, "checks");
543
659
  const node = {
544
660
  id,
@@ -569,8 +685,11 @@ export function parseGate(id, body) {
569
685
  /**
570
686
  * Parse a `human <id> { ... }` block into a {@link HumanNode}.
571
687
  */
572
- export function parseHuman(id, body) {
688
+ export function parseHuman(id, body, _blockFieldMisuseWarnings) {
573
689
  const fields = parseFields(body);
690
+ if (_blockFieldMisuseWarnings) {
691
+ detectSwallowedFields(fields, id, _blockFieldMisuseWarnings);
692
+ }
574
693
  const node = {
575
694
  id,
576
695
  type: "human",
@@ -587,8 +706,11 @@ export function parseHuman(id, body) {
587
706
  /**
588
707
  * Parse a `group <id> { ... }` block into a {@link GroupNode}.
589
708
  */
590
- export function parseGroup(id, body) {
709
+ export function parseGroup(id, body, _blockFieldMisuseWarnings) {
591
710
  const fields = parseFields(body);
711
+ if (_blockFieldMisuseWarnings) {
712
+ detectSwallowedFields(fields, id, _blockFieldMisuseWarnings);
713
+ }
592
714
  const members = parseList(fields.members ?? "");
593
715
  const node = {
594
716
  id,
@@ -633,6 +755,24 @@ export function parseFlow(body, _edgeAttributeErrors) {
633
755
  // Match annotation at end of line: [when ..., max N, per agent-id, tolerance: N, race, wait 30s, reflection]
634
756
  // Must start with "when", "max", "per", "tolerance", "race", "wait", "join", "weight", or "reflection" to avoid matching fan-out lists.
635
757
  const bracketMatch = trimmed.match(/\[(when\s+.+?|max\s+\d+.*?|per\s+\S+.*?|tolerance\s*:.+?|race.*?|wait\s+\S+.*?|join\s+\S+.*?|weight\s+\S+.*?|reflection.*?)\]\s*$/);
758
+ // V92: two bracket groups on one edge. The grammar defines ONE annotation
759
+ // with comma-separated attributes (`[when ..., max N]`). Written as
760
+ // `[when ...] [max N]`, the matcher below spans from the first `[` to the
761
+ // last `]` and produces a corrupt condition — literally
762
+ // `a.verdict == fail] [max 3` — with no error at all.
763
+ // Count only ANNOTATION brackets. A fan-out list is also bracketed —
764
+ // `analyzer -> [reporter, alerter] [when ...]` is legal — so testing for
765
+ // `] [` alone flagged valid topologies.
766
+ const ATTR = /\[\s*(?:when|max|per|tolerance|race|wait|join|weight|reflection)\b/g;
767
+ const annotationBrackets = (trimmed.match(ATTR) ?? []).length;
768
+ if (annotationBrackets > 1 && _edgeAttributeErrors) {
769
+ _edgeAttributeErrors.push({
770
+ rule: "V92",
771
+ level: "error",
772
+ message: "Edge has two bracket groups — attributes go in ONE bracket, " +
773
+ `comma-separated: [when ..., max N] (line: ${trimmed})`,
774
+ });
775
+ }
636
776
  if (bracketMatch) {
637
777
  flowPart = trimmed.slice(0, bracketMatch.index).trim();
638
778
  const annotation = bracketMatch[1];
@@ -892,10 +1032,13 @@ function parseScoringConfig(body) {
892
1032
  */
893
1033
  export function parseStore(id, body) {
894
1034
  const fields = parseFields(body);
1035
+ // `brain` stores are file-native (Obsidian-format markdown) — their backend
1036
+ // is always `files`, never a database. Every other type defaults to lancedb.
1037
+ const type = fields.type ?? "semantic";
895
1038
  const store = {
896
1039
  id,
897
- type: fields.type ?? "semantic",
898
- backend: fields.backend ?? "lancedb",
1040
+ type,
1041
+ backend: fields.backend ?? (type === "brain" ? "files" : "lancedb"),
899
1042
  };
900
1043
  if (fields.description)
901
1044
  store.description = unquote(fields.description);
@@ -907,6 +1050,8 @@ export function parseStore(id, body) {
907
1050
  store.path = unquote(fields.path);
908
1051
  if (fields.extraction)
909
1052
  store.extraction = fields.extraction;
1053
+ if (fields.format)
1054
+ store.format = fields.format;
910
1055
  // Connection: may use `secret "uri"` modifier (same pattern as parseCheckpoint)
911
1056
  if (fields.connection) {
912
1057
  const rawConn = fields.connection.trim();
@@ -933,6 +1078,39 @@ export function parseStore(id, body) {
933
1078
  const lifecycleBlock = extractBlock(body, "lifecycle");
934
1079
  if (lifecycleBlock)
935
1080
  store.lifecycle = parseLifecycleConfig(lifecycleBlock.body);
1081
+ // Presentation-only `sources { gmail { color icon } slack { ... } }` block.
1082
+ // Maps a note's `source:` frontmatter to a graph color/icon. Cosmetic — the
1083
+ // visualizer reads it; bindings ignore it entirely.
1084
+ const sourcesBlock = extractBlock(body, "sources");
1085
+ if (sourcesBlock) {
1086
+ const sources = {};
1087
+ const re = /(?:^|\n)\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*\{/gm;
1088
+ let sm;
1089
+ while ((sm = re.exec(sourcesBlock.body)) !== null) {
1090
+ const name = sm[1];
1091
+ const startIdx = sm.index + sm[0].length;
1092
+ let depth = 1, i = startIdx;
1093
+ while (i < sourcesBlock.body.length && depth > 0) {
1094
+ if (sourcesBlock.body[i] === "{")
1095
+ depth++;
1096
+ else if (sourcesBlock.body[i] === "}")
1097
+ depth--;
1098
+ i++;
1099
+ }
1100
+ if (depth === 0) {
1101
+ const inner = parseFields(sourcesBlock.body.slice(startIdx, i - 1));
1102
+ const style = {};
1103
+ if (inner.color)
1104
+ style.color = unquote(inner.color);
1105
+ if (inner.icon)
1106
+ style.icon = unquote(inner.icon);
1107
+ if (style.color || style.icon)
1108
+ sources[name] = style;
1109
+ }
1110
+ }
1111
+ if (Object.keys(sources).length > 0)
1112
+ store.sources = sources;
1113
+ }
936
1114
  // Backend-config passthrough bag
937
1115
  const backendConfigBlock = extractBlock(body, "backend-config");
938
1116
  if (backendConfigBlock) {
@@ -1006,10 +1184,7 @@ export function parseMemory(body, _unknownSubBlockWarnings) {
1006
1184
  ];
1007
1185
  const knownSubsSet = new Set([
1008
1186
  ...knownSubs,
1009
- "store", "retrieval",
1010
- // Sub-blocks within store {} and retrieval {} (not top-level memory subs,
1011
- // but the V24 scanner sees them because it doesn't track depth)
1012
- "embedding", "index", "ingestion", "search", "lifecycle", "backend-config", "scoring",
1187
+ "store", "retrieval", // the two typed top-level blocks
1013
1188
  ]);
1014
1189
  for (const name of knownSubs) {
1015
1190
  const block = extractBlock(body, name);
@@ -1042,19 +1217,47 @@ export function parseMemory(body, _unknownSubBlockWarnings) {
1042
1217
  continue;
1043
1218
  retrievals.push(parseRetrieval(block.id, block.body));
1044
1219
  }
1045
- // Detect unknown sub-blocks
1220
+ // Detect unknown sub-blocks. Only consider TOP-LEVEL blocks of the memory
1221
+ // body — names nested inside a `store {}` or `retrieval {}` (e.g. a brain
1222
+ // store's `sources { gmail {} }`) are that block's own concern, not memory
1223
+ // sub-blocks. Walk the body tracking brace depth so we only flag depth-0 names.
1046
1224
  if (_unknownSubBlockWarnings) {
1047
- const subBlockRe = /(?:^|\n)\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*\{/gm;
1048
- let m;
1049
- while ((m = subBlockRe.exec(body)) !== null) {
1050
- const name = m[1];
1051
- if (!knownSubsSet.has(name)) {
1052
- _unknownSubBlockWarnings.push({
1053
- rule: "V24",
1054
- level: "warning",
1055
- message: `Unknown sub-block "${name}" in memory block — known sub-blocks are: ${knownSubs.join(", ")}`,
1056
- });
1225
+ let depth = 0;
1226
+ let i = 0;
1227
+ while (i < body.length) {
1228
+ const ch = body[i];
1229
+ if (ch === "}") {
1230
+ depth = Math.max(0, depth - 1);
1231
+ i++;
1232
+ continue;
1233
+ }
1234
+ if (ch === "{") {
1235
+ depth++;
1236
+ i++;
1237
+ continue;
1057
1238
  }
1239
+ // At top level, match a block header before `{`. Handles both the bare
1240
+ // form (`workspace {`) and the typed-block form (`store brain {`,
1241
+ // `retrieval smart {`) — in the latter the FIRST token is the keyword.
1242
+ if (depth === 0) {
1243
+ const rest = body.slice(i);
1244
+ const m = /^([a-zA-Z][a-zA-Z0-9_-]*)(?:\s+[a-zA-Z][a-zA-Z0-9_-]*)?\s*\{/.exec(rest);
1245
+ if (m && (i === 0 || /\s/.test(body[i - 1]))) {
1246
+ const name = m[1]; // the leading keyword (store / retrieval / workspace / …)
1247
+ if (!knownSubsSet.has(name)) {
1248
+ _unknownSubBlockWarnings.push({
1249
+ rule: "V24",
1250
+ level: "warning",
1251
+ message: `Unknown sub-block "${name}" in memory block — known sub-blocks are: ${knownSubs.join(", ")}`,
1252
+ });
1253
+ }
1254
+ // Advance into the block (past the `{`) so its body is depth-counted.
1255
+ i += m[0].length;
1256
+ depth++;
1257
+ continue;
1258
+ }
1259
+ }
1260
+ i++;
1058
1261
  }
1059
1262
  }
1060
1263
  return { memory, stores, retrievals };
@@ -1970,7 +2173,11 @@ export function parse(source) {
1970
2173
  const topBody = stripComments(topoBlock.body);
1971
2174
  // --- Meta ---
1972
2175
  const metaBlock = extractBlock(topBody, "meta");
2176
+ // V89/V90: collected while parsing blocks — a field written in a form the
2177
+ // grammar does not define. Attached to the AST below.
2178
+ const blockFieldMisuseWarnings = [];
1973
2179
  const metaFields = metaBlock ? parseMeta(metaBlock.body) : {};
2180
+ detectSwallowedFields(metaFields, "<meta>", blockFieldMisuseWarnings);
1974
2181
  const topology = {
1975
2182
  name: header.name,
1976
2183
  version: metaFields.version ?? "0.0.0",
@@ -1991,20 +2198,20 @@ export function parse(source) {
1991
2198
  // Orchestrator
1992
2199
  const orchBlock = extractBlock(topBody, "orchestrator");
1993
2200
  if (orchBlock) {
1994
- nodes.push(parseOrchestrator(orchBlock.body));
2201
+ nodes.push(parseOrchestrator(orchBlock.body, blockFieldMisuseWarnings));
1995
2202
  }
1996
2203
  // Actions
1997
2204
  const actionBlocks = extractAllBlocks(topBody, "action");
1998
2205
  for (const block of actionBlocks) {
1999
2206
  if (block.id) {
2000
- nodes.push(parseAction(block.id, block.body));
2207
+ nodes.push(parseAction(block.id, block.body, blockFieldMisuseWarnings));
2001
2208
  }
2002
2209
  }
2003
2210
  // Agents
2004
2211
  const agentBlocks = extractAllBlocks(topBody, "agent");
2005
2212
  for (const block of agentBlocks) {
2006
2213
  if (block.id) {
2007
- nodes.push(parseAgent(block.id, block.body, roles));
2214
+ nodes.push(parseAgent(block.id, block.body, roles, blockFieldMisuseWarnings));
2008
2215
  }
2009
2216
  }
2010
2217
  // Gates
@@ -2013,7 +2220,7 @@ export function parse(source) {
2013
2220
  const gateBlocks = extractAllBlocks(gatesBlock.body, "gate");
2014
2221
  for (const block of gateBlocks) {
2015
2222
  if (block.id) {
2016
- nodes.push(parseGate(block.id, block.body));
2223
+ nodes.push(parseGate(block.id, block.body, blockFieldMisuseWarnings));
2017
2224
  }
2018
2225
  }
2019
2226
  }
@@ -2021,14 +2228,14 @@ export function parse(source) {
2021
2228
  const humanBlocks = extractAllBlocks(topBody, "human");
2022
2229
  for (const block of humanBlocks) {
2023
2230
  if (block.id) {
2024
- nodes.push(parseHuman(block.id, block.body));
2231
+ nodes.push(parseHuman(block.id, block.body, blockFieldMisuseWarnings));
2025
2232
  }
2026
2233
  }
2027
2234
  // Group chat nodes
2028
2235
  const groupBlocks = extractAllBlocks(topBody, "group");
2029
2236
  for (const block of groupBlocks) {
2030
2237
  if (block.id) {
2031
- nodes.push(parseGroup(block.id, block.body));
2238
+ nodes.push(parseGroup(block.id, block.body, blockFieldMisuseWarnings));
2032
2239
  }
2033
2240
  }
2034
2241
  // --- Skills ---
@@ -2209,6 +2416,10 @@ export function parse(source) {
2209
2416
  if (unknownMemorySubBlockWarnings.length > 0) {
2210
2417
  ast._unknownMemorySubBlockWarnings = unknownMemorySubBlockWarnings;
2211
2418
  }
2419
+ // Attach V89 block-vs-KV field misuse errors for the validator to consume.
2420
+ if (blockFieldMisuseWarnings.length > 0) {
2421
+ ast._blockFieldMisuseWarnings = blockFieldMisuseWarnings;
2422
+ }
2212
2423
  // Attach source map for line number tracking in validation results.
2213
2424
  if (Object.keys(sourceMap).length > 0) {
2214
2425
  ast._sourceMap = sourceMap;