@crouton-kit/crouter 0.3.11 → 0.3.13

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 (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +407 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +55 -0
  125. package/dist/core/runtime/presence.js +198 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +87 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +31 -0
  135. package/dist/core/runtime/spawn.js +123 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +107 -0
  139. package/dist/core/runtime/tmux.js +244 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +396 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
@@ -101,6 +101,24 @@ describe('parseArgv: positional', () => {
101
101
  });
102
102
  });
103
103
  // ---------------------------------------------------------------------------
104
+ // parseArgv — stdin satisfied by a positional argument
105
+ // ---------------------------------------------------------------------------
106
+ describe('parseArgv: stdin-as-positional', () => {
107
+ const params = [
108
+ { kind: 'stdin', name: 'prompt', required: true, constraint: 'Task.' },
109
+ { kind: 'flag', name: 'agent', type: 'string', required: false, default: 'general', constraint: '' },
110
+ ];
111
+ test('a positional token satisfies a stdin param', async () => {
112
+ const result = await parseArgv(params, ['--agent', 'general', 'Say hi']);
113
+ assert.equal(result['prompt'], 'Say hi');
114
+ assert.equal(result['agent'], 'general');
115
+ });
116
+ test('positional-as-stdin works with the positional before flags', async () => {
117
+ const result = await parseArgv(params, ['Say hi', '--agent', 'general']);
118
+ assert.equal(result['prompt'], 'Say hi');
119
+ });
120
+ });
121
+ // ---------------------------------------------------------------------------
104
122
  // parseArgv — context-file
105
123
  // ---------------------------------------------------------------------------
