@crouton-kit/crouter 0.3.19 → 0.3.21

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 (90) 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/design/PERSONA.md +1 -0
  11. package/dist/builtin-personas/design/orchestrator.md +1 -0
  12. package/dist/builtin-personas/developer/PERSONA.md +1 -0
  13. package/dist/builtin-personas/developer/orchestrator.md +1 -0
  14. package/dist/builtin-personas/explore/PERSONA.md +1 -0
  15. package/dist/builtin-personas/explore/orchestrator.md +4 -0
  16. package/dist/builtin-personas/general/PERSONA.md +1 -0
  17. package/dist/builtin-personas/general/orchestrator.md +4 -0
  18. package/dist/builtin-personas/orchestration-kernel.md +1 -1
  19. package/dist/builtin-personas/plan/PERSONA.md +1 -0
  20. package/dist/builtin-personas/plan/orchestrator.md +1 -0
  21. package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
  22. package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
  23. package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
  24. package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
  25. package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
  26. package/dist/builtin-personas/review/PERSONA.md +1 -0
  27. package/dist/builtin-personas/review/orchestrator.md +4 -0
  28. package/dist/builtin-personas/spec/PERSONA.md +1 -0
  29. package/dist/builtin-personas/spec/orchestrator.md +1 -0
  30. package/dist/commands/human/queue.js +13 -2
  31. package/dist/commands/memory/__tests__/lint-schema.test.d.ts +1 -0
  32. package/dist/commands/memory/__tests__/lint-schema.test.js +29 -0
  33. package/dist/commands/memory/find.js +10 -15
  34. package/dist/commands/memory/lint.d.ts +7 -0
  35. package/dist/commands/memory/lint.js +10 -1
  36. package/dist/commands/memory/shared.js +1 -2
  37. package/dist/commands/memory/write.js +6 -9
  38. package/dist/commands/node.js +51 -26
  39. package/dist/commands/push.js +10 -15
  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-attach-limits.test.d.ts +1 -0
  44. package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
  45. package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
  46. package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
  47. package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
  48. package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
  49. package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
  50. package/dist/core/__tests__/broker-dialogs.test.js +126 -0
  51. package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
  52. package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
  53. package/dist/core/__tests__/broker-lifecycle.test.js +14 -604
  54. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.d.ts +1 -0
  55. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
  56. package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
  57. package/dist/core/__tests__/cascade-close.test.js +3 -2
  58. package/dist/core/__tests__/context-intro.test.js +23 -27
  59. package/dist/core/__tests__/detach-focus.test.js +2 -2
  60. package/dist/core/__tests__/helpers/broker-clients.d.ts +43 -0
  61. package/dist/core/__tests__/helpers/broker-clients.js +178 -0
  62. package/dist/core/__tests__/live-mutation-verbs.test.d.ts +1 -0
  63. package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
  64. package/dist/core/__tests__/live-mutation.test.js +4 -147
  65. package/dist/core/__tests__/memory.test.js +4 -4
  66. package/dist/core/__tests__/subscription-delivery.test.js +3 -3
  67. package/dist/core/bootstrap.js +18 -14
  68. package/dist/core/canvas/canvas.js +1 -1
  69. package/dist/core/canvas/types.d.ts +6 -0
  70. package/dist/core/help.d.ts +2 -2
  71. package/dist/core/help.js +1 -1
  72. package/dist/core/render.d.ts +4 -3
  73. package/dist/core/render.js +38 -41
  74. package/dist/core/resolver.js +1 -1
  75. package/dist/core/runtime/bearings.d.ts +2 -2
  76. package/dist/core/runtime/bearings.js +2 -2
  77. package/dist/core/runtime/launch.d.ts +9 -2
  78. package/dist/core/runtime/launch.js +20 -3
  79. package/dist/core/runtime/nodes.d.ts +4 -0
  80. package/dist/core/runtime/nodes.js +1 -0
  81. package/dist/core/runtime/promote.js +3 -0
  82. package/dist/core/runtime/reset.js +3 -2
  83. package/dist/core/runtime/spawn.d.ts +4 -0
  84. package/dist/core/runtime/spawn.js +2 -1
  85. package/dist/core/substrate/render.d.ts +1 -2
  86. package/dist/core/substrate/render.js +9 -12
  87. package/dist/core/substrate/schema.d.ts +18 -13
  88. package/dist/core/substrate/schema.js +12 -11
  89. package/dist/pi-extensions/canvas-context-intro.js +5 -3
  90. package/package.json +1 -1
