@north-light/crouter 0.3.219 → 0.3.221

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 (155) hide show
  1. package/dist/api/client.d.ts +9 -0
  2. package/dist/api/client.js +10 -0
  3. package/dist/api/dto/chat-inventory.d.ts +43 -0
  4. package/dist/api/dto/chat-inventory.js +11 -0
  5. package/dist/api/dto/profiles.d.ts +19 -5
  6. package/dist/api/dto/profiles.js +2 -1
  7. package/dist/api/index.d.ts +1 -0
  8. package/dist/api/index.js +1 -0
  9. package/dist/api/routes.d.ts +1 -0
  10. package/dist/api/routes.js +1 -0
  11. package/dist/build-root.d.ts +2 -6
  12. package/dist/build-root.js +51 -4
  13. package/dist/builtin-memory/internal/agent-shaping.md +3 -1
  14. package/dist/builtin-memory/internal/memory-loading.md +4 -0
  15. package/dist/builtin-memory/plan/roadmap.md +7 -1
  16. package/dist/builtin-memory/spec/guide.md +7 -1
  17. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/claude-plugin-commands.ts +1 -1
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crtr-commands/index.ts +7 -1
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +7 -2
  20. package/dist/clients/attach/__tests__/ref-autocomplete.test.js +1 -1
  21. package/dist/clients/attach/__tests__/titled-editor-preview.test.js +1 -1
  22. package/dist/clients/attach/overlays/file-review.js +2 -2
  23. package/dist/clients/attach/session/keys.d.ts +1 -1
  24. package/dist/clients/attach/session/profile-files.js +1 -1
  25. package/dist/clients/attach/viewer.js +690 -690
  26. package/dist/clients/inbox/review/launch.d.ts +8 -4
  27. package/dist/clients/inbox/review/launch.js +55 -5
  28. package/dist/clients/inbox/review/review-client.d.ts +1 -0
  29. package/dist/clients/inbox/review/review-client.js +4 -0
  30. package/dist/clients/inbox/review-adapter.d.ts +1 -8
  31. package/dist/clients/inbox/review-adapter.js +4 -52
  32. package/dist/commands/memory/lint.js +2 -1
  33. package/dist/commands/memory/read.js +1 -0
  34. package/dist/commands/memory.js +1 -1
  35. package/dist/commands/pkg/market-manage.js +165 -75
  36. package/dist/commands/pkg/plugin-inspect.js +19 -2
  37. package/dist/commands/pkg/plugin-manage.d.ts +8 -3
  38. package/dist/commands/pkg/plugin-manage.js +72 -24
  39. package/dist/commands/profile/default.js +6 -10
  40. package/dist/commands/profile/list.js +5 -3
  41. package/dist/commands/profile/new.js +21 -8
  42. package/dist/commands/profile/project.js +25 -19
  43. package/dist/commands/profile/show.js +3 -3
  44. package/dist/commands/surface-inbox.js +1 -0
  45. package/dist/commands/sys/__tests__/migrate.test.js +16 -5
  46. package/dist/commands/sys/doctor.js +35 -5
  47. package/dist/commands/sys/migrate.js +38 -19
  48. package/dist/commands/sys/panels/broker-limits-panel.js +3 -3
  49. package/dist/commands/sys/setup-core.js +1 -1
  50. package/dist/commands/sys/sync-project-guidance.js +1 -1
  51. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +1 -1
  52. package/dist/core/__tests__/fixtures/c5-command-boundary-ext.js +24 -0
  53. package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -18
  54. package/dist/core/__tests__/fixtures/fake-engine.js +8 -1
  55. package/dist/core/__tests__/helpers/broker-clients.d.ts +1 -0
  56. package/dist/core/__tests__/helpers/broker-clients.js +1 -0
  57. package/dist/core/__tests__/inline-memory-refs.test.js +36 -2
  58. package/dist/core/__tests__/profile-project-memory-delivery.test.js +217 -0
  59. package/dist/core/__tests__/seam/dormancy-release.test.js +37 -1
  60. package/dist/core/__tests__/serial/broker-sdk-wiring.test.js +102 -2
  61. package/dist/core/bootstrap.js +6 -0
  62. package/dist/core/canvas/browse/app.js +5 -2
  63. package/dist/core/canvas/browse/model.d.ts +25 -15
  64. package/dist/core/canvas/browse/model.js +86 -65
  65. package/dist/core/canvas/render-source.d.ts +6 -0
  66. package/dist/core/canvas/render-source.js +7 -1
  67. package/dist/core/canvas/render.js +10 -2
  68. package/dist/core/command-hooks/artifact.d.ts +10 -0
  69. package/dist/core/command-hooks/artifact.js +129 -0
  70. package/dist/core/command-hooks/catalog.d.ts +14 -0
  71. package/dist/core/command-hooks/catalog.js +38 -0
  72. package/dist/core/command-hooks/compose.d.ts +15 -0
  73. package/dist/core/command-hooks/compose.js +99 -0
  74. package/dist/core/command-hooks/discovery.d.ts +87 -0
  75. package/dist/core/command-hooks/discovery.js +174 -0
  76. package/dist/core/command-hooks/help.d.ts +5 -0
  77. package/dist/core/command-hooks/help.js +18 -0
  78. package/dist/core/command-hooks/index.d.ts +6 -0
  79. package/dist/core/command-hooks/index.js +6 -0
  80. package/dist/core/command-hooks/report.d.ts +23 -0
  81. package/dist/core/command-hooks/report.js +19 -0
  82. package/dist/core/command-hooks/schema.d.ts +27 -0
  83. package/dist/core/command-hooks/schema.js +68 -0
  84. package/dist/core/command-hooks/transport/exec-invoke.d.ts +22 -0
  85. package/dist/core/command-hooks/transport/exec-invoke.js +274 -0
  86. package/dist/core/command-plugins/presence.d.ts +2 -0
  87. package/dist/core/command-plugins/presence.js +17 -0
  88. package/dist/core/command-plugins/transport/exec-invoke.d.ts +5 -0
  89. package/dist/core/command-plugins/transport/exec-invoke.js +58 -5
  90. package/dist/core/command.d.ts +8 -1
  91. package/dist/core/command.js +12 -10
  92. package/dist/core/help.d.ts +7 -1
  93. package/dist/core/io.d.ts +9 -1
  94. package/dist/core/io.js +44 -2
  95. package/dist/core/memory/inline-ref-inventory.d.ts +2 -1
  96. package/dist/core/memory/inline-ref-inventory.js +15 -8
  97. package/dist/core/memory-resolver.d.ts +13 -1
  98. package/dist/core/memory-resolver.js +25 -19
  99. package/dist/core/profiles/manifest.d.ts +13 -2
  100. package/dist/core/profiles/manifest.js +84 -18
  101. package/dist/core/profiles/select.js +9 -9
  102. package/dist/core/render.js +11 -0
  103. package/dist/core/runtime/advertised-command-invocation.d.ts +20 -0
  104. package/dist/core/runtime/advertised-command-invocation.js +233 -0
  105. package/dist/core/runtime/bearings.js +1 -1
  106. package/dist/core/runtime/broker/client-registry.d.ts +6 -3
  107. package/dist/core/runtime/broker/client-registry.js +6 -4
  108. package/dist/core/runtime/broker/event-projection.js +7 -0
  109. package/dist/core/runtime/broker/frame-dispatch.d.ts +1 -1
  110. package/dist/core/runtime/broker/frame-dispatch.js +16 -10
  111. package/dist/core/runtime/broker/read-ops.d.ts +4 -0
  112. package/dist/core/runtime/broker/read-ops.js +6 -2
  113. package/dist/core/runtime/broker-extension-render.js +1 -1
  114. package/dist/core/runtime/broker-inventory.d.ts +5 -0
  115. package/dist/core/runtime/broker-inventory.js +191 -0
  116. package/dist/core/runtime/broker-protocol.d.ts +13 -2
  117. package/dist/core/runtime/broker.js +10 -1
  118. package/dist/core/runtime/command-surface.d.ts +33 -0
  119. package/dist/core/runtime/command-surface.js +81 -0
  120. package/dist/core/runtime/node-read.js +5 -0
  121. package/dist/core/scope.d.ts +26 -1
  122. package/dist/core/scope.js +52 -12
  123. package/dist/core/substrate/on-read.d.ts +7 -1
  124. package/dist/core/substrate/on-read.js +13 -4
  125. package/dist/core/substrate/render.js +14 -5
  126. package/dist/core/substrate/schema.d.ts +11 -1
  127. package/dist/core/substrate/schema.js +11 -2
  128. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +4 -4
  129. package/dist/daemon/api/handlers/chat-inventory.d.ts +2 -0
  130. package/dist/daemon/api/handlers/chat-inventory.js +25 -0
  131. package/dist/daemon/api/handlers/profiles.js +7 -1
  132. package/dist/daemon/api/map.d.ts +2 -1
  133. package/dist/daemon/api/map.js +3 -2
  134. package/dist/daemon/api/server.js +2 -0
  135. package/dist/hook-authoring.d.ts +75 -0
  136. package/dist/hook-authoring.js +358 -0
  137. package/dist/hook-process.d.ts +7 -0
  138. package/dist/hook-process.js +34 -0
  139. package/dist/index.d.ts +2 -0
  140. package/dist/index.js +2 -0
  141. package/dist/migrations/002-profile-project-memory.d.ts +2 -0
  142. package/dist/migrations/002-profile-project-memory.js +71 -0
  143. package/dist/migrations/profile-manifests.d.ts +30 -0
  144. package/dist/migrations/profile-manifests.js +70 -0
  145. package/dist/migrations/registry.js +10 -5
  146. package/dist/migrations/types.d.ts +28 -1
  147. package/dist/migrations/types.js +15 -9
  148. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +21 -4
  149. package/dist/pi-extensions/canvas-structured-output.js +85 -2
  150. package/dist/types.d.ts +15 -6
  151. package/dist/types.js +5 -1
  152. package/package.json +1 -1
  153. package/runtime.lock.json +2 -2
  154. package/dist/clients/attach/__tests__/file-review-focus.test.js +0 -49
  155. /package/dist/{clients/attach/__tests__/file-review-focus.test.d.ts → core/__tests__/profile-project-memory-delivery.test.d.ts} +0 -0
