@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.
- package/bin/crtrd +2 -0
- package/dist/builtin-personas/design/base.md +9 -0
- package/dist/builtin-personas/design/orchestrator.md +10 -0
- package/dist/builtin-personas/developer/base.md +9 -0
- package/dist/builtin-personas/developer/orchestrator.md +12 -0
- package/dist/builtin-personas/explore/base.md +9 -0
- package/dist/builtin-personas/explore/orchestrator.md +9 -0
- package/dist/builtin-personas/general/base.md +5 -0
- package/dist/builtin-personas/general/orchestrator.md +7 -0
- package/dist/builtin-personas/orchestration-kernel.md +71 -0
- package/dist/builtin-personas/plan/base.md +7 -0
- package/dist/builtin-personas/plan/orchestrator.md +12 -0
- package/dist/builtin-personas/review/base.md +7 -0
- package/dist/builtin-personas/review/orchestrator.md +9 -0
- package/dist/builtin-personas/runtime-base.md +39 -0
- package/dist/builtin-personas/spec/base.md +7 -0
- package/dist/builtin-personas/spec/orchestrator.md +10 -0
- package/dist/builtin-skills/skills/design/SKILL.md +51 -0
- package/dist/builtin-skills/skills/development/SKILL.md +109 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
- package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
- package/dist/cli.js +25 -27
- package/dist/commands/{job.d.ts → attention.d.ts} +1 -1
- package/dist/commands/attention.js +152 -0
- package/dist/commands/canvas.d.ts +2 -0
- package/dist/commands/canvas.js +35 -0
- package/dist/commands/{agent.d.ts → daemon.d.ts} +1 -1
- package/dist/commands/daemon.js +111 -0
- package/dist/commands/dashboard.d.ts +2 -0
- package/dist/commands/dashboard.js +65 -0
- package/dist/commands/human/prompts.d.ts +5 -0
- package/dist/commands/human/prompts.js +269 -0
- package/dist/commands/human/queue.d.ts +3 -0
- package/dist/commands/human/queue.js +133 -0
- package/dist/commands/human/shared.d.ts +43 -0
- package/dist/commands/human/shared.js +107 -0
- package/dist/commands/human.js +15 -427
- package/dist/commands/node.d.ts +2 -0
- package/dist/commands/node.js +354 -0
- package/dist/commands/pkg/market-inspect.d.ts +1 -0
- package/dist/commands/pkg/market-inspect.js +157 -0
- package/dist/commands/pkg/market-manage.d.ts +1 -0
- package/dist/commands/pkg/market-manage.js +316 -0
- package/dist/commands/pkg/market.d.ts +1 -0
- package/dist/commands/pkg/market.js +16 -0
- package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
- package/dist/commands/pkg/plugin-inspect.js +142 -0
- package/dist/commands/pkg/plugin-manage.d.ts +1 -0
- package/dist/commands/pkg/plugin-manage.js +294 -0
- package/dist/commands/pkg/plugin.d.ts +1 -0
- package/dist/commands/pkg/plugin.js +16 -0
- package/dist/commands/pkg/shared.d.ts +5 -0
- package/dist/commands/pkg/shared.js +61 -0
- package/dist/commands/pkg.js +8 -1004
- package/dist/commands/push.d.ts +3 -0
- package/dist/commands/push.js +159 -0
- package/dist/commands/revive.d.ts +2 -0
- package/dist/commands/revive.js +64 -0
- package/dist/commands/skill/author.d.ts +3 -0
- package/dist/commands/skill/author.js +147 -0
- package/dist/commands/skill/find.d.ts +4 -0
- package/dist/commands/skill/find.js +254 -0
- package/dist/commands/skill/read.d.ts +1 -0
- package/dist/commands/skill/read.js +89 -0
- package/dist/commands/skill/shared.d.ts +19 -0
- package/dist/commands/skill/shared.js +207 -0
- package/dist/commands/skill/state.d.ts +3 -0
- package/dist/commands/skill/state.js +69 -0
- package/dist/commands/skill.js +12 -681
- package/dist/commands/sys/config.d.ts +1 -0
- package/dist/commands/sys/config.js +186 -0
- package/dist/commands/sys/doctor.d.ts +1 -0
- package/dist/commands/sys/doctor.js +369 -0
- package/dist/commands/sys/shared.d.ts +3 -0
- package/dist/commands/sys/shared.js +24 -0
- package/dist/commands/sys/update.d.ts +2 -0
- package/dist/commands/sys/update.js +114 -0
- package/dist/commands/sys.js +9 -694
- package/dist/core/__tests__/argv-parser.test.js +19 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
- package/dist/core/__tests__/canvas.test.js +154 -0
- package/dist/core/__tests__/reset.test.js +105 -0
- package/dist/core/__tests__/resolver.test.js +69 -1
- package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
- package/dist/core/__tests__/unknown-path.test.js +52 -0
- package/dist/core/bootstrap.d.ts +2 -0
- package/dist/core/bootstrap.js +66 -0
- package/dist/core/canvas/attention.d.ts +24 -0
- package/dist/core/canvas/attention.js +94 -0
- package/dist/core/canvas/canvas.d.ts +40 -0
- package/dist/core/canvas/canvas.js +210 -0
- package/dist/core/canvas/db.d.ts +7 -0
- package/dist/core/canvas/db.js +61 -0
- package/dist/core/canvas/index.d.ts +4 -0
- package/dist/core/canvas/index.js +6 -0
- package/dist/core/canvas/paths.d.ts +16 -0
- package/dist/core/canvas/paths.js +62 -0
- package/dist/core/canvas/render.d.ts +30 -0
- package/dist/core/canvas/render.js +186 -0
- package/dist/core/canvas/types.d.ts +87 -0
- package/dist/core/canvas/types.js +8 -0
- package/dist/core/command.d.ts +63 -2
- package/dist/core/command.js +97 -24
- package/dist/core/feed/feed.d.ts +43 -0
- package/dist/core/feed/feed.js +116 -0
- package/dist/core/feed/inbox.d.ts +50 -0
- package/dist/core/feed/inbox.js +124 -0
- package/dist/core/frontmatter.d.ts +10 -0
- package/dist/core/frontmatter.js +24 -9
- package/dist/core/help.d.ts +39 -8
- package/dist/core/help.js +69 -35
- package/dist/core/io.d.ts +15 -1
- package/dist/core/io.js +56 -6
- package/dist/core/personas/index.d.ts +12 -0
- package/dist/core/personas/index.js +10 -0
- package/dist/core/personas/loader.d.ts +44 -0
- package/dist/core/personas/loader.js +157 -0
- package/dist/core/personas/resolve.d.ts +36 -0
- package/dist/core/personas/resolve.js +110 -0
- package/dist/core/render.d.ts +11 -0
- package/dist/core/render.js +126 -0
- package/dist/core/resolver.d.ts +10 -0
- package/dist/core/resolver.js +160 -2
- package/dist/core/runtime/front-door.d.ts +10 -0
- package/dist/core/runtime/front-door.js +97 -0
- package/dist/core/runtime/kickoff.d.ts +23 -0
- package/dist/core/runtime/kickoff.js +134 -0
- package/dist/core/runtime/launch.d.ts +34 -0
- package/dist/core/runtime/launch.js +85 -0
- package/dist/core/runtime/nodes.d.ts +38 -0
- package/dist/core/runtime/nodes.js +95 -0
- package/dist/core/runtime/presence.d.ts +38 -0
- package/dist/core/runtime/presence.js +152 -0
- package/dist/core/runtime/promote.d.ts +30 -0
- package/dist/core/runtime/promote.js +105 -0
- package/dist/core/runtime/reset.d.ts +13 -0
- package/dist/core/runtime/reset.js +97 -0
- package/dist/core/runtime/revive.d.ts +26 -0
- package/dist/core/runtime/revive.js +89 -0
- package/dist/core/runtime/roadmap.d.ts +12 -0
- package/dist/core/runtime/roadmap.js +52 -0
- package/dist/core/runtime/spawn.d.ts +33 -0
- package/dist/core/runtime/spawn.js +118 -0
- package/dist/core/runtime/stop-guard.d.ts +18 -0
- package/dist/core/runtime/stop-guard.js +33 -0
- package/dist/core/runtime/tmux.d.ts +88 -0
- package/dist/core/runtime/tmux.js +198 -0
- package/dist/core/spawn.d.ts +17 -80
- package/dist/core/spawn.js +15 -219
- package/dist/daemon/crtrd-cli.d.ts +1 -0
- package/dist/daemon/crtrd-cli.js +4 -0
- package/dist/daemon/crtrd.d.ts +20 -0
- package/dist/daemon/crtrd.js +200 -0
- package/dist/daemon/manage.d.ts +17 -0
- package/dist/daemon/manage.js +57 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
- package/dist/pi-extensions/canvas-nav.d.ts +32 -0
- package/dist/pi-extensions/canvas-nav.js +536 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
- package/dist/pi-extensions/canvas-stophook.js +373 -0
- package/dist/types.d.ts +21 -0
- package/dist/types.js +3 -0
- package/package.json +6 -5
- package/dist/commands/agent.js +0 -384
- package/dist/commands/debug.d.ts +0 -3
- package/dist/commands/debug.js +0 -179
- package/dist/commands/job.js +0 -344
- package/dist/commands/plan.d.ts +0 -4
- package/dist/commands/plan.js +0 -309
- package/dist/commands/spec.d.ts +0 -3
- package/dist/commands/spec.js +0 -286
- package/dist/core/__tests__/flow-leaves.test.js +0 -248
- package/dist/core/__tests__/job.test.js +0 -310
- package/dist/core/__tests__/jobs.test.js +0 -66
- package/dist/core/jobs.d.ts +0 -101
- package/dist/core/jobs.js +0 -462
- package/dist/prompts/agent.d.ts +0 -18
- package/dist/prompts/agent.js +0 -153
- package/dist/prompts/debug.d.ts +0 -8
- package/dist/prompts/debug.js +0 -44
- /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
- /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
- /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 (
|
|
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
|
+
});
|
package/dist/core/bootstrap.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/bootstrap.js
CHANGED
|
@@ -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[];
|