@@ -0,0 +1,102 @@
1
+ // Tests for the canvas-node inbox watcher pi extension, part 2 — the
2
+ // refresh-yield HOLD path and idle delivery. Split out of
3
+ // canvas-inbox-watcher.test.ts (see its header) so node:test's file-level
4
+ // parallelism applies; the tests and the scaffold are moved here UNCHANGED.
5
+ //
6
+ // Run with: node --import tsx/esm --test src/core/__tests__/canvas-inbox-watcher-hold.test.ts
7
+ import { test, describe, before, after, afterEach } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdirSync, rmSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join } from 'node:path';
12
+ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
13
+ import { appendInbox } from '../feed/inbox.js';
14
+ import { createNode, setIntent } from '../canvas/canvas.js';
15
+ import { closeDb } from '../canvas/db.js';
16
+ // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
17
+ // resolve+seed tick, a read tick, and the debounce window before asserting.
18
+ const TICK_MS = 800;
19
+ const DEBOUNCE_MS = 1000;
20
+ const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 500;
21
+ let origHome;
22
+ let origNode;
23
+ const homes = [];
24
+ const disposers = [];
25
+ /** Point CRTR_HOME at a fresh temp canvas root and bind CRTR_NODE_ID. */
26
+ function freshNode(nodeId) {
27
+ const home = join(tmpdir(), `crtr-canvas-watcher-${Date.now()}-${Math.random().toString(36).slice(2)}`);
28
+ mkdirSync(home, { recursive: true });
29
+ homes.push(home);
30
+ process.env['CRTR_HOME'] = home;
31
+ process.env['CRTR_NODE_ID'] = nodeId;
32
+ }
33
+ function makeFakePi() {
34
+ const handlers = {};
35
+ return {
36
+ injected: [],
37
+ on(e, h) { handlers[e] = h; },
38
+ sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
39
+ fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
40
+ };
41
+ }
42
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
43
+ before(() => {
44
+ origHome = process.env['CRTR_HOME'];
45
+ origNode = process.env['CRTR_NODE_ID'];
46
+ });
47
+ afterEach(() => {
48
+ while (disposers.length > 0)
49
+ disposers.pop()();
50
+ });
51
+ after(() => {
52
+ if (origHome === undefined)
53
+ delete process.env['CRTR_HOME'];
54
+ else
55
+ process.env['CRTR_HOME'] = origHome;
56
+ if (origNode === undefined)
57
+ delete process.env['CRTR_NODE_ID'];
58
+ else
59
+ process.env['CRTR_NODE_ID'] = origNode;
60
+ for (const h of homes) {
61
+ try {
62
+ rmSync(h, { recursive: true, force: true });
63
+ }
64
+ catch { /* noop */ }
65
+ }
66
+ });
67
+ describe('canvas inbox watcher — hold + idle delivery', () => {
68
+ test('refresh-yield in flight: inbox entries are HELD, then delivered once intent clears (no loss)', async () => {
69
+ freshNode('node-refresh');
70
+ closeDb(); // rebind the canvas db to this test's fresh home
71
+ const meta = {
72
+ node_id: 'node-refresh', name: 'r', created: new Date().toISOString(),
73
+ cwd: '/tmp', kind: 'general', mode: 'base', lifecycle: 'resident',
74
+ status: 'active', intent: 'refresh',
75
+ };
76
+ createNode(meta);
77
+ const pi = makeFakePi();
78
+ disposers.push(registerCanvasInboxWatcher(pi));
79
+ // Streaming (mid-turn) when a child finishes — normally this would steer.
80
+ pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
81
+ await wait(TICK_MS + 100);
82
+ appendInbox('node-refresh', { from: 'child-x', tier: 'urgent', kind: 'final', label: 'done' });
83
+ await wait(SETTLE_MS);
84
+ assert.equal(pi.injected.length, 0, 'entries are held while a refresh-yield is in flight (no steer-hijack)');
85
+ // The fresh pi clears intent on boot; the held entry must then be delivered
86
+ // — the cursor was never advanced, so nothing is lost.
87
+ setIntent('node-refresh', null);
88
+ await wait(SETTLE_MS);
89
+ assert.equal(pi.injected.length, 1, 'the held entry is delivered once the refresh clears');
90
+ });
91
+ test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
92
+ freshNode('node-idle');
93
+ const pi = makeFakePi();
94
+ disposers.push(registerCanvasInboxWatcher(pi));
95
+ // No agent_start fired → watcher treats the node as idle.
96
+ await wait(TICK_MS + 100);
97
+ appendInbox('node-idle', { from: 'child-3', tier: 'normal', kind: 'final', label: 'done while idle' });
98
+ await wait(SETTLE_MS);
99
+ assert.equal(pi.injected.length, 1);
100
+ assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
101
+ });
102
+ });
@@ -4,6 +4,8 @@
4
4
  //
