@crouton-kit/crouter 0.3.20 → 0.3.22

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 (144) hide show
  1. package/dist/builtin-memory/crouter-development/marketplaces.md +2 -4
  2. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +3 -5
  3. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +3 -6
  4. package/dist/builtin-memory/crouter-development/personas.md +2 -6
  5. package/dist/builtin-memory/crouter-development/plugins.md +2 -4
  6. package/dist/builtin-memory/design.md +2 -4
  7. package/dist/builtin-memory/development.md +3 -3
  8. package/dist/builtin-memory/planning.md +3 -4
  9. package/dist/builtin-memory/spec.md +3 -10
  10. package/dist/builtin-personas/orchestration-kernel.md +1 -1
  11. package/dist/commands/canvas-history/read.d.ts +1 -0
  12. package/dist/commands/canvas-history/read.js +61 -0
  13. package/dist/commands/canvas-history/search.d.ts +1 -0
  14. package/dist/commands/canvas-history/search.js +270 -0
  15. package/dist/commands/canvas-history/show.d.ts +1 -0
  16. package/dist/commands/canvas-history/show.js +79 -0
  17. package/dist/commands/canvas-history.d.ts +2 -0
  18. package/dist/commands/canvas-history.js +33 -0
  19. package/dist/commands/canvas.js +3 -2
  20. package/dist/commands/human/queue.js +2 -2
  21. package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
  22. package/dist/commands/memory/find.js +14 -52
  23. package/dist/commands/memory/lint.d.ts +7 -0
  24. package/dist/commands/memory/lint.js +29 -35
  25. package/dist/commands/memory/list.js +8 -18
  26. package/dist/commands/memory/read.js +4 -37
  27. package/dist/commands/memory/shared.js +1 -2
  28. package/dist/commands/memory/write.js +6 -9
  29. package/dist/commands/node.js +47 -25
  30. package/dist/commands/pkg/bridge.d.ts +1 -0
  31. package/dist/commands/pkg/bridge.js +137 -0
  32. package/dist/commands/pkg/plugin-inspect.js +7 -6
  33. package/dist/commands/pkg.js +2 -1
  34. package/dist/commands/push.js +10 -15
  35. package/dist/commands/skill/author.js +35 -44
  36. package/dist/commands/skill/shared.d.ts +1 -5
  37. package/dist/commands/skill/shared.js +9 -63
  38. package/dist/commands/skill.js +5 -6
  39. package/dist/commands/sys/doctor.js +6 -144
  40. package/dist/commands/tmux-spread.js +16 -1
  41. package/dist/commands/view-cycle.js +9 -5
  42. package/dist/commands/view-run.js +3 -2
  43. package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
  44. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
  45. package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
  46. package/dist/core/__tests__/child-death-wake.test.js +133 -185
  47. package/dist/core/__tests__/context-intro.test.js +23 -27
  48. package/dist/core/__tests__/daemon-liveness.test.js +28 -258
  49. package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
  50. package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
  51. package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
  52. package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
  53. package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
  54. package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
  55. package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
  56. package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +4 -3
  57. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
  58. package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
  59. package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +7 -7
  60. package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
  61. package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
  62. package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
  63. package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
  64. package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
  65. package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
  66. package/dist/core/__tests__/full/spike-harness.test.d.ts +1 -0
  67. package/dist/core/__tests__/full/spike-harness.test.js +117 -0
  68. package/dist/core/__tests__/grace-clock.test.js +72 -75
  69. package/dist/core/__tests__/helpers/harness.d.ts +35 -1
  70. package/dist/core/__tests__/helpers/harness.js +70 -12
  71. package/dist/core/__tests__/live-mutation-verbs.test.js +100 -105
  72. package/dist/core/__tests__/live-mutation.test.js +111 -134
  73. package/dist/core/__tests__/memory.test.js +4 -4
  74. package/dist/core/__tests__/revive.test.js +96 -86
  75. package/dist/core/__tests__/subscription-delivery.test.js +116 -138
  76. package/dist/core/__tests__/wake-origin.test.js +54 -50
  77. package/dist/core/bootstrap.js +18 -14
  78. package/dist/core/bridge-map.d.ts +19 -0
  79. package/dist/core/bridge-map.js +73 -0
  80. package/dist/core/canvas/history.d.ts +91 -0
  81. package/dist/core/canvas/history.js +389 -0
  82. package/dist/core/canvas/index.d.ts +1 -0
  83. package/dist/core/canvas/index.js +1 -0
  84. package/dist/core/config.js +3 -22
  85. package/dist/core/help.d.ts +2 -2
  86. package/dist/core/help.js +1 -1
  87. package/dist/core/memory-resolver.d.ts +9 -2
  88. package/dist/core/memory-resolver.js +47 -7
  89. package/dist/core/render.d.ts +4 -3
  90. package/dist/core/render.js +38 -41
  91. package/dist/core/resolver.d.ts +1 -30
  92. package/dist/core/resolver.js +6 -546
  93. package/dist/core/runtime/bearings.d.ts +2 -2
  94. package/dist/core/runtime/bearings.js +2 -2
  95. package/dist/core/runtime/persona.js +5 -7
  96. package/dist/core/scope.d.ts +7 -5
  97. package/dist/core/scope.js +16 -17
  98. package/dist/core/substrate/ceiling.d.ts +28 -0
  99. package/dist/core/substrate/ceiling.js +87 -0
  100. package/dist/core/substrate/index.d.ts +2 -0
  101. package/dist/core/substrate/index.js +1 -0
  102. package/dist/core/substrate/on-read.js +18 -2
  103. package/dist/core/substrate/render.d.ts +3 -5
  104. package/dist/core/substrate/render.js +68 -59
  105. package/dist/core/substrate/schema.d.ts +18 -13
  106. package/dist/core/substrate/schema.js +12 -11
  107. package/dist/pi-extensions/canvas-context-intro.js +5 -3
  108. package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
  109. package/dist/types.d.ts +3 -18
  110. package/dist/types.js +0 -8
  111. package/package.json +4 -3
  112. package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
  113. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
  114. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
  115. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
  116. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
  117. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
  118. package/dist/builtin-skills/skills/design/SKILL.md +0 -51
  119. package/dist/builtin-skills/skills/development/SKILL.md +0 -109
  120. package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
  121. package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
  122. package/dist/commands/skill/state.d.ts +0 -3
  123. package/dist/commands/skill/state.js +0 -71
  124. package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
  125. package/dist/core/__tests__/broker-dialogs.test.js +0 -126
  126. package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
  127. package/dist/core/__tests__/resolver.test.js +0 -181
  128. package/dist/core/__tests__/spike-harness.test.js +0 -242
  129. /package/dist/{core/__tests__/broker-attach-limits.test.d.ts → commands/memory/__tests__/lint-schema.test.d.ts} +0 -0
  130. /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
  131. /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
  132. /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
  133. /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
  134. /package/dist/core/__tests__/{cascade-close.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
  135. /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/cascade-close.test.d.ts} +0 -0
  136. /package/dist/core/__tests__/{detach-focus.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
  137. /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
  138. /package/dist/core/__tests__/{placement-focus.test.d.ts → full/detach-focus.test.d.ts} +0 -0
  139. /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
  140. /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-focus.test.d.ts} +0 -0
  141. /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
  142. /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-revive.test.d.ts} +0 -0
  143. /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
  144. /package/dist/core/__tests__/{spike-harness.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
@@ -5,16 +5,9 @@ import { fileURLToPath } from 'node:url';
5
5
  import { CRTR_DIR_NAME } from '../types.js';
6
6
  import { usage } from './errors.js';
7
7
  let cachedProjectRoot;
8
- export function builtinSkillsRoot() {
9
- // Resolve relative to this file: src/core/scope.ts → src/builtin-skills/ OR dist/core/scope.js → dist/builtin-skills/
10
- const thisFile = fileURLToPath(import.meta.url);
11
- const coreDir = dirname(thisFile);
12
- const pkgDir = dirname(coreDir); // src/ or dist/
13
- return join(pkgDir, 'builtin-skills');
14
- }
15
8
  export function builtinViewsRoot() {
16
- // Resolved relative to this file exactly like builtinSkillsRoot — sibling of
17
- // builtin-skills: dist/core/scope.js → dist/builtin-views/ (src/ at dev time).
9
+ // Resolved relative to this file: dist/core/scope.js dist/builtin-views/
10
+ // (src/ at dev time).
18
11
  const thisFile = fileURLToPath(import.meta.url);
19
12
  const coreDir = dirname(thisFile);
20
13
  const pkgDir = dirname(coreDir); // src/ or dist/
@@ -22,11 +15,10 @@ export function builtinViewsRoot() {
22
15
  }
23
16
  export function builtinMemoryRoot() {
24
17
  // The substrate's shipped built-in documents live in their OWN package dir,
25
- // a sibling of builtin-skills — resolved relative to this file exactly like
26
- // builtinSkillsRoot/builtinViewsRoot: dist/core/scope.js → dist/builtin-memory/
18
+ // resolved relative to this file: dist/core/scope.js → dist/builtin-memory/
27
19
  // (src/ at dev time). This is a HARD special case (design verdict m2): builtin
28
- // memory is NOT scopeRoot('builtin')/memory, which would land under
29
- // builtin-skills/ builtin's scopeRoot is builtinSkillsRoot().
20
+ // memory is the only content the builtin scope carries the scope has no
21
+ // scopeRoot (scopeRoot('builtin') is null).
30
22
  const thisFile = fileURLToPath(import.meta.url);
31
23
  const coreDir = dirname(thisFile);
32
24
  const pkgDir = dirname(coreDir); // src/ or dist/
@@ -65,8 +57,10 @@ export function projectScopeRoot(startDir) {
65
57
  return findProjectScopeRoot(startDir);
66
58
  }
67
59
  export function scopeRoot(scope) {
60
+ // The builtin scope has no scope-root dir: its only content is memory
61
+ // (builtinMemoryRoot) and views (builtinViewsRoot), both hard special cases.
68
62
  if (scope === 'builtin')
69
- return builtinSkillsRoot();
63
+ return null;
70
64
  return scope === 'user' ? userScopeRoot() : projectScopeRoot();
71
65
  }
72
66
  export function requireScopeRoot(scope) {
@@ -98,15 +92,20 @@ export function scopeSkillsDir(scope) {
98
92
  return root ? join(root, 'skills') : null;
99
93
  }
100
94
  /** Where substrate memory documents live per scope. Builtin memory is the
101
- * special case — its own package dir (builtinMemoryRoot, a sibling of
102
- * builtin-skills), NOT scopeRoot('builtin')/memory — mirroring viewsDir's
103
- * builtin handling. User and project memory live at scopeRoot(scope)/memory. */
95
+ * special case — its own package dir (builtinMemoryRoot), since the builtin
96
+ * scope has no scopeRoot — mirroring viewsDir's builtin handling. User and
97
+ * project memory live at scopeRoot(scope)/memory. */
104
98
  export function scopeMemoryDir(scope) {
105
99
  if (scope === 'builtin')
106
100
  return builtinMemoryRoot();
107
101
  const root = scope === 'user' ? userScopeRoot() : projectScopeRoot();
108
102
  return root ? join(root, 'memory') : null;
109
103
  }
104
+ /** Where a plugin's substrate memory documents live: `<plugin.root>/memory`.
105
+ * The resolver mounts these under the virtual `<pluginName>/` namespace. */
106
+ export function pluginMemoryDir(plugin) {
107
+ return join(plugin.root, 'memory');
108
+ }
110
109
  /** Where view definition dirs live per scope. Builtin views sit directly under
111
110
  * builtinViewsRoot() (no `views/` segment — they ARE the builtin views dir),
112
111
  * matching the loader's `<root>/<name>/view.mjs` resolution. */
@@ -0,0 +1,28 @@
1
+ import { type Rung, type SubstrateDoc } from './schema.js';
2
+ /** The reserved basename (no extension) that marks a directory's index doc. */
3
+ export declare const INDEX_NAME = "INDEX";
4
+ /** Does this path-derived name denote a directory INDEX (`taste/INDEX`, or a
5
+ * top-level `INDEX`)? */
6
+ export declare function isIndexName(name: string): boolean;
7
+ /** The directory an INDEX doc represents: `taste/INDEX` → `taste`, `INDEX` → ''. */
8
+ export declare function indexDirOf(name: string): string;
9
+ /** The name a doc renders under in agent-facing surfaces: an INDEX doc renders
10
+ * under its directory name (the dir entry); every other doc keeps its name. */
11
+ export declare function displayName(name: string): string;
12
+ /** The two visibility surfaces an INDEX can cap. */
13
+ export type Surface = 'systemPromptVisibility' | 'fileReadVisibility';
14
+ /** Build the dir → governing-INDEX map. Precedence is first-occurrence-wins, so
15
+ * callers MUST pass docs precedence-ordered (project > user > builtin) — the
16
+ * highest-precedence INDEX governs a dir, mirroring the memory resolver. */
17
+ export declare function buildCeilingIndex(docs: SubstrateDoc[]): Map<string, SubstrateDoc>;
18
+ /** A doc's effective rung on `surface` after applying every ancestor INDEX
19
+ * ceiling: min(own rung, each ancestor INDEX's rung). A `none` ancestor INDEX
20
+ * hides the whole subtree (min with none = none). */
21
+ export declare function effectiveRung(doc: SubstrateDoc, ceil: Map<string, SubstrateDoc>, surface: Surface): Rung;
22
+ /** Apply ceilings to a doc list for a surface: each doc gets its effective rung
23
+ * on `surface` written back into that field, and INDEX docs are renamed to
24
+ * their dir entry. Returns NEW objects only where something changed (cheap,
25
+ * preserves identity for untouched docs). Does NOT drop `none`-rung docs — the
26
+ * caller filters per surface (boot drops them; node-local intentionally keeps
27
+ * them). */
28
+ export declare function applyCeilings(docs: SubstrateDoc[], surface: Surface): SubstrateDoc[];
@@ -0,0 +1,87 @@
1
+ // ceiling.ts — INDEX.md dir-level entries and the subtree rung ceiling.
2
+ //
3
+ // A directory under a memory store may carry an `INDEX.md` with the SAME
4
+ // substrate frontmatter as any doc. That file makes the directory a first-class
5
+ // substrate entry: it renders as ONE entry at dir level (headed by the dir
6
+ // name, not `…/INDEX`), and its rung is a CEILING for everything in its subtree.
7
+ //
8
+ // effective rung of a descendant = min(own rung, every ancestor INDEX's rung)
9
+ //
10
+ // So a dir's INDEX at `preview` lets its files surface at most at `preview`; an
11
+ // INDEX at `none` hides the whole subtree. This is the single, explicit
12
+ // mechanism for collapsing a directory in agent-facing surfaces (boot render +
13
+ // on-read) — there is no parallel auto-hiding heuristic. See design taste:
14
+ // `crtr memory read taste/document-substrate`.
15
+ import { rungRank } from './schema.js';
16
+ /** The reserved basename (no extension) that marks a directory's index doc. */
17
+ export const INDEX_NAME = 'INDEX';
18
+ /** Does this path-derived name denote a directory INDEX (`taste/INDEX`, or a
19
+ * top-level `INDEX`)? */
20
+ export function isIndexName(name) {
21
+ return name === INDEX_NAME || name.endsWith('/' + INDEX_NAME);
22
+ }
23
+ /** The directory an INDEX doc represents: `taste/INDEX` → `taste`, `INDEX` → ''. */
24
+ export function indexDirOf(name) {
25
+ return name === INDEX_NAME ? '' : name.slice(0, name.length - INDEX_NAME.length - 1);
26
+ }
27
+ /** The name a doc renders under in agent-facing surfaces: an INDEX doc renders
28
+ * under its directory name (the dir entry); every other doc keeps its name. */
29
+ export function displayName(name) {
30
+ return isIndexName(name) ? indexDirOf(name) : name;
31
+ }
32
+ /** Build the dir → governing-INDEX map. Precedence is first-occurrence-wins, so
33
+ * callers MUST pass docs precedence-ordered (project > user > builtin) — the
34
+ * highest-precedence INDEX governs a dir, mirroring the memory resolver. */
35
+ export function buildCeilingIndex(docs) {
36
+ const m = new Map();
37
+ for (const d of docs) {
38
+ if (!isIndexName(d.name))
39
+ continue;
40
+ const dir = indexDirOf(d.name);
41
+ if (!m.has(dir))
42
+ m.set(dir, d);
43
+ }
44
+ return m;
45
+ }
46
+ function minRung(a, b) {
47
+ return rungRank(a) <= rungRank(b) ? a : b;
48
+ }
49
+ /** Ancestor directory names of a doc, nearest-last: `a/b/c` → [`a/b`, `a`];
50
+ * `a` → []. An INDEX doc's OWN dir is included (a self-cap is a no-op), so a
51
+ * nested INDEX still inherits its parents' ceilings. */
52
+ function ancestorDirs(name) {
53
+ const parts = name.split('/');
54
+ parts.pop(); // drop the leaf
55
+ const out = [];
56
+ for (let i = parts.length; i > 0; i--)
57
+ out.push(parts.slice(0, i).join('/'));
58
+ return out;
59
+ }
60
+ /** A doc's effective rung on `surface` after applying every ancestor INDEX
61
+ * ceiling: min(own rung, each ancestor INDEX's rung). A `none` ancestor INDEX
62
+ * hides the whole subtree (min with none = none). */
63
+ export function effectiveRung(doc, ceil, surface) {
64
+ let rung = doc[surface];
65
+ for (const dir of ancestorDirs(doc.name)) {
66
+ const idx = ceil.get(dir);
67
+ if (idx)
68
+ rung = minRung(rung, idx[surface]);
69
+ }
70
+ return rung;
71
+ }
72
+ /** Apply ceilings to a doc list for a surface: each doc gets its effective rung
73
+ * on `surface` written back into that field, and INDEX docs are renamed to
74
+ * their dir entry. Returns NEW objects only where something changed (cheap,
75
+ * preserves identity for untouched docs). Does NOT drop `none`-rung docs — the
76
+ * caller filters per surface (boot drops them; node-local intentionally keeps
77
+ * them). */
78
+ export function applyCeilings(docs, surface) {
79
+ const ceil = buildCeilingIndex(docs);
80
+ return docs.map((d) => {
81
+ const rung = effectiveRung(d, ceil, surface);
82
+ const name = displayName(d.name);
83
+ if (rung === d[surface] && name === d.name)
84
+ return d;
85
+ return { ...d, [surface]: rung, name };
86
+ });
87
+ }
@@ -3,5 +3,7 @@ export type { DocKind, Rung, GatePredicate, SubstrateSchema, SubstrateDoc } from
3
3
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
4
4
  export type { NodeConfigSubject } from './subject.js';
5
5
  export { gatePasses } from './gate.js';
6
+ export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, applyCeilings, } from './ceiling.js';
7
+ export type { Surface } from './ceiling.js';
6
8
  export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock } from './render.js';
7
9
  export { renderOnReadDocs } from './on-read.js';
@@ -14,5 +14,6 @@ KIND_DEFAULT_RUNGS,
14
14
  parseSubstrateFrontmatter, parseSubstrateDoc, previewLine, } from './schema.js';
15
15
  export { scopeForCwd, spineDepth, assembleNodeSubject } from './subject.js';
16
16
  export { gatePasses } from './gate.js';
17
+ export { INDEX_NAME, isIndexName, indexDirOf, displayName, buildCeilingIndex, effectiveRung, applyCeilings, } from './ceiling.js';
17
18
  export { renderSkillsSection, renderPreferencesSection, renderReferencesBlock } from './render.js';
18
19
  export { renderOnReadDocs } from './on-read.js';
@@ -36,7 +36,7 @@ import { CRTR_DIR_NAME } from '../../types.js';
36
36
  import { pathExists, readText, walkFiles } from '../fs-utils.js';
37
37
  import { parseFrontmatterGeneric } from '../frontmatter.js';
38
38
  import { listAllMemoryDocs } from '../memory-resolver.js';
39
- import { assembleNodeSubject, gatePasses, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, } from './index.js';
39
+ import { assembleNodeSubject, buildCeilingIndex, displayName, effectiveRung, gatePasses, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, } from './index.js';
40
40
  import { cachedSubstrateDocs } from './session-cache.js';
41
41
  // Ancestor dirs we never look inside for a `.crouter/memory/` store (the read
42
42
  // file may live under a build/dependency tree; `.crouter` is NOT junk here — it
@@ -264,6 +264,18 @@ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
264
264
  // Outermost-first: the nearest/most-specific doc reads last — closest to the
265
265
  // file content that follows it (the applies-to set, order -1, trails).
266
266
  candidates.sort((a, b) => b.order - a.order);
267
+ // INDEX ceiling: build the dir → governing-INDEX map over the whole resolved
268
+ // corpus PLUS the candidate docs, so a dir's INDEX caps (or `none`-hides) its
269
+ // subtree on-read exactly as it does at boot. The candidate's own
270
+ // fileReadVisibility and name are overridden by the effective rung / dir entry.
271
+ let ceil;
272
+ try {
273
+ const resolved = cachedSubstrateDocs(listAllMemoryDocs, parseSubstrateDoc);
274
+ ceil = buildCeilingIndex([...resolved, ...candidates.map((c) => c.doc)]);
275
+ }
276
+ catch {
277
+ ceil = buildCeilingIndex(candidates.map((c) => c.doc));
278
+ }
267
279
  const rendered = [];
268
280
  for (const c of candidates) {
269
281
  // Never re-surface the doc the agent is literally reading.
@@ -276,7 +288,11 @@ export function renderOnReadDocs(nodeId, readFilePath, seen = new Set()) {
276
288
  try {
277
289
  if (!gatePasses(c.doc, subject))
278
290
  continue; // gated out for this node
279
- block = renderDocEnvelope(c.doc, absReadFile);
291
+ const rung = effectiveRung(c.doc, ceil, 'fileReadVisibility');
292
+ const doc = rung === c.doc.fileReadVisibility && displayName(c.doc.name) === c.doc.name
293
+ ? c.doc
294
+ : { ...c.doc, fileReadVisibility: rung, name: displayName(c.doc.name) };
295
+ block = renderDocEnvelope(doc, absReadFile);
280
296
  }
281
297
  catch {
282
298
  continue; // a single bad doc never breaks the read
@@ -1,8 +1,7 @@
1
1
  /** The `## Skills` system-prompt section: every eligible `kind: skill` doc,
2
2
  * rendered at its `system-prompt-visibility`. `name`-rung skills collapse into
3
- * one compact catalog (group-collapsed, unioned with plugin/marketplace
4
- * skills); `preview`/`content`-rung skills each get a `###` sub-section.
5
- * Returns '' when nothing is eligible. */
3
+ * one compact, plugin-grouped catalog; `preview`/`content`-rung skills each get
4
+ * a `###` sub-section. Returns '' when nothing is eligible. */
6
5
  export declare function renderSkillsSection(nodeId: string): string;
7
6
  /** The `## Preferences` system-prompt section: every eligible `kind: preference`
8
7
  * doc as its own `###` sub-section, at its `system-prompt-visibility` (the
@@ -14,8 +13,7 @@ export declare function renderPreferencesSection(nodeId: string): string;
14
13
  * drops it when ''). Holds every eligible `kind: reference` resolver doc at its
15
14
  * `system-prompt-visibility` (reference boot default is `none`, so only
16
15
  * author-promoted references show) PLUS the node-local memory docs (any kind),
17
- * each a `###` sub-section. Replaces the old `<memory>` block. Returns '' when
18
- * nothing is eligible.
16
+ * each a `###` sub-section. Returns '' when nothing is eligible.
19
17
  *
20
18
  * DEFENSIVE: each doc is rendered in its own try/catch so a single malformed
21
19
  * doc drops only itself (with a loud stderr warning naming the offending path),
@@ -5,8 +5,7 @@
5
5
  // (renderPreferencesSection) — strings the D2 `before_agent_start` extension
6
6
  // splices into the system prompt (built by a sibling AFTER this module);
7
7
  // • the `<crtr-context>` half — `## References` (renderReferencesBlock) — the
8
- // string wired into bearings.ts's session_start message, REPLACING the old
9
- // `<memory>` block.
8
+ // string wired into bearings.ts's session_start message.
10
9
  //
11
10
  // Every doc flows through the same pipeline (design §4/§6/§9):
12
11
  // MemoryDoc → parseSubstrateDoc → (null-filter non-substrate) →
@@ -19,14 +18,13 @@
19
18
  // null for a non-substrate doc and is `.filter()`ed out; per-file loads are
20
19
  // wrapped so one bad file is skipped, not fatal.
21
20
  import { relative, sep } from 'node:path';
22
- import { SCOPE_SKILL_PLUGIN } from '../../types.js';
23
- import { listAllPlugins, listAllSkills } from '../resolver.js';
21
+ import { listAllPlugins } from '../resolver.js';
24
22
  import { renderCatalogSection } from '../../commands/skill/shared.js';
25
23
  import { listAllMemoryDocs } from '../memory-resolver.js';
26
24
  import { parseFrontmatterGeneric } from '../frontmatter.js';
27
25
  import { pathExists, readText, walkFiles } from '../fs-utils.js';
28
26
  import { memoryDir } from '../runtime/memory.js';
29
- import { assembleNodeSubject, gatePasses, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, } from './index.js';
27
+ import { applyCeilings, assembleNodeSubject, gatePasses, isIndexName, parseSubstrateDoc, parseSubstrateFrontmatter, previewLine, } from './index.js';
30
28
  import { cachedAllPlugins, cachedNodeSubject, cachedSubstrateDocs, } from './session-cache.js';
31
29
  // ---------------------------------------------------------------------------
32
30
  // The shared per-doc pipeline.
@@ -36,7 +34,12 @@ import { cachedAllPlugins, cachedNodeSubject, cachedSubstrateDocs, } from './ses
36
34
  * system-prompt rung above `none`. Resolver = user + project + builtin scopes
37
35
  * (precedence-ordered); node-local is loaded separately (see nodeLocalDocs).
38
36
  * Uses the per-session cache so the full corpus is scanned+parsed at most once
39
- * per session across the three boot-render calls. */
37
+ * per session across the three boot-render calls.
38
+ *
39
+ * Ceilings are applied over the WHOLE corpus (cross-kind) BEFORE the kind
40
+ * filter: an INDEX.md renders as its dir entry (`taste`, not `taste/INDEX`) at
41
+ * its own rung, and every descendant's rung is capped by its ancestor INDEX
42
+ * rungs — a `none` dir hides its whole subtree. */
40
43
  function resolverDocs(subject, kind) {
41
44
  let docs;
42
45
  try {
@@ -45,19 +48,25 @@ function resolverDocs(subject, kind) {
45
48
  catch {
46
49
  return [];
47
50
  }
48
- return docs
51
+ return applyCeilings(docs, 'systemPromptVisibility')
49
52
  .filter((d) => d.kind === kind)
50
53
  .filter((d) => gatePasses(d, subject))
51
- .filter((d) => d.systemPromptVisibility !== 'none');
54
+ .filter((d) => d.systemPromptVisibility !== 'none')
55
+ // Re-sort after the INDEX rename (taste/INDEX → taste) so a dir entry sorts
56
+ // immediately BEFORE its children, keeping scope precedence (project > user
57
+ // > builtin) intact.
58
+ .sort((a, b) => scopeRank(a.scope) - scopeRank(b.scope) || a.name.localeCompare(b.name));
59
+ }
60
+ function scopeRank(scope) {
61
+ return scope === 'project' ? 0 : scope === 'user' ? 1 : 2;
52
62
  }
53
63
  /** The node-local memory docs eligible at boot. Node-local memory lives in the
54
64
  * canvas home (`nodes/<id>/context/memory/`) and is OUTSIDE the scope resolver,
55
65
  * so it is loaded directly here, its raw frontmatter run through
56
66
  * parseSubstrateFrontmatter (mirroring the resolver pipeline), then gate-passed.
57
- * Old-format / non-substrate files (incl. the MEMORY.md index, which has no
58
- * `kind`) parse to null and drop out. Returned across ALL kinds — node-local is
59
- * the catch-all this-node store and rides into the references block as the
60
- * replacement for the old node-local `<memory>` stanza.
67
+ * Non-substrate files (those with no `kind`) parse to null and drop out.
68
+ * Returned across ALL kinds — node-local is the catch-all this-node store and
69
+ * rides into the references block.
61
70
  *
62
71
  * IMPORTANT: node-local docs are NOT filtered by `systemPromptVisibility` rung.
63
72
  * A migrated node-local reference defaults to rung `none`, which would make it
@@ -116,50 +125,49 @@ function renderSubSection(d) {
116
125
  // ---------------------------------------------------------------------------
117
126
  // 1. Skills section — `## Skills` (system prompt).
118
127
  // ---------------------------------------------------------------------------
119
- /** The compact, group-collapsed `name`-rung catalog: the UNION of name-rung
120
- * substrate `skill` docs (the migrated scope-local + builtin skills, rendered
121
- * as scope-local `_` entries) WITH plugin/marketplace skills (which stay
122
- * resolver-provided, NOT migrated named-plugin skills at user/project scope;
123
- * the SCOPE_SKILL_PLUGIN sentinel and builtin scope are EXCLUDED because they
124
- * become substrate docs). Reuses skill/shared.ts's renderCatalogSection
125
- * group-collapse so the boot catalog matches today's `<skills count=N>` shape.
126
- * Returns '' when nothing is in the catalog. */
127
- function renderSkillCatalog(nameRungSkillDocs) {
128
- let pluginSkills;
129
- try {
130
- pluginSkills = listAllSkills().filter((s) => s.enabled && s.plugin !== SCOPE_SKILL_PLUGIN && s.scope !== 'builtin');
131
- }
132
- catch {
133
- pluginSkills = [];
134
- }
135
- const entries = [
136
- ...pluginSkills.map((s) => ({ scope: s.scope, plugin: s.plugin, name: s.name })),
137
- ...nameRungSkillDocs.map((d) => ({ scope: d.scope, plugin: SCOPE_SKILL_PLUGIN, name: d.name })),
138
- ];
139
- if (entries.length === 0)
140
- return '';
141
- // Group by scope+plugin → forest-root names (drop nested children), mirroring
142
- // buildSkillCatalog's source construction.
128
+ /** The compact, group-collapsed `name`-rung catalog of substrate `skill` docs.
129
+ * Every leaf is a migrated/generated substrate doc (native, builtin, or a
130
+ * plugin's `<pluginName>/` subtree) there is no second, resolver-provided
131
+ * skill corpus. Each doc self-groups by its top-dir segment: a name with a
132
+ * slash sources to its top segment (the plugin name); a bare name sources to
133
+ * '' (a scope-local native/builtin skill). A plugin whose INDEX is elevated to
134
+ * preview/content renders as its own `### <plugin>` subsection, so its catalog
135
+ * group is dropped here (via `elevatedSources`) to avoid a double render.
136
+ * Reuses skill/shared.ts's renderCatalogSection group-collapse. Returns ''
137
+ * when nothing is in the catalog. */
138
+ function renderSkillCatalog(nameRungSkillDocs, elevatedSources) {
143
139
  const bySource = new Map();
144
- for (const e of entries) {
145
- const key = `${e.scope}\t${e.plugin}`;
146
- const arr = bySource.get(key);
147
- if (arr)
148
- arr.push(e);
140
+ for (const d of nameRungSkillDocs) {
141
+ // INDEX docs are structural ceilings (already renamed to their dir entry by
142
+ // applyCeilings), never catalog leaves — drop defensively.
143
+ if (isIndexName(d.name))
144
+ continue;
145
+ const slash = d.name.indexOf('/');
146
+ const plugin = slash === -1 ? '' : d.name.slice(0, slash);
147
+ const leaf = slash === -1 ? d.name : d.name.slice(slash + 1);
148
+ // A plugin represented by its own elevated ### subsection is dropped from the
149
+ // catalog so it is not rendered twice.
150
+ if (plugin !== '' && elevatedSources.has(plugin))
151
+ continue;
152
+ const key = `${d.scope}\t${plugin}`;
153
+ const src = bySource.get(key);
154
+ if (src)
155
+ src.roots.push(leaf);
149
156
  else
150
- bySource.set(key, [e]);
157
+ bySource.set(key, { scope: d.scope, plugin, roots: [leaf] });
151
158
  }
159
+ if (bySource.size === 0)
160
+ return '';
152
161
  const projectSources = [];
153
162
  const userSources = [];
154
- for (const [key, group] of bySource) {
155
- const [scope, plugin] = key.split('\t');
156
- const names = group.map((g) => g.name);
157
- const roots = names
158
- .filter((n) => !names.some((m) => m !== n && n.startsWith(m + '/')))
163
+ for (const { scope, plugin, roots } of bySource.values()) {
164
+ // Drop nested children so each source contributes only its top-level skills.
165
+ const top = roots
166
+ .filter((n) => !roots.some((m) => m !== n && n.startsWith(m + '/')))
159
167
  .sort();
160
- if (roots.length === 0)
168
+ if (top.length === 0)
161
169
  continue;
162
- (scope === 'project' ? projectSources : userSources).push({ plugin, roots });
170
+ (scope === 'project' ? projectSources : userSources).push({ plugin, roots: top });
163
171
  }
164
172
  const descriptions = new Map();
165
173
  try {
@@ -182,19 +190,21 @@ function renderSkillCatalog(nameRungSkillDocs) {
182
190
  }
183
191
  /** The `## Skills` system-prompt section: every eligible `kind: skill` doc,
184
192
  * rendered at its `system-prompt-visibility`. `name`-rung skills collapse into
185
- * one compact catalog (group-collapsed, unioned with plugin/marketplace
186
- * skills); `preview`/`content`-rung skills each get a `###` sub-section.
187
- * Returns '' when nothing is eligible. */
193
+ * one compact, plugin-grouped catalog; `preview`/`content`-rung skills each get
194
+ * a `###` sub-section. Returns '' when nothing is eligible. */
188
195
  export function renderSkillsSection(nodeId) {
189
196
  const subject = cachedNodeSubject(nodeId, assembleNodeSubject);
190
197
  if (subject === null)
191
198
  return '';
192
199
  const skills = resolverDocs(subject, 'skill');
193
- const catalog = renderSkillCatalog(skills.filter((d) => d.systemPromptVisibility === 'name'));
194
- const elevated = skills
195
- .filter((d) => d.systemPromptVisibility !== 'name')
196
- .map(renderSubSection);
197
- const blocks = [catalog, ...elevated].filter((s) => s !== '');
200
+ const elevated = skills.filter((d) => d.systemPromptVisibility !== 'name');
201
+ // A plugin whose INDEX is elevated surfaces as its own `### <plugin>`
202
+ // subsection (its display name is the plugin/dir name after ceiling rename);
203
+ // the catalog drops that plugin's group so it is not rendered twice.
204
+ const elevatedSources = new Set(elevated.map((d) => d.name));
205
+ const catalog = renderSkillCatalog(skills.filter((d) => d.systemPromptVisibility === 'name'), elevatedSources);
206
+ const elevatedBlocks = elevated.map(renderSubSection);
207
+ const blocks = [catalog, ...elevatedBlocks].filter((s) => s !== '');
198
208
  if (blocks.length === 0)
199
209
  return '';
200
210
  return `## Skills\n\n${blocks.join('\n\n')}`;
@@ -225,8 +235,7 @@ export function renderPreferencesSection(nodeId) {
225
235
  * drops it when ''). Holds every eligible `kind: reference` resolver doc at its
226
236
  * `system-prompt-visibility` (reference boot default is `none`, so only
227
237
  * author-promoted references show) PLUS the node-local memory docs (any kind),
228
- * each a `###` sub-section. Replaces the old `<memory>` block. Returns '' when
229
- * nothing is eligible.
238
+ * each a `###` sub-section. Returns '' when nothing is eligible.
230
239
  *
231
240
  * DEFENSIVE: each doc is rendered in its own try/catch so a single malformed
232
241
  * doc drops only itself (with a loud stderr warning naming the offending path),
@@ -21,15 +21,17 @@ export declare const KIND_DEFAULT_RUNGS: Record<DocKind, {
21
21
  * `all`/`any`/`not` combinators (design §4). */
22
22
  export type GatePredicate = Record<string, unknown>;
23
23
  /** The frontmatter-derived schema of a substrate document, with kind-aware
24
- * defaults applied. Required fields (`kind`/`when`/`why`) and optionals all
25
- * resolved to concrete typed values. */
24
+ * defaults applied. Required fields (`kind`/`when-and-why-to-read`) and
25
+ * optionals all resolved to concrete typed values. */
26
26
  export interface SubstrateSchema {
27
27
  /** Which of the three semantic kinds. */
28
28
  kind: DocKind;
29
- /** Routing condition"When you are in a situation like X…" (design §4). */
30
- when: string;
31
- /** Payoff "…because Z." Half of the generated preview line. */
32
- why: string;
29
+ /** The read-routing line a single sentence answering WHEN to read this doc
30
+ * and WHY it is worth the read: "When <circumstance>, this <kind> should be
31
+ * read <because <payoff>>." (design §4). This is read-routing why an agent
32
+ * should spend the read — NEVER justification of why the content should be
33
+ * obeyed. It IS the preview verbatim. Frontmatter key `when-and-why-to-read`. */
34
+ whenAndWhyToRead: string;
33
35
  /** Human-facing abbreviation for `crtr memory list`. NEVER loaded into an
34
36
  * agent's context (design §3). Empty string when absent. */
35
37
  shortForm: string;
@@ -62,15 +64,18 @@ export interface SubstrateDoc extends SubstrateSchema {
62
64
  * resolver) into a typed schema with defaults applied. Returns `null` when the
63
65
  * record is absent or carries no valid `kind` — i.e. it is not a substrate
64
66
  * document and cannot be classified or defaulted. Tolerant of every other
65
- * imperfection (missing `when`/`why` default to '', a bad rung falls back to
66
- * the kind default), so a renderer mapping over many docs never throws. */
67
+ * imperfection (a missing `when-and-why-to-read` defaults to '', a bad rung
68
+ * falls back to the kind default), so a renderer mapping over many docs never
69
+ * throws. Authoring-time enforcement of the field lives in `crtr memory lint`. */
67
70
  export declare function parseSubstrateFrontmatter(fm: Record<string, unknown> | null): SubstrateSchema | null;
68
71
  /** Parse a resolved MemoryDoc into a fully-typed SubstrateDoc (schema + the
69
72
  * resolver's name/scope/path/body). Returns `null` for a non-substrate doc
70
73
  * (no valid `kind`), so callers can `docs.map(parseSubstrateDoc).filter(...)`. */
71
74
  export declare function parseSubstrateDoc(doc: MemoryDoc): SubstrateDoc | null;
72
- /** The generated `preview`-rung routing line (design §4), composed from the two
73
- * required prose fields and the kind: `"{when}, read this {kind}. {why}."`.
74
- * Both boot and on-read render render this identical line, so it lives once
75
- * here to prevent drift. Light cleanup avoids doubled punctuation. */
76
- export declare function previewLine(doc: Pick<SubstrateSchema, 'kind' | 'when' | 'why'>): string;
75
+ /** The `preview`-rung routing line (design §4): the `when-and-why-to-read`
76
+ * field rendered essentially verbatim it is already authored as the complete
77
+ * routing sentence ("When …, this <kind> should be read …"), so there is no
78
+ * template to compose. Both boot and on-read render this identical line, so it
79
+ * lives once here to prevent drift. Light cleanup normalizes the trailing
80
+ * period. */
81
+ export declare function previewLine(doc: Pick<SubstrateSchema, 'whenAndWhyToRead'>): string;
@@ -50,8 +50,9 @@ export const KIND_DEFAULT_RUNGS = {
50
50
  * resolver) into a typed schema with defaults applied. Returns `null` when the
51
51
  * record is absent or carries no valid `kind` — i.e. it is not a substrate
52
52
  * document and cannot be classified or defaulted. Tolerant of every other
53
- * imperfection (missing `when`/`why` default to '', a bad rung falls back to
54
- * the kind default), so a renderer mapping over many docs never throws. */
53
+ * imperfection (a missing `when-and-why-to-read` defaults to '', a bad rung
54
+ * falls back to the kind default), so a renderer mapping over many docs never
55
+ * throws. Authoring-time enforcement of the field lives in `crtr memory lint`. */
55
56
  export function parseSubstrateFrontmatter(fm) {
56
57
  if (fm === null)
57
58
  return null;
@@ -61,8 +62,7 @@ export function parseSubstrateFrontmatter(fm) {
61
62
  const defaults = KIND_DEFAULT_RUNGS[kind];
62
63
  return {
63
64
  kind,
64
- when: strField(fm.when),
65
- why: strField(fm.why),
65
+ whenAndWhyToRead: strField(fm['when-and-why-to-read']),
66
66
  shortForm: strField(fm['short-form']),
67
67
  systemPromptVisibility: parseRung(fm['system-prompt-visibility'], defaults.systemPrompt),
68
68
  fileReadVisibility: parseRung(fm['file-read-visibility'], defaults.fileRead),
@@ -79,14 +79,15 @@ export function parseSubstrateDoc(doc) {
79
79
  return null;
80
80
  return { ...schema, name: doc.name, scope: doc.scope, path: doc.path, body: doc.body };
81
81
  }
82
- /** The generated `preview`-rung routing line (design §4), composed from the two
83
- * required prose fields and the kind: `"{when}, read this {kind}. {why}."`.
84
- * Both boot and on-read render render this identical line, so it lives once
85
- * here to prevent drift. Light cleanup avoids doubled punctuation. */
82
+ /** The `preview`-rung routing line (design §4): the `when-and-why-to-read`
83
+ * field rendered essentially verbatim it is already authored as the complete
84
+ * routing sentence ("When …, this <kind> should be read …"), so there is no
85
+ * template to compose. Both boot and on-read render this identical line, so it
86
+ * lives once here to prevent drift. Light cleanup normalizes the trailing
87
+ * period. */
86
88
  export function previewLine(doc) {
87
- const when = doc.when.trim().replace(/[.,]+$/, '');
88
- const why = doc.why.trim().replace(/\.+$/, '');
89
- return `${when}, read this ${doc.kind}. ${why}.`;
89
+ const line = doc.whenAndWhyToRead.trim().replace(/\.+$/, '');
90
+ return line === '' ? '' : `${line}.`;
90
91
  }
91
92
  // ---------------------------------------------------------------------------
92
93
  // Field coercion helpers (private).
@@ -15,9 +15,11 @@
15
15
  //
16
16
  // The block carries: the path to the node's own context dir and the framing for
17
17
  // what belongs there (a shared document store for the other nodes). EVERY node
18
- // also gets a <memory> block merging the indexes of its three scoped memory
19
- // stores (user-global, project, node-local), each labeled with its dir — one
20
- // consistent surface, since every node is born with all three stores. An
18
+ // also gets a `## References` block rendered from the document substrate
19
+ // eligible `reference` docs at their system-prompt visibility rung, plus the
20
+ // node's own node-local substrate docs so the bearings name what to read on
21
+ // demand. (Skills and preferences surface as their own `## Skills` /
22
+ // `## Preferences` sections of the system prompt, not in this block.) An
21
23
  // orchestrator additionally gets the across-refresh-cycles framing (the one
22
24
  // thing a terminal worker's bearings drop). The prose lives in
23
25
  // core/runtime/bearings.ts (shared with the promotion guidance dump).
@@ -43,8 +43,24 @@ import { getNode } from '../core/canvas/index.js';
43
43
  // one watcher is live. Pattern copied verbatim from agent-inbox-watcher.ts.
44
44
  // ---------------------------------------------------------------------------
45
45
  let liveTimer;
46
- const TICK_MS = 800; // polling cadence
47
- const DEBOUNCE_MS = 1000; // flush once the burst has been quiet for this long
46
+ const DEFAULT_TICK_MS = 800; // polling cadence
47
+ const DEFAULT_DEBOUNCE_MS = 1000; // flush once the burst has been quiet for this long
48
+ // Testability seam: a test can inject a much shorter cadence so it doesn't have
49
+ // to sleep against the real ~800ms/1000ms wall-clock timing. Read per-registration
50
+ // (not as an import-time module const) so each registerCanvasInboxWatcher() call
51
+ // picks up the env current at that moment. Unset → production defaults.
52
+ function resolveCadence() {
53
+ const parse = (raw, fallback) => {
54
+ if (raw === undefined)
55
+ return fallback;
56
+ const n = Number(raw);
57
+ return Number.isFinite(n) && n > 0 ? n : fallback;
58
+ };
59
+ return {
60
+ tickMs: parse(process.env['CRTR_WATCHER_TICK_MS'], DEFAULT_TICK_MS),
61
+ debounceMs: parse(process.env['CRTR_WATCHER_DEBOUNCE_MS'], DEFAULT_DEBOUNCE_MS),
62
+ };
63
+ }
48
64
  // ---------------------------------------------------------------------------
49
65
  // Extension
50
66
  // ---------------------------------------------------------------------------
@@ -56,6 +72,7 @@ const DEBOUNCE_MS = 1000; // flush once the burst has been quiet for this long
56
72
  * the module-level liveTimer guard is the actual stacking prevention.
57
73
  */
58
74
  export function registerCanvasInboxWatcher(pi) {
75
+ const { tickMs: TICK_MS, debounceMs: DEBOUNCE_MS } = resolveCadence();
59
76
  // Capture the latest event context so isIdle() is readable inside the timer
60
77
  // callback, which has no ctx of its own.
61
78
  let lastCtx;