@@ -0,0 +1,217 @@
1
+ // A selected profile's project relationship caps AUTOMATIC delivery — boot and
2
+ // workspace-open — and nothing else. The reported failure this suite exists for:
3
+ // a profile project that is also the node's cwd rendered its authored rung, as
4
+ // if the relationship applied only to projects reached through the manifest.
5
+ //
6
+ // One real fixture drives the real scope resolver, the real boot renderer, the
7
+ // real workspace-open renderer, and the real `crtr memory read` leaf: a profile
8
+ // whose four projects sit at none/name/preview/content in manifest order, with
9
+ // the `preview` project as cwd and canonical-name collisions across projects and
10
+ // against the wider user scope.
11
+ //
12
+ // Migration convergence is NOT here — it lives in migration.test.ts.
13
+ //
14
+ // Run: node --conditions=crtr-src --import tsx/esm --test src/core/__tests__/profile-project-memory-delivery.test.ts
15
+ import { test, before, beforeEach, after } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { dirname, join } from 'node:path';
20
+ import { closeDb } from '../canvas/db.js';
21
+ import { resetScopeCache } from '../scope.js';
22
+ import { createProfile } from '../profiles/manifest.js';
23
+ import { spawnNode } from '../runtime/nodes.js';
24
+ import { clearSessionCache } from '../substrate/session-cache.js';
25
+ import { renderKnowledgeBlock } from '../substrate/render-node.js';
26
+ import { renderWorkspaceOpenDocs } from '../substrate/on-read-node.js';
27
+ import { deliveredAtOrAbove, loadInjectedDocs, saveInjectedDocs, } from '../substrate/injected-store.js';
28
+ import { readLeaf } from '../../commands/memory/read.js';
29
+ const B = {
30
+ pnone: 'BODY-T9-PNONE',
31
+ pname: 'BODY-T9-PNAME',
32
+ ppreview: 'BODY-T9-PPREVIEW',
33
+ pcontent: 'BODY-T9-PCONTENT',
34
+ sharedFromPnone: 'BODY-T9-SHARED-FROM-PNONE',
35
+ sharedFromPcontent: 'BODY-T9-SHARED-FROM-PCONTENT',
36
+ nameFromPname: 'BODY-T9-NAMEDUP-FROM-PNAME',
37
+ nameFromPcontent: 'BODY-T9-NAMEDUP-FROM-PCONTENT',
38
+ userDupFromPnone: 'BODY-T9-USERDUP-FROM-PNONE',
39
+ userDupFromUser: 'BODY-T9-USERDUP-FROM-USER',
40
+ };
41
+ let home;
42
+ let canvasHome;
43
+ let profileId;
44
+ let prevHome;
45
+ let prevCanvasHome;
46
+ let prevProfile;
47
+ let prevNode;
48
+ let prevCwd;
49
+ let cwdProject;
50
+ let ppreviewDocPath;
51
+ /** A doc delivered automatically at the top rung by both automatic channels —
52
+ * every cap observed below is the relationship's, never the author's. */
53
+ function autoDoc(when, body) {
54
+ return [
55
+ '---',
56
+ 'kind: knowledge',
57
+ `when-and-why-to-read: "${when}"`,
58
+ 'surfaces:',
59
+ ' - {on: boot, at: content}',
60
+ ' - {on: workspace-open, at: content}',
61
+ '---',
62
+ '',
63
+ body,
64
+ '',
65
+ ].join('\n');
66
+ }
67
+ /** No surfaces at all: invisible at boot, counted into its dir's `[+N more]`. */
68
+ function bootlessDoc(when, body) {
69
+ return ['---', 'kind: knowledge', `when-and-why-to-read: "${when}"`, '---', '', body, ''].join('\n');
70
+ }
71
+ function writeDoc(storeDir, relName, contents) {
72
+ const path = join(storeDir, `${relName}.md`);
73
+ mkdirSync(dirname(path), { recursive: true });
74
+ writeFileSync(path, contents);
75
+ return path;
76
+ }
77
+ before(() => {
78
+ prevHome = process.env['HOME'];
79
+ prevCanvasHome = process.env['CRTR_HOME'];
80
+ prevProfile = process.env['CRTR_PROFILE_ID'];
81
+ prevNode = process.env['CRTR_NODE_ID'];
82
+ prevCwd = process.cwd();
83
+ // realpath: the manifest owner persists realpath-resolved project paths. A raw
84
+ // /var/folders path on macOS makes the cwd ancestor walk and the profile
85
+ // pointer name two different roots, and the cwd project then reads as uncapped
86
+ // for the wrong reason.
87
+ home = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-ppm-home-')));
88
+ canvasHome = realpathSync(mkdtempSync(join(tmpdir(), 'crtr-ppm-canvas-')));
89
+ process.env['HOME'] = home;
90
+ process.env['CRTR_HOME'] = canvasHome;
91
+ const store = (slug) => {
92
+ const dir = join(home, slug, '.crouter', 'memory');
93
+ mkdirSync(dir, { recursive: true });
94
+ return dir;
95
+ };
96
+ const pnone = store('pnone');
97
+ const pname = store('pname');
98
+ const ppreview = store('ppreview');
99
+ const pcontent = store('pcontent');
100
+ const user = join(home, '.crouter', 'memory');
101
+ mkdirSync(user, { recursive: true });
102
+ writeDoc(pnone, 't9-pnone-doc', autoDoc('When the none project delivers', B.pnone));
103
+ writeDoc(pname, 't9-pname-doc', autoDoc('When the name project delivers', B.pname));
104
+ ppreviewDocPath = writeDoc(ppreview, 't9-ppreview-doc', autoDoc('When the cwd project delivers', B.ppreview));
105
+ writeDoc(pcontent, 't9-pcontent-doc', autoDoc('When the content project delivers', B.pcontent));
106
+ // Collision one: `none` (earlier in manifest order) vs `content`. The cap must
107
+ // apply before name dedupe, so the dropped entry cannot shadow the winner.
108
+ writeDoc(pnone, 't9-shared-doc', autoDoc('When the shared name resolves', B.sharedFromPnone));
109
+ writeDoc(pcontent, 't9-shared-doc', autoDoc('When the shared name resolves', B.sharedFromPcontent));
110
+ // Collision two: `name` vs `content`. A non-none cap keeps its project's
111
+ // precedence and discloses at the capped rung — it does not step aside.
112
+ writeDoc(pname, 't9-namedup-doc', autoDoc('When the name-capped duplicate resolves', B.nameFromPname));
113
+ writeDoc(pcontent, 't9-namedup-doc', autoDoc('When the name-capped duplicate resolves', B.nameFromPcontent));
114
+ // Collision three: `none` vs the wider user scope, which project precedence
115
+ // would otherwise beat.
116
+ writeDoc(pnone, 't9-userdup-doc', autoDoc('When the user duplicate resolves', B.userDupFromPnone));
117
+ writeDoc(user, 't9-userdup-doc', autoDoc('When the user duplicate resolves', B.userDupFromUser));
118
+ // Hidden-count pair: a top-level dir always renders, so a dir holding only a
119
+ // bootless doc shows as `<dir>/` + `[+1 more]` — unless its project is `none`.
120
+ writeDoc(pnone, join('t9-none-hidden', 'probe'), bootlessDoc('When counted', 'HIDDEN-NONE'));
121
+ writeDoc(pcontent, join('t9-hidden-control', 'probe'), bootlessDoc('When counted', 'HIDDEN-CONTROL'));
122
+ profileId = createProfile('t9', [
123
+ { path: join(home, 'pnone'), memory: 'none' },
124
+ { path: join(home, 'pname'), memory: 'name' },
125
+ { path: join(home, 'ppreview'), memory: 'preview' },
126
+ { path: join(home, 'pcontent'), memory: 'content' },
127
+ ]).profileId;
128
+ process.env['CRTR_PROFILE_ID'] = profileId;
129
+ // The node works INSIDE a capped project: the reported failure.
130
+ cwdProject = join(home, 'ppreview');
131
+ process.chdir(cwdProject);
132
+ });
133
+ beforeEach(() => {
134
+ resetScopeCache();
135
+ clearSessionCache();
136
+ });
137
+ after(() => {
138
+ closeDb();
139
+ resetScopeCache();
140
+ clearSessionCache();
141
+ process.chdir(prevCwd);
142
+ rmSync(home, { recursive: true, force: true });
143
+ rmSync(canvasHome, { recursive: true, force: true });
144
+ const restore = (key, value) => {
145
+ if (value === undefined)
146
+ delete process.env[key];
147
+ else
148
+ process.env[key] = value;
149
+ };
150
+ restore('HOME', prevHome);
151
+ restore('CRTR_HOME', prevCanvasHome);
152
+ restore('CRTR_PROFILE_ID', prevProfile);
153
+ restore('CRTR_NODE_ID', prevNode);
154
+ });
155
+ function newNode() {
156
+ return spawnNode({ kind: 'general', cwd: cwdProject, parent: null, profile_id: profileId }).node_id;
157
+ }
158
+ test('boot delivery caps every profile project at its relationship, including the one used as cwd', () => {
159
+ const boot = renderKnowledgeBlock(newNode(), new Map());
160
+ // The control: an uncapped relationship still renders its authored content.
161
+ assert.ok(boot.includes(B.pcontent), `the content-capped project renders its body; got:\n${boot}`);
162
+ // The reported failure. cwd reaches this root before the manifest pointer
163
+ // does, and the relationship still decides the ceiling.
164
+ assert.ok(!boot.includes(B.ppreview), `the cwd project is capped at preview — its body must not render; got:\n${boot}`);
165
+ assert.match(boot, /t9-ppreview-doc {2}# read when: When the cwd project delivers\./, `the cwd project's doc discloses at preview; got:\n${boot}`);
166
+ // `name` discloses the bare identifier and nothing else.
167
+ assert.ok(!boot.includes(B.pname), 'a name-capped project must not render a body');
168
+ assert.match(boot, /[├└]─ t9-pname-doc$/m, `the name-capped doc discloses as a bare name; got:\n${boot}`);
169
+ assert.ok(!boot.includes('When the name project delivers'), 'a name-capped doc discloses no preview line');
170
+ });
171
+ test('a `none` project contributes nothing to boot — no body, no name, no hidden count, no shadow', () => {
172
+ const boot = renderKnowledgeBlock(newNode(), new Map());
173
+ assert.ok(!boot.includes(B.pnone), 'a none-capped body must not render');
174
+ assert.ok(!boot.includes('t9-pnone-doc'), 'a none-capped doc must not appear as a name either');
175
+ // Hidden counts are disclosure too: the control dir proves a bootless doc
176
+ // does surface as a `[+N more]` count, so the none project's absence is the cap.
177
+ assert.match(boot, /t9-hidden-control\/\n.*\[\+1 more\]/, `the control dir shows its hidden count; got:\n${boot}`);
178
+ assert.ok(!boot.includes('t9-none-hidden'), 'a none-capped project contributes no `[+N more]` count');
179
+ // Shadowing: `none` wins name dedupe on precedence, so a cap applied after
180
+ // dedupe would silently delete the winner instead of deferring to it.
181
+ assert.ok(boot.includes(B.sharedFromPcontent) && !boot.includes(B.sharedFromPnone), `the content project wins the collision the none project would have taken; got:\n${boot}`);
182
+ assert.ok(boot.includes(B.userDupFromUser) && !boot.includes(B.userDupFromPnone), `a none project does not shadow the wider user-scope winner; got:\n${boot}`);
183
+ });
184
+ test('a non-none cap keeps its project precedence and discloses the duplicate at the capped rung', () => {
185
+ const boot = renderKnowledgeBlock(newNode(), new Map());
186
+ assert.match(boot, /[├└]─ t9-namedup-doc$/m, `the name-capped duplicate wins dedupe; got:\n${boot}`);
187
+ assert.ok(!boot.includes(B.nameFromPname), 'the winner discloses at `name`, never its body');
188
+ assert.ok(!boot.includes(B.nameFromPcontent), 'the content project does not take a collision the name project already won');
189
+ });
190
+ test('workspace-open clamps each project store the same way boot does', () => {
191
+ const opened = renderWorkspaceOpenDocs(newNode(), new Map());
192
+ assert.ok(opened.includes(B.pcontent), `the content-capped project delivers its body; got:\n${opened}`);
193
+ assert.ok(!opened.includes(B.ppreview), `the cwd project must not deliver its body; got:\n${opened}`);
194
+ assert.match(opened, /name="t9-ppreview-doc"[^>]*>\nWhen the cwd project delivers\./, `the cwd project delivers its preview line; got:\n${opened}`);
195
+ assert.match(opened, /name="t9-pname-doc"[^>]*\/>/, `the name-capped doc delivers name-only; got:\n${opened}`);
196
+ assert.ok(!opened.includes('t9-pnone-doc'), 'the none-capped project delivers nothing at workspace-open');
197
+ assert.ok(!opened.includes(B.sharedFromPnone), 'nor its side of a name collision');
198
+ });
199
+ test('an explicit `crtr memory read` returns and records the full body a cap withheld', async () => {
200
+ const node = newNode();
201
+ process.env['CRTR_NODE_ID'] = node;
202
+ const seen = loadInjectedDocs(node);
203
+ const opened = renderWorkspaceOpenDocs(node, seen);
204
+ assert.ok(!opened.includes(B.ppreview), 'workspace-open disclosed the capped doc at preview only');
205
+ saveInjectedDocs(node, seen);
206
+ const result = (await readLeaf.run({ name: 't9-ppreview-doc', frontmatter: false }));
207
+ assert.equal(result.path, ppreviewDocPath, 'the read resolved the cwd project doc');
208
+ assert.ok(result.content.includes(B.ppreview), `an explicit read is uncapped and returns the full body; got:\n${result.content}`);
209
+ const afterRead = loadInjectedDocs(node);
210
+ assert.ok(deliveredAtOrAbove(afterRead, ppreviewDocPath, 'content'), 'the explicit read records a content delivery, upgrading the preview disclosure');
211
+ // A later automatic event runs at the capped rung, which is now below what the
212
+ // transcript holds, so it re-delivers nothing.
213
+ assert.equal(renderWorkspaceOpenDocs(node, afterRead), '', 'a later lower automatic event re-delivers nothing');
214
+ // ...and the emptiness is the dedup set, not a dead render.
215
+ assert.ok(renderWorkspaceOpenDocs(node, new Map()).includes('t9-ppreview-doc'), 'the same call against a fresh transcript still delivers');
216
+ delete process.env['CRTR_NODE_ID'];
217
+ });
@@ -19,20 +19,23 @@
19
19
  // they belong to the compiled seam tier; neither needs tmux.
20
20
  import { after, before, test } from 'node:test';
21
21
  import assert from 'node:assert/strict';
22
- import { writeFileSync } from 'node:fs';
22
+ import { readFileSync, writeFileSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { appendInbox, readCursor, readInboxSince } from '../../feed/inbox.js';
25
25
  import { registerWarmSpare, subscribe, updateNode } from '../../canvas/index.js';
26
26
  import { isPidAlive } from '../../canvas/pid.js';
27
27
  import { reviveNode } from '../../runtime/revive.js';
28
28
  import { createHarness } from '../helpers/harness.js';
29
+ import { createAttachKit } from '../helpers/broker-clients.js';
29
30
  let h;
30
31
  let root;
32
+ const attach = createAttachKit(() => h);
31
33
  before(async () => {
32
34
  h = await createHarness({ headless: true, sessionPrefix: 'crtr-dormancy' });
33
35
  root = h.spawnRoot('dormancy-release suite root');
34
36
  });
35
37
  after(async () => {
38
+ attach.closeAll();
36
39
  if (h !== undefined)
37
40
  await h.dispose();
38
41
  });
@@ -187,6 +190,39 @@ test('an unattended resident with no live obligation completes on the daemon clo
187
190
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
188
191
  }
189
192
  });
