@crouton-kit/crouter 0.3.16 → 0.3.18

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 (104) hide show
  1. package/dist/builtin-personas/design/{base.md → PERSONA.md} +4 -0
  2. package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
  3. package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
  4. package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
  5. package/dist/builtin-personas/orchestration-kernel.md +6 -6
  6. package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
  7. package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
  8. package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
  9. package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
  10. package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
  11. package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
  12. package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
  13. package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
  14. package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
  15. package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
  16. package/dist/commands/canvas-browse.d.ts +2 -0
  17. package/dist/commands/canvas-browse.js +45 -0
  18. package/dist/commands/canvas-prune.js +11 -2
  19. package/dist/commands/canvas.js +3 -2
  20. package/dist/commands/daemon.js +1 -1
  21. package/dist/commands/human/prompts.js +3 -9
  22. package/dist/commands/human/shared.d.ts +26 -1
  23. package/dist/commands/human/shared.js +48 -10
  24. package/dist/commands/node.js +66 -4
  25. package/dist/commands/skill/author.js +2 -2
  26. package/dist/core/__tests__/cascade-close.test.js +199 -0
  27. package/dist/core/__tests__/daemon-boot.test.js +7 -0
  28. package/dist/core/__tests__/daemon-liveness.test.js +59 -4
  29. package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
  30. package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
  31. package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
  32. package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
  33. package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
  34. package/dist/core/__tests__/grace-clock.test.js +115 -0
  35. package/dist/core/__tests__/helpers/harness.d.ts +78 -0
  36. package/dist/core/__tests__/helpers/harness.js +406 -0
  37. package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
  38. package/dist/core/__tests__/human-surface-target.test.js +98 -0
  39. package/dist/core/__tests__/lifecycle.test.js +6 -13
  40. package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
  41. package/dist/core/__tests__/live-mutation.test.js +341 -0
  42. package/dist/core/__tests__/persona-subkind.test.js +18 -15
  43. package/dist/core/__tests__/placement-focus.test.js +53 -15
  44. package/dist/core/__tests__/relaunch.test.js +12 -12
  45. package/dist/core/__tests__/reset.test.js +11 -6
  46. package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
  47. package/dist/core/__tests__/spike-harness.test.js +241 -0
  48. package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
  49. package/dist/core/__tests__/subscription-delivery.test.js +233 -0
  50. package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
  51. package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
  52. package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
  53. package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
  54. package/dist/core/canvas/browse/app.d.ts +4 -0
  55. package/dist/core/canvas/browse/app.js +349 -0
  56. package/dist/core/canvas/browse/model.d.ts +97 -0
  57. package/dist/core/canvas/browse/model.js +258 -0
  58. package/dist/core/canvas/browse/render.d.ts +41 -0
  59. package/dist/core/canvas/browse/render.js +387 -0
  60. package/dist/core/canvas/browse/terminal.d.ts +23 -0
  61. package/dist/core/canvas/browse/terminal.js +100 -0
  62. package/dist/core/canvas/canvas.d.ts +9 -2
  63. package/dist/core/canvas/canvas.js +41 -3
  64. package/dist/core/canvas/paths.d.ts +4 -1
  65. package/dist/core/canvas/paths.js +10 -4
  66. package/dist/core/canvas/render.d.ts +10 -0
  67. package/dist/core/canvas/render.js +25 -1
  68. package/dist/core/canvas/types.js +2 -2
  69. package/dist/core/feed/inbox.d.ts +0 -3
  70. package/dist/core/feed/inbox.js +1 -5
  71. package/dist/core/help.d.ts +6 -0
  72. package/dist/core/help.js +7 -0
  73. package/dist/core/personas/index.d.ts +4 -3
  74. package/dist/core/personas/index.js +3 -2
  75. package/dist/core/personas/loader.d.ts +34 -16
  76. package/dist/core/personas/loader.js +102 -29
  77. package/dist/core/personas/resolve.d.ts +4 -4
  78. package/dist/core/personas/resolve.js +16 -14
  79. package/dist/core/runtime/busy.d.ts +8 -0
  80. package/dist/core/runtime/busy.js +46 -0
  81. package/dist/core/runtime/lifecycle.d.ts +1 -1
  82. package/dist/core/runtime/lifecycle.js +12 -4
  83. package/dist/core/runtime/naming.d.ts +3 -3
  84. package/dist/core/runtime/naming.js +6 -6
  85. package/dist/core/runtime/placement.d.ts +32 -5
  86. package/dist/core/runtime/placement.js +81 -14
  87. package/dist/core/runtime/reset.d.ts +11 -8
  88. package/dist/core/runtime/reset.js +23 -18
  89. package/dist/core/spawn.d.ts +20 -1
  90. package/dist/core/spawn.js +52 -5
  91. package/dist/daemon/crtrd.js +43 -21
  92. package/dist/pi-extensions/canvas-nav.js +106 -55
  93. package/dist/pi-extensions/canvas-resume.d.ts +0 -1
  94. package/dist/pi-extensions/canvas-resume.js +35 -126
  95. package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
  96. package/dist/pi-extensions/canvas-stophook.js +16 -0
  97. package/dist/prompts/skill.js +6 -1
  98. package/package.json +1 -1
  99. package/dist/commands/__tests__/skill.test.js +0 -290
  100. package/dist/core/__tests__/pkg.test.js +0 -218
  101. package/dist/core/__tests__/sys.test.js +0 -208
  102. /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
  103. /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
  104. /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
