create-claude-cabinet 0.45.0 → 0.46.0
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/README.md +4 -4
- package/lib/cli.js +26 -0
- package/lib/engagement-server-setup.js +34 -9
- package/lib/migrate-from-omega.js +13 -1
- package/lib/mux-setup.js +33 -9
- package/lib/watchtower-setup.js +210 -0
- package/package.json +5 -1
- package/templates/cabinet/_cabinet-member-template.md +8 -3
- package/templates/cabinet/advisories-state-schema.md +34 -7
- package/templates/cabinet/composition-patterns.md +4 -3
- package/templates/cabinet/skill-output-conventions.md +35 -1
- package/templates/cabinet/watchtower-contracts.md +89 -1
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
- package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
- package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
- package/templates/mux/bin/mux +281 -55
- package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
- package/templates/scripts/__tests__/advisories.test.mjs +262 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +137 -0
- package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +108 -3
- package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
- package/templates/scripts/__tests__/ring3-dedup.test.mjs +387 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
- package/templates/scripts/watchtower-advisories.mjs +305 -0
- package/templates/scripts/watchtower-build-context.mjs +110 -11
- package/templates/scripts/watchtower-lib.mjs +177 -1
- package/templates/scripts/watchtower-queue.mjs +146 -1
- package/templates/scripts/watchtower-ring1.mjs +129 -9
- package/templates/scripts/watchtower-ring2.mjs +118 -21
- package/templates/scripts/watchtower-ring3-close.mjs +466 -49
- package/templates/scripts/watchtower-routines.mjs +358 -0
- package/templates/scripts/watchtower-status.sh +1 -1
- package/templates/skills/audit/SKILL.md +5 -1
- package/templates/skills/briefing/SKILL.md +342 -234
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
- package/templates/skills/cabinet-historian/SKILL.md +14 -11
- package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
- package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
- package/templates/skills/cc-publish/SKILL.md +105 -19
- package/templates/skills/debrief/SKILL.md +127 -12
- package/templates/skills/execute/SKILL.md +6 -0
- package/templates/skills/inbox/SKILL.md +67 -6
- package/templates/skills/orient/SKILL.md +69 -47
- package/templates/skills/plan/SKILL.md +8 -0
- package/templates/skills/qa-drain/SKILL.md +119 -0
- package/templates/skills/session-handoff/SKILL.md +175 -6
- package/templates/skills/triage-audit/SKILL.md +6 -0
- package/templates/skills/watchtower/SKILL.md +46 -1
- package/templates/watchtower/config.json.template +3 -1
- package/templates/watchtower/queue/items/item.json.schema +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Session advisor pass (act:aded4fc9): the re-homed standing advisors'
|
|
2
|
+
// close-side seat in Ring 3.
|
|
3
|
+
//
|
|
4
|
+
// 1-4. discoverSessionAdvisors — index-driven roster discovery: missing
|
|
5
|
+
// index, corrupt index, mandate-without-directive (data error,
|
|
6
|
+
// skipped), and the happy path. A `debrief` mandate must NOT match —
|
|
7
|
+
// record-keeper keeps its /debrief seat and is not advisor-pass
|
|
8
|
+
// roster (the vocabulary is `session-close`, deliberately distinct).
|
|
9
|
+
// 5-7. parseAdvisorFindings — cap at 2, urgency normalization, garbage in
|
|
10
|
+
// → [] out.
|
|
11
|
+
// 8-11. advisorPass with an injected callFn (no SDK, no network): items
|
|
12
|
+
// file as `advisor-finding` with member evidence and thread_ids;
|
|
13
|
+
// dedup vs pending titles suppresses; one throwing advisor doesn't
|
|
14
|
+
// sink the others; no roster → filed: 0.
|
|
15
|
+
//
|
|
16
|
+
// Fixtures are hermetic: WATCHTOWER_DIR points at a temp dir and is set
|
|
17
|
+
// BEFORE the dynamic imports (the queue lib and ring3-close read it into
|
|
18
|
+
// module consts at load) — a static import would target the LIVE inbox.
|
|
19
|
+
|
|
20
|
+
import { test } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), 'advisor-pass-'));
|
|
27
|
+
process.env.WATCHTOWER_DIR = root;
|
|
28
|
+
mkdirSync(join(root, 'queue', 'items'), { recursive: true });
|
|
29
|
+
mkdirSync(join(root, 'state', 'threads'), { recursive: true });
|
|
30
|
+
|
|
31
|
+
const q = await import('../watchtower-queue.mjs');
|
|
32
|
+
const r3 = await import('../watchtower-ring3-close.mjs');
|
|
33
|
+
|
|
34
|
+
test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
35
|
+
|
|
36
|
+
// --- Fixture project with a skills index + member SKILL.md files ----------
|
|
37
|
+
|
|
38
|
+
function makeProject(indexContent) {
|
|
39
|
+
const projDir = mkdtempSync(join(tmpdir(), 'advisor-proj-'));
|
|
40
|
+
const skillsDir = join(projDir, '.claude', 'skills');
|
|
41
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
42
|
+
if (indexContent !== undefined) {
|
|
43
|
+
writeFileSync(join(skillsDir, '_index.json'),
|
|
44
|
+
typeof indexContent === 'string' ? indexContent : JSON.stringify(indexContent));
|
|
45
|
+
}
|
|
46
|
+
return projDir;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function memberEntry(name, { mandate, directives = {} } = {}) {
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
path: `.claude/skills/${name}/SKILL.md`,
|
|
53
|
+
type: 'cabinet',
|
|
54
|
+
...(mandate ? { standingMandate: mandate } : {}),
|
|
55
|
+
...(Object.keys(directives).length ? { directives } : {}),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeMemberSkill(projDir, name, body = `# ${name}\n\nIdentity prose.\n`) {
|
|
60
|
+
const dir = join(projDir, '.claude', 'skills', name);
|
|
61
|
+
mkdirSync(dir, { recursive: true });
|
|
62
|
+
writeFileSync(join(dir, 'SKILL.md'), body);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const ROSTER_INDEX = {
|
|
66
|
+
skills: [
|
|
67
|
+
memberEntry('cabinet-historian', {
|
|
68
|
+
mandate: ['plan', 'execute', 'session-close', 'briefing'],
|
|
69
|
+
directives: { 'session-close': 'Verify decisions were captured.', briefing: 'Surface prior art.' },
|
|
70
|
+
}),
|
|
71
|
+
memberEntry('cabinet-user-advocate', {
|
|
72
|
+
mandate: ['session-close', 'briefing'],
|
|
73
|
+
directives: { 'session-close': 'Flag legibility gaps.', briefing: 'Explain one thing.' },
|
|
74
|
+
}),
|
|
75
|
+
// debrief mandate must NOT be swept into the close pass
|
|
76
|
+
memberEntry('cabinet-record-keeper', {
|
|
77
|
+
mandate: ['audit', 'debrief'],
|
|
78
|
+
directives: { debrief: 'Check doc accuracy.' },
|
|
79
|
+
}),
|
|
80
|
+
// mandate without directive = data error, skipped
|
|
81
|
+
memberEntry('cabinet-broken', {
|
|
82
|
+
mandate: ['session-close'],
|
|
83
|
+
directives: {},
|
|
84
|
+
}),
|
|
85
|
+
memberEntry('orient-quick', {}),
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const itemFiles = () => readdirSync(join(root, 'queue', 'items')).filter(f => f.endsWith('.json'));
|
|
90
|
+
const readItems = () => itemFiles().map(f => JSON.parse(readFileSync(join(root, 'queue', 'items', f), 'utf8')));
|
|
91
|
+
function clearQueue() {
|
|
92
|
+
for (const f of itemFiles()) rmSync(join(root, 'queue', 'items', f));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- discoverSessionAdvisors ----------------------------------------------
|
|
96
|
+
|
|
97
|
+
test('discovery: missing index returns empty roster with reason', () => {
|
|
98
|
+
const projDir = makeProject(undefined);
|
|
99
|
+
const { advisors, reason } = r3.discoverSessionAdvisors(projDir);
|
|
100
|
+
assert.equal(advisors.length, 0);
|
|
101
|
+
assert.match(reason, /no skills index/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('discovery: corrupt index returns empty roster with reason, never throws', () => {
|
|
105
|
+
const projDir = makeProject('{not json');
|
|
106
|
+
const { advisors, reason } = r3.discoverSessionAdvisors(projDir);
|
|
107
|
+
assert.equal(advisors.length, 0);
|
|
108
|
+
assert.match(reason, /unparseable/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('discovery: session-close mandate + directive selects; debrief mandate does not', () => {
|
|
112
|
+
const projDir = makeProject(ROSTER_INDEX);
|
|
113
|
+
const { advisors } = r3.discoverSessionAdvisors(projDir);
|
|
114
|
+
const names = advisors.map(a => a.name).sort();
|
|
115
|
+
assert.deepEqual(names, ['cabinet-historian', 'cabinet-user-advocate']);
|
|
116
|
+
const historian = advisors.find(a => a.name === 'cabinet-historian');
|
|
117
|
+
assert.equal(historian.directive, 'Verify decisions were captured.');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('discovery: mandate without directives.session-close is skipped (data error)', () => {
|
|
121
|
+
const projDir = makeProject(ROSTER_INDEX);
|
|
122
|
+
const { advisors } = r3.discoverSessionAdvisors(projDir);
|
|
123
|
+
assert.ok(!advisors.some(a => a.name === 'cabinet-broken'));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// --- parseAdvisorFindings ---------------------------------------------------
|
|
127
|
+
|
|
128
|
+
test('parse: caps findings at 2 and normalizes urgency', () => {
|
|
129
|
+
const text = JSON.stringify([
|
|
130
|
+
{ title: 'one', summary: 's1', urgency: 'urgent' },
|
|
131
|
+
{ title: 'two', summary: 's2', urgency: 'bogus' },
|
|
132
|
+
{ title: 'three', summary: 's3', urgency: 'low' },
|
|
133
|
+
]);
|
|
134
|
+
const findings = r3.parseAdvisorFindings(text);
|
|
135
|
+
assert.equal(findings.length, 2);
|
|
136
|
+
assert.equal(findings[0].urgency, 'urgent');
|
|
137
|
+
assert.equal(findings[1].urgency, 'normal'); // bogus → normal
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('parse: extracts the array from surrounding prose', () => {
|
|
141
|
+
const findings = r3.parseAdvisorFindings('Here you go:\n[{"title":"x","summary":"y"}]\nDone.');
|
|
142
|
+
assert.equal(findings.length, 1);
|
|
143
|
+
assert.equal(findings[0].title, 'x');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('parse: garbage, non-arrays, and titleless entries yield []', () => {
|
|
147
|
+
assert.deepEqual(r3.parseAdvisorFindings('no json here'), []);
|
|
148
|
+
assert.deepEqual(r3.parseAdvisorFindings('{"title":"obj not array"}'), []);
|
|
149
|
+
assert.deepEqual(r3.parseAdvisorFindings('[{"summary":"missing title"}]'), []);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// --- advisorPass (injected callFn — hermetic) -------------------------------
|
|
153
|
+
|
|
154
|
+
const PROJECT_NAME = 'advisor-test-proj';
|
|
155
|
+
|
|
156
|
+
function project(projDir) {
|
|
157
|
+
return { name: PROJECT_NAME, path: projDir, slug: PROJECT_NAME };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
test('pass: files advisor-finding items with member evidence and thread ids', async () => {
|
|
161
|
+
clearQueue();
|
|
162
|
+
const projDir = makeProject(ROSTER_INDEX);
|
|
163
|
+
writeMemberSkill(projDir, 'cabinet-historian');
|
|
164
|
+
writeMemberSkill(projDir, 'cabinet-user-advocate');
|
|
165
|
+
|
|
166
|
+
const callFn = async (system) => {
|
|
167
|
+
if (system.includes('cabinet-historian')) {
|
|
168
|
+
return JSON.stringify([{ title: 'Uncaptured publish-window decision', summary: 'The tag ruling never reached memory.', urgency: 'normal' }]);
|
|
169
|
+
}
|
|
170
|
+
return '[]'; // user-advocate stays silent — the common case
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const result = await r3.advisorPass('fake transcript', project(projDir), 'sess-1', ['thread-a'], { callFn });
|
|
174
|
+
assert.equal(result.filed, 1);
|
|
175
|
+
|
|
176
|
+
const items = readItems();
|
|
177
|
+
assert.equal(items.length, 1);
|
|
178
|
+
const item = items[0];
|
|
179
|
+
assert.equal(item.category, 'advisor-finding');
|
|
180
|
+
assert.equal(item.title, 'historian: Uncaptured publish-window decision');
|
|
181
|
+
assert.equal(item.evidence.member, 'cabinet-historian');
|
|
182
|
+
assert.equal(item.evidence.directive_key, 'session-close');
|
|
183
|
+
assert.equal(item.filed_by, 'ring3-close');
|
|
184
|
+
assert.deepEqual(item.thread_ids, ['thread-a']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('pass: dedup vs pending titles suppresses a re-filed finding', async () => {
|
|
188
|
+
clearQueue();
|
|
189
|
+
const projDir = makeProject(ROSTER_INDEX);
|
|
190
|
+
writeMemberSkill(projDir, 'cabinet-historian');
|
|
191
|
+
writeMemberSkill(projDir, 'cabinet-user-advocate');
|
|
192
|
+
|
|
193
|
+
q.createItem({
|
|
194
|
+
project: PROJECT_NAME,
|
|
195
|
+
project_path: projDir,
|
|
196
|
+
category: 'advisor-finding',
|
|
197
|
+
title: 'historian: Uncaptured publish-window decision',
|
|
198
|
+
summary: 'already pending',
|
|
199
|
+
context_anchor: 'fixture',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const callFn = async (system) =>
|
|
203
|
+
system.includes('cabinet-historian')
|
|
204
|
+
? JSON.stringify([{ title: 'Uncaptured publish-window decision', summary: 'same finding again' }])
|
|
205
|
+
: '[]';
|
|
206
|
+
|
|
207
|
+
const result = await r3.advisorPass('fake transcript', project(projDir), 'sess-2', [], { callFn });
|
|
208
|
+
assert.equal(result.filed, 0);
|
|
209
|
+
assert.equal(readItems().length, 1); // only the fixture remains
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('pass: one throwing advisor does not sink the others', async () => {
|
|
213
|
+
clearQueue();
|
|
214
|
+
const projDir = makeProject(ROSTER_INDEX);
|
|
215
|
+
writeMemberSkill(projDir, 'cabinet-historian');
|
|
216
|
+
writeMemberSkill(projDir, 'cabinet-user-advocate');
|
|
217
|
+
|
|
218
|
+
const callFn = async (system) => {
|
|
219
|
+
if (system.includes('cabinet-historian')) throw new Error('API down');
|
|
220
|
+
return JSON.stringify([{ title: 'Unexplained gate mechanism', summary: 'used but never explained' }]);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const result = await r3.advisorPass('fake transcript', project(projDir), 'sess-3', [], { callFn });
|
|
224
|
+
assert.equal(result.filed, 1);
|
|
225
|
+
assert.equal(readItems()[0].evidence.member, 'cabinet-user-advocate');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('pass: project without a roster files nothing and never calls the model', async () => {
|
|
229
|
+
clearQueue();
|
|
230
|
+
const projDir = makeProject({ skills: [memberEntry('cabinet-qa', { mandate: ['plan', 'execute'] })] });
|
|
231
|
+
let called = 0;
|
|
232
|
+
const result = await r3.advisorPass('fake transcript', project(projDir), 'sess-4', [], {
|
|
233
|
+
callFn: async () => { called++; return '[]'; },
|
|
234
|
+
});
|
|
235
|
+
assert.equal(result.filed, 0);
|
|
236
|
+
assert.equal(called, 0);
|
|
237
|
+
assert.equal(readItems().length, 0);
|
|
238
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Hermetic tests for the environment-advisories pass (act:f9ea075d).
|
|
2
|
+
// 1:1 with advisories-state-schema.md — every rule has a named case.
|
|
3
|
+
//
|
|
4
|
+
// Everything is injected: projectPath (mkdtemp fixture), pluginProbe,
|
|
5
|
+
// homeDir (temp, so railway/registry reads find nothing), and `now` (fixed
|
|
6
|
+
// date for deterministic throttle behavior). The module reads files at call
|
|
7
|
+
// time, so a static import is safe.
|
|
8
|
+
|
|
9
|
+
import { test } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
|
|
15
|
+
import { runAdvisoryPass } from '../watchtower-advisories.mjs';
|
|
16
|
+
|
|
17
|
+
const TODAY = '2026-06-13';
|
|
18
|
+
|
|
19
|
+
// Make a project fixture. Always writes .claude/briefing/_briefing.md so the
|
|
20
|
+
// briefing-file advisory stays quiet unless a test omits it on purpose.
|
|
21
|
+
function makeProject({ files = {}, briefing = true } = {}) {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), 'adv-'));
|
|
23
|
+
if (briefing) {
|
|
24
|
+
mkdirSync(join(dir, '.claude', 'briefing'), { recursive: true });
|
|
25
|
+
writeFileSync(join(dir, '.claude', 'briefing', '_briefing.md'), '# briefing\n');
|
|
26
|
+
}
|
|
27
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
28
|
+
const full = join(dir, rel);
|
|
29
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
30
|
+
writeFileSync(full, content);
|
|
31
|
+
}
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A throwaway home dir (no ~/.claude.json, no cc-registry.json) so the
|
|
36
|
+
// railway and registry-orphan advisories never fire in these tests.
|
|
37
|
+
function emptyHome() {
|
|
38
|
+
return mkdtempSync(join(tmpdir(), 'home-'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const statePath = (dir) => join(dir, '.claude', 'cabinet', 'advisories-state.json');
|
|
42
|
+
function readState(dir) {
|
|
43
|
+
const p = statePath(dir);
|
|
44
|
+
return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : null;
|
|
45
|
+
}
|
|
46
|
+
function seedState(dir, state) {
|
|
47
|
+
mkdirSync(join(dir, '.claude', 'cabinet'), { recursive: true });
|
|
48
|
+
writeFileSync(statePath(dir), JSON.stringify(state, null, 2));
|
|
49
|
+
}
|
|
50
|
+
const surfaced = (result, id) => result.some((a) => a.id === id);
|
|
51
|
+
|
|
52
|
+
const probeReturning = (text) => () => text; // ruby-lsp installed iff text includes it
|
|
53
|
+
const PROBE_NONE = probeReturning(''); // nothing installed
|
|
54
|
+
const PROBE_RUBY = probeReturning('ruby-lsp\n'); // ruby-lsp installed
|
|
55
|
+
const PROBE_NULL = () => null; // probe could not answer
|
|
56
|
+
|
|
57
|
+
function run(dir, { probe = PROBE_NONE, home = emptyHome(), now = TODAY } = {}) {
|
|
58
|
+
return runAdvisoryPass({ projectPath: dir, pluginProbe: probe, homeDir: home, now });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Rule 1: no entry → suggested, surfaces ---
|
|
62
|
+
|
|
63
|
+
test('no entry → surfaces, writes suggested count 1', () => {
|
|
64
|
+
const dir = makeProject({ files: { Gemfile: 'source "x"\n' } });
|
|
65
|
+
const r = run(dir);
|
|
66
|
+
assert.ok(surfaced(r, 'lsp:ruby'));
|
|
67
|
+
const e = readState(dir)['lsp:ruby'];
|
|
68
|
+
assert.equal(e.status, 'suggested');
|
|
69
|
+
assert.equal(e.count, 1);
|
|
70
|
+
assert.equal(e.signal, 'gemfile');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// --- Rule 4: suggested count<2 surfaces & increments; count>=2 goes quiet ---
|
|
74
|
+
|
|
75
|
+
test('suggested count<2 surfaces and increments; count>=2 quiet', () => {
|
|
76
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
77
|
+
run(dir); // count 1 → 2 surfaced... wait: first run creates count 1
|
|
78
|
+
let e = readState(dir)['lsp:ruby'];
|
|
79
|
+
assert.equal(e.count, 1);
|
|
80
|
+
const r2 = run(dir); // count 1 (<2) → surface, becomes 2
|
|
81
|
+
assert.ok(surfaced(r2, 'lsp:ruby'));
|
|
82
|
+
e = readState(dir)['lsp:ruby'];
|
|
83
|
+
assert.equal(e.count, 2);
|
|
84
|
+
const r3 = run(dir); // count 2 (>=2), unchanged signal → quiet
|
|
85
|
+
assert.ok(!surfaced(r3, 'lsp:ruby'));
|
|
86
|
+
assert.equal(readState(dir)['lsp:ruby'].count, 2); // no further increment
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// --- Rule 3: declined + unchanged signal → silent ---
|
|
90
|
+
|
|
91
|
+
test('declined + unchanged signal → silent', () => {
|
|
92
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
93
|
+
seedState(dir, { 'lsp:ruby': { status: 'declined', count: 1, last_shown: '2026-06-01', signal: 'gemfile' } });
|
|
94
|
+
const r = run(dir);
|
|
95
|
+
assert.ok(!surfaced(r, 'lsp:ruby'));
|
|
96
|
+
assert.equal(readState(dir)['lsp:ruby'].status, 'declined');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// --- Rule 3: declined + changed signal → re-surface once, reset ---
|
|
100
|
+
|
|
101
|
+
test('declined + changed signal → re-surface once, reset to suggested', () => {
|
|
102
|
+
// stored signal 'gemfile'; now Gemfile AND a .rb file → 'gemfile+rb'
|
|
103
|
+
const dir = makeProject({ files: { Gemfile: 'x\n', 'lib/app.rb': 'puts 1\n' } });
|
|
104
|
+
seedState(dir, { 'lsp:ruby': { status: 'declined', count: 3, last_shown: '2026-06-01', signal: 'gemfile' } });
|
|
105
|
+
const r = run(dir);
|
|
106
|
+
assert.ok(surfaced(r, 'lsp:ruby'));
|
|
107
|
+
const e = readState(dir)['lsp:ruby'];
|
|
108
|
+
assert.equal(e.status, 'suggested');
|
|
109
|
+
assert.equal(e.count, 1);
|
|
110
|
+
assert.equal(e.signal, 'gemfile+rb');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// --- Rule 4: suggested count>=2 + changed signal → reset + surface ---
|
|
114
|
+
|
|
115
|
+
test('suggested count>=2 + changed signal → reset to count 1 + surface', () => {
|
|
116
|
+
const dir = makeProject({ files: { Gemfile: 'x\n', 'lib/app.rb': 'x\n' } });
|
|
117
|
+
seedState(dir, { 'lsp:ruby': { status: 'suggested', count: 5, last_shown: '2026-06-01', signal: 'gemfile' } });
|
|
118
|
+
const r = run(dir);
|
|
119
|
+
assert.ok(surfaced(r, 'lsp:ruby'));
|
|
120
|
+
assert.equal(readState(dir)['lsp:ruby'].count, 1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- Rule 2: installed is terminal both directions ---
|
|
124
|
+
|
|
125
|
+
test('probe confirms installed → flips to terminal installed, never surfaces', () => {
|
|
126
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
127
|
+
const r = run(dir, { probe: PROBE_RUBY });
|
|
128
|
+
assert.ok(!surfaced(r, 'lsp:ruby'));
|
|
129
|
+
assert.equal(readState(dir)['lsp:ruby'].status, 'installed');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('installed never reverses, even when probe later returns false or null', () => {
|
|
133
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
134
|
+
seedState(dir, { 'lsp:ruby': { status: 'installed', count: 0, last_shown: TODAY, signal: 'gemfile' } });
|
|
135
|
+
assert.ok(!surfaced(run(dir, { probe: PROBE_NONE }), 'lsp:ruby'));
|
|
136
|
+
assert.equal(readState(dir)['lsp:ruby'].status, 'installed');
|
|
137
|
+
assert.ok(!surfaced(run(dir, { probe: PROBE_NULL }), 'lsp:ruby'));
|
|
138
|
+
assert.equal(readState(dir)['lsp:ruby'].status, 'installed');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// --- Static-signal hookify: sticky decline + re-arm by clearing ---
|
|
142
|
+
|
|
143
|
+
test('declined hookify stays silent across runs (static signal), re-arms when entry cleared', () => {
|
|
144
|
+
const dir = makeProject({ files: { '.claude/rules/enforcement-pipeline.md': '# pipeline\n' } });
|
|
145
|
+
seedState(dir, { 'plugin:hookify': { status: 'declined', count: 1, last_shown: '2026-06-01', signal: 'enforcement-pipeline' } });
|
|
146
|
+
assert.ok(!surfaced(run(dir, { probe: PROBE_NONE }), 'plugin:hookify'));
|
|
147
|
+
assert.ok(!surfaced(run(dir, { probe: PROBE_NONE }), 'plugin:hookify'));
|
|
148
|
+
// clear the entry → re-armed
|
|
149
|
+
seedState(dir, {});
|
|
150
|
+
assert.ok(surfaced(run(dir, { probe: PROBE_NONE }), 'plugin:hookify'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// --- Tri-state null probe ---
|
|
154
|
+
|
|
155
|
+
test('null probe: probe-suppressed LSP still surfaces; probe-gated hookify stays silent + no mutation', () => {
|
|
156
|
+
const dir = makeProject({ files: { Gemfile: 'x\n', '.claude/rules/enforcement-pipeline.md': '# p\n' } });
|
|
157
|
+
const r = run(dir, { probe: PROBE_NULL });
|
|
158
|
+
assert.ok(surfaced(r, 'lsp:ruby'), 'LSP surfaces on unknown probe (signal is on disk)');
|
|
159
|
+
assert.ok(!surfaced(r, 'plugin:hookify'), 'hookify frozen on unknown probe (gate is the probe)');
|
|
160
|
+
// hookify entry must NOT have been created/mutated by a frozen pass
|
|
161
|
+
assert.equal(readState(dir)['plugin:hookify'], undefined);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// --- File-absent / malformed state degrade ---
|
|
165
|
+
|
|
166
|
+
test('absent state file → every advisory never-seen (first run creates)', () => {
|
|
167
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
168
|
+
assert.equal(readState(dir), null);
|
|
169
|
+
assert.ok(surfaced(run(dir), 'lsp:ruby'));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('malformed state JSON → treated as absent, never throws', () => {
|
|
173
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
174
|
+
mkdirSync(join(dir, '.claude', 'cabinet'), { recursive: true });
|
|
175
|
+
writeFileSync(statePath(dir), '{ not valid json ');
|
|
176
|
+
let r;
|
|
177
|
+
assert.doesNotThrow(() => { r = run(dir); });
|
|
178
|
+
assert.ok(surfaced(r, 'lsp:ruby'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// --- Atomic write preserves unrelated ids ---
|
|
182
|
+
|
|
183
|
+
test('write preserves unrelated advisory entries', () => {
|
|
184
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
185
|
+
seedState(dir, { 'lsp:rust': { status: 'declined', count: 2, last_shown: '2026-06-01', signal: 'cargo' } });
|
|
186
|
+
run(dir);
|
|
187
|
+
const s = readState(dir);
|
|
188
|
+
assert.ok(s['lsp:ruby'], 'new entry written');
|
|
189
|
+
assert.equal(s['lsp:rust'].status, 'declined', 'unrelated entry preserved');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// --- no-probe advisory: briefing-file fires when absent ---
|
|
193
|
+
|
|
194
|
+
test('briefing-file advisory fires when _briefing.md absent (no-probe)', () => {
|
|
195
|
+
const dir = makeProject({ briefing: false });
|
|
196
|
+
const r = run(dir);
|
|
197
|
+
assert.ok(surfaced(r, 'briefing-file'));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// --- Sorted-token determinism ---
|
|
201
|
+
|
|
202
|
+
test('signal tokens are sorted (gemfile+rb), not readdir-order dependent', () => {
|
|
203
|
+
const dir = makeProject({ files: { Gemfile: 'x\n', 'src/a.rb': 'x\n' } });
|
|
204
|
+
run(dir);
|
|
205
|
+
assert.equal(readState(dir)['lsp:ruby'].signal, 'gemfile+rb');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// --- Bounded scan catches src/** without a root manifest ---
|
|
209
|
+
|
|
210
|
+
test('depth-limited scan finds src/**/*.ts even without root tsconfig', () => {
|
|
211
|
+
const dir = makeProject({ files: { 'src/app/main.ts': 'export const x = 1\n' } });
|
|
212
|
+
const r = run(dir);
|
|
213
|
+
assert.ok(surfaced(r, 'lsp:typescript'));
|
|
214
|
+
assert.equal(readState(dir)['lsp:typescript'].signal, 'ts');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('scan denylist: a .ts only inside node_modules does NOT trigger', () => {
|
|
218
|
+
const dir = makeProject({ files: { 'node_modules/pkg/index.ts': 'x\n' } });
|
|
219
|
+
const r = run(dir);
|
|
220
|
+
assert.ok(!surfaced(r, 'lsp:typescript'));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// --- Throttle ---
|
|
224
|
+
|
|
225
|
+
test('throttle: probe skipped when _meta.last_probe is today', () => {
|
|
226
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
227
|
+
seedState(dir, {
|
|
228
|
+
_meta: { last_probe: TODAY },
|
|
229
|
+
'lsp:ruby': { status: 'suggested', count: 1, last_shown: '2026-06-12', signal: 'gemfile' },
|
|
230
|
+
});
|
|
231
|
+
let called = 0;
|
|
232
|
+
const countingProbe = () => { called++; return ''; };
|
|
233
|
+
run(dir, { probe: countingProbe, now: TODAY });
|
|
234
|
+
assert.equal(called, 0, 'probe not called when throttled');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('throttle: probe runs and stamps _meta.last_probe when stale', () => {
|
|
238
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
239
|
+
seedState(dir, {
|
|
240
|
+
_meta: { last_probe: '2026-06-10' },
|
|
241
|
+
'lsp:ruby': { status: 'suggested', count: 1, last_shown: '2026-06-10', signal: 'gemfile' },
|
|
242
|
+
});
|
|
243
|
+
let called = 0;
|
|
244
|
+
const countingProbe = () => { called++; return ''; };
|
|
245
|
+
run(dir, { probe: countingProbe, now: TODAY });
|
|
246
|
+
assert.equal(called, 1, 'probe called when stale');
|
|
247
|
+
assert.equal(readState(dir)._meta.last_probe, TODAY);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// --- _meta is never treated as an advisory entry ---
|
|
251
|
+
|
|
252
|
+
test('_meta is not surfaced or mutated as an advisory', () => {
|
|
253
|
+
const dir = makeProject({ files: { Gemfile: 'x\n' } });
|
|
254
|
+
const r = run(dir);
|
|
255
|
+
assert.ok(!r.some((a) => a.id === '_meta'));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// --- never throws on a totally broken project path ---
|
|
259
|
+
|
|
260
|
+
test('missing projectPath → returns [] (never throws)', () => {
|
|
261
|
+
assert.deepEqual(runAdvisoryPass({ projectPath: null }), []);
|
|
262
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// applyBatch — the bulk disposition path used by /briefing's batch
|
|
2
|
+
// dispositions. Structural contract under test:
|
|
3
|
+
// B1: gated categories (qa-handoff) NEVER batch — a batch containing one
|
|
4
|
+
// fails whole, before any write (all-or-nothing pre-validation).
|
|
5
|
+
// B2: the typed reason (resolution_type AND notes) is required — a batch is
|
|
6
|
+
// one decision applied to many items; the reason lands on every item.
|
|
7
|
+
// B3: unknown ids fail the whole batch before any write.
|
|
8
|
+
// B4: ungated items batch fine (dismiss and resolve), stamping the typed
|
|
9
|
+
// reason on each; non-pending items are skipped and reported, not errors.
|
|
10
|
+
// B5: 'routine' is dispatched but NOT gated — batchable, and terminal batch
|
|
11
|
+
// exits clear its mux dispatch-queue descriptors (queued + in-flight).
|
|
12
|
+
//
|
|
13
|
+
// Fixtures are self-contained: WATCHTOWER_DIR / MUX_QA_DIR point at a temp
|
|
14
|
+
// dir and are set BEFORE the dynamic import (the lib reads them into module
|
|
15
|
+
// consts at load) — a static import would silently target the LIVE inbox.
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), 'batch-disp-'));
|
|
24
|
+
process.env.WATCHTOWER_DIR = root;
|
|
25
|
+
const muxQaRoot = join(root, 'mux-qa');
|
|
26
|
+
process.env.MUX_QA_DIR = muxQaRoot;
|
|
27
|
+
writeFileSync(join(root, 'config.json'), '{}\n');
|
|
28
|
+
|
|
29
|
+
const q = await import('../watchtower-queue.mjs');
|
|
30
|
+
|
|
31
|
+
test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
32
|
+
|
|
33
|
+
function makeItem(overrides = {}) {
|
|
34
|
+
return q.createItem({
|
|
35
|
+
project: 'test-proj',
|
|
36
|
+
project_path: '/tmp/test-proj',
|
|
37
|
+
category: 'knowledge-extraction',
|
|
38
|
+
title: 'extracted lesson',
|
|
39
|
+
summary: 'test',
|
|
40
|
+
context_anchor: 'session abc',
|
|
41
|
+
...overrides,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const TYPED = { resolution_type: 'stale', notes: 'overtaken by events — batch archive from /briefing' };
|
|
46
|
+
|
|
47
|
+
test('GATED_CATEGORIES is exported and contains qa-handoff', () => {
|
|
48
|
+
assert.ok(q.GATED_CATEGORIES instanceof Set);
|
|
49
|
+
assert.ok(q.GATED_CATEGORIES.has('qa-handoff'));
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('B1: a batch containing a qa-handoff item fails whole — no item mutated', () => {
|
|
53
|
+
const plain = makeItem();
|
|
54
|
+
const gatedId = makeItem({ category: 'qa-handoff', title: 'QA handoff: merge x' });
|
|
55
|
+
assert.throws(
|
|
56
|
+
() => q.applyBatch([plain, gatedId], { disposition: 'dismiss', ...TYPED }),
|
|
57
|
+
(err) => err.message.includes(gatedId) && err.message.includes('recipient gate'),
|
|
58
|
+
);
|
|
59
|
+
// All-or-nothing: the ungated sibling is untouched too.
|
|
60
|
+
assert.equal(q.getItem(plain).status, 'pending');
|
|
61
|
+
assert.equal(q.getItem(gatedId).status, 'pending');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('B2: typed reason is required — missing resolution_type or notes throws before writes', () => {
|
|
65
|
+
const id = makeItem();
|
|
66
|
+
assert.throws(() => q.applyBatch([id], { disposition: 'dismiss', notes: 'no type' }),
|
|
67
|
+
/resolution_type AND notes/);
|
|
68
|
+
assert.throws(() => q.applyBatch([id], { disposition: 'dismiss', resolution_type: 'stale' }),
|
|
69
|
+
/resolution_type AND notes/);
|
|
70
|
+
assert.throws(() => q.applyBatch([id], { disposition: 'dismiss', resolution_type: ' ', notes: ' ' }),
|
|
71
|
+
/resolution_type AND notes/);
|
|
72
|
+
assert.equal(q.getItem(id).status, 'pending');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('B3: unknown id fails the whole batch before any write', () => {
|
|
76
|
+
const id = makeItem();
|
|
77
|
+
assert.throws(() => q.applyBatch([id, 'dec-deadbeef'], { disposition: 'dismiss', ...TYPED }),
|
|
78
|
+
/unknown item dec-deadbeef/);
|
|
79
|
+
assert.equal(q.getItem(id).status, 'pending');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('B3b: invalid disposition and empty ids throw', () => {
|
|
83
|
+
const id = makeItem();
|
|
84
|
+
assert.throws(() => q.applyBatch([id], { disposition: 'archive', ...TYPED }), /disposition/);
|
|
85
|
+
assert.throws(() => q.applyBatch([], { disposition: 'dismiss', ...TYPED }), /non-empty/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('B4: dismiss batch applies to every ungated item, stamping the typed reason', () => {
|
|
89
|
+
const ids = [makeItem(), makeItem({ category: 'pattern-promotion' }), makeItem({ category: 'completion-review' })];
|
|
90
|
+
const result = q.applyBatch(ids, { disposition: 'dismiss', ...TYPED });
|
|
91
|
+
assert.deepEqual(result.applied.sort(), [...ids].sort());
|
|
92
|
+
assert.deepEqual(result.skipped_not_pending, []);
|
|
93
|
+
for (const id of ids) {
|
|
94
|
+
const item = q.getItem(id);
|
|
95
|
+
assert.equal(item.status, 'dismissed');
|
|
96
|
+
assert.equal(item.resolution_type, 'stale');
|
|
97
|
+
assert.equal(item.resolution_notes, TYPED.notes);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('B4b: resolve batch stamps resolution (defaulting to resolution_type) on each item', () => {
|
|
102
|
+
const ids = [makeItem(), makeItem()];
|
|
103
|
+
const result = q.applyBatch(ids, {
|
|
104
|
+
disposition: 'resolve',
|
|
105
|
+
resolution_type: 'captured-to-memory',
|
|
106
|
+
notes: 'all routed to memory in one /briefing pass',
|
|
107
|
+
});
|
|
108
|
+
assert.deepEqual(result.applied.sort(), [...ids].sort());
|
|
109
|
+
for (const id of ids) {
|
|
110
|
+
const item = q.getItem(id);
|
|
111
|
+
assert.equal(item.status, 'resolved');
|
|
112
|
+
assert.equal(item.resolution, 'captured-to-memory');
|
|
113
|
+
assert.equal(item.resolution_type, 'captured-to-memory');
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('B4c: non-pending items are skipped and reported, not errors', () => {
|
|
118
|
+
const open = makeItem();
|
|
119
|
+
const done = makeItem();
|
|
120
|
+
q.dismissItem(done, { notes: 'raced by another session', resolution_type: 'noise' });
|
|
121
|
+
const result = q.applyBatch([open, done], { disposition: 'dismiss', ...TYPED });
|
|
122
|
+
assert.deepEqual(result.applied, [open]);
|
|
123
|
+
assert.deepEqual(result.skipped_not_pending, [done]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('B5: routine items batch (dispatched, not gated) and dispatch descriptors are cleared', () => {
|
|
127
|
+
const id = makeItem({ category: 'routine', title: 'routine: morning-briefing' });
|
|
128
|
+
const deskDir = join(muxQaRoot, 'flow-desk');
|
|
129
|
+
mkdirSync(join(deskDir, 'in-flight'), { recursive: true });
|
|
130
|
+
writeFileSync(join(deskDir, `${id}.json`), '{}\n');
|
|
131
|
+
writeFileSync(join(deskDir, 'in-flight', `${id}.json`), '{}\n');
|
|
132
|
+
const result = q.applyBatch([id], { disposition: 'dismiss', ...TYPED });
|
|
133
|
+
assert.deepEqual(result.applied, [id]);
|
|
134
|
+
assert.equal(q.getItem(id).status, 'dismissed');
|
|
135
|
+
assert.ok(!existsSync(join(deskDir, `${id}.json`)), 'queued descriptor cleared');
|
|
136
|
+
assert.ok(!existsSync(join(deskDir, 'in-flight', `${id}.json`)), 'in-flight descriptor cleared');
|
|
137
|
+
});
|