193
+ test('a non-attending observer does not prevent unattended resident parking', { timeout: 30_000 }, async () => {
194
+ const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
195
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
196
+ const nodeId = h.fabricateBrokerNode({
197
+ id: 'settled-with-system-observer',
198
+ parent: root,
199
+ lifecycle: 'resident',
200
+ status: 'idle',
201
+ intent: 'idle-release',
202
+ });
203
+ updateNode(nodeId, { launch: { extensions: [], tools: [], systemPrompt: '', env: {} } });
204
+ reviveNode(nodeId, { resume: true });
205
+ const boot = await h.awaitBoot(nodeId);
206
+ await attach.attach(nodeId, 'observer', 'system-observer-test', { attends: false });
207
+ assert.equal(JSON.parse(readFileSync(join(h.home, 'nodes', nodeId, 'job', 'attach.json'), 'utf8')).viewers, 0, 'a non-attending hello is not persisted as human attendance');
208
+ try {
209
+ const seenAt = 12_000;
210
+ await h.tick(seenAt);
211
+ await h.tick(seenAt + 1500);
212
+ await h.waitFor(() => h.node(nodeId)?.status === 'done' && !isPidAlive(boot.pid), {
213
+ timeoutMs: 10_000,
214
+ intervalMs: 20,
215
+ label: 'system-observed unattended completion and broker exit',
216
+ });
217
+ await h.awaitFleetExit(nodeId);
218
+ }
219
+ finally {
220
+ if (priorParkMs === undefined)
221
+ delete process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
222
+ else
223
+ process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = priorParkMs;
224
+ }
225
+ });
190
226
  test('a connected broker viewer keeps an obligation-free resident live', { timeout: 30_000 }, async () => {
191
227
  const priorParkMs = process.env['CRTR_TEST_UNATTENDED_PARK_MS'];
192
228
  process.env['CRTR_TEST_UNATTENDED_PARK_MS'] = '1500';
@@ -26,9 +26,10 @@ import { tmpdir } from 'node:os';
26
26
  import { join, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../../runtime/broker-sdk.js';
29
- import { buildBrokerSession, makeBrokerUiContext } from '../../runtime/broker.js';
30
- import { isLeadingEngineCommand, resolveSteerCall, chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, isHiddenJsonlBranchEntry, stripHiddenEntriesFromJsonlExport, engineLeadingCommandTokens, } from '../../runtime/broker/frame-dispatch.js';
29
+ import { buildBrokerSession, makeBrokerUiContext, snapshotMessages } from '../../runtime/broker.js';
30
+ import { isLeadingEngineCommand, resolveSteerCall, chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, promptWithAdvertisedCommandInvocation, isHiddenJsonlBranchEntry, stripHiddenEntriesFromJsonlExport, engineLeadingCommandTokens, } from '../../runtime/broker/frame-dispatch.js';
31
31
  import { CANVAS_EXTENSIONS } from '../../runtime/launch.js';
32
+ import { prepareAdvertisedCommandPersistence } from '../../runtime/advertised-command-invocation.js';
32
33
  // The REAL engine, assembled from broker-sdk's static SDK re-exports — bypasses
33
34
  // the CRTR_BROKER_ENGINE seam entirely so these tests can never accidentally hit
34
35
  // the fake (the whole point: prove the wiring the mock hid).
@@ -403,6 +404,105 @@ test('C5 — isLeadingEngineCommand against a REAL session (registered extension
403
404
  rmSync(cwd, { recursive: true, force: true });
404
405
  }
405
406
  });
407
+ test('an advertised custom command persists its exact raw invocation without replacing expanded agent input', async () => {
408
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-advertised-command-'));
409
+ try {
410
+ const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C5_EXT] }));
411
+ try {
412
+ const raw = '/gatewaycmd exact spacing ';
413
+ await promptWithAdvertisedCommandInvocation(session, raw, () => session.prompt(raw, {}));
414
+ const custom = session.sessionManager
415
+ .buildSessionContext()
416
+ .messages.find((message) => message.role === 'custom' && message.customType === 'fixture-output');
417
+ assert.deepEqual(custom, {
418
+ role: 'custom',
419
+ customType: 'fixture-output',
420
+ content: 'expanded agent input',
421
+ display: true,
422
+ details: { fixture: true },
423
+ crouterAdvertisedCommandInvocation: { raw },
424
+ timestamp: custom?.timestamp,
425
+ });
426
+ const welcome = snapshotMessages(session);
427
+ assert.ok(welcome);
428
+ const welcomeCustom = welcome.filter(({ message }) => message.role === 'custom' && message.customType === 'fixture-output');
429
+ assert.equal(welcomeCustom.length, 1, 'welcome reconstructs one advertised custom message');
430
+ assert.deepEqual(welcomeCustom[0].message
431
+ .crouterAdvertisedCommandInvocation, { raw }, 'welcome preserves the exact advertised invocation spacing');
432
+ const persisted = session.sessionManager
433
+ .getEntries()
434
+ .find((entry) => entry.type === 'custom_message' &&
435
+ entry.customType === 'fixture-output');
436
+ assert.equal(persisted?.type, 'custom_message');
437
+ if (persisted?.type !== 'custom_message')
438
+ assert.fail('custom message was not persisted');
439
+ assert.equal(persisted.content, 'expanded agent input');
440
+ assert.deepEqual(persisted.details, {
441
+ crouterPersistenceEnvelope: true,
442
+ crouterAdvertisedCommandInvocation: { raw },
443
+ extensionDetails: { fixture: true },
444
+ });
445
+ }
446
+ finally {
447
+ session.dispose();
448
+ }
449
+ }
450
+ finally {
451
+ rmSync(cwd, { recursive: true, force: true });
452
+ }
453
+ });
454
+ test('advertised invocation metadata survives nextTurn delivery and null content normalization', async () => {
455
+ const cwd = mkdtempSync(join(tmpdir(), 'crtr-advertised-next-turn-'));
456
+ try {
457
+ const { session } = await buildBrokerSession(realEngine, cfg(cwd, { extensionPaths: [C5_EXT] }));
458
+ try {
459
+ const rawA = '/gatewaynext first exact text ';
460
+ const rawB = '/gatewaynext second exact text ';
461
+ for (const raw of [rawA, rawB]) {
462
+ await promptWithAdvertisedCommandInvocation(session, raw, () => session.prompt(raw, {}));
463
+ }
464
+ const pending = session._pendingNextTurnMessages;
465
+ assert.deepEqual(pending.map((queued) => ({
466
+ content: queued.content,
467
+ details: queued.details,
468
+ invocation: queued.crouterAdvertisedCommandInvocation,
469
+ })), [
470
+ {
471
+ content: [],
472
+ details: 'extension-details',
473
+ invocation: { raw: rawA },
474
+ },
475
+ {
476
+ content: [],
477
+ details: 'extension-details',
478
+ invocation: { raw: rawB },
479
+ },
480
+ ]);
481
+ // Simulate a queue policy draining B before A. Payload equality cannot
482
+ // identify them; the app-message metadata must bind each persistence.
483
+ for (const queued of [pending[1], pending[0]]) {
484
+ prepareAdvertisedCommandPersistence(session, queued);
485
+ session.sessionManager.appendCustomMessageEntry(queued.customType, queued.content, queued.display, queued.details);
486
+ }
487
+ const restored = session.sessionManager
488
+ .buildSessionContext()
489
+ .messages.slice(-2);
490
+ assert.deepEqual(restored.map((message) => ({
491
+ details: message.details,
492
+ invocation: message.crouterAdvertisedCommandInvocation,
493
+ })), [
494
+ { details: 'extension-details', invocation: { raw: rawB } },
495
+ { details: 'extension-details', invocation: { raw: rawA } },
496
+ ]);
497
+ }
498
+ finally {
499
+ session.dispose();
500
+ }
501
+ }
502
+ finally {
503
+ rmSync(cwd, { recursive: true, force: true });
504
+ }
505
+ });
406
506
  test('C5 — chooseGuidanceDeliveryMode truth table (pure)', () => {
407
507
  // idle prompt (routeCall='prompt', not streaming) -> guidance precedes the
408
508
  // human call, in the SAME turn.
@@ -14,6 +14,12 @@ function shouldSkipForArgv(argv) {
14
14
  const sub = argv[2];
15
15
  if (sub === undefined)
16
16
  return true;
17
+ // `sys migrate` converges state that every other read already depends on:
18
+ // resolving a scope here would read the selected profile through the current
19
+ // reader (blind to a pre-migration manifest) and could scaffold a cwd scope
20
+ // the migration then has to treat as real. It owns its own discovery.
21
+ if (sub === 'sys' && argv[3] === 'migrate')
22
+ return true;
17
23
  return SKIP_SUBCOMMANDS.has(sub);
18
24
  }
19
25
  export function ensureProjectScope(argv) {
@@ -10,6 +10,7 @@
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import { resolve } from 'node:path';
12
12
  import { dashboardRowsAllFromSource, enrichRowsFromSource, loadPreviewFromSource, renderForestFromSource } from '../render-source.js';
13
+ import { fullName } from '../labels.js';
13
14
  import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
14
15
  import { buildTree, flatten, pruneNode, TABS } from './model.js';
15
16
  import { loadPins, savePins } from './pins.js';
@@ -483,8 +484,10 @@ export async function runBrowse(opts) {
483
484
  /* best-effort — a rename failure never wedges the picker */
484
485
  }
485
486
  const n = tree.nodes.get(id);
486
- if (n !== undefined)
487
- n.row.name = name;
487
+ if (n !== undefined) {
488
+ n.row.handle = name;
489
+ n.row.name = fullName({ name, kind: n.row.kind, description: n.row.description });
490
+ }
488
491
  }
489
492
  recompute(state.renameTarget ?? undefined);
490
493
  scheduleFlush();
@@ -70,11 +70,8 @@ export interface SnippetLine {
70
70
  text: string;
71
71
  hi: Set<number>;
72
72
  }
73
- /** Highlight indices for the preview: the literal case-insensitive SUBSTRING span
74
- * when present (so "where does this text appear?" is answered exactly), else the
75
- * scattered subsequence indices, else empty (empty query). This is a different,
76
- * stricter model than the subsequence super-search on purpose — a contiguous span
77
- * is what reads as a highlight. */
73
+ /** The literal case-insensitive substring span in a conversation preview.
74
+ * Scattered letters are not a message match and never receive a highlight. */
78
75
  export declare function highlightIndices(query: string, text: string): Set<number>;
79
76
  /**
80
77
  * Build the preview snippet for `text` under the live `query`: up to `maxLines`
@@ -91,17 +88,27 @@ export declare function previewSnippet(query: string, text: string, width: numbe
91
88
  * windowed in the preview, so search matches a prompt from ANYWHERE in the
92
89
  * conversation, not just the first one. */
93
90
  export declare function promptText(row: DashboardRow): string;
94
- /** Does this row match the live query? Super-search spans name (which already
95
- * folds in the pi-generated description), kind, short-id, AND every user prompt
96
- * in the conversation (`promptText`). Empty query matches everything. */
91
+ /** Does this row match the live query? Empty query matches everything; a
92
+ * non-empty query uses the same fields and match tiers as relevance ranking. */
97
93
  export declare function queryMatch(query: string, row: DashboardRow): boolean;
98
- /** Score how well `query` matches one field, 0 (no match) → 1 (exact). Tiers:
99
- * exact > prefix > word-boundary substring > interior substring > subsequence.
100
- * An interior match decays slightly the later it starts so leading matches win. */
101
- export declare function fieldScore(query: string, text: string): number;
102
- /** Weighted relevance of a row to the query across all searched fields. 0 means
103
- * no field matched (excluded from relevance results, same as `queryMatch`). */
104
- export declare function scoreRow(query: string, row: DashboardRow): number;
94
+ export type MatchTier = 0 | 1 | 2 | 3;
95
+ export interface FieldMatch {
96
+ /** 3 = bounded word/phrase, 2 = contiguous partial, 1 = subsequence. */
97
+ tier: MatchTier;
98
+ /** Tie-break quality within one tier. */
99
+ quality: number;
100
+ }
101
+ export interface RelevanceScore {
102
+ tier: MatchTier;
103
+ strength: number;
104
+ }
105
+ /** One field's best match. Match class is deliberately separate from quality:
106
+ * a bounded literal always outranks a contiguous partial, which always outranks
107
+ * a scattered subsequence, regardless of field weights. */
108
+ export declare function fieldMatch(query: string, text: string): FieldMatch;
109
+ /** Rank a row by its strongest match class, then by weighted evidence only from
110
+ * that class. Weaker classes cannot add up to beat one stronger literal match. */
111
+ export declare function scoreRow(query: string, row: DashboardRow): RelevanceScore;
105
112
  /** The "most recent message" signal for the attention sort: the pi session-file
106
113
  * mtime (ms) the snapshot stamps on each row, falling back to the `created`
107
114
  * birth timestamp when no session file exists. Larger = more recent.
@@ -111,6 +118,9 @@ export declare function scoreRow(query: string, row: DashboardRow): number;
111
118
  * update this single accessor. `created` is ISO 8601, so its epoch-ms is a
112
119
  * monotonic stand-in within the same tier. */
113
120
  export declare function attentionMtime(row: DashboardRow): number;
121
+ /** Up to a 10% relevance lift for recent conversation activity, halving after a
122
+ * week. Match tier remains separate, so freshness never promotes fuzzy over literal. */
123
+ export declare function relevanceFreshness(row: DashboardRow, now: number): number;
114
124
  /** Attention tier (lower = higher priority, shown first):
115
125
  * T0 — attached AND streaming (`viewed && streaming`)
116
126
  * T1 — attached, not streaming (`viewed`)