106
124
  describe('parseArgv: context-file', () => {
@@ -167,7 +185,7 @@ describe('renderLeafArgv: help format', () => {
167
185
  });
168
186
  test('contains Output section', () => {
169
187
  const out = renderLeafArgv(help);
170
- assert.ok(out.includes('Output (stdout, JSON)'));
188
+ assert.ok(out.includes('Output (fields carried in the rendered result)'));
171
189
  });
172
190
  test('contains Effects section', () => {
173
191
  const out = renderLeafArgv(help);
@@ -0,0 +1,100 @@
1
+ // Tests for the canvas-node inbox watcher pi extension.
2
+ //
3
+ // Run with: node --import tsx/esm --test src/core/__tests__/canvas-inbox-watcher.test.ts
4
+ //
5
+ // Focus: a finished node (push final → InboxEntry kind 'final') must STEER a
6
+ // mid-stream subscriber, not queue behind its current turn as a follow-up.
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
+ // Mirror the watcher's internal cadence (TICK_MS=800, DEBOUNCE_MS=1000): allow a
15
+ // resolve+seed tick, a read tick, and the debounce window before asserting.
16
+ const TICK_MS = 800;
17
+ const DEBOUNCE_MS = 1000;
18
+ const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 500;
19
+ let origHome;
20
+ let origNode;
21
+ const homes = [];
22
+ const disposers = [];
23
+ /** Point CRTR_HOME at a fresh temp canvas root and bind CRTR_NODE_ID. */
24
+ function freshNode(nodeId) {
25
+ const home = join(tmpdir(), `crtr-canvas-watcher-${Date.now()}-${Math.random().toString(36).slice(2)}`);
26
+ mkdirSync(home, { recursive: true });
27
+ homes.push(home);
28
+ process.env['CRTR_HOME'] = home;
29
+ process.env['CRTR_NODE_ID'] = nodeId;
30
+ }
31
+ function makeFakePi() {
32
+ const handlers = {};
33
+ return {
34
+ injected: [],
35
+ on(e, h) { handlers[e] = h; },
36
+ sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
37
+ fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
38
+ };
39
+ }
40
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
41
+ before(() => {
42
+ origHome = process.env['CRTR_HOME'];
43
+ origNode = process.env['CRTR_NODE_ID'];
44
+ });
45
+ afterEach(() => {
46
+ while (disposers.length > 0)
47
+ disposers.pop()();
48
+ });
49
+ after(() => {
50
+ if (origHome === undefined)
51
+ delete process.env['CRTR_HOME'];
52
+ else
53
+ process.env['CRTR_HOME'] = origHome;
54
+ if (origNode === undefined)
55
+ delete process.env['CRTR_NODE_ID'];
56
+ else
57
+ process.env['CRTR_NODE_ID'] = origNode;
58
+ for (const h of homes) {
59
+ try {
60
+ rmSync(h, { recursive: true, force: true });
61
+ }
62
+ catch { /* noop */ }
63
+ }
64
+ });
65
+ describe('canvas inbox watcher — finished-node delivery', () => {
66
+ test('mid-stream: a finished node (kind final) steers the subscriber', async () => {
67
+ freshNode('node-final');
68
+ const pi = makeFakePi();
69
+ disposers.push(registerCanvasInboxWatcher(pi));
70
+ // Subscriber is actively streaming when the worker finishes.
71
+ pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
72
+ await wait(TICK_MS + 100);
73
+ appendInbox('node-final', { from: 'child-1', tier: 'normal', kind: 'final', label: 'all done' });
74
+ await wait(SETTLE_MS);
75
+ assert.equal(pi.injected.length, 1, 'one coalesced injection');
76
+ assert.equal(pi.injected[0].deliverAs, 'steer', 'a finished node steers, not follows up');
77
+ });
78
+ test('mid-stream: a routine update still follows up', async () => {
79
+ freshNode('node-update');
80
+ const pi = makeFakePi();
81
+ disposers.push(registerCanvasInboxWatcher(pi));
82
+ pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
83
+ await wait(TICK_MS + 100);
84
+ appendInbox('node-update', { from: 'child-2', tier: 'normal', kind: 'update', label: 'still working' });
85
+ await wait(SETTLE_MS);
86
+ assert.equal(pi.injected.length, 1);
87
+ assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
88
+ });
89
+ test('idle: a finished node triggers a fresh turn (no deliverAs)', async () => {
90
+ freshNode('node-idle');
91
+ const pi = makeFakePi();
92
+ disposers.push(registerCanvasInboxWatcher(pi));
93
+ // No agent_start fired → watcher treats the node as idle.
94
+ await wait(TICK_MS + 100);
95
+ appendInbox('node-idle', { from: 'child-3', tier: 'normal', kind: 'final', label: 'done while idle' });
96
+ await wait(SETTLE_MS);
97
+ assert.equal(pi.injected.length, 1);
98
+ assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
99
+ });
100
+ });
@@ -0,0 +1,154 @@
1
+ import { test, before, after, beforeEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { createNode, getNode, getRow, updateNode, setStatus, listNodes, subscribe, unsubscribe, setSubscriptionActive, recordSpawn, subscribersOf, subscriptionsOf, view, hasActiveLiveSubscription, rebuildIndex, } from '../canvas/canvas.js';
7
+ import { closeDb } from '../canvas/db.js';
8
+ import { contextDir, reportsDir, jobDir, nodeMetaPath, } from '../canvas/paths.js';
9
+ let home;
10
+ function node(id, over = {}) {
11
+ return {
12
+ node_id: id,
13
+ name: id,
14
+ created: new Date().toISOString(),
15
+ cwd: '/tmp/work',
16
+ kind: 'general',
17
+ mode: 'base',
18
+ lifecycle: 'terminal',
19
+ status: 'active',
20
+ ...over,
21
+ };
22
+ }
23
+ before(() => {
24
+ home = mkdtempSync(join(tmpdir(), 'crtr-canvas-'));
25
+ process.env['CRTR_HOME'] = home;
26
+ });
27
+ beforeEach(() => {
28
+ // Fresh db + dirs per test for isolation.
29
+ closeDb();
30
+ rmSync(home, { recursive: true, force: true });
31
+ });
32
+ after(() => {
33
+ closeDb();
34
+ rmSync(home, { recursive: true, force: true });
35
+ delete process.env['CRTR_HOME'];
36
+ });
37
+ test('createNode scaffolds dirs, writes meta, indexes the row', () => {
38
+ createNode(node('a'));
39
+ assert.ok(existsSync(contextDir('a')));
40
+ assert.ok(existsSync(reportsDir('a')));
41
+ assert.ok(existsSync(jobDir('a')));
42
+ assert.ok(existsSync(nodeMetaPath('a')));
43
+ const meta = getNode('a');
44
+ assert.equal(meta?.node_id, 'a');
45
+ assert.equal(meta?.kind, 'general');
46
+ const row = getRow('a');
47
+ assert.equal(row?.node_id, 'a');
48
+ assert.equal(row?.status, 'active');
49
+ });
50
+ test('meta.json is the source of truth on disk', () => {
51
+ createNode(node('a', { kind: 'developer' }));
52
+ const raw = JSON.parse(readFileSync(nodeMetaPath('a'), 'utf8'));
53
+ assert.equal(raw.kind, 'developer');
54
+ });
55
+ test('updateNode merges meta and re-indexes the row', () => {
56
+ createNode(node('a'));
57
+ updateNode('a', { mode: 'orchestrator', lifecycle: 'resident' });
58
+ assert.equal(getNode('a')?.mode, 'orchestrator');
59
+ assert.equal(getRow('a')?.lifecycle, 'resident');
60
+ // unspecified fields preserved
61
+ assert.equal(getNode('a')?.kind, 'general');
62
+ });
63
+ test('setStatus updates both meta and row', () => {
64
+ createNode(node('a'));
65
+ setStatus('a', 'done');
66
+ assert.equal(getNode('a')?.status, 'done');
67
+ assert.equal(getRow('a')?.status, 'done');
68
+ });
69
+ test('listNodes filters by status', () => {
70
+ createNode(node('a', { status: 'active' }));
71
+ createNode(node('b', { status: 'idle' }));
72
+ createNode(node('c', { status: 'done' }));
73
+ assert.equal(listNodes().length, 3);
74
+ assert.deepEqual(listNodes({ status: ['active', 'idle'] }).map((n) => n.node_id).sort(), ['a', 'b']);
75
+ });
76
+ test('subscription spine: subscribersOf / subscriptionsOf', () => {
77
+ createNode(node('mgr'));
78
+ createNode(node('w1'));
79
+ createNode(node('w2'));
80
+ // mgr subscribes to both workers (parent watches children)
81
+ subscribe('mgr', 'w1');
82
+ subscribe('mgr', 'w2', false);
83
+ // w1's subscribers = who a w1 push fans out to = mgr
84
+ assert.deepEqual(subscribersOf('w1').map((s) => s.node_id), ['mgr']);
85
+ // mgr's subscriptions = its reports = both workers
86
+ const subs = subscriptionsOf('mgr');
87
+ assert.deepEqual(subs.map((s) => s.node_id).sort(), ['w1', 'w2']);
88
+ assert.equal(subs.find((s) => s.node_id === 'w1')?.active, true);
89
+ assert.equal(subs.find((s) => s.node_id === 'w2')?.active, false);
90
+ });
91
+ test('subscribe is idempotent and flips active', () => {
92
+ createNode(node('a'));
93
+ createNode(node('b'));
94
+ subscribe('a', 'b', true);
95
+ subscribe('a', 'b', false); // re-subscribe updates the flag, no dup
96
+ const subs = subscriptionsOf('a');
97
+ assert.equal(subs.length, 1);
98
+ assert.equal(subs[0].active, false);
99
+ setSubscriptionActive('a', 'b', true);
100
+ assert.equal(subscriptionsOf('a')[0].active, true);
101
+ unsubscribe('a', 'b');
102
+ assert.equal(subscriptionsOf('a').length, 0);
103
+ });
104
+ test('view = transitive closure down the subscription spine', () => {
105
+ // root → mid → leaf, plus root → sib
106
+ for (const id of ['root', 'mid', 'leaf', 'sib'])
107
+ createNode(node(id));
108
+ subscribe('root', 'mid');
109
+ subscribe('root', 'sib');
110
+ subscribe('mid', 'leaf');
111
+ assert.deepEqual(view('root').sort(), ['leaf', 'mid', 'sib']);
112
+ assert.deepEqual(view('mid'), ['leaf']);
113
+ });
114
+ test('view is cycle-safe', () => {
115
+ createNode(node('a'));
116
+ createNode(node('b'));
117
+ subscribe('a', 'b');
118
+ subscribe('b', 'a');
119
+ assert.deepEqual(view('a'), ['b']);
120
+ });
121
+ test('hasActiveLiveSubscription: the stop-guard primitive', () => {
122
+ createNode(node('mgr'));
123
+ createNode(node('child', { status: 'active' }));
124
+ // no subscription yet
125
+ assert.equal(hasActiveLiveSubscription('mgr'), false);
126
+ subscribe('mgr', 'child', true);
127
+ assert.equal(hasActiveLiveSubscription('mgr'), true); // active sub to a live node
128
+ setStatus('child', 'done');
129
+ assert.equal(hasActiveLiveSubscription('mgr'), false); // child no longer live
130
+ setStatus('child', 'active');
131
+ setSubscriptionActive('mgr', 'child', false);
132
+ assert.equal(hasActiveLiveSubscription('mgr'), false); // passive sub doesn't count
133
+ });
134
+ test('recordSpawn writes the audit-only spawned_by edge', () => {
135
+ createNode(node('parent'));
136
+ createNode(node('child', { parent: 'parent' }));
137
+ recordSpawn('child', 'parent');
138
+ // spawned_by does not appear in the subscription spine
139
+ assert.equal(subscriptionsOf('child').length, 0);
140
+ assert.equal(subscribersOf('parent').length, 0);
141
+ });
142
+ test('rebuildIndex reconstructs node rows from on-disk metas', () => {
143
+ createNode(node('a', { parent: null }));
144
+ createNode(node('b', { parent: 'a' }));
145
+ // wipe just the db, keep the node dirs
146
+ closeDb();
147
+ rmSync(join(home, 'canvas.db'), { force: true });
148
+ rmSync(join(home, 'canvas.db-wal'), { force: true });
149
+ rmSync(join(home, 'canvas.db-shm'), { force: true });
150
+ assert.equal(getRow('a'), null); // gone from index
151
+ rebuildIndex();
152
+ assert.equal(getRow('a')?.node_id, 'a');
153
+ assert.equal(getRow('b')?.parent, 'a');
154
+ });
@@ -0,0 +1,105 @@
1
+ // Run with: node --import tsx/esm --test src/core/__tests__/reset.test.ts
2
+ import { test, before, after, beforeEach } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { createNode, getNode, subscribe, setStatus, subscriptionsOf, view, } from '../canvas/canvas.js';
8
+ import { closeDb } from '../canvas/db.js';
9
+ import { reportsDir, inboxPath } from '../canvas/paths.js';
10
+ import { roadmapPath } from '../runtime/roadmap.js';
11
+ import { resetRoot } from '../runtime/reset.js';
12
+ let home;
13
+ function node(id, over = {}) {
14
+ return {
15
+ node_id: id,
16
+ name: id,
17
+ created: new Date().toISOString(),
18
+ cwd: '/tmp/work',
19
+ kind: 'general',
20
+ mode: 'base',
21
+ lifecycle: 'terminal',
22
+ status: 'active',
23
+ ...over,
24
+ };
25
+ }
26
+ before(() => {
27
+ home = mkdtempSync(join(tmpdir(), 'crtr-reset-'));
28
+ process.env['CRTR_HOME'] = home;
29
+ });
30
+ beforeEach(() => {
31
+ closeDb();
32
+ rmSync(home, { recursive: true, force: true });
33
+ });
34
+ after(() => {
35
+ closeDb();
36
+ rmSync(home, { recursive: true, force: true });
37
+ delete process.env['CRTR_HOME'];
38
+ });
39
+ test('resetRoot empties the root view, reaps descendants, and wipes working state', () => {
40
+ // root → child → grandchild (mirrors a parent that subscribes to its workers)
41
+ createNode(node('root', { parent: null, lifecycle: 'resident', mode: 'orchestrator', pi_session_id: 'old-sess' }));
42
+ createNode(node('child', { parent: 'root' }));
43
+ createNode(node('grand', { parent: 'child' }));
44
+ subscribe('root', 'child', true);
45
+ subscribe('child', 'grand', true);
46
+ // Root accumulated working state.
47
+ writeFileSync(roadmapPath('root'), '# Roadmap\nold goal\n');
48
+ writeFileSync(inboxPath('root'), '{"ts":"x","from":"child","tier":"normal","kind":"update","label":"hi"}\n');
49
+ writeFileSync(join(reportsDir('root'), '20260101T000000-update.md'), 'stale report');
50
+ assert.equal(view('root').length, 2, 'precondition: root sees 2 descendants');
51
+ const res = resetRoot('root', 'new-sess');
52
+ assert.equal(res.reset, true);
53
+ assert.deepEqual(res.detached, ['child'], 'root detaches its direct subscription');
54
+ assert.deepEqual(res.reaped.sort(), ['child', 'grand'], 'whole sub-DAG reaped');
55
+ // Graph is empty from the root's view.
56
+ assert.equal(view('root').length, 0, 'root view is empty after reset');
57
+ assert.equal(subscriptionsOf('root').length, 0, 'no outgoing edges remain');
58
+ // Descendants are dead (daemon will skip them).
59
+ assert.equal(getNode('child')?.status, 'dead');
60
+ assert.equal(getNode('grand')?.status, 'dead');
61
+ // Working state wiped.
62
+ assert.equal(existsSync(roadmapPath('root')), false, 'roadmap wiped');
63
+ assert.equal(existsSync(inboxPath('root')), false, 'inbox wiped');
64
+ // Root reset to a pristine base resident, rebound to the new session id.
65
+ const root = getNode('root');
66
+ assert.equal(root?.mode, 'base');
67
+ assert.equal(root?.lifecycle, 'resident');
68
+ assert.equal(root?.status, 'active');
69
+ assert.equal(root?.intent, null);
70
+ assert.equal(root?.pi_session_id, 'new-sess');
71
+ assert.ok(root?.launch, 'a fresh base launch spec was written');
72
+ });
73
+ test('resetRoot on a non-root only refreshes the session id (no reap)', () => {
74
+ createNode(node('root', { parent: null }));
75
+ createNode(node('child', { parent: 'root', pi_session_id: 'old' }));
76
+ subscribe('root', 'child', true);
77
+ subscribe('child', 'root', false); // contrived: ensure child has an outgoing edge
78
+ const res = resetRoot('child', 'fresh');
79
+ assert.equal(res.reset, false, 'a non-root is not a graph reset');
80
+ assert.deepEqual(res.reaped, []);
81
+ assert.deepEqual(res.detached, []);
82
+ assert.equal(getNode('child')?.pi_session_id, 'fresh', 'session id still refreshed');
83
+ assert.equal(getNode('child')?.status, 'active', 'child not reaped');
84
+ // The root that subscribes to the child is untouched.
85
+ assert.equal(getNode('root')?.status, 'active');
86
+ });
87
+ test('resetRoot is a no-op for an unknown node', () => {
88
+ const res = resetRoot('ghost', 'x');
89
+ assert.equal(res.reset, false);
90
+ assert.deepEqual(res.reaped, []);
91
+ assert.deepEqual(res.detached, []);
92
+ });
93
+ test('reaped descendants keep their meta on disk (orphaned, not deleted)', () => {
94
+ createNode(node('root', { parent: null }));
95
+ createNode(node('child', { parent: 'root' }));
96
+ subscribe('root', 'child', true);
97
+ setStatus('child', 'idle');
98
+ resetRoot('root', 'new');
99
+ // The node record persists (we detach + mark dead, we don't delete the node).
100
+ const child = getNode('child');
101
+ assert.ok(child, 'child meta still on disk');
102
+ assert.equal(child?.status, 'dead');
103
+ // It is just unreachable from the root.
104
+ assert.equal(view('root').length, 0);
105
+ });
@@ -0,0 +1,24 @@
1
+ export interface AskEntry {
2
+ node_id: string;
3
+ name: string;
4
+ cwd: string;
5
+ count: number;
6
+ }
7
+ /**
8
+ * Count pending asks for the cwd of a single node.
9
+ * Returns 0 when the node is unknown or humanloop is unavailable.
10
+ */
11
+ export declare function countAsks(nodeId: string): number;
12
+ /**
13
+ * Pending asks for all nodes reachable in the subscription sub-DAG from
14
+ * `rootId` (including root itself). De-duped by cwd: when multiple nodes
15
+ * share a cwd the first one encountered claims the entry.
16
+ *
17
+ * Returns only entries with count > 0.
18
+ */
19
+ export declare function pendingAsksForView(rootId: string): AskEntry[];
20
+ /**
21
+ * Pending asks across the entire canvas — every distinct cwd among all known
22
+ * nodes. Returns only entries with count > 0.
23
+ */
24
+ export declare function asksAcrossCanvas(): AskEntry[];
@@ -0,0 +1,94 @@
1
+ // attention.ts — pending human-ask counters across the canvas.
2
+ //
3
+ // Human asks are stored per-cwd, not per-node (interactionsRoot is keyed by
4
+ // the cwd the agent ran in, same pattern as humanloop's human list command).
5
+ // A cwd can be shared by multiple nodes, so we de-dup on cwd before summing to
6
+ // avoid counting the same pending ask N times.
7
+ //
8
+ // All public functions are best-effort: scanInbox failures return 0 / empty.
9
+ // Callers are display code (dashboard, attention queue) that must not blow up
10
+ // on a cold canvas or missing humanloop state.
11
+ import { scanInbox } from '@crouton-kit/humanloop';
12
+ import { interactionsRoot } from '../artifact.js';
13
+ import { getNode, listNodes, view } from './canvas.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Count pending asks for a single cwd root. Never throws.
19
+ *
20
+ * When `nodeId` is given, count only asks raised by THAT node — humanloop
21
+ * stamps `deck.source.nodeId` with the originating CRTR_NODE_ID, so two nodes
22
+ * sharing a cwd no longer pollute each other's count. Asks with no stamp
23
+ * (legacy, or raised outside a canvas node) are not attributable to any node
24
+ * and are excluded from the per-node count. Read via a cast so this doesn't
25
+ * hard-depend on a humanloop type bump.
26
+ */
27
+ function countForCwd(cwd, nodeId) {
28
+ try {
29
+ const items = scanInbox([interactionsRoot(cwd)]);
30
+ if (nodeId === undefined)
31
+ return items.length;
32
+ return items.filter((i) => i.source?.nodeId === nodeId).length;
33
+ }
34
+ catch {
35
+ // humanloop not installed, or interactions dir doesn't exist — both fine.
36
+ return 0;
37
+ }
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // Public API
41
+ // ---------------------------------------------------------------------------
42
+ /**
43
+ * Count pending asks for the cwd of a single node.
44
+ * Returns 0 when the node is unknown or humanloop is unavailable.
45
+ */
46
+ export function countAsks(nodeId) {
47
+ const node = getNode(nodeId);
48
+ if (node === null)
49
+ return 0;
50
+ return countForCwd(node.cwd, nodeId);
51
+ }
52
+ /**
53
+ * Pending asks for all nodes reachable in the subscription sub-DAG from
54
+ * `rootId` (including root itself). De-duped by cwd: when multiple nodes
55
+ * share a cwd the first one encountered claims the entry.
56
+ *
57
+ * Returns only entries with count > 0.
58
+ */
59
+ export function pendingAsksForView(rootId) {
60
+ // view() returns children only (excludes root), so prepend root.
61
+ const ids = [rootId, ...view(rootId)];
62
+ const seen = new Map(); // cwd → entry
63
+ for (const id of ids) {
64
+ const node = getNode(id);
65
+ if (node === null)
66
+ continue;
67
+ if (seen.has(node.cwd))
68
+ continue; // already counted this cwd
69
+ const count = countForCwd(node.cwd);
70
+ if (count === 0) {
71
+ // Still mark the cwd seen so later nodes with the same cwd are skipped.
72
+ seen.set(node.cwd, { node_id: id, name: node.name, cwd: node.cwd, count: 0 });
73
+ }
74
+ else {
75
+ seen.set(node.cwd, { node_id: id, name: node.name, cwd: node.cwd, count });
76
+ }
77
+ }
78
+ return Array.from(seen.values()).filter((e) => e.count > 0);
79
+ }
80
+ /**
81
+ * Pending asks across the entire canvas — every distinct cwd among all known
82
+ * nodes. Returns only entries with count > 0.
83
+ */
84
+ export function asksAcrossCanvas() {
85
+ const rows = listNodes();
86
+ const seen = new Map(); // cwd → entry
87
+ for (const row of rows) {
88
+ if (seen.has(row.cwd))
89
+ continue;
90
+ const count = countForCwd(row.cwd);
91
+ seen.set(row.cwd, { node_id: row.node_id, name: row.name, cwd: row.cwd, count });
92
+ }
93
+ return Array.from(seen.values()).filter((e) => e.count > 0);
94
+ }
@@ -0,0 +1,40 @@
1
+ import type { NodeMeta, NodeRow, NodeStatus, SubscriptionRef } from './types.js';
2
+ /** Create a node: scaffold its dirs, write meta.json, index the row. */
3
+ export declare function createNode(meta: NodeMeta): NodeMeta;
4
+ /** The canonical node record (from meta.json), or null if unknown. */
5
+ export declare function getNode(nodeId: string): NodeMeta | null;
6
+ /** The indexed row (from the db) — cheap for queries that don't need full meta. */
7
+ export declare function getRow(nodeId: string): NodeRow | null;
8
+ /** Merge a patch into a node's meta.json and re-index its row. */
9
+ export declare function updateNode(nodeId: string, patch: Partial<NodeMeta>): NodeMeta;
10
+ /** Convenience for the most common mutation. */
11
+ export declare function setStatus(nodeId: string, status: NodeStatus): void;
12
+ /** All rows, optionally filtered by status. */
13
+ export declare function listNodes(filter?: {
14
+ status?: NodeStatus | NodeStatus[];
15
+ }): NodeRow[];
16
+ /** Record `A subscribes_to B` — A receives B's output. active=true wakes A on
17
+ * emit; passive accumulates pointers without a wake. Mutable; callable by anyone. */
18
+ export declare function subscribe(subscriber: string, publisher: string, active?: boolean): void;
19
+ /** Drop a subscription edge. */
20
+ export declare function unsubscribe(subscriber: string, publisher: string): void;
21
+ /** Flip an existing subscription's wake behavior. */
22
+ export declare function setSubscriptionActive(subscriber: string, publisher: string, active: boolean): void;
23
+ /** Record the audit-only `child spawned_by parent` edge. */
24
+ export declare function recordSpawn(child: string, parent: string): void;
25
+ /** Who subscribes to `publisher` — the targets a push fans out to. */
26
+ export declare function subscribersOf(publisher: string): SubscriptionRef[];
27
+ /** Who `subscriber` subscribes to — its reports / the nodes feeding it. */
28
+ export declare function subscriptionsOf(subscriber: string): SubscriptionRef[];
29
+ /** A "view": every node whose output cascades up to `root` via subscriptions —
30
+ * the subscription sub-DAG reachable downward (root → its reports → theirs …).
31
+ * Returns ids excluding root, in BFS order. Cycle-safe. */
32
+ export declare function view(root: string): string[];
33
+ /** Stop-guard primitive: does this node hold an *active* subscription to a node
34
+ * that's still live (active|idle) — i.e. something that can actually wake it?
35
+ * If so, stopping is a legitimate await; if not, it must finish or escalate. */
36
+ export declare function hasActiveLiveSubscription(nodeId: string): boolean;
37
+ /** Rebuild node rows from on-disk metas (the db node table is a derived index).
38
+ * Edges are left intact — subscribes_to is db-authoritative; spawned_by is
39
+ * re-derived from each meta's `parent`. */
40
+ export declare function rebuildIndex(): void;