5
5
  // Focus: a finished node (push final → InboxEntry kind 'final') must STEER a
6
6
  // mid-stream subscriber, not queue behind its current turn as a follow-up.
7
+ // Part 2 — the refresh-yield HOLD path and idle delivery — lives in
8
+ // canvas-inbox-watcher-hold.test.ts (split for node:test file-level parallelism).
7
9
  import { test, describe, before, after, afterEach } from 'node:test';
8
10
  import assert from 'node:assert/strict';
9
11
  import { mkdirSync, rmSync } from 'node:fs';
@@ -11,8 +13,6 @@ import { tmpdir } from 'node:os';
11
13
  import { join } from 'node:path';
12
14
  import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
13
15
  import { appendInbox } from '../feed/inbox.js';
14
- import { createNode, setIntent } from '../canvas/canvas.js';
15
- import { closeDb } from '../canvas/db.js';
16
16
  // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
17
17
  // resolve+seed tick, a read tick, and the debounce window before asserting.
18
18
  const TICK_MS = 800;
@@ -88,38 +88,4 @@ describe('canvas inbox watcher — finished-node delivery', () => {
88
88
  assert.equal(pi.injected.length, 1);
89
89
  assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
90
90
  });
91
- test('refresh-yield in flight: inbox entries are HELD, then delivered once intent clears (no loss)', async () => {
92
- freshNode('node-refresh');
93
- closeDb(); // rebind the canvas db to this test's fresh home
94
- const meta = {
95
- node_id: 'node-refresh', name: 'r', created: new Date().toISOString(),
96
- cwd: '/tmp', kind: 'general', mode: 'base', lifecycle: 'resident',
97
- status: 'active', intent: 'refresh',
98
- };
99
- createNode(meta);
100
- const pi = makeFakePi();
101
- disposers.push(registerCanvasInboxWatcher(pi));
102
- // Streaming (mid-turn) when a child finishes — normally this would steer.
103
- pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
104
- await wait(TICK_MS + 100);
105
- appendInbox('node-refresh', { from: 'child-x', tier: 'urgent', kind: 'final', label: 'done' });
106
- await wait(SETTLE_MS);
107
- assert.equal(pi.injected.length, 0, 'entries are held while a refresh-yield is in flight (no steer-hijack)');
108
- // The fresh pi clears intent on boot; the held entry must then be delivered
109
- // — the cursor was never advanced, so nothing is lost.
110
- setIntent('node-refresh', null);
111
- await wait(SETTLE_MS);
112
- assert.equal(pi.injected.length, 1, 'the held entry is delivered once the refresh clears');
113
- });
114
- test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
115
- freshNode('node-idle');
116
- const pi = makeFakePi();
117
- disposers.push(registerCanvasInboxWatcher(pi));
118
- // No agent_start fired → watcher treats the node as idle.
119
- await wait(TICK_MS + 100);
120
- appendInbox('node-idle', { from: 'child-3', tier: 'normal', kind: 'final', label: 'done while idle' });
121
- await wait(SETTLE_MS);
122
- assert.equal(pi.injected.length, 1);
123
- assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
124
- });
125
91
  });