@@ -0,0 +1,301 @@
1
+ #!/usr/bin/env node
2
+ // fake-pi-host.ts — a deterministic stand-in for the pi LLM vehicle.
3
+ //
4
+ // This is NOT an LLM and NOT a mock of the extensions. It is the real pi
5
+ // vehicle's place in the system, occupied by a tiny event driver that loads the
6
+ // REAL canvas extensions (the `-e <path>` modules the runtime put in our argv)
7
+ // and fires REAL lifecycle events under harness control, so the real hooks
8
+ // drive real canvas state across the process boundary.
9
+ //
10
+ // The runtime exec's us exactly where it would exec `pi`, via the CRTR_PI_BINARY
11
+ // seam in piCommand (src/core/runtime/tmux.ts). We therefore receive:
12
+ // • argv: -e <ext> … -n <label> [--session <path>] [--model …] [--tools …]
13
+ // [--append-system-prompt <file>] [<kickoff prompt>] (buildPiArgv)
14
+ // • env: CRTR_NODE_ID, CRTR_KIND, CRTR_MODE, CRTR_LIFECYCLE, CRTR_HOME,
15
+ // CRTR_PARENT_NODE_ID, CRTR_ROOT_SESSION, CRTR_FRONT_DOOR (tmux -e)
16
+ //
17
+ // Run under tsx (so the `-e` paths resolve to the same .ts modules the CLI
18
+ // referenced when launched via `node --import tsx/esm src/cli.ts`).
19
+ //
20
+ // Control channel (one-way, polled file under the node dir):
21
+ // <CRTR_HOME>/nodes/<id>/fake-pi.cmd — harness writes one JSON command
22
+ // <CRTR_HOME>/nodes/<id>/fake-pi.ack — host appends an ack per command
23
+ // Proof / observability the harness reads:
24
+ // <CRTR_HOME>/nodes/<id>/fake-pi.boot.json — argv + env + loaded exts (latest boot)
25
+ // <CRTR_HOME>/nodes/<id>/fake-pi.boots.jsonl — append-only, one line per boot
26
+ // <CRTR_HOME>/nodes/<id>/fake-pi.events.jsonl — append-only, one line per fired event
27
+ // <CRTR_HOME>/nodes/<id>/fake-pi.injected.jsonl — every sendUserMessage
28
+ // <CRTR_HOME>/nodes/<id>/fake-pi.error — any boot/import failure
29
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, } from 'node:fs';
30
+ import { join } from 'node:path';
31
+ import { pathToFileURL } from 'node:url';
32
+ const env = process.env;
33
+ const nodeId = (env['CRTR_NODE_ID'] ?? '').trim();
34
+ const home = (env['CRTR_HOME'] ?? '').trim();
35
+ const rawArgv = process.argv.slice(2); // everything after `node host.ts`
36
+ function recordError(msg) {
37
+ try {
38
+ if (home && nodeId) {
39
+ const dir = join(home, 'nodes', nodeId);
40
+ mkdirSync(dir, { recursive: true });
41
+ appendFileSync(join(dir, 'fake-pi.error'), msg + '\n');
42
+ }
43
+ }
44
+ catch {
45
+ /* best effort */
46
+ }
47
+ try {
48
+ process.stderr.write('[fake-pi] ' + msg + '\n');
49
+ }
50
+ catch {
51
+ /* ignore */
52
+ }
53
+ }
54
+ function fail(msg) {
55
+ recordError(msg);
56
+ process.exit(17);
57
+ }
58
+ if (nodeId === '')
59
+ fail('CRTR_NODE_ID missing in env');
60
+ if (home === '')
61
+ fail('CRTR_HOME missing in env');
62
+ const nodeDir = join(home, 'nodes', nodeId);
63
+ mkdirSync(nodeDir, { recursive: true });
64
+ // --- parse the argv the runtime built (buildPiArgv → piCommand) ------------
65
+ const extPaths = [];
66
+ let sessionArg;
67
+ let label;
68
+ let prompt;
69
+ for (let i = 0; i < rawArgv.length; i++) {
70
+ const a = rawArgv[i];
71
+ if (a === '-e')
72
+ extPaths.push(rawArgv[++i]);
73
+ else if (a === '-n')
74
+ label = rawArgv[++i];
75
+ else if (a === '--session')
76
+ sessionArg = rawArgv[++i];
77
+ else if (a === '--model' || a === '--tools' || a === '--append-system-prompt' || a === '--fork')
78
+ i++;
79
+ else if (!a.startsWith('-'))
80
+ prompt = a; // positional kickoff (fresh start only)
81
+ }
82
+ const resuming = sessionArg !== undefined;
83
+ const sessionId = sessionArg ?? `fake-sess-${nodeId}-${Date.now()}`;
84
+ const sessionFile = join(nodeDir, 'fake-session.jsonl');
85
+ if (!existsSync(sessionFile))
86
+ writeFileSync(sessionFile, '');
87
+ const handlers = {};
88
+ let shutdownRequested = false;
89
+ let streaming = false;
90
+ const injected = [];
91
+ function recordInjected(rec) {
92
+ injected.push(rec);
93
+ try {
94
+ appendFileSync(join(nodeDir, 'fake-pi.injected.jsonl'), JSON.stringify({ ...rec, ts: Date.now() }) + '\n');
95
+ }
96
+ catch {
97
+ /* best effort */
98
+ }
99
+ }
100
+ // The faithful pi vehicle surface — the union of every method the 7 canvas
101
+ // extensions call at register time. `on` + `sendUserMessage` carry the
102
+ // lifecycle behavior under test; the rest are recording stubs so the chrome
103
+ // extensions (context-intro renderer, commands/nav slash-commands, goal-capture
104
+ // session name) register without throwing, exactly as they would against real pi.
105
+ const pi = {
106
+ on(event, h) {
107
+ (handlers[event] ??= []).push(h);
108
+ },
109
+ sendUserMessage(content, options) {
110
+ recordInjected({ content, deliverAs: options?.deliverAs });
111
+ },
112
+ sendMessage(message, options) {
113
+ recordInjected({ content: JSON.stringify(message), deliverAs: options?.deliverAs });
114
+ },
115
+ registerMessageRenderer(_customType, _renderer) {
116
+ /* spike: no-op recording stub */
117
+ },
118
+ registerCommand(_name, _options) {
119
+ /* spike: no-op recording stub */
120
+ },
121
+ registerShortcut(_shortcut, _options) {
122
+ /* spike: no-op recording stub */
123
+ },
124
+ setSessionName(_name) {
125
+ /* spike: no-op recording stub */
126
+ },
127
+ };
128
+ let eventSeq = 0;
129
+ // Append a durable record of every fired event BEFORE its handlers run. This is
130
+ // the harness's robust "the host received my command and is dispatching it"
131
+ // signal — it survives even when a handler tears the process down mid-flight
132
+ // (e.g. agent_end→reviveInPlace does respawn-pane -k on our own pane), which is
133
+ // exactly the case where an after-the-fact ack would be lost.
134
+ function recordEvent(event, ev) {
135
+ try {
136
+ const reason = ev?.reason;
137
+ appendFileSync(join(nodeDir, 'fake-pi.events.jsonl'), JSON.stringify({ seq: ++eventSeq, event, reason: reason ?? null, ts: Date.now() }) + '\n');
138
+ }
139
+ catch {
140
+ /* best effort */
141
+ }
142
+ }
143
+ async function fire(event, ev, ctx) {
144
+ recordEvent(event, ev);
145
+ for (const h of handlers[event] ?? []) {
146
+ try {
147
+ await h(ev, ctx);
148
+ }
149
+ catch (e) {
150
+ recordError(`handler ${event} threw: ${String(e)}`);
151
+ }
152
+ }
153
+ }
154
+ // The minimum fake-ctx shape (union across both focus extensions), all
155
+ // dereferenced defensively by the hooks (ctx?.x?.()).
156
+ const ctx = {
157
+ sessionManager: {
158
+ getSessionId: () => sessionId,
159
+ getSessionFile: () => sessionFile,
160
+ },
161
+ getContextUsage: () => ({ tokens: 1000 }),
162
+ shutdown: () => {
163
+ shutdownRequested = true;
164
+ },
165
+ isIdle: () => !streaming,
166
+ abort: () => {
167
+ /* spike: no-op */
168
+ },
169
+ };
170
+ // --- load the REAL extension modules from the -e paths ----------------------
171
+ const loaded = [];
172
+ const failedExt = [];
173
+ for (const p of extPaths) {
174
+ try {
175
+ const mod = await import(pathToFileURL(p).href);
176
+ const reg = (mod['default'] ?? mod['register']);
177
+ if (typeof reg === 'function') {
178
+ reg(pi);
179
+ loaded.push(p);
180
+ }
181
+ else {
182
+ failedExt.push(`${p} (no default export fn)`);
183
+ }
184
+ }
185
+ catch (e) {
186
+ failedExt.push(`${p} :: ${String(e)}`);
187
+ recordError(`import ${p} failed: ${String(e)}`);
188
+ }
189
+ }
190
+ // --- session_start: the real boot-confirm hook captures session id + pid -----
191
+ await fire('session_start', { reason: resuming ? 'resume' : 'startup' }, ctx);
192
+ // --- the boot proof the harness asserts on ----------------------------------
193
+ const boot = {
194
+ pid: process.pid,
195
+ nodeId,
196
+ home,
197
+ rawArgv,
198
+ extPaths,
199
+ loaded,
200
+ failedExt,
201
+ sessionId,
202
+ sessionFile,
203
+ resuming,
204
+ label: label ?? null,
205
+ prompt: prompt ?? null,
206
+ env: {
207
+ CRTR_NODE_ID: env['CRTR_NODE_ID'] ?? null,
208
+ CRTR_KIND: env['CRTR_KIND'] ?? null,
209
+ CRTR_MODE: env['CRTR_MODE'] ?? null,
210
+ CRTR_LIFECYCLE: env['CRTR_LIFECYCLE'] ?? null,
211
+ CRTR_NODE_CWD: env['CRTR_NODE_CWD'] ?? null,
212
+ CRTR_HOME: env['CRTR_HOME'] ?? null,
213
+ CRTR_PARENT_NODE_ID: env['CRTR_PARENT_NODE_ID'] ?? null,
214
+ CRTR_ROOT_SESSION: env['CRTR_ROOT_SESSION'] ?? null,
215
+ CRTR_FRONT_DOOR: env['CRTR_FRONT_DOOR'] ?? null,
216
+ },
217
+ injectedDuringBoot: injected.slice(),
218
+ };
219
+ writeFileSync(join(nodeDir, 'fake-pi.boot.json'), JSON.stringify(boot, null, 2));
220
+ // Append-only boot log so the harness can count re-boots (a resume after an
221
+ // idle-release wake, or a fresh pi after a refresh-yield reviveInPlace).
222
+ try {
223
+ appendFileSync(join(nodeDir, 'fake-pi.boots.jsonl'), JSON.stringify(boot) + '\n');
224
+ }
225
+ catch {
226
+ /* best effort */
227
+ }
228
+ // --- control loop: poll for one harness command at a time -------------------
229
+ const cmdFile = join(nodeDir, 'fake-pi.cmd');
230
+ const ackFile = join(nodeDir, 'fake-pi.ack');
231
+ function ack(id, body) {
232
+ try {
233
+ appendFileSync(ackFile, JSON.stringify({ id, ...body, ts: Date.now() }) + '\n');
234
+ }
235
+ catch {
236
+ /* best effort */
237
+ }
238
+ }
239
+ async function doShutdown() {
240
+ // A clean pi /quit. The stophook's session_shutdown handler resolves a still-
241
+ // active node to done (markCleanExitDone → finalize). Mirrors real pi exiting.
242
+ await fire('session_shutdown', { reason: 'quit' }, ctx);
243
+ ack('shutdown', { ok: true });
244
+ clearInterval(timer);
245
+ process.exit(0);
246
+ }
247
+ async function dispatch(cmd) {
248
+ const id = cmd.id ?? cmd.cmd ?? 'cmd';
249
+ switch (cmd.cmd) {
250
+ case 'shutdown':
251
+ await doShutdown();
252
+ break;
253
+ case 'stop':
254
+ streaming = false;
255
+ await fire('agent_end', {
256
+ messages: [
257
+ { role: 'assistant', stopReason: cmd.reason ?? 'stop', content: [{ type: 'text', text: cmd.text ?? '' }] },
258
+ ],
259
+ }, ctx);
260
+ ack(id, { ok: true });
261
+ break;
262
+ case 'turn':
263
+ streaming = true;
264
+ await fire('agent_start', {}, ctx);
265
+ streaming = false;
266
+ await fire('turn_end', { message: { role: 'assistant', usage: { input: 10, output: 5 }, model: 'fake' } }, ctx);
267
+ await fire('agent_end', { messages: [{ role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: cmd.text ?? '' }] }] }, ctx);
268
+ ack(id, { ok: true });
269
+ break;
270
+ default:
271
+ ack(id, { ok: false, error: `unknown cmd: ${String(cmd.cmd)}` });
272
+ }
273
+ }
274
+ async function step() {
275
+ if (shutdownRequested) {
276
+ await doShutdown();
277
+ return;
278
+ }
279
+ if (!existsSync(cmdFile))
280
+ return;
281
+ let cmd = null;
282
+ try {
283
+ cmd = JSON.parse(readFileSync(cmdFile, 'utf8'));
284
+ }
285
+ catch {
286
+ cmd = null;
287
+ }
288
+ try {
289
+ unlinkSync(cmdFile);
290
+ }
291
+ catch {
292
+ /* ignore */
293
+ }
294
+ if (cmd)
295
+ await dispatch(cmd);
296
+ if (shutdownRequested)
297
+ await doShutdown();
298
+ }
299
+ const timer = setInterval(() => {
300
+ void step().catch((e) => recordError(`step: ${String(e)}`));
301
+ }, 100);
@@ -0,0 +1,273 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/flagship-lifecycle.test.ts
2
+ //
3
+ // FLAGSHIP end-to-end lifecycle test — the faithful integration harness driving
4
+ // a full node-graph scenario through the REAL CLI, a REAL isolated tmux session,
5
+ // the REAL extension hooks (fired inside the fake-pi vehicle), and the REAL
6
+ // daemon decision pass (superviseTick, in-process). Every assertion reads the
7
+ // canvas data layer and is checked against the state-model ORACLE.
8
+ //
9
+ // Scenario (each hop asserted):
10
+ // A (base→orchestrator, RESIDENT root) spawns terminal child B.
11
+ // B spawns child C, then goes DORMANT (terminal idle-release, unfocused→pane closes).
12
+ // C finishes → ONE-HOP wakes B (A is NOT woken — the oracle's wake contract).
13
+ // B PROMOTES to orchestrator (terminal), takes a turn (persona drift), then
14
+ // YIELDS and revives in place (refresh-yield).
15
+ // B spawns child D; D finishes; B pushes its final up to A.
16
+ //
17
+ // 2×2 coverage (mode {base,orchestrator} × lifecycle {terminal,resident}):
18
+ // • base×resident — A's PERSISTED ROW SHAPE at S1 (a harness-set fixture,
19
+ // NOT a real bootRoot — see S1 + MINOR-1 caveat below).
20
+ // The resident BEHAVIOR (does not idle-release) is
21
+ // exercised faithfully in live-mutation.test.ts.
22
+ // • orchestrator×resident — A after promote (top-level orchestrator)
23
+ // • base×terminal — B/C/D workers (GENUINE managed-child birth at S3)
24
+ // • orchestrator×terminal — B after promote (sub-orchestrator)
25
+ //
26
+ // ── Known coverage boundaries (DELIBERATE, documented gaps) ────────────────
27
+ // This flagship + the live-mutation/cascade/subscription siblings cover the
28
+ // HAPPY-PATH lifecycle and the live-mutation axis faithfully. The following
29
+ // fault/grace/focus paths are OUT OF SCOPE for the faithful tier in this pass
30
+ // (some are backstopped at the in-process unit tier, cited):
31
+ // • crash → dead (a vehicle that boots then its pane vanishes mid-run) — unit:
32
+ // daemon-liveness.test.ts "pane GONE … crash → dead".
33
+ // • boot-failure push (a vehicle that never boots → surfaceBootFailure urgent
34
+ // push up the spine) — no faithful coverage here.
35
+ // • focused-FREEZE (F3: a focused-dormant node frozen via remain-on-exit,
36
+ // pane-alive but pi-dead) — unit: daemon-liveness.test.ts "idle-release +
37
+ // live (frozen) pane …"; the grace-window double-spawn guard around it is
38
+ // exercised faithfully in grace-clock.test.ts.
39
+ // • node lifecycle --detach (A3: orphaned-focus-row hazard) — untested.
40
+ // • node msg / focus / cycle wake of a dormant node (A7) — untested faithfully.
41
+ // These are intentional boundaries, not oversights.
42
+ import { test } from 'node:test';
43
+ import assert from 'node:assert/strict';
44
+ import { spawnSync } from 'node:child_process';
45
+ import { createHarness, hasTmux } from './helpers/harness.js';
46
+ import { STALL_REPROMPT } from '../runtime/stop-guard.js';
47
+ function sessionExists(session) {
48
+ return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
49
+ }
50
+ test('flagship: full node lifecycle — spawn, dormancy, one-hop wake, promote, yield, finish', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
51
+ const h = await createHarness({ sessionPrefix: 'crtr-flagship' });
52
+ try {
53
+ // ===================================================================
54
+ // S1 — A's PERSISTED ROW SHAPE: base × RESIDENT (the user's front door).
55
+ // ⚠ MINOR-1 caveat: this is NOT a real root birth. The harness mints A
56
+ // via in-process createNode over a hand-built meta whose defaults ARE
57
+ // mode:'base'/lifecycle:'resident' — the REAL birth/boot path (bootRoot)
58
+ // execs pi inline and never returns, so it is structurally unreachable
59
+ // from the harness (harness-design Wall #7). These four asserts therefore
60
+ // PIN THE PERSISTED ROW SHAPE the rest of the scenario builds on — they
61
+ // do NOT prove the real resident-birth path. The resident-at-birth 2×2
62
+ // quadrant is out of reach; the resident *behavior* (no idle-release) is
63
+ // exercised faithfully in live-mutation.test.ts, and the genuine real
64
+ // birth of a managed child (→ terminal) IS exercised at S3 below.
65
+ // ===================================================================
66
+ const A = h.spawnRoot('top-level orchestrator');
67
+ {
68
+ const a = h.node(A);
69
+ assert.equal(a.mode, 'base', 'A row shape: base');
70
+ assert.equal(a.lifecycle, 'resident', 'A row shape: resident (fixture, not a real boot)');
71
+ assert.equal(a.status, 'active', 'A active');
72
+ assert.equal(a.intent ?? null, null, 'A no intent');
73
+ }
74
+ // ===================================================================
75
+ // S2 — A promotes: orchestrator × RESIDENT (top-level orchestrator).
76
+ // mode flips; lifecycle/status/intent untouched (no transition()).
77
+ // ===================================================================
78
+ {
79
+ const res = h.cli(A, ['node', 'promote']);
80
+ assert.equal(res.code, 0, `promote A exit 0\n${res.stderr}`);
81
+ const a = h.node(A);
82
+ assert.equal(a.mode, 'orchestrator', 'A → orchestrator');
83
+ assert.equal(a.lifecycle, 'resident', 'A stays resident (no --resident)');
84
+ assert.equal(a.status, 'active', 'A still active (promote does not transition)');
85
+ assert.equal(a.intent ?? null, null, 'A intent untouched by promote');
86
+ }
87
+ // ===================================================================
88
+ // S3 — A spawns terminal child B: base × TERMINAL. The spawn seed wires
89
+ // the spine — A auto-subscribes ACTIVE to B, B spawned_by A.
90
+ // ===================================================================
91
+ const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
92
+ {
93
+ const b = h.node(B);
94
+ assert.equal(b.mode, 'base', 'B born base');
95
+ assert.equal(b.lifecycle, 'terminal', 'B born terminal (managed child)');
96
+ assert.equal(b.status, 'active', 'B active after boot');
97
+ assert.equal(b.intent ?? null, null, 'B no intent');
98
+ assert.equal(b.parent, A, 'B spawned_by / parent = A');
99
+ // The load-bearing spine seed: A subscribes_to B (active).
100
+ const subsToB = h.subscribers(B);
101
+ assert.deepEqual(subsToB, [{ node_id: A, active: true }], 'A is B\'s sole ACTIVE subscriber (spawn seed)');
102
+ assert.equal(h.inbox(A).length, 0, 'A inbox empty (spawn pushes nothing)');
103
+ }
104
+ // ===================================================================
105
+ // S4 — B spawns child C (base × terminal). B now holds an active live
106
+ // subscription to C → B is legitimately "awaiting" if it stops.
107
+ // ===================================================================
108
+ const C = await h.spawnChild(B, 'a subtask');
109
+ {
110
+ const c = h.node(C);
111
+ assert.equal(c.mode, 'base', 'C base');
112
+ assert.equal(c.lifecycle, 'terminal', 'C terminal');
113
+ assert.equal(c.status, 'active', 'C active');
114
+ assert.deepEqual(h.subscribers(C), [{ node_id: B, active: true }], 'B is C\'s sole ACTIVE subscriber (spawn seed)');
115
+ assert.deepEqual(h.subscriptions(B), [{ node_id: C, active: true }], 'B subscribes_to C (active)');
116
+ assert.equal(h.status(B), 'active', 'B still active');
117
+ }
118
+ // ===================================================================
119
+ // S5 — B goes DORMANT. It stops while awaiting C → terminal idle-release:
120
+ // transition('release') → idle / idle-release; ctx.shutdown kills pi;
121
+ // UNFOCUSED → the backstage pane CLOSES (fully dormant).
122
+ // ===================================================================
123
+ await h.stop(B);
124
+ await h.waitForStatus(B, 'idle');
125
+ {
126
+ const b = h.node(B);
127
+ assert.equal(b.status, 'idle', 'B idle (released)');
128
+ assert.equal(b.intent, 'idle-release', 'B intent=idle-release');
129
+ }
130
+ await h.waitForPaneGone(B);
131
+ assert.equal(h.paneAlive(B), false, 'B unfocused → pane closed on idle-release');
132
+ assert.equal(h.status(C), 'active', 'C still active while B sleeps');
133
+ // A untouched — a dormant terminal worker does not disturb its manager.
134
+ assert.equal(h.status(A), 'active', 'A still active');
135
+ assert.equal(h.inbox(A).length, 0, 'A inbox still empty');
136
+ // ===================================================================
137
+ // S6 — C FINISHES (push final). Pointer fans to subscribersOf(C) = {B}
138
+ // ONLY. C → done/done. A is NOT a subscriber of C → A inbox stays
139
+ // empty. This is the one-hop wake contract at the push site.
140
+ // ===================================================================
141
+ await h.finish(C, 'C result body');
142
+ {
143
+ const c = h.node(C);
144
+ assert.equal(c.status, 'done', 'C done');
145
+ assert.equal(c.intent, 'done', 'C intent=done');
146
+ assert.equal(h.paneAlive(C), false, 'C pane closed on done');
147
+ const bInbox = h.inbox(B);
148
+ const cFinal = bInbox.find((e) => e.from === C && e.kind === 'final');
149
+ assert.ok(cFinal, 'B inbox received C\'s final pointer (one hop)');
150
+ // ORACLE: ONE-HOP. A is not woken — only B (the direct subscriber) hears C.
151
+ assert.equal(h.inbox(A).length, 0, 'A inbox empty — A NOT woken by C (one-hop)');
152
+ }
153
+ // ===================================================================
154
+ // S7 — WAKE B. The in-process daemon second pass sees B idle/idle-release,
155
+ // pi dead, an unseen inbox entry → reviveNode(resume). B → active,
156
+ // intent cleared (revive). Its FRESH watcher delivers C's report.
157
+ // ===================================================================
158
+ const injBeforeWake = h.injected(B).length;
159
+ await h.tick(); // one superviseTick: 1st pass nulls the stale window, 2nd revives on inbox
160
+ await h.waitForStatus(B, 'active');
161
+ {
162
+ const b = h.node(B);
163
+ assert.equal(b.status, 'active', 'B revived → active');
164
+ assert.equal(b.intent ?? null, null, 'B intent cleared by revive');
165
+ }
166
+ await h.awaitBoot(B, { minCount: 2 }); // the resume boot (spawn boot + wake boot)
167
+ const wakeDigests = await h.awaitWake(B, { sinceCount: injBeforeWake, match: /C result body/ });
168
+ assert.ok(wakeDigests.some((d) => /C result body/.test(d)), 'B\'s real inbox-watcher delivered C\'s report after the wake');
169
+ // Still one-hop: A remained dormant-as-resident and was never touched.
170
+ assert.equal(h.inbox(A).length, 0, 'A inbox STILL empty after B woke (one-hop confirmed)');
171
+ assert.equal(h.status(A), 'active', 'A unchanged');
172
+ // ===================================================================
173
+ // S8 — B PROMOTES: base → orchestrator, lifecycle TERMINAL unchanged
174
+ // (no --resident). orchestrator × terminal (the sub-orchestrator).
175
+ // ===================================================================
176
+ {
177
+ const res = h.cli(B, ['node', 'promote', '--kind', 'developer']);
178
+ assert.equal(res.code, 0, `promote B exit 0\n${res.stderr}`);
179
+ const b = h.node(B);
180
+ assert.equal(b.mode, 'orchestrator', 'B → orchestrator');
181
+ assert.equal(b.lifecycle, 'terminal', 'B stays TERMINAL (promote is mode-only)');
182
+ assert.equal(b.status, 'active', 'B still active');
183
+ assert.equal(b.intent ?? null, null, 'B intent untouched by promote');
184
+ }
185
+ // ===================================================================
186
+ // S9 — B takes a TURN. turn_end detects the base→orchestrator persona
187
+ // drift and injects the orchestration guidance as a 'steer', then
188
+ // commits the ack. agent_end then stalls (no live sub, no final) →
189
+ // STALL_REPROMPT (followUp). Both are real stophook branches.
190
+ // ===================================================================
191
+ const injBeforeTurn = h.injected(B).length;
192
+ await h.turn(B, 'orchestrating');
193
+ const turnInjected = await h.waitFor(() => {
194
+ const fresh = h.injected(B).slice(injBeforeTurn);
195
+ // MINOR-3: match the steer by CONTENT (/ORCHESTRATOR/i), not deliverAs
196
+ // alone — mirroring live-mutation.test.ts's orchestrationSteers() — so
197
+ // this pins the orchestration guidance specifically, not "some steer."
198
+ const steer = fresh.find((e) => e.deliverAs === 'steer' && /ORCHESTRATOR/i.test(e.content));
199
+ const reprompt = fresh.find((e) => e.content.includes(STALL_REPROMPT));
200
+ return steer && reprompt ? fresh : null;
201
+ }, { timeoutMs: 15_000, label: 'B turn injected persona-drift steer + stall reprompt' });
202
+ assert.ok(turnInjected.some((e) => e.deliverAs === 'steer' && /ORCHESTRATOR/i.test(e.content)), 'turn_end injected the base→orchestrator persona-drift guidance (steer with ORCHESTRATOR content)');
203
+ assert.ok(turnInjected.some((e) => e.content.includes(STALL_REPROMPT)), 'agent_end stalled (orchestrator, no live sub, no final) → STALL_REPROMPT');
204
+ assert.equal(h.status(B), 'active', 'B still active after the turn (reprompt keeps it alive)');
205
+ // ===================================================================
206
+ // S10 — B YIELDS + revives. node yield → intent=refresh (active kept);
207
+ // agent_end (b') runs reviveInPlace (respawn-pane -k) IN the fake-pi
208
+ // pane; the fresh pi's session_start clears refresh → active. mode
209
+ // and lifecycle survive the refresh.
210
+ // ===================================================================
211
+ await h.yieldNode(B, 'refresh against the roadmap');
212
+ {
213
+ const b = h.node(B);
214
+ assert.equal(b.status, 'active', 'B active after refresh-yield');
215
+ assert.equal(b.intent ?? null, null, 'B intent=refresh cleared by the fresh boot');
216
+ assert.equal(b.mode, 'orchestrator', 'B mode survives the refresh');
217
+ assert.equal(b.lifecycle, 'terminal', 'B lifecycle survives the refresh');
218
+ }
219
+ // ===================================================================
220
+ // S11 — B spawns child D (base × terminal). New spine seed B→D active.
221
+ // ===================================================================
222
+ const D = await h.spawnChild(B, 'a second subtask');
223
+ {
224
+ const d = h.node(D);
225
+ assert.equal(d.mode, 'base', 'D base');
226
+ assert.equal(d.lifecycle, 'terminal', 'D terminal');
227
+ assert.equal(d.status, 'active', 'D active');
228
+ assert.ok(h.subscriptions(B).some((s) => s.node_id === D && s.active), 'B subscribes_to D (active)');
229
+ }
230
+ // ===================================================================
231
+ // S12 — D FINISHES → pointer to B (its only subscriber).
232
+ // ===================================================================
233
+ await h.finish(D, 'D result body');
234
+ {
235
+ assert.equal(h.node(D).status, 'done', 'D done');
236
+ const dFinal = h.inbox(B).find((e) => e.from === D && e.kind === 'final');
237
+ assert.ok(dFinal, 'B inbox received D\'s final pointer');
238
+ }
239
+ // ===================================================================
240
+ // S13 — B pushes its FINAL up the spine → done/done; the pointer fans to
241
+ // subscribersOf(B) = {A}. NOW A finally hears B (its own explicit
242
+ // push — the only way the chain propagates a hop).
243
+ // ===================================================================
244
+ {
245
+ const res = h.cli(B, ['push', 'final', 'all work complete']);
246
+ assert.equal(res.code, 0, `B push final exit 0\n${res.stderr}`);
247
+ const b = h.node(B);
248
+ assert.equal(b.status, 'done', 'B done after push final');
249
+ assert.equal(b.intent, 'done', 'B intent=done');
250
+ const aInbox = h.inbox(A);
251
+ assert.equal(aInbox.length, 1, 'A inbox now has exactly B\'s final (one hop, on B\'s push)');
252
+ assert.equal(aInbox[0].from, B, 'A\'s entry is from B');
253
+ assert.equal(aInbox[0].kind, 'final', 'A\'s entry is a final');
254
+ }
255
+ // Close B's window faithfully (done branch shutdown).
256
+ await h.stop(B);
257
+ await h.waitForPaneGone(B);
258
+ assert.equal(h.paneAlive(B), false, 'B pane closed on done');
259
+ // A — the resident top-level orchestrator — is still alive and well.
260
+ {
261
+ const a = h.node(A);
262
+ assert.equal(a.status, 'active', 'A still active at the end');
263
+ assert.equal(a.mode, 'orchestrator', 'A still orchestrator');
264
+ assert.equal(a.lifecycle, 'resident', 'A still resident');
265
+ }
266
+ }
267
+ finally {
268
+ const session = h.session;
269
+ await h.dispose();
270
+ // Teardown leaves NO stray session.
271
+ assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
272
+ }
273
+ });
@@ -0,0 +1 @@
1
+ export {};