@crouton-kit/crouter 0.3.14 → 0.3.16
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/dist/build-root.d.ts +8 -0
- package/dist/build-root.js +30 -0
- package/dist/builtin-personas/design/base.md +3 -7
- package/dist/builtin-personas/design/orchestrator.md +4 -3
- package/dist/builtin-personas/developer/base.md +3 -7
- package/dist/builtin-personas/developer/orchestrator.md +5 -4
- package/dist/builtin-personas/explore/base.md +3 -7
- package/dist/builtin-personas/explore/orchestrator.md +1 -5
- package/dist/builtin-personas/general/base.md +2 -4
- package/dist/builtin-personas/general/orchestrator.md +2 -4
- package/dist/builtin-personas/lifecycle/resident.md +2 -0
- package/dist/builtin-personas/lifecycle/terminal.md +6 -0
- package/dist/builtin-personas/orchestration-kernel.md +42 -3
- package/dist/builtin-personas/plan/base.md +3 -5
- package/dist/builtin-personas/plan/orchestrator.md +5 -4
- package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
- package/dist/builtin-personas/review/base.md +3 -5
- package/dist/builtin-personas/review/orchestrator.md +2 -6
- package/dist/builtin-personas/runtime-base.md +3 -19
- package/dist/builtin-personas/spec/base.md +3 -5
- package/dist/builtin-personas/spec/orchestrator.md +4 -3
- package/dist/builtin-personas/spine/has-manager.md +10 -0
- package/dist/builtin-personas/spine/no-manager.md +2 -0
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
- package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
- package/dist/cli.js +6 -29
- package/dist/commands/attention.js +76 -7
- package/dist/commands/canvas-prune.d.ts +2 -0
- package/dist/commands/canvas-prune.js +66 -0
- package/dist/commands/canvas.js +5 -8
- package/dist/commands/chord.d.ts +2 -0
- package/dist/commands/chord.js +143 -0
- package/dist/commands/daemon.js +8 -5
- package/dist/commands/dashboard.js +2 -0
- package/dist/commands/human/prompts.js +28 -27
- package/dist/commands/human/queue.js +30 -14
- package/dist/commands/human/shared.d.ts +26 -21
- package/dist/commands/human/shared.js +45 -67
- package/dist/commands/human.js +4 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +221 -99
- package/dist/commands/pkg/market-inspect.js +6 -4
- package/dist/commands/pkg/market-manage.js +10 -6
- package/dist/commands/pkg/market.js +2 -4
- package/dist/commands/pkg/plugin-inspect.js +6 -4
- package/dist/commands/pkg/plugin-manage.js +12 -7
- package/dist/commands/pkg/plugin.js +2 -4
- package/dist/commands/pkg.js +0 -4
- package/dist/commands/push.js +178 -15
- package/dist/commands/revive.js +5 -3
- package/dist/commands/skill/author.js +6 -4
- package/dist/commands/skill/find.js +8 -5
- package/dist/commands/skill/read.js +2 -0
- package/dist/commands/skill/state.js +6 -4
- package/dist/commands/skill.js +0 -6
- package/dist/commands/sys/config.js +21 -7
- package/dist/commands/sys/doctor.js +2 -0
- package/dist/commands/sys/update.js +4 -0
- package/dist/commands/sys.js +0 -6
- package/dist/commands/tmux-spread.d.ts +2 -0
- package/dist/commands/tmux-spread.js +129 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
- package/dist/core/__tests__/child-followup.test.d.ts +1 -0
- package/dist/core/__tests__/child-followup.test.js +83 -0
- package/dist/core/__tests__/close.test.d.ts +1 -0
- package/dist/core/__tests__/close.test.js +148 -0
- package/dist/core/__tests__/context-intro.test.d.ts +1 -0
- package/dist/core/__tests__/context-intro.test.js +196 -0
- package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-boot.test.js +93 -0
- package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-liveness.test.js +223 -0
- package/dist/core/__tests__/focuses.test.d.ts +1 -0
- package/dist/core/__tests__/focuses.test.js +196 -0
- package/dist/core/__tests__/fork.test.d.ts +1 -0
- package/dist/core/__tests__/fork.test.js +91 -0
- package/dist/core/__tests__/home-session.test.d.ts +1 -0
- package/dist/core/__tests__/home-session.test.js +153 -0
- package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
- package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
- package/dist/core/__tests__/keystone.test.d.ts +1 -0
- package/dist/core/__tests__/keystone.test.js +185 -0
- package/dist/core/__tests__/kickoff.test.d.ts +1 -0
- package/dist/core/__tests__/kickoff.test.js +89 -0
- package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle.test.js +178 -0
- package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
- package/dist/core/__tests__/listing-completeness.test.js +31 -0
- package/dist/core/__tests__/memory.test.d.ts +1 -0
- package/dist/core/__tests__/memory.test.js +152 -0
- package/dist/core/__tests__/migration.test.d.ts +1 -0
- package/dist/core/__tests__/migration.test.js +238 -0
- package/dist/core/__tests__/pane-column.test.d.ts +1 -0
- package/dist/core/__tests__/pane-column.test.js +153 -0
- package/dist/core/__tests__/passive-subscription.test.js +24 -1
- package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
- package/dist/core/__tests__/persona-compose.test.js +53 -0
- package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
- package/dist/core/__tests__/persona-subkind.test.js +62 -0
- package/dist/core/__tests__/persona.test.d.ts +1 -0
- package/dist/core/__tests__/persona.test.js +107 -0
- package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
- package/dist/core/__tests__/placement-focus.test.js +266 -0
- package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
- package/dist/core/__tests__/placement-reconcile.test.js +212 -0
- package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
- package/dist/core/__tests__/placement-revive.test.js +238 -0
- package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/placement-teardown.test.js +178 -0
- package/dist/core/__tests__/prune.test.d.ts +1 -0
- package/dist/core/__tests__/prune.test.js +116 -0
- package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
- package/dist/core/__tests__/push-final-guard.test.js +71 -0
- package/dist/core/__tests__/relaunch.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch.test.js +334 -0
- package/dist/core/__tests__/reset.test.js +26 -7
- package/dist/core/__tests__/revive.test.d.ts +1 -0
- package/dist/core/__tests__/revive.test.js +217 -0
- package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
- package/dist/core/__tests__/spawn-root.test.js +73 -0
- package/dist/core/__tests__/steer-note.test.d.ts +1 -0
- package/dist/core/__tests__/steer-note.test.js +39 -0
- package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
- package/dist/core/__tests__/stop-guard.test.js +82 -0
- package/dist/core/__tests__/subcommand-tier.test.js +35 -33
- package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
- package/dist/core/__tests__/tmux-surface.test.js +105 -0
- package/dist/core/__tests__/unknown-path.test.js +8 -2
- package/dist/core/canvas/attention.d.ts +10 -0
- package/dist/core/canvas/attention.js +40 -0
- package/dist/core/canvas/canvas.d.ts +66 -7
- package/dist/core/canvas/canvas.js +209 -21
- package/dist/core/canvas/db.d.ts +8 -0
- package/dist/core/canvas/db.js +205 -4
- package/dist/core/canvas/focuses.d.ts +22 -0
- package/dist/core/canvas/focuses.js +81 -0
- package/dist/core/canvas/index.d.ts +3 -0
- package/dist/core/canvas/index.js +3 -0
- package/dist/core/canvas/labels.d.ts +27 -0
- package/dist/core/canvas/labels.js +36 -0
- package/dist/core/canvas/render.js +25 -10
- package/dist/core/canvas/telemetry.d.ts +14 -0
- package/dist/core/canvas/telemetry.js +35 -0
- package/dist/core/canvas/types.d.ts +115 -12
- package/dist/core/command.d.ts +25 -1
- package/dist/core/command.js +23 -15
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +3 -3
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.js +24 -11
- package/dist/core/help.d.ts +26 -13
- package/dist/core/help.js +44 -37
- package/dist/core/personas/index.d.ts +1 -1
- package/dist/core/personas/index.js +1 -1
- package/dist/core/personas/loader.d.ts +40 -1
- package/dist/core/personas/loader.js +63 -1
- package/dist/core/personas/resolve.d.ts +13 -6
- package/dist/core/personas/resolve.js +46 -34
- package/dist/core/runtime/bearings.d.ts +20 -0
- package/dist/core/runtime/bearings.js +92 -0
- package/dist/core/runtime/close.d.ts +14 -0
- package/dist/core/runtime/close.js +151 -0
- package/dist/core/runtime/demote.js +24 -12
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +23 -6
- package/dist/core/runtime/kickoff.js +92 -36
- package/dist/core/runtime/launch.d.ts +26 -12
- package/dist/core/runtime/launch.js +78 -19
- package/dist/core/runtime/lifecycle.d.ts +13 -0
- package/dist/core/runtime/lifecycle.js +86 -0
- package/dist/core/runtime/memory.d.ts +43 -0
- package/dist/core/runtime/memory.js +165 -0
- package/dist/core/runtime/naming.d.ts +22 -0
- package/dist/core/runtime/naming.js +166 -0
- package/dist/core/runtime/nodes.d.ts +39 -1
- package/dist/core/runtime/nodes.js +69 -10
- package/dist/core/runtime/persona.d.ts +25 -0
- package/dist/core/runtime/persona.js +139 -0
- package/dist/core/runtime/placement.d.ts +299 -0
- package/dist/core/runtime/placement.js +688 -0
- package/dist/core/runtime/promote.d.ts +14 -7
- package/dist/core/runtime/promote.js +57 -67
- package/dist/core/runtime/reset.d.ts +47 -4
- package/dist/core/runtime/reset.js +223 -52
- package/dist/core/runtime/revive.d.ts +26 -2
- package/dist/core/runtime/revive.js +166 -39
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +163 -43
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +18 -8
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +113 -20
- package/dist/core/runtime/tmux.js +221 -39
- package/dist/core/spawn.js +15 -0
- package/dist/daemon/crtrd.d.ts +12 -1
- package/dist/daemon/crtrd.js +152 -34
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
- package/dist/pi-extensions/canvas-commands.js +16 -13
- package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
- package/dist/pi-extensions/canvas-context-intro.js +164 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
- package/dist/pi-extensions/canvas-goal-capture.js +15 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
- package/dist/pi-extensions/canvas-nav.d.ts +12 -4
- package/dist/pi-extensions/canvas-nav.js +594 -262
- package/dist/pi-extensions/canvas-resume.d.ts +22 -0
- package/dist/pi-extensions/canvas-resume.js +173 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
- package/dist/pi-extensions/canvas-stophook.js +340 -228
- package/dist/types.d.ts +28 -0
- package/dist/types.js +16 -0
- package/package.json +2 -2
- package/dist/core/runtime/presence.d.ts +0 -38
- package/dist/core/runtime/presence.js +0 -154
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
|
|
8
|
+
import { canvasDbPath, ensureHome } from '../canvas/paths.js';
|
|
9
|
+
const RUNTIME_COLUMNS = ['intent', 'pi_pid', 'window', 'tmux_session'];
|
|
10
|
+
let home;
|
|
11
|
+
function userVersion(db) {
|
|
12
|
+
return db.prepare('PRAGMA user_version').get()
|
|
13
|
+
.user_version;
|
|
14
|
+
}
|
|
15
|
+
function nodeColumns(db) {
|
|
16
|
+
return db.prepare('PRAGMA table_info(nodes)').all().map((r) => r.name);
|
|
17
|
+
}
|
|
18
|
+
before(() => {
|
|
19
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-migration-'));
|
|
20
|
+
process.env['CRTR_HOME'] = home;
|
|
21
|
+
});
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
// Fresh db + dirs per test for isolation.
|
|
24
|
+
closeDb();
|
|
25
|
+
rmSync(home, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
after(() => {
|
|
28
|
+
closeDb();
|
|
29
|
+
rmSync(home, { recursive: true, force: true });
|
|
30
|
+
delete process.env['CRTR_HOME'];
|
|
31
|
+
});
|
|
32
|
+
test('opening a fresh home migrates to the latest user_version', () => {
|
|
33
|
+
const db = openDb();
|
|
34
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
35
|
+
});
|
|
36
|
+
test('a fresh db has the four additive runtime columns', () => {
|
|
37
|
+
const db = openDb();
|
|
38
|
+
const cols = nodeColumns(db);
|
|
39
|
+
for (const c of RUNTIME_COLUMNS) {
|
|
40
|
+
assert.ok(cols.includes(c), `expected nodes.${c} to exist`);
|
|
41
|
+
}
|
|
42
|
+
// `status` predates Phase 1 and must not be duplicated.
|
|
43
|
+
assert.equal(cols.filter((c) => c === 'status').length, 1, 'status must appear exactly once');
|
|
44
|
+
});
|
|
45
|
+
test('a simulated v0 db migrates forward without data loss', () => {
|
|
46
|
+
// Build a v0 db by hand: the baseline tables only, user_version 0, no runtime
|
|
47
|
+
// columns — exactly the shape of the live fleet before this phase.
|
|
48
|
+
ensureHome();
|
|
49
|
+
const raw = new DatabaseSync(canvasDbPath());
|
|
50
|
+
raw.exec(`
|
|
51
|
+
CREATE TABLE nodes (
|
|
52
|
+
node_id TEXT PRIMARY KEY,
|
|
53
|
+
name TEXT NOT NULL,
|
|
54
|
+
kind TEXT NOT NULL,
|
|
55
|
+
mode TEXT NOT NULL DEFAULT 'base',
|
|
56
|
+
lifecycle TEXT NOT NULL DEFAULT 'terminal',
|
|
57
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
58
|
+
cwd TEXT NOT NULL,
|
|
59
|
+
parent TEXT,
|
|
60
|
+
created TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
CREATE TABLE edges (
|
|
63
|
+
type TEXT NOT NULL,
|
|
64
|
+
from_id TEXT NOT NULL,
|
|
65
|
+
to_id TEXT NOT NULL,
|
|
66
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
67
|
+
created TEXT NOT NULL,
|
|
68
|
+
PRIMARY KEY (type, from_id, to_id)
|
|
69
|
+
);
|
|
70
|
+
`);
|
|
71
|
+
raw.exec('PRAGMA user_version = 0;');
|
|
72
|
+
raw
|
|
73
|
+
.prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created) VALUES (?,?,?,?,?,?)')
|
|
74
|
+
.run('n1', 'N1', 'general', 'done', '/tmp/work', '2020-01-01T00:00:00Z');
|
|
75
|
+
raw
|
|
76
|
+
.prepare('INSERT INTO edges (type, from_id, to_id, created) VALUES (?,?,?,?)')
|
|
77
|
+
.run('subscribes_to', 'n1', 'n1', '2020-01-01T00:00:00Z');
|
|
78
|
+
// Precondition: genuinely a v0 db with no runtime columns.
|
|
79
|
+
assert.equal(userVersion(raw), 0);
|
|
80
|
+
assert.ok(!nodeColumns(raw).includes('intent'));
|
|
81
|
+
raw.close();
|
|
82
|
+
// openDb() runs the migration runner forward.
|
|
83
|
+
const db = openDb();
|
|
84
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
85
|
+
// Runtime columns now exist.
|
|
86
|
+
const cols = nodeColumns(db);
|
|
87
|
+
for (const c of RUNTIME_COLUMNS) {
|
|
88
|
+
assert.ok(cols.includes(c), `expected nodes.${c} after migration`);
|
|
89
|
+
}
|
|
90
|
+
// Pre-existing data is intact — no loss, no mutation of existing columns.
|
|
91
|
+
const row = db
|
|
92
|
+
.prepare('SELECT node_id, status, cwd FROM nodes WHERE node_id = ?')
|
|
93
|
+
.get('n1');
|
|
94
|
+
assert.equal(row.node_id, 'n1');
|
|
95
|
+
assert.equal(row.status, 'done');
|
|
96
|
+
assert.equal(row.cwd, '/tmp/work');
|
|
97
|
+
const edgeCount = db.prepare('SELECT COUNT(*) AS n FROM edges').get().n;
|
|
98
|
+
assert.equal(edgeCount, 1);
|
|
99
|
+
// The new columns are seeded NULL for the migrated row — nothing observed.
|
|
100
|
+
const rt = db
|
|
101
|
+
.prepare('SELECT intent, pi_pid, "window", tmux_session FROM nodes WHERE node_id = ?')
|
|
102
|
+
.get('n1');
|
|
103
|
+
assert.equal(rt['intent'], null);
|
|
104
|
+
assert.equal(rt['pi_pid'], null);
|
|
105
|
+
assert.equal(rt['window'], null);
|
|
106
|
+
assert.equal(rt['tmux_session'], null);
|
|
107
|
+
});
|
|
108
|
+
// --- v4: edges FK ON DELETE CASCADE (the one irreversible rebuild) ------------
|
|
109
|
+
/** Build a hand-rolled db at exactly v3 (baseline tables + the four runtime
|
|
110
|
+
* columns, edges with NO foreign key, `user_version = 3`) — the shape the live
|
|
111
|
+
* fleet reaches just before v4. Caller seeds it, we close it; the next openDb()
|
|
112
|
+
* runs only v4 over it. */
|
|
113
|
+
function buildV3Db() {
|
|
114
|
+
ensureHome();
|
|
115
|
+
const raw = new DatabaseSync(canvasDbPath());
|
|
116
|
+
raw.exec(`
|
|
117
|
+
CREATE TABLE nodes (
|
|
118
|
+
node_id TEXT PRIMARY KEY,
|
|
119
|
+
name TEXT NOT NULL,
|
|
120
|
+
kind TEXT NOT NULL,
|
|
121
|
+
mode TEXT NOT NULL DEFAULT 'base',
|
|
122
|
+
lifecycle TEXT NOT NULL DEFAULT 'terminal',
|
|
123
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
124
|
+
cwd TEXT NOT NULL,
|
|
125
|
+
parent TEXT,
|
|
126
|
+
created TEXT NOT NULL,
|
|
127
|
+
intent TEXT,
|
|
128
|
+
pi_pid INTEGER,
|
|
129
|
+
window TEXT,
|
|
130
|
+
tmux_session TEXT
|
|
131
|
+
);
|
|
132
|
+
CREATE TABLE edges (
|
|
133
|
+
type TEXT NOT NULL,
|
|
134
|
+
from_id TEXT NOT NULL,
|
|
135
|
+
to_id TEXT NOT NULL,
|
|
136
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
137
|
+
created TEXT NOT NULL,
|
|
138
|
+
PRIMARY KEY (type, from_id, to_id)
|
|
139
|
+
);
|
|
140
|
+
PRAGMA user_version = 3;
|
|
141
|
+
`);
|
|
142
|
+
return raw;
|
|
143
|
+
}
|
|
144
|
+
function insertNode(db, id) {
|
|
145
|
+
db.prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created) VALUES (?,?,?,?,?,?)').run(id, id, 'general', 'active', '/tmp/work', '2020-01-01T00:00:00Z');
|
|
146
|
+
}
|
|
147
|
+
function insertEdge(db, type, from, to) {
|
|
148
|
+
db.prepare('INSERT INTO edges (type, from_id, to_id, created) VALUES (?,?,?,?)').run(type, from, to, '2020-01-01T00:00:00Z');
|
|
149
|
+
}
|
|
150
|
+
function edgeCount(db) {
|
|
151
|
+
return db.prepare('SELECT COUNT(*) AS n FROM edges').get().n;
|
|
152
|
+
}
|
|
153
|
+
test('v4 migrates a db with a pre-existing orphan edge without throwing', () => {
|
|
154
|
+
const raw = buildV3Db();
|
|
155
|
+
insertNode(raw, 'n1');
|
|
156
|
+
insertNode(raw, 'n2');
|
|
157
|
+
insertEdge(raw, 'subscribes_to', 'n1', 'n2'); // non-orphan: both endpoints exist
|
|
158
|
+
insertEdge(raw, 'spawned_by', 'n1', 'ghost'); // ORPHAN: 'ghost' has no node row
|
|
159
|
+
assert.equal(userVersion(raw), 3);
|
|
160
|
+
assert.equal(edgeCount(raw), 2);
|
|
161
|
+
raw.close();
|
|
162
|
+
// openDb() runs v4. It must NOT throw on the orphan, and must drop it.
|
|
163
|
+
const db = openDb();
|
|
164
|
+
assert.equal(userVersion(db), MIGRATIONS.length); // migrated forward to the head
|
|
165
|
+
// The orphan edge is filtered; the non-orphan edge + its count are preserved.
|
|
166
|
+
assert.equal(edgeCount(db), 1);
|
|
167
|
+
const surviving = db
|
|
168
|
+
.prepare('SELECT type, from_id, to_id FROM edges')
|
|
169
|
+
.get();
|
|
170
|
+
assert.equal(surviving.type, 'subscribes_to');
|
|
171
|
+
assert.equal(surviving.from_id, 'n1');
|
|
172
|
+
assert.equal(surviving.to_id, 'n2');
|
|
173
|
+
});
|
|
174
|
+
test('v4 leaves the FK live: an edge to a missing node is rejected', () => {
|
|
175
|
+
const raw = buildV3Db();
|
|
176
|
+
insertNode(raw, 'n1');
|
|
177
|
+
raw.close();
|
|
178
|
+
const db = openDb();
|
|
179
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
180
|
+
// Inserting an edge whose endpoint has no node row now violates the FK.
|
|
181
|
+
assert.throws(() => db
|
|
182
|
+
.prepare('INSERT INTO edges (type, from_id, to_id, active, created) VALUES (?,?,?,?,?)')
|
|
183
|
+
.run('spawned_by', 'n1', 'missing', 1, '2020-01-01T00:00:00Z'), /FOREIGN KEY|constraint/i);
|
|
184
|
+
// A fully-valid edge (both endpoints present) still inserts fine.
|
|
185
|
+
insertNode(db, 'n3');
|
|
186
|
+
assert.doesNotThrow(() => db
|
|
187
|
+
.prepare('INSERT INTO edges (type, from_id, to_id, active, created) VALUES (?,?,?,?,?)')
|
|
188
|
+
.run('subscribes_to', 'n1', 'n3', 1, '2020-01-01T00:00:00Z'));
|
|
189
|
+
});
|
|
190
|
+
test('v4 deleting a node cascade-deletes its edges', () => {
|
|
191
|
+
const raw = buildV3Db();
|
|
192
|
+
insertNode(raw, 'a');
|
|
193
|
+
insertNode(raw, 'b');
|
|
194
|
+
insertEdge(raw, 'subscribes_to', 'a', 'b');
|
|
195
|
+
insertEdge(raw, 'spawned_by', 'b', 'a');
|
|
196
|
+
raw.close();
|
|
197
|
+
const db = openDb();
|
|
198
|
+
assert.equal(edgeCount(db), 2);
|
|
199
|
+
// Delete one endpoint — every edge touching it cascades away.
|
|
200
|
+
db.prepare('DELETE FROM nodes WHERE node_id = ?').run('a');
|
|
201
|
+
assert.equal(edgeCount(db), 0);
|
|
202
|
+
});
|
|
203
|
+
test('v4 is idempotent on re-open', () => {
|
|
204
|
+
const raw = buildV3Db();
|
|
205
|
+
insertNode(raw, 'n1');
|
|
206
|
+
insertEdge(raw, 'subscribes_to', 'n1', 'n1');
|
|
207
|
+
raw.close();
|
|
208
|
+
const db = openDb();
|
|
209
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
210
|
+
assert.equal(edgeCount(db), 1);
|
|
211
|
+
// Re-running migrate() directly is a no-op (gate skips applied steps); the
|
|
212
|
+
// edges table is NOT rebuilt again.
|
|
213
|
+
assert.doesNotThrow(() => migrate(db));
|
|
214
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
215
|
+
assert.equal(edgeCount(db), 1);
|
|
216
|
+
// Re-opening from disk is likewise a no-op — the FK + data persist.
|
|
217
|
+
closeDb();
|
|
218
|
+
const db2 = openDb();
|
|
219
|
+
assert.equal(userVersion(db2), MIGRATIONS.length);
|
|
220
|
+
assert.equal(edgeCount(db2), 1);
|
|
221
|
+
});
|
|
222
|
+
test('migration is idempotent on re-open and on re-run', () => {
|
|
223
|
+
// First open migrates v0 -> latest.
|
|
224
|
+
const db = openDb();
|
|
225
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
226
|
+
// Re-running migrate() directly is a no-op (the gate skips applied steps).
|
|
227
|
+
// If the gate were broken, the second ALTER ADD COLUMN would throw.
|
|
228
|
+
assert.doesNotThrow(() => migrate(db));
|
|
229
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
230
|
+
// Re-opening from disk is likewise a no-op — no duplicate-column error.
|
|
231
|
+
closeDb();
|
|
232
|
+
const db2 = openDb();
|
|
233
|
+
assert.equal(userVersion(db2), MIGRATIONS.length);
|
|
234
|
+
const cols = nodeColumns(db2);
|
|
235
|
+
for (const c of RUNTIME_COLUMNS) {
|
|
236
|
+
assert.ok(cols.includes(c), `expected nodes.${c} to persist`);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/pane-column.test.ts
|
|
2
|
+
//
|
|
3
|
+
// STEP 2 of the placement/focus migration: the `pane` runtime column —
|
|
4
|
+
// LOCATION's authoritative handle (the durable tmux `%pane_id`). This step is
|
|
5
|
+
// PURELY ADDITIVE: the column exists, setPresence writes it, getNode/getRow read
|
|
6
|
+
// it back, but nothing READS it for behavior yet (population wires up in Steps
|
|
7
|
+
// 3+). Covers:
|
|
8
|
+
// - migration v5 adds `pane` to a fresh db (and a legacy v4 db migrates up)
|
|
9
|
+
// - createNode seeds pane; setPresence writes it atomically with window/session
|
|
10
|
+
// - getNode (hydrated view) + getRow (row) both read pane back
|
|
11
|
+
// - a row never given a pane reads pane=null (legacy/back-compat)
|
|
12
|
+
// - the migration is idempotent / forward-only (re-open is a no-op)
|
|
13
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
16
|
+
import { tmpdir } from 'node:os';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
19
|
+
import { createNode, getNode, getRow, setPresence } from '../canvas/canvas.js';
|
|
20
|
+
import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
|
|
21
|
+
import { canvasDbPath, ensureHome } from '../canvas/paths.js';
|
|
22
|
+
let home;
|
|
23
|
+
function node(id, over = {}) {
|
|
24
|
+
return {
|
|
25
|
+
node_id: id,
|
|
26
|
+
name: id,
|
|
27
|
+
created: new Date().toISOString(),
|
|
28
|
+
cwd: '/tmp/work',
|
|
29
|
+
kind: 'general',
|
|
30
|
+
mode: 'base',
|
|
31
|
+
lifecycle: 'terminal',
|
|
32
|
+
status: 'active',
|
|
33
|
+
...over,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function userVersion(db) {
|
|
37
|
+
return db.prepare('PRAGMA user_version').get().user_version;
|
|
38
|
+
}
|
|
39
|
+
function nodeColumns(db) {
|
|
40
|
+
return db.prepare('PRAGMA table_info(nodes)').all().map((r) => r.name);
|
|
41
|
+
}
|
|
42
|
+
before(() => {
|
|
43
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-pane-'));
|
|
44
|
+
process.env['CRTR_HOME'] = home;
|
|
45
|
+
});
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
closeDb();
|
|
48
|
+
rmSync(home, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
after(() => {
|
|
51
|
+
closeDb();
|
|
52
|
+
rmSync(home, { recursive: true, force: true });
|
|
53
|
+
delete process.env['CRTR_HOME'];
|
|
54
|
+
});
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Migration v5 — the additive `pane` column.
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
test('a fresh db migrates to the latest version and has the pane column', () => {
|
|
59
|
+
const db = openDb();
|
|
60
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
61
|
+
assert.ok(nodeColumns(db).includes('pane'), 'nodes.pane exists on a fresh db');
|
|
62
|
+
});
|
|
63
|
+
test('a v4 db migrates forward, adding pane=NULL without disturbing existing data', () => {
|
|
64
|
+
// Hand-build a db at exactly v4: baseline + the four v2 runtime columns +
|
|
65
|
+
// edges WITH the v4 FK shape, user_version=4 — the shape just before v5.
|
|
66
|
+
ensureHome();
|
|
67
|
+
const raw = new DatabaseSync(canvasDbPath());
|
|
68
|
+
raw.exec(`
|
|
69
|
+
CREATE TABLE nodes (
|
|
70
|
+
node_id TEXT PRIMARY KEY, name TEXT NOT NULL, kind TEXT NOT NULL,
|
|
71
|
+
mode TEXT NOT NULL DEFAULT 'base', lifecycle TEXT NOT NULL DEFAULT 'terminal',
|
|
72
|
+
status TEXT NOT NULL DEFAULT 'active', cwd TEXT NOT NULL, parent TEXT,
|
|
73
|
+
created TEXT NOT NULL,
|
|
74
|
+
intent TEXT, pi_pid INTEGER, window TEXT, tmux_session TEXT
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE edges (
|
|
77
|
+
type TEXT NOT NULL, from_id TEXT NOT NULL, to_id TEXT NOT NULL,
|
|
78
|
+
active INTEGER NOT NULL DEFAULT 1, created TEXT NOT NULL,
|
|
79
|
+
PRIMARY KEY (type, from_id, to_id),
|
|
80
|
+
FOREIGN KEY (from_id) REFERENCES nodes(node_id) ON DELETE CASCADE,
|
|
81
|
+
FOREIGN KEY (to_id) REFERENCES nodes(node_id) ON DELETE CASCADE
|
|
82
|
+
);
|
|
83
|
+
PRAGMA user_version = 4;
|
|
84
|
+
`);
|
|
85
|
+
raw
|
|
86
|
+
.prepare('INSERT INTO nodes (node_id, name, kind, status, cwd, created, window, tmux_session) VALUES (?,?,?,?,?,?,?,?)')
|
|
87
|
+
.run('legacy', 'Legacy', 'general', 'idle', '/tmp/work', '2020-01-01T00:00:00Z', '@9', 'crtr');
|
|
88
|
+
assert.equal(userVersion(raw), 4);
|
|
89
|
+
assert.ok(!nodeColumns(raw).includes('pane'), 'precondition: no pane column at v4');
|
|
90
|
+
raw.close();
|
|
91
|
+
// openDb() runs v5 forward.
|
|
92
|
+
const db = openDb();
|
|
93
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
94
|
+
assert.ok(nodeColumns(db).includes('pane'), 'pane column added by v5');
|
|
95
|
+
// The pre-existing row is intact; its pane backfills NULL (additive default).
|
|
96
|
+
const row = db
|
|
97
|
+
.prepare('SELECT status, window, tmux_session, pane FROM nodes WHERE node_id = ?')
|
|
98
|
+
.get('legacy');
|
|
99
|
+
assert.equal(row['status'], 'idle', 'existing status untouched');
|
|
100
|
+
assert.equal(row['window'], '@9', 'existing window untouched');
|
|
101
|
+
assert.equal(row['tmux_session'], 'crtr', 'existing session untouched');
|
|
102
|
+
assert.equal(row['pane'], null, 'pane defaults NULL for a legacy row');
|
|
103
|
+
});
|
|
104
|
+
test('the migration is idempotent / forward-only on re-run and re-open', () => {
|
|
105
|
+
const db = openDb();
|
|
106
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
107
|
+
// Re-running migrate() is a no-op — the gate skips applied steps, so the v5
|
|
108
|
+
// ALTER ADD COLUMN never fires twice (which would throw "duplicate column").
|
|
109
|
+
assert.doesNotThrow(() => migrate(db));
|
|
110
|
+
assert.equal(userVersion(db), MIGRATIONS.length);
|
|
111
|
+
// Re-opening from disk is likewise a no-op.
|
|
112
|
+
closeDb();
|
|
113
|
+
const db2 = openDb();
|
|
114
|
+
assert.equal(userVersion(db2), MIGRATIONS.length);
|
|
115
|
+
assert.ok(nodeColumns(db2).includes('pane'));
|
|
116
|
+
});
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Round-trip: pane is a RUNTIME field, authoritative in the row, read back by
|
|
119
|
+
// both the hydrated view (getNode) and the row (getRow).
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
test('createNode seeds pane and getNode/getRow read it back', () => {
|
|
122
|
+
createNode(node('n', { tmux_session: 'crtr', window: '@1', pane: '%5' }));
|
|
123
|
+
assert.equal(getNode('n')?.pane, '%5', 'getNode hydrates pane from the row');
|
|
124
|
+
assert.equal(getRow('n')?.pane, '%5', 'getRow returns pane on the NodeRow');
|
|
125
|
+
});
|
|
126
|
+
test('a node created without a pane reads pane=null (legacy / back-compat)', () => {
|
|
127
|
+
createNode(node('n', { tmux_session: 'crtr', window: '@1' }));
|
|
128
|
+
assert.equal(getNode('n')?.pane, null, 'no pane given → null on the hydrated view');
|
|
129
|
+
assert.equal(getRow('n')?.pane, null, 'no pane given → null on the row');
|
|
130
|
+
});
|
|
131
|
+
test('setPresence writes pane atomically alongside window + session', () => {
|
|
132
|
+
createNode(node('n'));
|
|
133
|
+
setPresence('n', { tmux_session: 'user-sess', window: '@2', pane: '%7' });
|
|
134
|
+
const m = getNode('n');
|
|
135
|
+
assert.equal(m?.pane, '%7', 'setPresence wrote the pane');
|
|
136
|
+
assert.equal(m?.window, '@2', 'setPresence wrote the window in the same statement');
|
|
137
|
+
assert.equal(m?.tmux_session, 'user-sess', 'setPresence wrote the session in the same statement');
|
|
138
|
+
});
|
|
139
|
+
test('setPresence without pane writes null (the Step-2 contract: nobody reads pane yet)', () => {
|
|
140
|
+
createNode(node('n', { pane: '%7' }));
|
|
141
|
+
assert.equal(getNode('n')?.pane, '%7');
|
|
142
|
+
// A presence write that omits pane resets it to null — fine for Step 2 because
|
|
143
|
+
// no reader depends on pane until the placement layer lands (Steps 3+).
|
|
144
|
+
setPresence('n', { tmux_session: 'crtr', window: '@3' });
|
|
145
|
+
assert.equal(getNode('n')?.pane, null, 'omitted pane → null (no half-written LOCATION)');
|
|
146
|
+
});
|
|
147
|
+
test('pane is a RUNTIME field — it never leaks into meta.json identity', () => {
|
|
148
|
+
createNode(node('n', { pane: '%5' }));
|
|
149
|
+
setPresence('n', { pane: '%9', window: '@1', tmux_session: 'crtr' });
|
|
150
|
+
// meta.json is durable identity only; the runtime pane lives in the row.
|
|
151
|
+
const onDisk = openDb().prepare('SELECT pane FROM nodes WHERE node_id = ?').get('n');
|
|
152
|
+
assert.equal(onDisk.pane, '%9', 'pane is authoritative in the row');
|
|
153
|
+
});
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// Run: node --import tsx/esm --test src/core/__tests__/passive-subscription.test.ts
|
|
10
10
|
import { test, before, beforeEach, after } from 'node:test';
|
|
11
11
|
import assert from 'node:assert/strict';
|
|
12
|
-
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
12
|
+
import { mkdtempSync, rmSync, existsSync, appendFileSync } from 'node:fs';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { createNode, subscribe } from '../canvas/canvas.js';
|
|
@@ -96,6 +96,29 @@ test('drainPassive reads then clears (surfaces exactly once)', () => {
|
|
|
96
96
|
assert.equal(drainPassive('observer').length, 0);
|
|
97
97
|
assert.equal(readPassive('observer').length, 0);
|
|
98
98
|
});
|
|
99
|
+
test('drainPassive tolerates a corrupt line — keeps every good entry, loses only the bad one', () => {
|
|
100
|
+
createNode(node('observer'));
|
|
101
|
+
appendPassive('observer', { from: 'a', tier: 'normal', kind: 'update', label: 'one' });
|
|
102
|
+
appendPassive('observer', { from: 'b', tier: 'normal', kind: 'update', label: 'two' });
|
|
103
|
+
// A torn/garbage line lands between good entries (a partial write, a crash
|
|
104
|
+
// mid-append). The whole feed must NOT be discarded over it.
|
|
105
|
+
appendFileSync(passivePath('observer'), '{not valid json\n', 'utf8');
|
|
106
|
+
appendPassive('observer', { from: 'c', tier: 'normal', kind: 'update', label: 'three' });
|
|
107
|
+
const drained = drainPassive('observer');
|
|
108
|
+
assert.equal(drained.length, 3, 'all three good entries survive the corrupt line');
|
|
109
|
+
assert.deepEqual(drained.map((e) => e.label), ['one', 'two', 'three']);
|
|
110
|
+
// And it still cleared: a second drain is empty (snapshot removed).
|
|
111
|
+
assert.equal(drainPassive('observer').length, 0);
|
|
112
|
+
assert.equal(existsSync(passivePath('observer')), false);
|
|
113
|
+
});
|
|
114
|
+
test('readPassive tolerates a corrupt line without clearing', () => {
|
|
115
|
+
createNode(node('observer'));
|
|
116
|
+
appendPassive('observer', { from: 'a', tier: 'normal', kind: 'update', label: 'one' });
|
|
117
|
+
appendFileSync(passivePath('observer'), 'garbage}\n', 'utf8');
|
|
118
|
+
appendPassive('observer', { from: 'b', tier: 'normal', kind: 'update', label: 'two' });
|
|
119
|
+
const read = readPassive('observer');
|
|
120
|
+
assert.deepEqual(read.map((e) => e.label), ['one', 'two']);
|
|
121
|
+
});
|
|
99
122
|
test('formatPassive renders timestamped XML update blocks', () => {
|
|
100
123
|
const entries = [
|
|
101
124
|
{ ts: '2026-06-03T12:00:00.000Z', from: 'pub-a', tier: 'normal', kind: 'update', label: 'alpha happened' },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Run: node --import tsx/esm --test src/core/__tests__/persona-compose.test.ts
|
|
2
|
+
//
|
|
3
|
+
// The static system-prompt composer (core/personas/resolve.ts) varies the
|
|
4
|
+
// runtime protocol on TWO axes beyond kind×mode:
|
|
5
|
+
// • lifecycle (terminal | resident) — the "how you end" contract.
|
|
6
|
+
// • spine position (hasManager) — whether the push-up family is taught AT ALL.
|
|
7
|
+
// These assert the four corners, especially the resident+no-manager root that
|
|
8
|
+
// must never hear about `push` (final OR update).
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { resolve } from '../personas/index.js';
|
|
12
|
+
// The lifecycle fragment is identified by its signature contract phrase, not by
|
|
13
|
+
// the raw "push final" substring — the resident fragment legitimately NAMES
|
|
14
|
+
// `push final` only to forbid it. The kind body is now lifecycle-neutral, so the
|
|
15
|
+
// only finish contract in the prompt is whichever fragment composed.
|
|
16
|
+
const TERMINAL_CONTRACT = /owe a final result and you reap when done/i;
|
|
17
|
+
const RESIDENT_CONTRACT = /never forced to submit a final result/i;
|
|
18
|
+
const PUSH_UP = /crtr push (update|urgent)/i;
|
|
19
|
+
test('terminal + has-manager (the default child worker): terminal finish + push up', () => {
|
|
20
|
+
const p = resolve('general', 'base', { lifecycle: 'terminal', hasManager: true });
|
|
21
|
+
assert.match(p.systemPrompt, TERMINAL_CONTRACT, 'a terminal node owes a final and finishes via push final');
|
|
22
|
+
assert.doesNotMatch(p.systemPrompt, RESIDENT_CONTRACT);
|
|
23
|
+
assert.match(p.systemPrompt, PUSH_UP, 'a managed node reports up via push update/urgent');
|
|
24
|
+
assert.equal(p.lifecycle, 'terminal');
|
|
25
|
+
});
|
|
26
|
+
test('resident + no-manager (the user-facing root): resident finish, and NO push-up family at all', () => {
|
|
27
|
+
const p = resolve('general', 'base', { lifecycle: 'resident', hasManager: false });
|
|
28
|
+
assert.match(p.systemPrompt, RESIDENT_CONTRACT, 'a resident root is never forced to submit');
|
|
29
|
+
assert.doesNotMatch(p.systemPrompt, TERMINAL_CONTRACT, 'it does not get the terminal finish contract');
|
|
30
|
+
assert.doesNotMatch(p.systemPrompt, PUSH_UP, 'a top-of-spine root has nobody to push up to');
|
|
31
|
+
assert.match(p.systemPrompt, /top of your spine/i, 'oriented as top-of-spine');
|
|
32
|
+
assert.match(p.systemPrompt, /dormant/i, 'told it goes dormant and wakes');
|
|
33
|
+
});
|
|
34
|
+
test('resident + has-manager (interactable sub-orchestrator): resident finish, but still push up', () => {
|
|
35
|
+
const p = resolve('general', 'base', { lifecycle: 'resident', hasManager: true });
|
|
36
|
+
assert.match(p.systemPrompt, RESIDENT_CONTRACT, 'resident is never forced to submit a final');
|
|
37
|
+
assert.doesNotMatch(p.systemPrompt, TERMINAL_CONTRACT);
|
|
38
|
+
assert.match(p.systemPrompt, PUSH_UP, 'still reports progress up to its manager');
|
|
39
|
+
});
|
|
40
|
+
test('terminal + no-manager (a terminal root): terminal finish, but no push up', () => {
|
|
41
|
+
const p = resolve('general', 'base', { lifecycle: 'terminal', hasManager: false });
|
|
42
|
+
assert.match(p.systemPrompt, TERMINAL_CONTRACT, 'terminal still finishes via push final (self-completes)');
|
|
43
|
+
assert.doesNotMatch(p.systemPrompt, PUSH_UP, 'nobody subscribes, so no report-up family');
|
|
44
|
+
});
|
|
45
|
+
test('the lifecycle-neutral base survives in every corner (delegate + human ask)', () => {
|
|
46
|
+
for (const hasManager of [true, false]) {
|
|
47
|
+
for (const lifecycle of ['terminal', 'resident']) {
|
|
48
|
+
const p = resolve('general', 'base', { lifecycle, hasManager });
|
|
49
|
+
assert.match(p.systemPrompt, /crtr node new/, `delegate verb present (${lifecycle}/${hasManager})`);
|
|
50
|
+
assert.match(p.systemPrompt, /crtr human ask/, `human-ask present (${lifecycle}/${hasManager})`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Run: node --import tsx/esm --test src/core/__tests__/persona-subkind.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Scoped persona sub-kinds: a kind owns specialist reviewer personas at
|
|
4
|
+
// `<kind>/reviewers/<name>/base.md`, enumerated by `subKindsFor(kind)` and
|
|
5
|
+
// rendered into that kind's composed prompt (and nowhere else) by `resolve`.
|
|
6
|
+
// Visibility = membership: only `plan` sees the `plan/reviewers/*` menu; the
|
|
7
|
+
// sub-kinds never pollute the global `availableKinds()` list; and a sub-kind
|
|
8
|
+
// itself boots as a real composed persona with the terminal finish contract.
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { resolve } from '../personas/resolve.js';
|
|
12
|
+
import { subKindsFor, availableKinds } from '../personas/loader.js';
|
|
13
|
+
const PLAN_REVIEWER_KINDS = [
|
|
14
|
+
'plan/reviewers/architecture-fit',
|
|
15
|
+
'plan/reviewers/code-smells',
|
|
16
|
+
'plan/reviewers/pattern-consistency',
|
|
17
|
+
'plan/reviewers/requirements-coverage',
|
|
18
|
+
'plan/reviewers/security',
|
|
19
|
+
];
|
|
20
|
+
const MENU_HEADER = 'Reviewer sub-kinds you may spawn';
|
|
21
|
+
test('subKindsFor("plan") returns the five reviewers sorted, each with a non-empty summary', () => {
|
|
22
|
+
const subs = subKindsFor('plan');
|
|
23
|
+
assert.deepEqual(subs.map((s) => s.kind), PLAN_REVIEWER_KINDS, 'the five plan reviewer kind strings in sorted order');
|
|
24
|
+
for (const s of subs) {
|
|
25
|
+
assert.ok(s.summary.length > 0, `${s.kind} carries a non-empty summary`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
test('sub-kinds do not recurse and absent rosters yield []', () => {
|
|
29
|
+
assert.deepEqual(subKindsFor('explore'), [], 'explore owns no reviewers/');
|
|
30
|
+
assert.deepEqual(subKindsFor('plan/reviewers/security'), [], 'a sub-kind owns no nested reviewers/ — no recursion');
|
|
31
|
+
});
|
|
32
|
+
test('availableKinds() contains no plan/reviewers/* — sub-kinds never pollute the global list', () => {
|
|
33
|
+
const kinds = availableKinds();
|
|
34
|
+
for (const k of PLAN_REVIEWER_KINDS) {
|
|
35
|
+
assert.ok(!kinds.includes(k), `${k} must not appear in availableKinds()`);
|
|
36
|
+
}
|
|
37
|
+
assert.ok(!kinds.some((k) => k.includes('reviewers')), 'no kind contains "reviewers"');
|
|
38
|
+
});
|
|
39
|
+
test('resolve(plan, orchestrator) renders the menu with all five reviewer strings', () => {
|
|
40
|
+
const p = resolve('plan', 'orchestrator', { lifecycle: 'terminal', hasManager: true });
|
|
41
|
+
assert.match(p.systemPrompt, new RegExp(MENU_HEADER), 'the menu header is present');
|
|
42
|
+
for (const k of PLAN_REVIEWER_KINDS) {
|
|
43
|
+
assert.ok(p.systemPrompt.includes(k), `menu lists ${k}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
test('resolve(plan, base) ALSO renders the menu (render-for-both decision)', () => {
|
|
47
|
+
const p = resolve('plan', 'base', { lifecycle: 'terminal', hasManager: true });
|
|
48
|
+
assert.match(p.systemPrompt, new RegExp(MENU_HEADER), 'a base plan node sees the roster too');
|
|
49
|
+
for (const k of PLAN_REVIEWER_KINDS) {
|
|
50
|
+
assert.ok(p.systemPrompt.includes(k), `menu lists ${k}`);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
test('resolve(general, orchestrator) does NOT render the menu (visibility = membership)', () => {
|
|
54
|
+
const p = resolve('general', 'orchestrator', { lifecycle: 'terminal', hasManager: true });
|
|
55
|
+
assert.doesNotMatch(p.systemPrompt, new RegExp(MENU_HEADER), 'general owns no roster, so no menu');
|
|
56
|
+
});
|
|
57
|
+
test('a reviewer sub-kind boots as a real composed persona with the terminal finish contract and no menu', () => {
|
|
58
|
+
const p = resolve('plan/reviewers/security', 'base', { lifecycle: 'terminal', hasManager: true });
|
|
59
|
+
assert.ok(p.systemPrompt.includes('concrete exploit path'), 'the security-reviewer lens expertise is present');
|
|
60
|
+
assert.match(p.systemPrompt, /owe a final result and you reap when done/i, 'the terminal finish contract composed in — it boots as a real persona');
|
|
61
|
+
assert.doesNotMatch(p.systemPrompt, new RegExp(MENU_HEADER), 'a sub-kind owns no roster of its own — it renders no menu');
|
|
62
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|