@@ -103,8 +103,9 @@ test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), anc
103
103
  // ===================================================================
104
104
  const closeRes = h.cli(A, ['node', 'close', '--node', B]);
105
105
  assert.equal(closeRes.code, 0, `node close exit 0\n${closeRes.stderr}`);
106
- // The CLI's own rendered report: cascade root B, exactly 4 closed, 0 spared.
107
- assert.match(closeRes.stdout, new RegExp(`<closed id="${B}" count="4" spared="0"\\s*/>`), `close report names B as root, count=4, spared=0\n${closeRes.stdout}`);
106
+ // The CLI's own rendered report (plain markdown): cascade root B, exactly
107
+ // 4 closed, 0 spared (no spared clause is emitted when none were spared).
108
+ assert.match(closeRes.stdout, new RegExp(`^Closed ${B} and its exclusive subtree — 4 node\\(s\\) closed\\.`), `close report names B as root, count=4, spared=0\n${closeRes.stdout}`);
108
109
  // ===================================================================
109
110
  // ASSERT 1 — the A5 DELIVERABLE: the EXACT terminal status of every
110
111
  // cascade-reaped node. CURRENT behavior: status='canceled', intent=null,
@@ -1,10 +1,8 @@
1
1
  // Tests for the <crtr-context> bearings preamble:
2
2
  // 1. Worker and orchestrator bearings carry the `## References` block
3
- // (substrate reference docs + node-local docs as `###` sub-sections) in
4
- // place of the removed `<memory>` block no `label · dir` stanzas, no
5
- // `(empty)` markers, no MEMORY.md index inlining. Orchestrators add the
6
- // across-cycles framing; promotion delivers that same orchestrator
7
- // context-dir framing to a node that spawned base.
3
+ // (substrate reference docs + node-local docs as `###` sub-sections).
4
+ // Orchestrators add the across-cycles framing; promotion delivers that
5
+ // same orchestrator context-dir framing to a node that spawned base.
8
6
  // 2. canvas-context-intro injects the block as its own session message at
9
7
  // session_start (before the first prompt), idempotent across resumes.
10
8
  //
@@ -36,53 +34,51 @@ after(() => {
36
34
  delete process.env['CRTR_HOME'];
37
35
  delete process.env['CRTR_NODE_ID'];
38
36
  });
