@crouton-kit/crouter 0.3.8 → 0.3.12

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 (184) 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 +25 -27
  23. package/dist/commands/{job.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/{agent.d.ts → daemon.d.ts} +1 -1
  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 +15 -427
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +354 -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 +8 -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 +12 -681
  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 +9 -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/__tests__/resolver.test.js +69 -1
  84. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  85. package/dist/core/__tests__/unknown-path.test.js +52 -0
  86. package/dist/core/bootstrap.d.ts +2 -0
  87. package/dist/core/bootstrap.js +66 -0
  88. package/dist/core/canvas/attention.d.ts +24 -0
  89. package/dist/core/canvas/attention.js +94 -0
  90. package/dist/core/canvas/canvas.d.ts +40 -0
  91. package/dist/core/canvas/canvas.js +210 -0
  92. package/dist/core/canvas/db.d.ts +7 -0
  93. package/dist/core/canvas/db.js +61 -0
  94. package/dist/core/canvas/index.d.ts +4 -0
  95. package/dist/core/canvas/index.js +6 -0
  96. package/dist/core/canvas/paths.d.ts +16 -0
  97. package/dist/core/canvas/paths.js +62 -0
  98. package/dist/core/canvas/render.d.ts +30 -0
  99. package/dist/core/canvas/render.js +186 -0
  100. package/dist/core/canvas/types.d.ts +87 -0
  101. package/dist/core/canvas/types.js +8 -0
  102. package/dist/core/command.d.ts +63 -2
  103. package/dist/core/command.js +97 -24
  104. package/dist/core/feed/feed.d.ts +43 -0
  105. package/dist/core/feed/feed.js +116 -0
  106. package/dist/core/feed/inbox.d.ts +50 -0
  107. package/dist/core/feed/inbox.js +124 -0
  108. package/dist/core/frontmatter.d.ts +10 -0
  109. package/dist/core/frontmatter.js +24 -9
  110. package/dist/core/help.d.ts +39 -8
  111. package/dist/core/help.js +69 -35
  112. package/dist/core/io.d.ts +15 -1
  113. package/dist/core/io.js +56 -6
  114. package/dist/core/personas/index.d.ts +12 -0
  115. package/dist/core/personas/index.js +10 -0
  116. package/dist/core/personas/loader.d.ts +44 -0
  117. package/dist/core/personas/loader.js +157 -0
  118. package/dist/core/personas/resolve.d.ts +36 -0
  119. package/dist/core/personas/resolve.js +110 -0
  120. package/dist/core/render.d.ts +11 -0
  121. package/dist/core/render.js +126 -0
  122. package/dist/core/resolver.d.ts +10 -0
  123. package/dist/core/resolver.js +160 -2
  124. package/dist/core/runtime/front-door.d.ts +10 -0
  125. package/dist/core/runtime/front-door.js +97 -0
  126. package/dist/core/runtime/kickoff.d.ts +23 -0
  127. package/dist/core/runtime/kickoff.js +134 -0
  128. package/dist/core/runtime/launch.d.ts +34 -0
  129. package/dist/core/runtime/launch.js +85 -0
  130. package/dist/core/runtime/nodes.d.ts +38 -0
  131. package/dist/core/runtime/nodes.js +95 -0
  132. package/dist/core/runtime/presence.d.ts +38 -0
  133. package/dist/core/runtime/presence.js +152 -0
  134. package/dist/core/runtime/promote.d.ts +30 -0
  135. package/dist/core/runtime/promote.js +105 -0
  136. package/dist/core/runtime/reset.d.ts +13 -0
  137. package/dist/core/runtime/reset.js +97 -0
  138. package/dist/core/runtime/revive.d.ts +26 -0
  139. package/dist/core/runtime/revive.js +89 -0
  140. package/dist/core/runtime/roadmap.d.ts +12 -0
  141. package/dist/core/runtime/roadmap.js +52 -0
  142. package/dist/core/runtime/spawn.d.ts +33 -0
  143. package/dist/core/runtime/spawn.js +118 -0
  144. package/dist/core/runtime/stop-guard.d.ts +18 -0
  145. package/dist/core/runtime/stop-guard.js +33 -0
  146. package/dist/core/runtime/tmux.d.ts +88 -0
  147. package/dist/core/runtime/tmux.js +198 -0
  148. package/dist/core/spawn.d.ts +17 -80
  149. package/dist/core/spawn.js +15 -219
  150. package/dist/daemon/crtrd-cli.d.ts +1 -0
  151. package/dist/daemon/crtrd-cli.js +4 -0
  152. package/dist/daemon/crtrd.d.ts +20 -0
  153. package/dist/daemon/crtrd.js +200 -0
  154. package/dist/daemon/manage.d.ts +17 -0
  155. package/dist/daemon/manage.js +57 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  158. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  159. package/dist/pi-extensions/canvas-nav.js +536 -0
  160. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  161. package/dist/pi-extensions/canvas-stophook.js +373 -0
  162. package/dist/types.d.ts +21 -0
  163. package/dist/types.js +3 -0
  164. package/package.json +6 -5
  165. package/dist/commands/agent.js +0 -384
  166. package/dist/commands/debug.d.ts +0 -3
  167. package/dist/commands/debug.js +0 -179
  168. package/dist/commands/job.js +0 -344
  169. package/dist/commands/plan.d.ts +0 -4
  170. package/dist/commands/plan.js +0 -309
  171. package/dist/commands/spec.d.ts +0 -3
  172. package/dist/commands/spec.js +0 -286
  173. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  174. package/dist/core/__tests__/job.test.js +0 -310
  175. package/dist/core/__tests__/jobs.test.js +0 -66
  176. package/dist/core/jobs.d.ts +0 -101
  177. package/dist/core/jobs.js +0 -462
  178. package/dist/prompts/agent.d.ts +0 -18
  179. package/dist/prompts/agent.js +0 -153
  180. package/dist/prompts/debug.d.ts +0 -8
  181. package/dist/prompts/debug.js +0 -44
  182. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  183. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  184. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.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
+ });
@@ -6,7 +6,9 @@ import assert from 'node:assert/strict';
6
6
  import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
