@crouton-kit/crouter 0.3.21 → 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 (111) hide show
  1. package/dist/commands/canvas-history/read.d.ts +1 -0
  2. package/dist/commands/canvas-history/read.js +61 -0
  3. package/dist/commands/canvas-history/search.d.ts +1 -0
  4. package/dist/commands/canvas-history/search.js +270 -0
  5. package/dist/commands/canvas-history/show.d.ts +1 -0
  6. package/dist/commands/canvas-history/show.js +79 -0
  7. package/dist/commands/canvas-history.d.ts +2 -0
  8. package/dist/commands/canvas-history.js +33 -0
  9. package/dist/commands/canvas.js +3 -2
  10. package/dist/commands/memory/find.js +7 -40
  11. package/dist/commands/memory/lint.js +19 -34
  12. package/dist/commands/memory/list.js +8 -18
  13. package/dist/commands/memory/read.js +4 -37
  14. package/dist/commands/pkg/bridge.d.ts +1 -0
  15. package/dist/commands/pkg/bridge.js +137 -0
  16. package/dist/commands/pkg/plugin-inspect.js +7 -6
  17. package/dist/commands/pkg.js +2 -1
  18. package/dist/commands/skill/author.js +35 -44
  19. package/dist/commands/skill/shared.d.ts +1 -5
  20. package/dist/commands/skill/shared.js +9 -63
  21. package/dist/commands/skill.js +5 -6
  22. package/dist/commands/sys/doctor.js +6 -144
  23. package/dist/core/__tests__/broker-dormant-wake.test.js +71 -34
  24. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +21 -5
  25. package/dist/core/__tests__/canvas-inbox-watcher.test.js +21 -5
  26. package/dist/core/__tests__/child-death-wake.test.js +133 -185
  27. package/dist/core/__tests__/daemon-liveness.test.js +28 -258
  28. package/dist/core/__tests__/flagship-lifecycle.test.js +156 -135
  29. package/dist/core/__tests__/frame-decoder-perf.test.js +51 -25
  30. package/dist/core/__tests__/{broker-attach-limits.test.js → full/broker-attach-limits.test.js} +62 -35
  31. package/dist/core/__tests__/{broker-attach-stream.test.js → full/broker-attach-stream.test.js} +54 -31
  32. package/dist/core/__tests__/full/broker-crash-teardown.test.js +143 -0
  33. package/dist/core/__tests__/full/broker-dialogs.test.js +153 -0
  34. package/dist/core/__tests__/full/broker-lifecycle.test.js +116 -0
  35. package/dist/core/__tests__/{cascade-close.test.js → full/cascade-close.test.js} +1 -1
  36. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +292 -0
  37. package/dist/core/__tests__/{dead-pane-regression.test.js → full/dead-pane-regression.test.js} +2 -2
  38. package/dist/core/__tests__/{detach-focus.test.js → full/detach-focus.test.js} +5 -5
  39. package/dist/core/__tests__/{human-new-window-regression.test.js → full/human-new-window-regression.test.js} +2 -2
  40. package/dist/core/__tests__/{placement-focus.test.js → full/placement-focus.test.js} +5 -5
  41. package/dist/core/__tests__/{placement-reconcile.test.js → full/placement-reconcile.test.js} +3 -3
  42. package/dist/core/__tests__/{placement-revive.test.js → full/placement-revive.test.js} +5 -5
  43. package/dist/core/__tests__/{placement-teardown.test.js → full/placement-teardown.test.js} +4 -4
  44. package/dist/core/__tests__/{review-render-pane-regression.test.js → full/review-render-pane-regression.test.js} +2 -2
  45. package/dist/core/__tests__/full/spike-harness.test.js +117 -0
  46. package/dist/core/__tests__/grace-clock.test.js +72 -75
  47. package/dist/core/__tests__/helpers/harness.d.ts +35 -1
  48. package/dist/core/__tests__/helpers/harness.js +70 -12
  49. package/dist/core/__tests__/live-mutation-verbs.test.js +99 -104
  50. package/dist/core/__tests__/live-mutation.test.js +111 -134
  51. package/dist/core/__tests__/revive.test.js +96 -86
  52. package/dist/core/__tests__/subscription-delivery.test.js +116 -138
  53. package/dist/core/__tests__/wake-origin.test.js +54 -50
  54. package/dist/core/bridge-map.d.ts +19 -0
  55. package/dist/core/bridge-map.js +73 -0
  56. package/dist/core/canvas/history.d.ts +91 -0
  57. package/dist/core/canvas/history.js +389 -0
  58. package/dist/core/canvas/index.d.ts +1 -0
  59. package/dist/core/canvas/index.js +1 -0
  60. package/dist/core/config.js +3 -22
  61. package/dist/core/memory-resolver.d.ts +9 -2
  62. package/dist/core/memory-resolver.js +47 -7
  63. package/dist/core/resolver.d.ts +1 -30
  64. package/dist/core/resolver.js +6 -546
  65. package/dist/core/runtime/persona.js +5 -7
  66. package/dist/core/scope.d.ts +7 -5
  67. package/dist/core/scope.js +16 -17
  68. package/dist/core/substrate/ceiling.d.ts +28 -0
  69. package/dist/core/substrate/ceiling.js +87 -0
  70. package/dist/core/substrate/index.d.ts +2 -0
  71. package/dist/core/substrate/index.js +1 -0
  72. package/dist/core/substrate/on-read.js +18 -2
  73. package/dist/core/substrate/render.d.ts +2 -3
  74. package/dist/core/substrate/render.js +63 -51
  75. package/dist/pi-extensions/canvas-inbox-watcher.js +19 -2
  76. package/dist/types.d.ts +3 -18
  77. package/dist/types.js +0 -8
  78. package/package.json +4 -3
  79. package/dist/builtin-skills/.crouter-plugin/plugin.json +0 -5
  80. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +0 -157
  81. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +0 -106
  82. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +0 -49
  83. package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +0 -49
  84. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +0 -156
  85. package/dist/builtin-skills/skills/design/SKILL.md +0 -51
  86. package/dist/builtin-skills/skills/development/SKILL.md +0 -109
  87. package/dist/builtin-skills/skills/planning/SKILL.md +0 -59
  88. package/dist/builtin-skills/skills/spec/SKILL.md +0 -83
  89. package/dist/commands/skill/state.d.ts +0 -3
  90. package/dist/commands/skill/state.js +0 -71
  91. package/dist/core/__tests__/broker-crash-teardown.test.js +0 -116
  92. package/dist/core/__tests__/broker-dialogs.test.js +0 -126
  93. package/dist/core/__tests__/broker-lifecycle.test.js +0 -87
  94. package/dist/core/__tests__/resolver.test.js +0 -181
  95. package/dist/core/__tests__/spike-harness.test.js +0 -242
  96. /package/dist/core/__tests__/{broker-attach-limits.test.d.ts → full/broker-attach-limits.test.d.ts} +0 -0
  97. /package/dist/core/__tests__/{broker-attach-stream.test.d.ts → full/broker-attach-stream.test.d.ts} +0 -0
  98. /package/dist/core/__tests__/{broker-crash-teardown.test.d.ts → full/broker-crash-teardown.test.d.ts} +0 -0
  99. /package/dist/core/__tests__/{broker-dialogs.test.d.ts → full/broker-dialogs.test.d.ts} +0 -0
  100. /package/dist/core/__tests__/{broker-lifecycle.test.d.ts → full/broker-lifecycle.test.d.ts} +0 -0
  101. /package/dist/core/__tests__/{cascade-close.test.d.ts → full/cascade-close.test.d.ts} +0 -0
  102. /package/dist/core/__tests__/{dead-pane-regression.test.d.ts → full/daemon-liveness-pane.full.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{detach-focus.test.d.ts → full/dead-pane-regression.test.d.ts} +0 -0
  104. /package/dist/core/__tests__/{human-new-window-regression.test.d.ts → full/detach-focus.test.d.ts} +0 -0
  105. /package/dist/core/__tests__/{placement-focus.test.d.ts → full/human-new-window-regression.test.d.ts} +0 -0
  106. /package/dist/core/__tests__/{placement-reconcile.test.d.ts → full/placement-focus.test.d.ts} +0 -0
  107. /package/dist/core/__tests__/{placement-revive.test.d.ts → full/placement-reconcile.test.d.ts} +0 -0
  108. /package/dist/core/__tests__/{placement-teardown.test.d.ts → full/placement-revive.test.d.ts} +0 -0
  109. /package/dist/core/__tests__/{resolver.test.d.ts → full/placement-teardown.test.d.ts} +0 -0
  110. /package/dist/core/__tests__/{review-render-pane-regression.test.d.ts → full/review-render-pane-regression.test.d.ts} +0 -0
  111. /package/dist/core/__tests__/{spike-harness.test.d.ts → full/spike-harness.test.d.ts} +0 -0
@@ -1,87 +0,0 @@
1
- // Run with: node --import tsx/esm --test src/core/__tests__/broker-lifecycle.test.ts
2
- //
3
- // The broker-backed lifecycle suite — the acceptance-gate proof of the headless-
4
- // broker migration (plan T11). It drives the REAL `crtr` CLI into an isolated
5
- // REAL tmux session and spawns `--headless` nodes onto the REAL headless broker
6
- // host (host.ts), which boots a REAL detached broker PROCESS (broker.ts) per
7
- // node. The broker hosts a fake SDK engine (fixtures/fake-engine.ts) loaded via
8
- // the CRTR_BROKER_ENGINE seam — NOTHING in the broker, the host, the stophook,
9
- // the inbox-watcher, the stop-guard, or the daemon (superviseTick) is mocked.
10
- // Every assertion reads straight off the canvas data layer.
11
- //
12
- // The suite is SPLIT across sibling files (one isolated harness each) so
13
- // node:test's file-level parallelism applies — the tests themselves are the
14
- // original acceptance gate, unchanged:
15
- // THIS FILE — items 1–2 + 3 (LIVE): spawn, LIVE wake
16
- // broker-dormant-wake.test.ts — item 3 (DORMANT): idle-release → daemon revive
17
- // broker-crash-teardown.test.ts — items 4–6 + M-1/M-2: crash, teardown, boot failure
18
- // broker-dialogs.test.ts — §5.4 C2 + gates G5/G5b/G6 (dialogs)
19
- // broker-attach-stream.test.ts — gates G1/G1b/G2/G3 (drive + relay + snapshot)
20
- // broker-attach-limits.test.ts — gates G4/G7/G8/G9 (arbitration + caps + one-writer)
21
- //
22
- // Acceptance items (plan §4) → the asserting test:
23
- // 1 spawn --headless → host_kind='broker', null placement, live broker pid → "spawn"
24
- // 2 supervised by broker-pid signal-0 (stophook recordPid = broker pid) → "spawn"
25
- // 3 inbox wakes LIVE (in-broker watcher) AND DORMANT (idle-release → daemon) → "live wake" here + "dormant wake" (broker-dormant-wake.test.ts)
26
- // 7 tmux stays default — the existing suite passes UNMODIFIED (this split only
27
- // MOVES tests between files + extracts harness wiring; nothing is rewritten).
28
- import { test, before, after } from 'node:test';
29
- import assert from 'node:assert/strict';
30
- import { createHarness, hasTmux } from './helpers/harness.js';
31
- import { appendInbox } from '../feed/inbox.js';
32
- import { isPidAlive } from '../canvas/pid.js';
33
- let h;
34
- let root;
35
- before(async () => {
36
- if (!hasTmux())
37
- return;
38
- h = await createHarness({ sessionPrefix: 'crtr-broker' });
39
- root = h.spawnRoot('broker-suite root');
40
- });
41
- after(async () => {
42
- if (h !== undefined)
43
- await h.dispose();
44
- });
45
- // ===========================================================================
46
- // Item 1 + 2 — spawn --headless: broker host, NULL placement, a live broker pid
47
- // that IS the daemon's supervision signal (stophook recordPid(process.pid)).
48
- // ===========================================================================
49
- test('spawn --headless → broker host_kind, null placement, live broker pid; daemon leaves a healthy broker', { skip: !hasTmux() }, async () => {
50
- const id = await h.spawnHeadlessChild(root, 'headless worker — spawn');
51
- const node = h.node(id);
52
- // Item 1: the broker host + a paneless placement.
53
- assert.equal(node.host_kind, 'broker', "host_kind === 'broker'");
54
- assert.equal(node.window ?? null, null, 'window is NULL (paneless broker)');
55
- assert.equal(node.pane ?? null, null, 'pane is NULL (paneless broker)');
56
- assert.equal(node.tmux_session ?? null, null, 'tmux_session is NULL (paneless broker)');
57
- assert.ok(node.pi_pid != null, 'a broker pid was recorded as pi_pid');
58
- assert.equal(isPidAlive(node.pi_pid), true, 'the broker pid is alive');
59
- // Item 2: the supervision signal IS the broker pid — the stophook recorded
60
- // process.pid (the broker's own pid) on session_start, == the boot proof pid.
61
- const boot = await h.awaitBoot(id);
62
- assert.equal(boot.pid, node.pi_pid, 'boot-proof pid (broker process.pid) === supervised pi_pid');
63
- // The daemon supervises it by that pid alone: a healthy broker is left untouched.
64
- const before = h.bootCount(id);
65
- await h.tick();
66
- assert.equal(h.status(id), 'active', 'a healthy broker stays active across a daemon tick');
67
- assert.equal(h.node(id).pi_pid, node.pi_pid, 'pid unchanged — not revived');
68
- assert.equal(h.bootCount(id), before, 'no reboot');
69
- assert.equal(isPidAlive(node.pi_pid), true, 'broker still alive');
70
- });
71
- // ===========================================================================
72
- // Item 3 (LIVE) — the in-broker inbox-watcher delivers an inbox push WITHOUT an
73
- // exit or revive (the broker stays alive; pi.sendUserMessage → injected).
74
- // ===========================================================================
75
- test('LIVE wake — in-broker watcher injects an inbox push with no exit/revive', { skip: !hasTmux() }, async () => {
76
- const id = await h.spawnHeadlessChild(root, 'headless worker — live wake');
77
- const pid = h.node(id).pi_pid;
78
- const boots = h.bootCount(id);
79
- appendInbox(id, { from: 'tester', tier: 'normal', kind: 'message', label: 'live-wake', data: { body: 'do this live' } });
80
- // The REAL in-broker watcher (800ms poll + 1000ms debounce) delivers via
81
- // pi.sendUserMessage → fake-pi.injected.jsonl.
82
- await h.awaitWake(id, { match: /do this live/, timeoutMs: 15_000 });
83
- // No exit, no revive: same broker pid, still alive, no new boot.
84
- assert.equal(h.bootCount(id), boots, 'no new boot — the live broker was not revived');
85
- assert.equal(h.node(id).pi_pid, pid, 'pi_pid unchanged — the broker never exited');
86
- assert.equal(isPidAlive(pid), true, 'the broker is still alive after the live wake');
87
- });
@@ -1,181 +0,0 @@
1
- // Tests for parseSkillQualifier (slash-only form) and config migration.
2
- //
3
- // Run with: node --import tsx/esm --test 'src/core/__tests__/resolver.test.ts'
4
- import { test, describe, before, after } from 'node:test';
5
- import assert from 'node:assert/strict';
6
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
7
- import { tmpdir } from 'node:os';
8
- import { join } from 'node:path';
9
- import { parseSkillQualifier, resolveSkill } from '../resolver.js';
10
- import { resetScopeCache } from '../scope.js';
11
- import { CrtrError } from '../errors.js';
12
- import { InputError } from '../io.js';
13
- import { readConfig } from '../config.js';
14
- import { SCHEMA_VERSION } from '../../types.js';
15
- // ---------------------------------------------------------------------------
16
- // parseSkillQualifier — slash-only form
17
- // ---------------------------------------------------------------------------
18
- describe('parseSkillQualifier', () => {
19
- test('bare name returns single segment', () => {
20
- const r = parseSkillQualifier('foo');
21
- assert.deepEqual(r, { segments: ['foo'] });
22
- });
23
- test('plugin/name returns two segments', () => {
24
- const r = parseSkillQualifier('ai/interface');
25
- assert.deepEqual(r, { segments: ['ai', 'interface'] });
26
- });
27
- test('plugin/nested/name returns three segments', () => {
28
- const r = parseSkillQualifier('ai/interface/cli-design');
29
- assert.deepEqual(r, { segments: ['ai', 'interface', 'cli-design'] });
30
- });
31
- test('scope-qualified user/name sets scope, single segment', () => {
32
- const r = parseSkillQualifier('user/my-skill');
33
- assert.deepEqual(r, { scope: 'user', segments: ['my-skill'] });
34
- });
35
- test('scope-qualified project/name sets scope', () => {
36
- const r = parseSkillQualifier('project/my-skill');
37
- assert.deepEqual(r, { scope: 'project', segments: ['my-skill'] });
38
- });
39
- test('scope-qualified user/ai/x/y sets scope and leaves rest as segments', () => {
40
- const r = parseSkillQualifier('user/ai/x/y');
41
- assert.deepEqual(r, { scope: 'user', segments: ['ai', 'x', 'y'] });
42
- });
43
- test('colon in input throws InputError with invalid_qualifier', () => {
44
- assert.throws(() => parseSkillQualifier('ai:interface/cli-design'), (e) => {
45
- assert.ok(e instanceof InputError, 'should be InputError');
46
- assert.equal(e.payload.error, 'invalid_qualifier');
47
- assert.equal(e.payload.received, 'ai:interface/cli-design');
48
- assert.ok(e.payload.next.includes('ai/interface/cli-design'), `next should contain slash form, got: ${e.payload.next}`);
49
- return true;
50
- });
51
- });
52
- test('legacy scope:name form throws invalid_qualifier', () => {
53
- assert.throws(() => parseSkillQualifier('user:my-skill'), (e) => {
54
- assert.ok(e instanceof InputError);
55
- assert.equal(e.payload.error, 'invalid_qualifier');
56
- assert.ok(e.payload.next.includes('user/my-skill'));
57
- return true;
58
- });
59
- });
60
- test('legacy scope:plugin/name form throws invalid_qualifier', () => {
61
- assert.throws(() => parseSkillQualifier('user:ai/interface/cli-design'), (e) => {
62
- assert.ok(e instanceof InputError);
63
- assert.equal(e.payload.error, 'invalid_qualifier');
64
- assert.ok(e.payload.next.includes('user/ai/interface/cli-design'));
65
- return true;
66
- });
67
- });
68
- });
69
- // ---------------------------------------------------------------------------
70
- // Config migration: colon keys → slash keys on readConfig with old schema_version
71
- // ---------------------------------------------------------------------------
72
- describe('config migration: skill keys colon → slash', () => {
73
- let testHomeDir;
74
- let origHome;
75
- let crouterDir;
76
- before(() => {
77
- testHomeDir = join(tmpdir(), `crtr-resolver-test-${Date.now()}`);
78
- mkdirSync(testHomeDir, { recursive: true });
79
- origHome = process.env['HOME'];
80
- process.env['HOME'] = testHomeDir;
81
- crouterDir = join(testHomeDir, '.crouter');
82
- mkdirSync(crouterDir, { recursive: true });
83
- });
84
- after(() => {
85
- if (origHome === undefined) {
86
- delete process.env['HOME'];
87
- }
88
- else {
89
- process.env['HOME'] = origHome;
90
- }
91
- rmSync(testHomeDir, { recursive: true, force: true });
92
- });
93
- test('colon keys are migrated to slash keys and written with new schema_version', () => {
94
- const oldConfig = {
95
- schema_version: 1,
96
- marketplaces: {},
97
- plugins: {},
98
- skills: {
99
- 'ai:foo': { enabled: true },
100
- 'ai:interface/cli-design': { enabled: false },
101
- },
102
- auto_update: { crtr: 'notify', content: 'notify', interval_hours: 24 },
103
- max_panes_per_window: 3,
104
- };
105
- writeFileSync(join(crouterDir, 'config.json'), JSON.stringify(oldConfig), 'utf8');
106
- const cfg = readConfig('user');
107
- assert.ok(!('ai:foo' in cfg.skills), 'old colon key should be gone');
108
- assert.ok('ai/foo' in cfg.skills, 'slash key should exist');
109
- assert.equal(cfg.skills['ai/foo']?.enabled, true, 'enabled state preserved');
110
- assert.ok(!('ai:interface/cli-design' in cfg.skills), 'old colon key should be gone');
111
- assert.ok('ai/interface/cli-design' in cfg.skills, 'slash key should exist for nested');
112
- assert.equal(cfg.skills['ai/interface/cli-design']?.enabled, false, 'disabled state preserved');
113
- assert.equal(cfg.schema_version, SCHEMA_VERSION, 'schema_version bumped to current');
114
- });
115
- });
116
- // ---------------------------------------------------------------------------
117
- // resolveSkill — leaf-name fallback
118
- // ---------------------------------------------------------------------------
119
- describe('resolveSkill leaf-name fallback', () => {
120
- let testHomeDir;
121
- let origHome;
122
- function writePluginSkill(plugin, skillPath) {
123
- const root = join(testHomeDir, '.crouter', 'plugins', plugin);
124
- mkdirSync(join(root, '.crouter-plugin'), { recursive: true });
125
- writeFileSync(join(root, '.crouter-plugin', 'plugin.json'), JSON.stringify({ name: plugin, version: '0.0.1' }), 'utf8');
126
- const skillDir = join(root, 'skills', ...skillPath.split('/'));
127
- mkdirSync(skillDir, { recursive: true });
128
- writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${skillPath.split('/').pop()}\n---\nbody`, 'utf8');
129
- }
130
- before(() => {
131
- testHomeDir = join(tmpdir(), `crtr-leaf-test-${Date.now()}`);
132
- mkdirSync(testHomeDir, { recursive: true });
133
- origHome = process.env['HOME'];
134
- process.env['HOME'] = testHomeDir;
135
- resetScopeCache();
136
- // Unique leaf: only one plugin has it, reached via nested path.
137
- writePluginSkill('ai', 'interface/cli-design');
138
- // Colliding leaf: two plugins both expose `dup` at different paths.
139
- writePluginSkill('pa', 'x/dup');
140
- writePluginSkill('pb', 'y/dup');
141
- });
142
- after(() => {
143
- if (origHome === undefined)
144
- delete process.env['HOME'];
145
- else
146
- process.env['HOME'] = origHome;
147
- resetScopeCache();
148
- rmSync(testHomeDir, { recursive: true, force: true });
149
- });
150
- test('bare leaf name resolves to the nested skill', () => {
151
- const s = resolveSkill('cli-design');
152
- assert.equal(s.name, 'interface/cli-design');
153
- assert.equal(s.plugin, 'ai');
154
- });
155
- test('full path still resolves directly', () => {
156
- const s = resolveSkill('ai/interface/cli-design');
157
- assert.equal(s.name, 'interface/cli-design');
158
- assert.equal(s.plugin, 'ai');
159
- });
160
- test('colliding leaf name throws ambiguous listing full paths', () => {
161
- assert.throws(() => resolveSkill('dup'), (e) => {
162
- assert.ok(e instanceof CrtrError, 'should be CrtrError');
163
- assert.equal(e.code, 'ambiguous');
164
- assert.match(e.message, /pa\/x\/dup/);
165
- assert.match(e.message, /pb\/y\/dup/);
166
- return true;
167
- });
168
- });
169
- test('colliding leaf is resolvable via full path', () => {
170
- const s = resolveSkill('pb/y/dup');
171
- assert.equal(s.plugin, 'pb');
172
- assert.equal(s.name, 'y/dup');
173
- });
174
- test('unknown leaf still throws not_found', () => {
175
- assert.throws(() => resolveSkill('no-such-leaf-xyz'), (e) => {
176
- assert.ok(e instanceof CrtrError);
177
- assert.equal(e.code, 'not_found');
178
- return true;
179
- });
180
- });
181
- });
@@ -1,242 +0,0 @@
1
- // Run with: node --import tsx/esm --test src/core/__tests__/spike-harness.test.ts
2
- //
3
- // SPIKE — a throwaway-grade proof that a faithful integration harness for the
4
- // node/canvas runtime is feasible. It drives the REAL `crtr` CLI into an
5
- // isolated REAL tmux session, substitutes a FAKE-PI vehicle (the fake-pi-host
6
- // fixture) via the CRTR_PI_BINARY seam, and proves the spawned window actually
7
- // exec's the fake pi with the right argv+env, that the fake loads the REAL
8
- // extensions, and that one real lifecycle hook drives a real canvas transition.
9
- //
10
- // Milestones (de-risk order):
11
- // 1. SEAM — piCommand substitutes CRTR_PI_BINARY only when set (unit).
12
- // 2. ROUND-TRIP— real `node new` → isolated tmux window → fake pi boots with
13
- // CRTR_NODE_ID + the -e env intact (GO/NO-GO).
14
- // 3. REAL HOOKS— the fake pi loads the real stophook and a clean /quit drives
15
- // status=done via the real session_shutdown handler.
16
- // 4. TEARDOWN — the isolated session + fake-pi procs are killed; no strays.
17
- import { test, before, after } from 'node:test';
18
- import assert from 'node:assert/strict';
19
- import { spawnSync } from 'node:child_process';
20
- import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
21
- import { tmpdir } from 'node:os';
22
- import { join, dirname } from 'node:path';
23
- import { fileURLToPath } from 'node:url';
24
- import { createRequire } from 'node:module';
25
- import { createNode, getNode } from '../canvas/canvas.js';
26
- import { closeDb } from '../canvas/db.js';
27
- import { piCommand } from '../runtime/placement.js';
28
- import { CANVAS_EXTENSIONS } from '../runtime/launch.js';
29
- // --- locations --------------------------------------------------------------
30
- const HERE = dirname(fileURLToPath(import.meta.url)); // src/core/__tests__
31
- const CROUTER = join(HERE, '..', '..', '..'); // package root
32
- const CLI_SRC = join(CROUTER, 'src', 'cli.ts');
33
- const FAKE_PI_HOST = join(HERE, 'fixtures', 'fake-pi-host.ts');
34
- const TSX_ESM = createRequire(import.meta.url).resolve('tsx/esm');
35
- // A multi-word launcher baked verbatim ahead of the (shell-quoted) argv.
36
- const FAKE_PI_BINARY = `${process.execPath} --import ${TSX_ESM} ${FAKE_PI_HOST}`;
37
- function hasTmux() {
38
- return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
39
- }
40
- function tmuxSessionExists(session) {
41
- return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
42
- }
43
- // --- env isolation: scrub every canvas var the harness itself runs under, so
44
- // the spawned CLI cannot leak into the real canvas. -------------------------
45
- const CANVAS_ENV_KEYS = [
46
- 'CRTR_NODE_ID',
47
- 'CRTR_HOME',
48
- 'CRTR_ROOT_SESSION',
49
- 'CRTR_SUBTREE',
50
- 'CRTR_NODE_SESSION',
51
- 'CRTR_PARENT_NODE_ID',
52
- 'CRTR_FRONT_DOOR',
53
- 'CRTR_KIND',
54
- 'CRTR_MODE',
55
- 'CRTR_LIFECYCLE',
56
- 'CRTR_NODE_CWD',
57
- 'CRTR_PI_BINARY',
58
- 'TMUX',
59
- 'TMUX_PANE',
60
- ];
61
- function cleanBaseEnv() {
62
- const e = {};
63
- for (const [k, v] of Object.entries(process.env))
64
- if (v !== undefined)
65
- e[k] = v;
66
- for (const k of CANVAS_ENV_KEYS)
67
- delete e[k];
68
- // Contain per-invocation bootstrap + auto-update side effects.
69
- e['CRTR_NO_BOOTSTRAP'] = '1';
70
- e['CRTR_NO_AUTO_UPDATE'] = '1';
71
- e['CRTR_NO_BOOT_SKILL'] = '1';
72
- e['CRTR_NO_MODE_CMDS'] = '1';
73
- e['CRTR_NO_AUTO_INIT'] = '1';
74
- return e;
75
- }
76
- function node(id, over = {}) {
77
- return {
78
- node_id: id,
79
- name: id,
80
- created: new Date().toISOString(),
81
- cwd: CROUTER,
82
- kind: 'general',
83
- mode: 'base',
84
- lifecycle: 'resident',
85
- status: 'active',
86
- parent: null,
87
- ...over,
88
- };
89
- }
90
- async function waitFor(probe, opts = {}) {
91
- const timeoutMs = opts.timeoutMs ?? 20_000;
92
- const intervalMs = opts.intervalMs ?? 150;
93
- const deadline = Date.now() + timeoutMs;
94
- for (;;) {
95
- const v = probe();
96
- if (v)
97
- return v;
98
- if (Date.now() > deadline)
99
- throw new Error(`waitFor timed out: ${opts.label ?? 'condition'}`);
100
- await new Promise((r) => setTimeout(r, intervalMs));
101
- }
102
- }
103
- // --- harness state ----------------------------------------------------------
104
- let home;
105
- let tmpHome;
106
- let origHome;
107
- const sessionsToKill = new Set();
108
- const pidsToKill = new Set();
109
- before(() => {
110
- origHome = process.env['CRTR_HOME'];
111
- home = mkdtempSync(join(tmpdir(), 'crtr-spike-home-'));
112
- tmpHome = mkdtempSync(join(tmpdir(), 'crtr-spike-HOME-'));
113
- // The harness reads/writes the isolated canvas in-process.
114
- process.env['CRTR_HOME'] = home;
115
- closeDb();
116
- });
117
- after(() => {
118
- for (const s of sessionsToKill)
119
- spawnSync('tmux', ['kill-session', '-t', s], { stdio: 'ignore' });
120
- for (const p of pidsToKill) {
121
- try {
122
- process.kill(p, 'SIGKILL');
123
- }
124
- catch {
125
- /* already gone */
126
- }
127
- }
128
- closeDb();
129
- if (home)
130
- rmSync(home, { recursive: true, force: true });
131
- if (tmpHome)
132
- rmSync(tmpHome, { recursive: true, force: true });
133
- if (origHome === undefined)
134
- delete process.env['CRTR_HOME'];
135
- else
136
- process.env['CRTR_HOME'] = origHome;
137
- });
138
- // ===========================================================================
139
- // MILESTONE 1 — the CRTR_PI_BINARY seam (always runs; no tmux needed).
140
- // ===========================================================================
141
- test('M1 seam: piCommand exec\'s `pi` when CRTR_PI_BINARY is unset, substitutes when set', () => {
142
- const saved = process.env['CRTR_PI_BINARY'];
143
- try {
144
- delete process.env['CRTR_PI_BINARY'];
145
- const unset = piCommand(['-e', '/abs/ext.ts', '-n', 'label']);
146
- assert.equal(unset, "pi '-e' '/abs/ext.ts' '-n' 'label'", 'unset → identical to exec pi');
147
- assert.ok(unset.startsWith('pi '), 'unset → leads with the literal pi binary');
148
- process.env['CRTR_PI_BINARY'] = '/tmp/fake-pi';
149
- const set = piCommand(['-e', '/abs/ext.ts']);
150
- assert.ok(set.startsWith('/tmp/fake-pi '), 'set → leads with the substituted binary');
151
- assert.ok(!set.startsWith('pi '), 'set → no longer the literal pi');
152
- assert.equal(set, "/tmp/fake-pi '-e' '/abs/ext.ts'", 'argv still shell-quoted after the substitution');
153
- // A multi-word launcher is spliced verbatim (argv stays quoted).
154
- process.env['CRTR_PI_BINARY'] = 'node --import tsx/esm host.ts';
155
- assert.equal(piCommand(['-n', 'x']), "node --import tsx/esm host.ts '-n' 'x'", 'multi-word binary spliced ahead of the quoted argv');
156
- // An explicit binary arg still overrides the env.
157
- assert.ok(piCommand(['-n', 'x'], 'pi').startsWith('pi '), 'explicit binary arg wins over the env seam');
158
- }
159
- finally {
160
- if (saved === undefined)
161
- delete process.env['CRTR_PI_BINARY'];
162
- else
163
- process.env['CRTR_PI_BINARY'] = saved;
164
- }
165
- });
166
- // ===========================================================================
167
- // MILESTONES 2 + 3 — real CLI → isolated tmux → fake pi → real hooks.
168
- // THE GO/NO-GO. Shares one spawned child across both milestones.
169
- // ===========================================================================
170
- test('M2+M3 round-trip: real `node new` reaches the fake pi via the seam, and a real hook drives status=done', { skip: !hasTmux() }, async () => {
171
- const session = `crtr-spike-${process.pid}-rt`;
172
- sessionsToKill.add(session);
173
- // Pre-create the isolated session (default tmux server — the runtime shells
174
- // `tmux` with no -L, so an -L server would be invisible to the real CLI).
175
- spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', CROUTER, 'sleep 600'], {
176
- stdio: 'ignore',
177
- });
178
- assert.ok(tmuxSessionExists(session), 'isolated tmux session created');
179
- // Bootstrap the acting node in the isolated canvas (the parent `node new`
180
- // spawns under). createNode shares the harness CRTR_HOME.
181
- createNode(node('A', { name: 'acting-root' }));
182
- // Drive the REAL CLI: `crtr node new` AS node A, into the isolated session,
183
- // with the fake-pi seam. Body passed as a positional (dodges the stdin hang).
184
- const env = cleanBaseEnv();
185
- env['CRTR_HOME'] = home;
186
- env['HOME'] = tmpHome;
187
- env['CRTR_NODE_ID'] = 'A';
188
- env['CRTR_NODE_SESSION'] = session;
189
- env['CRTR_PI_BINARY'] = FAKE_PI_BINARY;
190
- const res = spawnSync(process.execPath, ['--import', TSX_ESM, CLI_SRC, 'node', 'new', 'spike task', '--parent', 'A', '--cwd', CROUTER], { cwd: CROUTER, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 });
191
- assert.equal(res.status, 0, `node new should exit 0\n--- stdout ---\n${res.stdout}\n--- stderr ---\n${res.stderr}`);
192
- // Find the spawned child: the only node dir that isn't the acting root.
193
- closeDb();
194
- const nodesDir = join(home, 'nodes');
195
- const childId = readdirSync(nodesDir).find((d) => d !== 'A');
196
- assert.ok(childId, 'a child node dir was created by node new');
197
- // ---- MILESTONE 2 assertions: the round-trip reached the fake pi --------
198
- const bootPath = join(nodesDir, childId, 'fake-pi.boot.json');
199
- const errPath = join(nodesDir, childId, 'fake-pi.error');
200
- await waitFor(() => existsSync(bootPath), {
201
- timeoutMs: 30_000,
202
- label: `fake-pi boot proof at ${bootPath}${existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''}`,
203
- });
204
- const boot = JSON.parse(readFileSync(bootPath, 'utf8'));
205
- if (typeof boot.pid === 'number')
206
- pidsToKill.add(boot.pid);
207
- // env delivered via tmux -e arrived in the fake pi's process.env.
208
- assert.equal(boot.env.CRTR_NODE_ID, childId, 'CRTR_NODE_ID is the CHILD id, intact');
209
- assert.equal(boot.env.CRTR_HOME, home, 'CRTR_HOME isolated value intact');
210
- assert.ok(boot.env.CRTR_KIND, 'CRTR_KIND present');
211
- assert.ok(boot.env.CRTR_MODE, 'CRTR_MODE present');
212
- assert.ok(boot.env.CRTR_LIFECYCLE, 'CRTR_LIFECYCLE present');
213
- assert.equal(boot.env.CRTR_FRONT_DOOR, '1', 'CRTR_FRONT_DOOR overlay present');
214
- // argv from buildPiArgv arrived: every canvas -e extension + the kickoff.
215
- // Assert against the live CANVAS_EXTENSIONS count (8 at current HEAD — the
216
- // placement-v3 refactor added canvas-resume) so this never drifts again.
217
- assert.equal(boot.extPaths.length, CANVAS_EXTENSIONS.length, `all ${CANVAS_EXTENSIONS.length} canvas -e extension paths in argv`);
218
- assert.ok(boot.loaded.some((p) => p.includes('canvas-stophook')), 'real stophook module loaded by the fake pi');
219
- assert.ok(boot.loaded.some((p) => p.includes('canvas-inbox-watcher')), 'real inbox-watcher module loaded by the fake pi');
220
- assert.equal(boot.failedExt.length, 0, `no extension failed to load: ${JSON.stringify(boot.failedExt)}`);
221
- assert.equal(boot.resuming, false, 'fresh start (no --session)');
222
- assert.equal(boot.prompt, 'spike task', 'kickoff prompt is the last positional');
223
- // The REAL stophook session_start handler ran inside the fake pi and wrote
224
- // shared canvas state (proves the hook chain, not just the boot).
225
- closeDb();
226
- const afterBoot = getNode(childId);
227
- assert.ok(afterBoot, 'child node readable from the shared canvas');
228
- assert.equal(afterBoot.pi_session_id, boot.sessionId, 'stophook captured pi_session_id');
229
- assert.equal(afterBoot.status, 'active', 'child active after boot');
230
- // ---- MILESTONE 3: a clean /quit drives a real transition to done -------
231
- writeFileSync(join(nodesDir, childId, 'fake-pi.cmd'), JSON.stringify({ cmd: 'shutdown' }));
232
- const done = await waitFor(() => {
233
- closeDb();
234
- return getNode(childId)?.status === 'done' ? true : false;
235
- }, { timeoutMs: 20_000, label: 'child status=done after clean /quit' });
236
- assert.ok(done, 'real session_shutdown hook resolved the node to done');
237
- assert.equal(getNode(childId)?.status, 'done', 'status=done via the real stophook');
238
- // ---- MILESTONE 4: teardown leaves no stray session ---------------------
239
- spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
240
- sessionsToKill.delete(session);
241
- assert.ok(!tmuxSessionExists(session), 'isolated session killed, no stray left');
242
- });