39
- test('worker bearings: base framing + ## References block (no <memory>, no label·dir stanzas), NO across-cycles framing', () => {
40
- // Bug-regression: review finding M1 — buildContextBearings was changed from
41
- // buildMemoryBlock (<memory> + label·dir per-store stanzas) to
42
- // renderReferencesBlock (## References + ### <name> sub-sections). This test
43
- // locks in the new contract.
37
+ test('worker bearings: base framing + ## References block, NO across-cycles framing', () => {
38
+ // Bug-regression: review finding M1 — buildContextBearings renders the
39
+ // ## References block (renderReferencesBlock): ## References + ### <name>
40
+ // sub-sections. This test locks in that contract.
44
41
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
45
42
  // Seed a node-local substrate doc so the ## References block is non-empty.
46
43
  const dir = memoryDir(meta.node_id);
47
44
  mkdirSync(dir, { recursive: true });
48
- writeFileSync(join(dir, 'test-ref.md'), '---\nkind: reference\nwhen: when testing\nwhy: regression fixture\nsystem-prompt-visibility: preview\n---\nTest body.\n');
45
+ writeFileSync(join(dir, 'test-ref.md'), '---\nkind: reference\nwhen-and-why-to-read: When testing, this reference should be read because it is a regression fixture\nsystem-prompt-visibility: preview\n---\nTest body.\n');
49
46
  const block = buildContextIntro(meta.node_id);
50
47
  assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
51
48
  assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
52
- // New contract: NO <memory> block replaced by ## References.
49
+ // Reference content renders ONLY as ## References + ### sub-sections.
53
50
  assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
54
- // New contract: NO label·dir stanza headers (the removed per-store format).
51
+ // Per-store stanza headers (label · dir) never appear.
55
52
  assert.doesNotMatch(block, /user-global · /, 'no user-global label·dir stanza');
56
53
  assert.doesNotMatch(block, /node-local · /, 'no node-local label·dir stanza');
57
- // New contract: NO (empty) placeholder (the removed empty-store marker).
54
+ // No (empty) placeholder marker.
58
55
  assert.doesNotMatch(block, /\(empty\)/, 'no (empty) placeholder');
59
- // Reference content renders as ## References + ### sub-sections — no other shape.
60
56
  assert.match(block, /## References\n\n###/, '## References followed by ### sub-sections');
61
57
  // A terminal worker must NOT carry the orchestrator across-cycles framing.
62
58
  assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
63
59
  assert.match(block, /<\/crtr-context>/);
64
60
  });
65
- test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; the MEMORY.md index never renders', () => {
66
- // Bug-regression: review finding M1 — buildContextBearings replaced the old
67
- // <memory> pointer-line block with ## References. Node-local substrate docs
68
- // render as ### sub-sections at their rung; the old index file never surfaces.
61
+ test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; a non-substrate .md file is never inlined', () => {
62
+ // Bug-regression: review finding M1 — buildContextBearings renders ## References.
63
+ // Node-local substrate docs render as ### sub-sections at their rung; a
64
+ // non-substrate .md file (no frontmatter `kind`) never surfaces.
69
65
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
70
66
  promote(meta.node_id); // flip to orchestrator mode — the across-cycles gate
71
67
  const dir = memoryDir(meta.node_id);
72
68
  mkdirSync(dir, { recursive: true });
73
- // Write a MEMORY.md file to verify it is never surfaced in the block.
69
+ // Write a non-substrate MEMORY.md file to verify it is never surfaced in the block.
74
70
  const legacyIndexPath = join(dir, 'MEMORY.md');
75
71
  writeFileSync(legacyIndexPath, '# memory index — one pointer line per memory; how-to in "Your long-term memory".\n\n- [Flaky build](flaky-build.md) — first run fails\n');
76
72
  // A node-local substrate doc DOES ride into ## References at its rung.
77
- writeFileSync(join(dir, 'flaky-build.md'), '---\nkind: reference\nwhen: when the build flakes\nwhy: first run fails\nsystem-prompt-visibility: preview\n---\nFirst run always fails; rerun once.\n');
73
+ writeFileSync(join(dir, 'flaky-build.md'), '---\nkind: reference\nwhen-and-why-to-read: When the build flakes, this reference should be read because the first run fails\nsystem-prompt-visibility: preview\n---\nFirst run always fails; rerun once.\n');
78
74
  const block = buildContextIntro(meta.node_id);
79
75
  assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
80
76
  assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
81
77
  assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
82
78
  assert.match(block, /## References\n/, 'references block present');
83
79
  assert.match(block, /### flaky-build\n/, 'node-local doc gets its ### sub-section');
84
- assert.match(block, /when the build flakes, read this reference\. first run fails\./, 'preview rung renders the routing line');
85
- // The index file itself never renders: no header comment, no pointer line, no path.
80
+ assert.match(block, /When the build flakes, this reference should be read because the first run fails\./, 'preview rung renders the routing line');
81
+ // The non-substrate file never renders: no header line, no pointer line, no path.
86
82
  assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
87
83
  assert.ok(!block.includes('- [Flaky build](flaky-build.md)'), 'index pointer lines are NOT inlined');
88
84
  assert.ok(!block.includes(legacyIndexPath), 'no absolute index (MEMORY.md) path');
@@ -90,9 +86,9 @@ test('orchestrator bearings: across-cycles framing + node-local substrate docs r
90
86
  assert.match(block, /<\/crtr-context>/);
91
87
  });
92
88
  test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a ### title stub', () => {
93
- // Bug-regression: review findings M1 + M6 — the three-store `label · dir`
94
- // stanzas and (empty) markers are gone, and node-local docs are NOT filtered
95
- // on rung: a migrated node-local reference defaults
89
+ // Bug-regression: review findings M1 + M6 — the ## References block carries no
90
+ // per-store `label · dir` stanzas or (empty) markers, and node-local docs are
91
+ // NOT filtered on rung: a migrated node-local reference defaults
96
92
  // system-prompt-visibility: none and must still ride into ## References as
97
93
  // its bare title (never its body).
98
94
  const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
@@ -101,7 +97,7 @@ test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-non
101
97
  mkdirSync(dir, { recursive: true });
102
98
  writeFileSync(join(dir, 'rung-none-fact.md'), '---\nkind: reference\n---\nbody that must not render at the none rung\n');
103
99
  const block = buildContextIntro(meta.node_id);
104
- // No per-store stanza headers, no (empty) markers, no index paths.
100
+ // No per-store stanza headers, no (empty) markers, no MEMORY.md paths.
105
101
  assert.ok(!block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'no node-local stanza header');
106
102
  assert.ok(!block.includes('user-global · '), 'no user-global stanza header');
107
103
  assert.ok(!block.includes('project · '), 'no project stanza header');
@@ -109,7 +109,7 @@ test('node demote --detach on a FOCUSED node: flips terminal, relocates the pane
109
109
  // breaks the pane back into h.session.
110
110
  const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
111
111
  assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
112
- assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
112
+ assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
113
113
  closeDb();
114
114
  const b = getNode(B);
115
115
  // (a) lifecycle flipped to terminal IN PLACE.
@@ -180,7 +180,7 @@ test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant
180
180
  // --- Drive the REAL verb on the non-generating focused node.
181
181
  const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
182
182
  assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
183
- assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
183
+ assert.match(res.stdout, /relocated to the background/, `the agent was detached\n${res.stdout}`);
184
184
  closeDb();
185
185
  const b = getNode(B);
186
186
  // (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
@@ -0,0 +1,43 @@
1
+ import { type Socket } from 'node:net';
2
+ import type { Harness } from './harness.js';
3
+ import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
4
+ import { type BrokerToClient, type ClientToBroker, type ClientRole, type WelcomeFrame } from '../../runtime/broker-protocol.js';
5
+ export declare const delay: (ms: number) => Promise<void>;
6
+ export declare const tok: (s: string) => string;
7
+ export declare const frameHas: (f: BrokerToClient, token: string) => boolean;
8
+ export declare function brokerLogText(h: Harness, id: string): string;
9
+ /** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
10
+ * check). Exit-non-zero with empty stdout means "no holders". */
11
+ export declare function lsofHolders(path: string): number[] | null;
12
+ export interface Attached {
13
+ client: ViewSocketClient;
14
+ frames: BrokerToClient[];
15
+ welcome: WelcomeFrame;
16
+ send(frame: ClientToBroker): void;
17
+ waitFrame(pred: (f: BrokerToClient) => boolean, label: string, timeoutMs?: number): Promise<BrokerToClient>;
18
+ close(): void;
19
+ }
20
+ export interface RawClient {
21
+ socket: Socket;
22
+ frames: BrokerToClient[];
23
+ closed: () => boolean;
24
+ send(frame: ClientToBroker): void;
25
+ writeRaw(data: Buffer | string): void;
26
+ waitClosed(label: string, timeoutMs?: number): Promise<void>;
27
+ close(): void;
28
+ }
29
+ export interface AttachKit {
30
+ /** Connect a ViewSocketClient to a node's running broker, hello, await welcome. */
31
+ attach(id: string, role: ClientRole, clientId: string): Promise<Attached>;
32
+ /** Attach (as `role`) and retry until the welcome satisfies `pred`. */
33
+ attachUntil(id: string, role: ClientRole, clientId: string, pred: (a: Attached) => boolean, label: string): Promise<Attached>;
34
+ /** A raw node:net peer. read:false leaves the socket PAUSED (G8 stalled viewer). */
35
+ connectRaw(id: string, opts: {
36
+ read: boolean;
37
+ }): Promise<RawClient>;
38
+ /** Close every client opened since the last closeAll — wire into afterEach. */
39
+ closeAll(): void;
40
+ }
41
+ /** Build the attach kit against a lazily-resolved harness (the harness is
42
+ * created in the file's before() hook, after the kit is constructed). */
43
+ export declare function createAttachKit(getH: () => Harness): AttachKit;
@@ -0,0 +1,178 @@
1
+ // helpers/broker-clients.ts — the shared attach-client kit for the broker
2
+ // lifecycle/attach-gate suite (plan T8/T11). Extracted VERBATIM from the original
3
+ // single-file broker-lifecycle.test.ts when it was split into per-area files so
4
+ // node:test's file-level parallelism applies (each file holds its own isolated
5
+ // harness); the helpers are unchanged in behavior.
6
+ //
7
+ // The kit wraps the PRODUCTION attach client (pure node:net + the broker codec,
8
+ // no TUI) as the in-test controller/observer so the G1–G9 gate exercises the REAL
9
+ // client too (§0 one-writer: a viewer holds ONLY a socket), plus a raw node:net
10
+ // peer for the cases where the client lifecycle is awkward (G7 oversized line,
11
+ // G8 stalled viewer).
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { createConnection } from 'node:net';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { ViewSocketClient } from '../../../clients/attach/view-socket.js';
17
+ import { CLIENT_READ_CAPS, FrameDecoder, encodeFrame, } from '../../runtime/broker-protocol.js';
18
+ export const delay = (ms) => new Promise((r) => setTimeout(r, ms));
19
+ export const tok = (s) => `${s}-${Math.random().toString(36).slice(2, 8)}`;
20
+ export const frameHas = (f, token) => JSON.stringify(f).includes(token);
21
+ export function brokerLogText(h, id) {
22
+ try {
23
+ return readFileSync(join(h.home, 'nodes', id, 'job', 'broker.log'), 'utf8');
24
+ }
25
+ catch {
26
+ return '';
27
+ }
28
+ }
29
+ /** lsof the holders of `path`, or null when lsof is unavailable (skip the fd
30
+ * check). Exit-non-zero with empty stdout means "no holders". */
31
+ export function lsofHolders(path) {
32
+ if (spawnSync('which', ['lsof'], { stdio: 'ignore' }).status !== 0)
33
+ return null;
34
+ const out = (spawnSync('lsof', ['-t', '--', path], { encoding: 'utf8' }).stdout ?? '').trim();
35
+ if (out === '')
36
+ return [];
37
+ return out
38
+ .split('\n')
39
+ .map((l) => Number(l.trim()))
40
+ .filter((n) => Number.isFinite(n));
41
+ }
42
+ /** Build the attach kit against a lazily-resolved harness (the harness is
43
+ * created in the file's before() hook, after the kit is constructed). */
44
+ export function createAttachKit(getH) {
45
+ const liveClients = [];
46
+ // Connect a ViewSocketClient to a node's running broker, hello, await welcome.
47
+ // awaitBoot returns once the boot proof is written — which is BEFORE the broker's
48
+ // server.listen() binds view.sock — so a fresh attach can momentarily race the
49
+ // bind; retry the connect on BrokerUnavailable until it is listening.
50
+ async function attach(id, role, clientId) {
51
+ const h = getH();
52
+ await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
53
+ const frames = [];
54
+ let client;
55
+ for (let attempt = 0;; attempt++) {
56
+ client = new ViewSocketClient(id);
57
+ client.on('frame', (f) => frames.push(f));
58
+ try {
59
+ await new Promise((resolve, reject) => {
60
+ client.once('connect', resolve);
61
+ client.once('error', reject);
62
+ client.connect();
63
+ });
64
+ break;
65
+ }
66
+ catch (err) {
67
+ client.close();
68
+ if (attempt >= 30)
69
+ throw err;
70
+ await delay(100);
71
+ }
72
+ }
73
+ client.on('error', () => { }); // post-connect error sink (never throw uncaught)
74
+ // Register cleanup the instant the socket is connected — BEFORE the hello/welcome
75
+ // round-trip — so a welcome timeout cannot leak a connected socket past the test.
76
+ liveClients.push({ close: () => client.close() });
77
+ const waitFrame = (pred, label, timeoutMs = 15_000) => h.waitFor(() => frames.find(pred) ?? null, { label, timeoutMs });
78
+ client.send({ type: 'hello', role, client_id: clientId });
79
+ let welcome;
80
+ try {
81
+ welcome = (await waitFrame((f) => f.type === 'welcome', `welcome for ${clientId}`));
82
+ }
83
+ catch (err) {
84
+ client.close();
85
+ throw err;
86
+ }
87
+ return { client, frames, welcome, send: (f) => client.send(f), waitFrame, close: () => client.close() };
88
+ }
89
+ // Attach (as `role`) and retry until the welcome satisfies `pred` — used where the
90
+ // observable lands a beat after a prior action (G3 snapshot accrual, G5b control
91
+ // handoff after a controller detaches). Deterministic: it polls an observable.
92
+ async function attachUntil(id, role, clientId, pred, label) {
93
+ for (let attempt = 0;; attempt++) {
94
+ const a = await attach(id, role, `${clientId}-${attempt}`);
95
+ if (pred(a))
96
+ return a;
97
+ a.close();
98
+ if (attempt >= 40)
99
+ throw new Error(`attachUntil timed out: ${label}`);
100
+ await delay(150);
101
+ }
102
+ }
103
+ // A raw node:net peer. read:true decodes incoming frames; read:false leaves the
104
+ // socket PAUSED (no 'data' listener, never resumed) so it never drains — the
105
+ // stalled viewer (G8) whose backlog the broker must shed at the HWM.
106
+ async function connectRaw(id, opts) {
107
+ const h = getH();
108
+ await h.waitFor(() => existsSync(h.brokerSock(id)), { label: `view.sock for ${id}`, timeoutMs: 20_000 });
109
+ const frames = [];
110
+ const decoder = new FrameDecoder(CLIENT_READ_CAPS);
111
+ let isClosed = false;
112
+ const socket = await new Promise((resolve, reject) => {
113
+ const s = createConnection(h.brokerSock(id));
114
+ s.once('connect', () => resolve(s));
115
+ s.once('error', reject);
116
+ });
117
+ socket.on('close', () => {
118
+ isClosed = true;
119
+ });
120
+ socket.on('error', () => {
121
+ /* close follows */
122
+ });
123
+ if (opts.read) {
124
+ socket.on('data', (chunk) => {
125
+ try {
126
+ for (const f of decoder.push(chunk))
127
+ frames.push(f);
128
+ }
129
+ catch {
130
+ /* a client-side overflow is irrelevant here */
131
+ }
132
+ });
133
+ }
134
+ const rc = {
135
+ socket,
136
+ frames,
137
+ closed: () => isClosed,
138
+ send: (f) => {
139
+ try {
140
+ socket.write(encodeFrame(f));
141
+ }
142
+ catch {
143
+ /* dead */
144
+ }
145
+ },
146
+ writeRaw: (d) => {
147
+ try {
148
+ socket.write(d);
149
+ }
150
+ catch {
151
+ /* dead */
152
+ }
153
+ },
154
+ waitClosed: (label, timeoutMs = 15_000) => h.waitFor(() => (isClosed ? true : null), { label, timeoutMs }).then(() => undefined),
155
+ close: () => {
156
+ try {
157
+ socket.destroy();
158
+ }
159
+ catch {
160
+ /* ignore */
161
+ }
162
+ },
163
+ };
164
+ liveClients.push(rc);
165
+ return rc;
166
+ }
167
+ function closeAll() {
168
+ for (const c of liveClients.splice(0)) {
169
+ try {
170
+ c.close();
171
+ }
172
+ catch {
173
+ /* already gone */
174
+ }
175
+ }
176
+ }
177
+ return { attach, attachUntil, connectRaw, closeAll };
178
+ }
@@ -0,0 +1 @@
1
+ export {};