8
  import { join } from 'node:path';
9
- import { parseSkillQualifier } from '../resolver.js';
9
+ import { parseSkillQualifier, resolveSkill } from '../resolver.js';
10
+ import { resetScopeCache } from '../scope.js';
11
+ import { CrtrError } from '../errors.js';
10
12
  import { InputError } from '../io.js';
11
13
  import { readConfig } from '../config.js';
12
14
  import { SCHEMA_VERSION } from '../../types.js';
@@ -111,3 +113,69 @@ describe('config migration: skill keys colon → slash', () => {
111
113
  assert.equal(cfg.schema_version, SCHEMA_VERSION, 'schema_version bumped to current');
112
114
  });
113
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
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ // Regression tests for unknown-subcommand error recovery hints.
2
+ // Run with: node --import tsx/esm --test src/core/__tests__/unknown-path.test.ts
3
+ //
4
+ // The `next` road sign must name a command that actually exists: the FULL path
5
+ // to the deepest matched node, not just its local name. A prior bug emitted
6
+ // `crtr find -h` (dropping the `skill` parent) when `crtr skill find bogus` was
7
+ // invoked, sending the caller to a nonexistent top-level command.
8
+ import { test, describe } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { defineRoot, defineBranch, defineLeaf, walk, unknownPathError } from '../command.js';
11
+ const leaf = defineLeaf({
12
+ name: 'search',
13
+ help: { name: 'search', summary: 'search', output: [], outputKind: 'object', effects: ['None. Read-only.'] },
14
+ run: async () => ({}),
15
+ });
16
+ const findBranch = defineBranch({
17
+ name: 'find',
18
+ help: { name: 'find', summary: 'find', children: [{ name: 'search', desc: 'search', useWhen: 'x' }] },
19
+ children: [leaf],
20
+ });
21
+ const skillBranch = defineBranch({
22
+ name: 'skill',
23
+ help: { name: 'skill', summary: 'skill', children: [{ name: 'find', desc: 'find', useWhen: 'x' }] },
24
+ rootEntry: { concept: 'skill', desc: 'skill', useWhen: 'x' },
25
+ children: [findBranch],
26
+ });
27
+ const root = defineRoot({
28
+ tagline: 'test runtime',
29
+ globals: [],
30
+ subtrees: [skillBranch],
31
+ });
32
+ function nextHint(...tokens) {
33
+ const { node, path, remaining } = walk(root, tokens);
34
+ const err = unknownPathError(node, path, remaining[0]);
35
+ return err.details.next;
36
+ }
37
+ describe('unknown-path error: recovery hint names the full valid path', () => {
38
+ test('root-level unknown points at `crtr -h`', () => {
39
+ assert.match(nextHint('bogus'), /Run `crtr -h`/);
40
+ });
41
+ test('one-level unknown points at `crtr skill -h`', () => {
42
+ assert.match(nextHint('skill', 'bogus'), /Run `crtr skill -h`/);
43
+ });
44
+ test('two-level unknown points at `crtr skill find -h`, not `crtr find -h`', () => {
45
+ const hint = nextHint('skill', 'find', 'bogus');
46
+ assert.match(hint, /Run `crtr skill find -h`/);
47
+ assert.doesNotMatch(hint, /Run `crtr find -h`/);
48
+ });
49
+ test('valid children of the matched node are listed', () => {
50
+ assert.match(nextHint('skill', 'find', 'bogus'), /Valid children: search\./);
51
+ });
52
+ });
@@ -1,6 +1,8 @@
1
+ import type { RootDef } from './command.js';
1
2
  export declare const OFFICIAL_MARKETPLACE_NAME = "crouter-official-marketplace";
2
3
  export declare const OFFICIAL_MARKETPLACE_URL = "https://github.com/crouton-labs/crouter-official-marketplace.git";
3
4
  export declare const OFFICIAL_MARKETPLACE_REF = "main";
4
5
  export declare function ensureBootSkill(argv: string[]): void;
6
+ export declare function ensureSlashCommands(root: RootDef, argv: string[]): void;
5
7
  export declare function ensureOfficialMarketplace(argv: string[]): void;
6
8
  export declare function ensureProjectScope(argv: string[]): void;
@@ -6,6 +6,7 @@ import { ensureDir, pathExists, readText, removePath, nowIso } from './fs-utils.
6
6
  import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized } from './config.js';
7
7
  import { clone } from './git.js';
8
8
  import { readMarketplaceManifest } from './manifest.js';
9
+ import { collectSlashSpecs } from './command.js';
9
10
  import { CRTR_DIR_NAME } from '../types.js';
10
11
  export const OFFICIAL_MARKETPLACE_NAME = 'crouter-official-marketplace';
11
12
  export const OFFICIAL_MARKETPLACE_URL = 'https://github.com/crouton-labs/crouter-official-marketplace.git';
@@ -94,6 +95,71 @@ export function ensureBootSkill(argv) {
94
95
  }
95
96
  }
96
97
  }
98
+ // ---------------------------------------------------------------------------
99
+ // Slash commands (editor prompt templates) auto-installed for opted-in nodes.
100
+ //
101
+ // Any command that declares a `slash` SlashSpec is rendered to a markdown
102
+ // template and dropped into the host's command dirs on each crtr run — pi reads
103
+ // `~/.pi/agent/prompts/<name>.md`, Claude Code reads `~/.claude/commands/<name>.md`,
104
+ // so `/name` becomes available. Marker-guarded (never clobbers a user-edited
105
+ // file) and version-rolled like the boot skill. Kill switch: CRTR_NO_MODE_CMDS=1.
106
+ // ---------------------------------------------------------------------------
107
+ const SLASH_CMD_MARKER = '<!-- crtr-mode-cmd v1 -->';
108
+ const SLASH_CMD_MARKER_PREFIX = '<!-- crtr-mode-cmd v';
109
+ /** Render a SlashSpec to a full template file (frontmatter + marker + body). */
110
+ function renderSlashTemplate(spec) {
111
+ const hint = spec.argumentHint !== undefined
112
+ ? `argument-hint: ${JSON.stringify(spec.argumentHint)}\n`
113
+ : '';
114
+ return `---\ndescription: ${spec.description}\n${hint}---\n\n${SLASH_CMD_MARKER}\n\n${spec.body}\n`;
115
+ }
116
+ /** Write `content` to `file` unless a user-customized file is already there.
117
+ * Rolls forward our own (marker-bearing) versions; skips if identical. */
118
+ function writeSlashFileIfOurs(dir, name, content) {
119
+ const file = join(dir, `${name}.md`);
120
+ if (pathExists(file)) {
121
+ const existing = readText(file);
122
+ if (!existing.includes(SLASH_CMD_MARKER_PREFIX))
123
+ return; // user's own file
124
+ if (existing === content)
125
+ return; // already current
126
+ }
127
+ ensureDir(dir);
128
+ writeFileSync(file, content, 'utf8');
129
+ }
130
+ export function ensureSlashCommands(root, argv) {
131
+ try {
132
+ if (process.env.CRTR_NO_MODE_CMDS === '1')
133
+ return;
134
+ if (shouldSkipForArgv(argv))
135
+ return;
136
+ const specs = collectSlashSpecs(root);
137
+ if (specs.length === 0)
138
+ return;
139
+ // Target each host's command dir, but only when that host is actually in use
140
+ // (its root dir exists). We never create ~/.pi or ~/.claude ourselves.
141
+ const targets = [];
142
+ if (pathExists(join(homedir(), '.pi', 'agent'))) {
143
+ targets.push(join(homedir(), '.pi', 'agent', 'prompts'));
144
+ }
145
+ if (pathExists(join(homedir(), '.claude'))) {
146
+ targets.push(join(homedir(), '.claude', 'commands'));
147
+ }
148
+ if (targets.length === 0)
149
+ return;
150
+ for (const spec of specs) {
151
+ const content = renderSlashTemplate(spec);
152
+ for (const dir of targets)
153
+ writeSlashFileIfOurs(dir, spec.name, content);
154
+ }
155
+ }
156
+ catch (e) {
157
+ if (process.env.CRTR_DEBUG === '1') {
158
+ const msg = e instanceof Error ? e.message : String(e);
159
+ process.stderr.write(`crtr: slash-command error: ${msg}\n`);
160
+ }
161
+ }
162
+ }
97
163
  export function ensureOfficialMarketplace(argv) {
98
164
  try {
99
165
  if (process.env.CRTR_NO_BOOTSTRAP === '1')
@@ -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[];