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,312 @@
|
|
|
1
|
+
// Routine dispatch engine (act:c2a55c08) — hermetic suite.
|
|
2
|
+
// R1: declaration validation warns-and-skips, never throws.
|
|
3
|
+
// R2: trigger evaluation is pure and correct per type (time-of-day fires
|
|
4
|
+
// once per day, interval, path-nonempty + cooldown, session-close).
|
|
5
|
+
// R3: a firing files a 'routine' inbox item AND pushes through the single
|
|
6
|
+
// mux dispatch path; state records last_fired; mux-absent degrades to
|
|
7
|
+
// inbox-only without losing the item.
|
|
8
|
+
// R4: structural anti-pile-up — a pending item blocks refiring; a STALE
|
|
9
|
+
// pending item is superseded and redispatched.
|
|
10
|
+
// R5: queue-lib generalization — terminal exits on 'routine' items clear
|
|
11
|
+
// mux descriptors (resolve/dismiss/expire/runExpiry), with no QA gate.
|
|
12
|
+
//
|
|
13
|
+
// Fixtures are self-contained: WATCHTOWER_DIR / MUX_QA_DIR /
|
|
14
|
+
// WATCHTOWER_MUX_BIN are set BEFORE the dynamic import (both libs read env
|
|
15
|
+
// into module consts at load) — a static import would target live state.
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
import {
|
|
20
|
+
mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync,
|
|
21
|
+
chmodSync, readdirSync,
|
|
22
|
+
} from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
|
|
26
|
+
const root = mkdtempSync(join(tmpdir(), 'routine-dispatch-'));
|
|
27
|
+
process.env.WATCHTOWER_DIR = root;
|
|
28
|
+
const muxQaRoot = join(root, 'mux-qa');
|
|
29
|
+
process.env.MUX_QA_DIR = muxQaRoot;
|
|
30
|
+
|
|
31
|
+
// Stub mux: records its argv (one line per call) and succeeds. Tests that
|
|
32
|
+
// need the mux-absent path point WATCHTOWER_MUX_BIN at a missing file.
|
|
33
|
+
const muxStub = join(root, 'mux-stub.sh');
|
|
34
|
+
const muxCalls = join(root, 'mux-calls.log');
|
|
35
|
+
writeFileSync(muxStub, `#!/bin/sh\necho "$@" >> "${muxCalls}"\nexit 0\n`);
|
|
36
|
+
chmodSync(muxStub, 0o755);
|
|
37
|
+
process.env.WATCHTOWER_MUX_BIN = muxStub;
|
|
38
|
+
|
|
39
|
+
const r = await import('../watchtower-routines.mjs');
|
|
40
|
+
const q = await import('../watchtower-queue.mjs');
|
|
41
|
+
|
|
42
|
+
test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
43
|
+
|
|
44
|
+
const projectPath = join(root, 'proj');
|
|
45
|
+
mkdirSync(projectPath, { recursive: true });
|
|
46
|
+
|
|
47
|
+
function routine(overrides = {}) {
|
|
48
|
+
return {
|
|
49
|
+
name: 'morning-briefing',
|
|
50
|
+
description: 'Walk the operator through the day',
|
|
51
|
+
trigger: { type: 'time-of-day', at: '08:30' },
|
|
52
|
+
script: '.claude/routines/morning-briefing.md',
|
|
53
|
+
...overrides,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function configWith(routines, projectName = 'proj-a') {
|
|
58
|
+
return { projects: { [projectName]: { path: projectPath, routines } } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function muxCallCount() {
|
|
62
|
+
try { return readFileSync(muxCalls, 'utf8').trim().split('\n').filter(Boolean).length; }
|
|
63
|
+
catch { return 0; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const at = (iso) => new Date(iso);
|
|
67
|
+
|
|
68
|
+
// --- R1: declaration validation ---
|
|
69
|
+
|
|
70
|
+
test('valid declaration passes for every trigger type', () => {
|
|
71
|
+
assert.deepEqual(r.validateRoutine(routine()), []);
|
|
72
|
+
assert.deepEqual(r.validateRoutine(routine({ trigger: { type: 'interval', minutes: 30 } })), []);
|
|
73
|
+
assert.deepEqual(r.validateRoutine(routine({ trigger: { type: 'path-nonempty', path: 'queue/' } })), []);
|
|
74
|
+
assert.deepEqual(r.validateRoutine(routine({ trigger: { type: 'session-close' } })), []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('invalid declarations name the broken field', () => {
|
|
78
|
+
assert.match(r.validateRoutine(routine({ name: 'Bad Name!' })).join(';'), /kebab-case/);
|
|
79
|
+
assert.match(r.validateRoutine(routine({ script: '' })).join(';'), /script/);
|
|
80
|
+
assert.match(r.validateRoutine(routine({ trigger: { type: 'cron' } })).join(';'), /trigger\.type/);
|
|
81
|
+
assert.match(r.validateRoutine(routine({ trigger: { type: 'time-of-day', at: '25:99' } })).join(';'), /HH:MM/);
|
|
82
|
+
assert.match(r.validateRoutine(routine({ trigger: { type: 'interval', minutes: 1 } })).join(';'), /interval/);
|
|
83
|
+
assert.match(r.validateRoutine(routine({ trigger: { type: 'path-nonempty' } })).join(';'), /path/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// --- R2: trigger evaluation (pure) ---
|
|
87
|
+
|
|
88
|
+
const tick = { type: 'tick' };
|
|
89
|
+
const close = { type: 'session-close' };
|
|
90
|
+
|
|
91
|
+
test('time-of-day: not due before the slot, due after, fires once per day', () => {
|
|
92
|
+
const rt = routine();
|
|
93
|
+
const before = at('2026-06-12T08:00:00');
|
|
94
|
+
const after = at('2026-06-12T09:00:00');
|
|
95
|
+
assert.equal(r.triggerDue(rt, null, tick, before, projectPath), false);
|
|
96
|
+
assert.equal(r.triggerDue(rt, null, tick, after, projectPath), true);
|
|
97
|
+
// already fired today after the slot → not due again
|
|
98
|
+
assert.equal(r.triggerDue(rt, '2026-06-12T08:31:00', tick, after, projectPath), false);
|
|
99
|
+
// fired yesterday → due again today
|
|
100
|
+
assert.equal(r.triggerDue(rt, '2026-06-11T08:31:00', tick, after, projectPath), true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('interval: due when never fired or the interval has elapsed', () => {
|
|
104
|
+
const rt = routine({ trigger: { type: 'interval', minutes: 30 } });
|
|
105
|
+
const now = at('2026-06-12T12:00:00');
|
|
106
|
+
assert.equal(r.triggerDue(rt, null, tick, now, projectPath), true);
|
|
107
|
+
assert.equal(r.triggerDue(rt, '2026-06-12T11:45:00', tick, now, projectPath), false);
|
|
108
|
+
assert.equal(r.triggerDue(rt, '2026-06-12T11:20:00', tick, now, projectPath), true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('path-nonempty: content gates, cooldown throttles', () => {
|
|
112
|
+
const qdir = join(projectPath, 'cmd-queue');
|
|
113
|
+
const rt = routine({ trigger: { type: 'path-nonempty', path: 'cmd-queue' } });
|
|
114
|
+
const now = at('2026-06-12T12:00:00');
|
|
115
|
+
// missing → not due; empty dir → not due; content → due
|
|
116
|
+
assert.equal(r.triggerDue(rt, null, tick, now, projectPath), false);
|
|
117
|
+
mkdirSync(qdir, { recursive: true });
|
|
118
|
+
assert.equal(r.triggerDue(rt, null, tick, now, projectPath), false);
|
|
119
|
+
writeFileSync(join(qdir, 'cmd.json'), '{}');
|
|
120
|
+
assert.equal(r.triggerDue(rt, null, tick, now, projectPath), true);
|
|
121
|
+
// default 60-min cooldown
|
|
122
|
+
assert.equal(r.triggerDue(rt, '2026-06-12T11:30:00', tick, now, projectPath), false);
|
|
123
|
+
assert.equal(r.triggerDue(rt, '2026-06-12T10:30:00', tick, now, projectPath), true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('session-close: only fires on session-close events, tick triggers only on ticks', () => {
|
|
127
|
+
const rt = routine({ trigger: { type: 'session-close' } });
|
|
128
|
+
const now = at('2026-06-12T12:00:00');
|
|
129
|
+
assert.equal(r.triggerDue(rt, null, close, now, projectPath), true);
|
|
130
|
+
assert.equal(r.triggerDue(rt, null, tick, now, projectPath), false);
|
|
131
|
+
assert.equal(r.triggerDue(routine(), null, close, at('2026-06-12T09:00:00'), projectPath), false);
|
|
132
|
+
// cooldown_minutes throttles back-to-back closes
|
|
133
|
+
const cooled = routine({ trigger: { type: 'session-close' }, cooldown_minutes: 30 });
|
|
134
|
+
assert.equal(r.triggerDue(cooled, '2026-06-12T11:50:00', close, now, projectPath), false);
|
|
135
|
+
assert.equal(r.triggerDue(cooled, '2026-06-12T11:20:00', close, now, projectPath), true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// --- R3: firing files the item and pushes the single dispatch path ---
|
|
139
|
+
|
|
140
|
+
test('tick pass fires a due routine: inbox item + mux dispatch + state', () => {
|
|
141
|
+
const callsBefore = muxCallCount();
|
|
142
|
+
const pass = r.runRoutinePass({
|
|
143
|
+
config: configWith([routine()]),
|
|
144
|
+
event: tick,
|
|
145
|
+
filedBy: 'ring1',
|
|
146
|
+
now: at('2026-06-12T09:00:00'),
|
|
147
|
+
});
|
|
148
|
+
assert.equal(pass.fired.length, 1);
|
|
149
|
+
assert.equal(pass.fired[0].status, 'dispatched');
|
|
150
|
+
assert.equal(muxCallCount(), callsBefore + 1);
|
|
151
|
+
|
|
152
|
+
const item = q.getItem(pass.fired[0].item_id);
|
|
153
|
+
assert.equal(item.category, 'routine');
|
|
154
|
+
assert.equal(item.filed_by, 'ring1');
|
|
155
|
+
assert.equal(item.evidence.routine_key, 'proj-a/morning-briefing');
|
|
156
|
+
assert.match(item.title, /morning-briefing/);
|
|
157
|
+
|
|
158
|
+
const state = r.loadRoutineState();
|
|
159
|
+
assert.equal(state['proj-a/morning-briefing'].last_item_id, item.id);
|
|
160
|
+
assert.equal(state['proj-a/morning-briefing'].last_fired, at('2026-06-12T09:00:00').toISOString());
|
|
161
|
+
|
|
162
|
+
// descriptor tmp file is cleaned up after the push
|
|
163
|
+
const tmpFiles = existsSync(join(root, 'tmp')) ? readdirSync(join(root, 'tmp')) : [];
|
|
164
|
+
assert.deepEqual(tmpFiles.filter((f) => f.startsWith('routine-')), []);
|
|
165
|
+
|
|
166
|
+
// cleanup for later tests
|
|
167
|
+
q.resolveItem(item.id, { resolution: 'ran', resolution_type: 'acted-on' });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('mux absent degrades to inbox-only without losing the item', () => {
|
|
171
|
+
process.env.WATCHTOWER_MUX_BIN = join(root, 'no-such-mux');
|
|
172
|
+
try {
|
|
173
|
+
const res = r.dispatchRoutine({
|
|
174
|
+
projectName: 'proj-a', projectPath,
|
|
175
|
+
routine: routine({ name: 'evening-review' }), filedBy: 'ring1',
|
|
176
|
+
});
|
|
177
|
+
assert.equal(res.status, 'inbox-only');
|
|
178
|
+
assert.equal(q.getItem(res.item_id).category, 'routine');
|
|
179
|
+
q.dismissItem(res.item_id, { notes: 'test cleanup' });
|
|
180
|
+
} finally {
|
|
181
|
+
process.env.WATCHTOWER_MUX_BIN = muxStub;
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('session-close pass fires only the named project, and only close-triggered routines', () => {
|
|
186
|
+
const config = {
|
|
187
|
+
projects: {
|
|
188
|
+
'proj-a': { path: projectPath, routines: [routine({ name: 'close-sweep', trigger: { type: 'session-close' } })] },
|
|
189
|
+
'proj-b': { path: projectPath, routines: [routine({ name: 'close-sweep-b', trigger: { type: 'session-close' } })] },
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
const pass = r.runRoutinePass({
|
|
193
|
+
config, event: { type: 'session-close', project: 'proj-a' },
|
|
194
|
+
filedBy: 'ring3-close', now: at('2026-06-12T13:00:00'),
|
|
195
|
+
});
|
|
196
|
+
assert.deepEqual(pass.fired.map((f) => f.key), ['proj-a/close-sweep']);
|
|
197
|
+
assert.equal(q.getItem(pass.fired[0].item_id).filed_by, 'ring3-close');
|
|
198
|
+
q.resolveItem(pass.fired[0].item_id, { resolution: 'ran', resolution_type: 'acted-on' });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('invalid routine is reported and skipped, valid siblings still fire', () => {
|
|
202
|
+
const pass = r.runRoutinePass({
|
|
203
|
+
config: configWith([
|
|
204
|
+
routine({ name: 'BROKEN NAME' }),
|
|
205
|
+
routine({ name: 'still-fires', trigger: { type: 'interval', minutes: 30 } }),
|
|
206
|
+
]),
|
|
207
|
+
event: tick, filedBy: 'ring1', now: at('2026-06-12T14:00:00'),
|
|
208
|
+
});
|
|
209
|
+
assert.equal(pass.invalid.length, 1);
|
|
210
|
+
assert.deepEqual(pass.fired.map((f) => f.key), ['proj-a/still-fires']);
|
|
211
|
+
q.resolveItem(pass.fired[0].item_id, { resolution: 'ran', resolution_type: 'acted-on' });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// --- R4: anti-pile-up ---
|
|
215
|
+
|
|
216
|
+
test('pending item blocks refiring; stale pending item is superseded and redispatched', () => {
|
|
217
|
+
const rt = routine({ name: 'daily-digest', trigger: { type: 'time-of-day', at: '07:00' } });
|
|
218
|
+
const day1 = at('2026-06-12T07:05:00');
|
|
219
|
+
const first = r.runRoutinePass({ config: configWith([rt]), event: tick, filedBy: 'ring1', now: day1 });
|
|
220
|
+
assert.equal(first.fired.length, 1);
|
|
221
|
+
const firstId = first.fired[0].item_id;
|
|
222
|
+
|
|
223
|
+
// same day, later tick → blocked by once-per-day; next day with the item
|
|
224
|
+
// still pending but younger than stale_after_hours → pending-exists skip
|
|
225
|
+
const day2early = at('2026-06-13T07:05:00');
|
|
226
|
+
const freshRt = { ...rt, stale_after_hours: 48 };
|
|
227
|
+
const blocked = r.runRoutinePass({ config: configWith([freshRt]), event: tick, filedBy: 'ring1', now: day2early });
|
|
228
|
+
assert.equal(blocked.fired.length, 0);
|
|
229
|
+
assert.deepEqual(blocked.skipped.map((s) => s.reason), ['pending-exists']);
|
|
230
|
+
assert.equal(q.getItem(firstId).status, 'pending');
|
|
231
|
+
|
|
232
|
+
// next day with default 24h staleness → old item superseded, new one filed.
|
|
233
|
+
// createItem stamps filed_at with the REAL clock, so backdate it on disk
|
|
234
|
+
// to put the pending item past the staleness window relative to day2.
|
|
235
|
+
const day2 = at('2026-06-13T07:10:00');
|
|
236
|
+
const fp = join(root, 'queue', 'items', `${firstId}.json`);
|
|
237
|
+
const pendingItem = JSON.parse(readFileSync(fp, 'utf8'));
|
|
238
|
+
pendingItem.filed_at = new Date(day2.getTime() - 25 * 3_600_000).toISOString();
|
|
239
|
+
writeFileSync(fp, JSON.stringify(pendingItem, null, 2));
|
|
240
|
+
const second = r.runRoutinePass({ config: configWith([rt]), event: tick, filedBy: 'ring1', now: day2 });
|
|
241
|
+
assert.equal(second.fired.length, 1);
|
|
242
|
+
assert.notEqual(second.fired[0].item_id, firstId);
|
|
243
|
+
assert.equal(q.getItem(firstId).status, 'superseded');
|
|
244
|
+
assert.match(q.getItem(firstId).resolution_notes, /newer firing/);
|
|
245
|
+
q.resolveItem(second.fired[0].item_id, { resolution: 'ran', resolution_type: 'acted-on' });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// --- R5: queue-lib generalization (descriptor clearing, no QA gate) ---
|
|
249
|
+
|
|
250
|
+
function plantDescriptors(itemId) {
|
|
251
|
+
const desk = join(muxQaRoot, 'some-desk');
|
|
252
|
+
mkdirSync(join(desk, 'in-flight'), { recursive: true });
|
|
253
|
+
writeFileSync(join(desk, `${itemId}.json`), '{}');
|
|
254
|
+
writeFileSync(join(desk, 'in-flight', `${itemId}.json`), '{}');
|
|
255
|
+
return desk;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function makeRoutineItem() {
|
|
259
|
+
return q.createItem({
|
|
260
|
+
project: 'proj-a', project_path: projectPath, category: 'routine',
|
|
261
|
+
title: 'Routine: x', summary: 's', context_anchor: 'routine proj-a/x',
|
|
262
|
+
evidence: { routine_key: 'proj-a/x' },
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
test('resolving a routine item needs no qa_verdict and clears descriptors (queued + in-flight)', () => {
|
|
267
|
+
const id = makeRoutineItem();
|
|
268
|
+
const desk = plantDescriptors(id);
|
|
269
|
+
const out = q.resolveItem(id, { resolution: 'ran', resolution_type: 'acted-on' });
|
|
270
|
+
assert.equal(out.status, 'resolved');
|
|
271
|
+
assert.equal(existsSync(join(desk, `${id}.json`)), false);
|
|
272
|
+
assert.equal(existsSync(join(desk, 'in-flight', `${id}.json`)), false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('dismissing a routine item needs no typed reason and clears descriptors', () => {
|
|
276
|
+
const id = makeRoutineItem();
|
|
277
|
+
const desk = plantDescriptors(id);
|
|
278
|
+
assert.equal(q.dismissItem(id).status, 'dismissed');
|
|
279
|
+
assert.equal(existsSync(join(desk, `${id}.json`)), false);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('routine items expire normally (no qa gate) and expiry clears descriptors', () => {
|
|
283
|
+
const id = makeRoutineItem();
|
|
284
|
+
const desk = plantDescriptors(id);
|
|
285
|
+
assert.equal(q.expireItem(id).status, 'expired');
|
|
286
|
+
assert.equal(existsSync(join(desk, `${id}.json`)), false);
|
|
287
|
+
assert.equal(existsSync(join(desk, 'in-flight', `${id}.json`)), false);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('runExpiry expires an aged routine item and clears its descriptor', () => {
|
|
291
|
+
const id = makeRoutineItem();
|
|
292
|
+
const desk = plantDescriptors(id);
|
|
293
|
+
// backdate filed_at past the expiry window
|
|
294
|
+
const fp = join(root, 'queue', 'items', `${id}.json`);
|
|
295
|
+
const item = JSON.parse(readFileSync(fp, 'utf8'));
|
|
296
|
+
item.filed_at = '2026-01-01T00:00:00.000Z';
|
|
297
|
+
writeFileSync(fp, JSON.stringify(item, null, 2));
|
|
298
|
+
const { expired } = q.runExpiry({ warnDays: 14, expireDays: 30 });
|
|
299
|
+
assert.ok(expired.some((i) => i.id === id));
|
|
300
|
+
assert.equal(q.getItem(id).status, 'expired');
|
|
301
|
+
assert.equal(existsSync(join(desk, `${id}.json`)), false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('qa-handoff gate untouched by the generalization (regression)', () => {
|
|
305
|
+
const id = q.createItem({
|
|
306
|
+
project: 'proj-a', project_path: projectPath, category: 'qa-handoff',
|
|
307
|
+
title: 'QA handoff: t', summary: 's', context_anchor: 'git show abc',
|
|
308
|
+
});
|
|
309
|
+
assert.throws(() => q.resolveItem(id, { resolution: 'done' }), /qa_verdict/);
|
|
310
|
+
assert.throws(() => q.expireItem(id), /never expire/);
|
|
311
|
+
q.dismissItem(id, { notes: 'test cleanup', resolution_type: 'noise' });
|
|
312
|
+
});
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Watchtower environment-advisories pass (act:f9ea075d).
|
|
4
|
+
//
|
|
5
|
+
// The single home for orient's stack-aware setup advisories — LSP plugins,
|
|
6
|
+
// the Railway MCP, hookify, a missing project briefing, registry orphans —
|
|
7
|
+
// with a per-project dismissal memory so nothing re-nags every session.
|
|
8
|
+
//
|
|
9
|
+
// HOME = the SessionStart context builder, NOT Ring 1: Ring 1's launchd cron
|
|
10
|
+
// PATH cannot reach `claude`, so the install-probe (`claude plugin list`)
|
|
11
|
+
// would fail there. The context builder runs in the real session env.
|
|
12
|
+
//
|
|
13
|
+
// This module owns ALL advisory I/O: signal computation, the throttled probe,
|
|
14
|
+
// reading and atomic-writing the dismissal state, and applying the rules in
|
|
15
|
+
// `advisories-state-schema.md`. The context builder only calls runAdvisoryPass
|
|
16
|
+
// and renders the returned list. The pass NEVER throws — any failure returns
|
|
17
|
+
// an empty list and persists nothing, so it can never block session start.
|
|
18
|
+
//
|
|
19
|
+
// State file: <projectPath>/.claude/cabinet/advisories-state.json. Read AND
|
|
20
|
+
// written at the SAME path (the schema blesses per-worktree divergence; the
|
|
21
|
+
// cardinal rule is never read one path and write another). atomicWrite is
|
|
22
|
+
// temp+rename — corruption-safe but not read-modify-write-safe; concurrent
|
|
23
|
+
// sessions accept lost-update semantics (a lost count++ = one extra nag,
|
|
24
|
+
// self-correcting).
|
|
25
|
+
|
|
26
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
27
|
+
import { join } from 'path';
|
|
28
|
+
import { homedir } from 'os';
|
|
29
|
+
import { execSync } from 'child_process';
|
|
30
|
+
import { atomicWrite } from './watchtower-lib.mjs';
|
|
31
|
+
|
|
32
|
+
const STATE_REL = join('.claude', 'cabinet', 'advisories-state.json');
|
|
33
|
+
const SCAN_MAX_DEPTH = 3;
|
|
34
|
+
const SCAN_DENYLIST = new Set([
|
|
35
|
+
'node_modules', 'dist', 'build', 'vendor', 'coverage', 'target', 'out',
|
|
36
|
+
]);
|
|
37
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
38
|
+
const SURFACE_COUNT_LIMIT = 2; // a `suggested` advisory surfaces while count < 2
|
|
39
|
+
|
|
40
|
+
// --- date / IO helpers ---
|
|
41
|
+
|
|
42
|
+
function todayUTC() {
|
|
43
|
+
// UTC ISO date, matching the schema's last_shown basis AND sqlite date('now')
|
|
44
|
+
// (the overdue query). Local-time formatting would drift at the day boundary.
|
|
45
|
+
return new Date().toISOString().slice(0, 10);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function safeReadJSON(filePath) {
|
|
49
|
+
try {
|
|
50
|
+
if (!existsSync(filePath)) return null;
|
|
51
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
52
|
+
} catch {
|
|
53
|
+
return null; // absent OR malformed → treated as "every advisory never-seen"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- bounded stack-file scan ---
|
|
58
|
+
|
|
59
|
+
// Existence-only, depth-limited, denylisted, early-exit. A recursive *.ts walk
|
|
60
|
+
// over node_modules would be pathological on the session-start critical path;
|
|
61
|
+
// a root-only scan would silently miss the common src/**/*.ts layout (a TS
|
|
62
|
+
// project with no root tsconfig). This catches src/** while staying bounded.
|
|
63
|
+
function hasFileWithExt(dir, ext, depth = SCAN_MAX_DEPTH) {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const subdirs = [];
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
if (e.isFile() && e.name.endsWith(ext)) return true;
|
|
73
|
+
if (e.isDirectory() && !SCAN_DENYLIST.has(e.name) && !e.name.startsWith('.')) {
|
|
74
|
+
subdirs.push(e.name);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (depth <= 0) return false;
|
|
78
|
+
for (const sd of subdirs) {
|
|
79
|
+
if (hasFileWithExt(join(dir, sd), ext, depth - 1)) return true;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function has(projectPath, rel) {
|
|
85
|
+
return existsSync(join(projectPath, rel));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Signal = a deterministic fingerprint of the indicators present. Tokens are
|
|
89
|
+
// SORTED before joining so the same stack always produces the same string —
|
|
90
|
+
// otherwise readdir order could flip "gemfile+rb" ↔ "rb+gemfile" and spuriously
|
|
91
|
+
// re-arm a declined advisory every session.
|
|
92
|
+
function signalFrom(tokens) {
|
|
93
|
+
const present = tokens.filter(Boolean);
|
|
94
|
+
return present.length ? present.slice().sort().join('+') : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- advisory descriptors ---
|
|
98
|
+
//
|
|
99
|
+
// kind:
|
|
100
|
+
// 'probe-suppressed' — signal is on-disk (stack files); the probe only
|
|
101
|
+
// SUPPRESSES (confirms installed → terminal). Unknown probe → still
|
|
102
|
+
// surface (the LSP advisories).
|
|
103
|
+
// 'probe-gated' — the surfacing predicate IS the probe ("X not installed");
|
|
104
|
+
// unknown probe → freeze, do not surface, do not mutate (hookify).
|
|
105
|
+
// 'no-probe' — pure filesystem/config signal (railway, briefing-file,
|
|
106
|
+
// registry-orphan).
|
|
107
|
+
|
|
108
|
+
function buildAdvisories(ctx) {
|
|
109
|
+
const { projectPath, claudeJsonText, registry } = ctx;
|
|
110
|
+
const list = [];
|
|
111
|
+
|
|
112
|
+
const lsp = [
|
|
113
|
+
{ id: 'lsp:typescript', tokens: () => [has(projectPath, 'tsconfig.json') && 'tsconfig', hasFileWithExt(projectPath, '.ts') && 'ts'], needle: 'typescript-lsp', action: 'TypeScript detected but typescript-lsp not installed — catches missing imports/type errors after edits. Install: /plugin install typescript-lsp' },
|
|
114
|
+
{ id: 'lsp:python', tokens: () => [has(projectPath, 'pyproject.toml') && 'pyproject', has(projectPath, 'requirements.txt') && 'requirements', hasFileWithExt(projectPath, '.py') && 'py'], needle: 'pyright-lsp', action: 'Python detected but pyright-lsp not installed. Install: /plugin install pyright-lsp' },
|
|
115
|
+
{ id: 'lsp:rust', tokens: () => [has(projectPath, 'Cargo.toml') && 'cargo'], needle: 'rust-analyzer-lsp', action: 'Rust detected but rust-analyzer-lsp not installed. Install: /plugin install rust-analyzer-lsp' },
|
|
116
|
+
{ id: 'lsp:go', tokens: () => [has(projectPath, 'go.mod') && 'gomod'], needle: 'gopls-lsp', action: 'Go detected but gopls-lsp not installed. Install: /plugin install gopls-lsp' },
|
|
117
|
+
{ id: 'lsp:ruby', tokens: () => [has(projectPath, 'Gemfile') && 'gemfile', hasFileWithExt(projectPath, '.rb') && 'rb'], needle: 'ruby-lsp', action: 'Ruby detected but ruby-lsp not active. Install the plugin (/plugin install ruby-lsp@claude-plugins-official), the gem (gem install ruby-lsp), and set ENABLE_LSP_TOOL=1.' },
|
|
118
|
+
];
|
|
119
|
+
for (const a of lsp) {
|
|
120
|
+
const signal = signalFrom(a.tokens());
|
|
121
|
+
if (signal) list.push({ id: a.id, kind: 'probe-suppressed', needle: a.needle, signal, action: a.action });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Railway MCP — railway.toml present AND no railway key registered in
|
|
125
|
+
// ~/.claude.json. NOTE: Ring 1 also marker-checks railway.toml (deploy
|
|
126
|
+
// detection) — do NOT consolidate; this adds the registration predicate.
|
|
127
|
+
if (has(projectPath, 'railway.toml')) {
|
|
128
|
+
const registered = !!claudeJsonText && /railway/i.test(claudeJsonText);
|
|
129
|
+
if (!registered) {
|
|
130
|
+
list.push({ id: 'mcp:railway', kind: 'no-probe', needle: null, signal: 'railway-unregistered',
|
|
131
|
+
action: 'railway.toml present but no Railway MCP registered — agents get a cleaner surface with it. Local: railway setup agent -y · Remote: register mcp.railway.com (OAuth).' });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// hookify — enforcement-pipeline.md exists AND hookify not installed. Signal
|
|
136
|
+
// is STATIC (the file, once created, stays), so a declined hookify stays
|
|
137
|
+
// declined until its entry is cleared (intended sticky case).
|
|
138
|
+
if (has(projectPath, join('.claude', 'rules', 'enforcement-pipeline.md'))) {
|
|
139
|
+
list.push({ id: 'plugin:hookify', kind: 'probe-gated', needle: 'hookify', signal: 'enforcement-pipeline',
|
|
140
|
+
action: 'This project has an enforcement pipeline but hookify is not installed — it generates hooks from natural language. Install: /plugin install hookify' });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Briefing file presence — surface only when ABSENT.
|
|
144
|
+
if (!has(projectPath, join('.claude', 'briefing', '_briefing.md'))) {
|
|
145
|
+
list.push({ id: 'briefing-file', kind: 'no-probe', needle: null, signal: 'missing',
|
|
146
|
+
action: 'No project briefing at .claude/briefing/_briefing.md — cabinet members bootstrap from it. Run /onboard to create one.' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Registry orphans — registry entries whose path no longer exists.
|
|
150
|
+
if (registry && Array.isArray(registry.projects)) {
|
|
151
|
+
const orphans = registry.projects
|
|
152
|
+
.filter((p) => p && p.path && !existsSync(p.path))
|
|
153
|
+
.map((p) => p.name || p.path)
|
|
154
|
+
.sort();
|
|
155
|
+
if (orphans.length > 0) {
|
|
156
|
+
list.push({ id: 'registry-orphan', kind: 'no-probe', needle: null, signal: `orphans:${orphans.join('+')}`,
|
|
157
|
+
action: `cc-registry lists project(s) whose path is gone: ${orphans.join(', ')} — consider removing them from ~/.claude/cc-registry.json.` });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return list;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- the probe ---
|
|
165
|
+
|
|
166
|
+
// Returns the lowercased `claude plugin list` output, or null if the probe
|
|
167
|
+
// could not answer (claude not on PATH, nonzero exit, timeout). null is the
|
|
168
|
+
// tri-state's "unknown" — never conflated with "absent".
|
|
169
|
+
function defaultPluginProbe() {
|
|
170
|
+
try {
|
|
171
|
+
const out = execSync('claude plugin list', {
|
|
172
|
+
encoding: 'utf8', timeout: PROBE_TIMEOUT_MS,
|
|
173
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
174
|
+
});
|
|
175
|
+
return typeof out === 'string' ? out.toLowerCase() : null;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// tri-state: true | false | null
|
|
182
|
+
function isInstalled(probeText, needle) {
|
|
183
|
+
if (probeText == null) return null;
|
|
184
|
+
return probeText.includes(needle.toLowerCase());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- the rule engine (advisories-state-schema.md) ---
|
|
188
|
+
|
|
189
|
+
// Returns { surface, entry, mutated }. `entry` is the entry to store (or the
|
|
190
|
+
// unchanged one); `mutated` says whether state changed (drives the dirty flag
|
|
191
|
+
// and avoids needless writes / frozen no-ops).
|
|
192
|
+
function applyRule(prev, { signal, kind, installed }, today) {
|
|
193
|
+
// Terminal: already installed → never surface, never change.
|
|
194
|
+
if (prev && prev.status === 'installed') {
|
|
195
|
+
return { surface: false, entry: prev, mutated: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Probe confirms installed now → flip terminal (both probe kinds).
|
|
199
|
+
if (kind !== 'no-probe' && installed === true) {
|
|
200
|
+
const entry = { status: 'installed', count: prev?.count || 0, last_shown: prev?.last_shown || today, signal };
|
|
201
|
+
return { surface: false, entry, mutated: true };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Probe-gated with unknown probe → predicate unknowable → freeze (no mutation).
|
|
205
|
+
if (kind === 'probe-gated' && installed === null) {
|
|
206
|
+
return { surface: false, entry: prev, mutated: false };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// No entry → surface, create.
|
|
210
|
+
if (!prev) {
|
|
211
|
+
return { surface: true, entry: { status: 'suggested', count: 1, last_shown: today, signal }, mutated: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (prev.status === 'declined') {
|
|
215
|
+
if (prev.signal === signal) return { surface: false, entry: prev, mutated: false }; // silent
|
|
216
|
+
// signal changed → stack evolved → re-surface once, reset.
|
|
217
|
+
return { surface: true, entry: { status: 'suggested', count: 1, last_shown: today, signal }, mutated: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (prev.status === 'suggested') {
|
|
221
|
+
if (prev.signal !== signal) {
|
|
222
|
+
// stack changed at any count → reset + surface.
|
|
223
|
+
return { surface: true, entry: { status: 'suggested', count: 1, last_shown: today, signal }, mutated: true };
|
|
224
|
+
}
|
|
225
|
+
if ((prev.count || 0) < SURFACE_COUNT_LIMIT) {
|
|
226
|
+
return { surface: true, entry: { ...prev, count: (prev.count || 0) + 1, last_shown: today, signal }, mutated: true };
|
|
227
|
+
}
|
|
228
|
+
return { surface: false, entry: prev, mutated: false }; // count >= 2, unchanged → quiet
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { surface: false, entry: prev, mutated: false };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- main entry ---
|
|
235
|
+
|
|
236
|
+
export function runAdvisoryPass({ projectPath, pluginProbe = defaultPluginProbe, homeDir = homedir(), now = todayUTC() } = {}) {
|
|
237
|
+
try {
|
|
238
|
+
if (!projectPath) return [];
|
|
239
|
+
|
|
240
|
+
const today = now;
|
|
241
|
+
const statePath = join(projectPath, STATE_REL);
|
|
242
|
+
const rawState = safeReadJSON(statePath) || {};
|
|
243
|
+
const meta = (rawState._meta && typeof rawState._meta === 'object') ? rawState._meta : {};
|
|
244
|
+
const lastProbe = meta.last_probe ?? null;
|
|
245
|
+
|
|
246
|
+
const claudeJsonText = (() => {
|
|
247
|
+
try { return readFileSync(join(homeDir, '.claude.json'), 'utf8'); } catch { return null; }
|
|
248
|
+
})();
|
|
249
|
+
const registry = safeReadJSON(join(homeDir, '.claude', 'cc-registry.json'));
|
|
250
|
+
|
|
251
|
+
const advisories = buildAdvisories({ projectPath, claudeJsonText, registry });
|
|
252
|
+
if (advisories.length === 0) return [];
|
|
253
|
+
|
|
254
|
+
// Probe at most once/day/checkout, and only if a probe-kind advisory is
|
|
255
|
+
// applicable. Throttle skip leaves probeText=null → LSP still surfaces
|
|
256
|
+
// from cached state, hookify stays silent (probe-gated null = freeze).
|
|
257
|
+
const needProbe = advisories.some((a) => a.kind !== 'no-probe');
|
|
258
|
+
const throttled = lastProbe === today;
|
|
259
|
+
let probeText = null;
|
|
260
|
+
let probed = false;
|
|
261
|
+
if (needProbe && !throttled) {
|
|
262
|
+
probeText = pluginProbe();
|
|
263
|
+
probed = true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const surfaced = [];
|
|
267
|
+
let dirty = false;
|
|
268
|
+
for (const a of advisories) {
|
|
269
|
+
const installed = a.kind === 'no-probe' ? false : isInstalled(probeText, a.needle);
|
|
270
|
+
const { surface, entry, mutated } = applyRule(rawState[a.id], { signal: a.signal, kind: a.kind, installed }, today);
|
|
271
|
+
if (mutated) {
|
|
272
|
+
rawState[a.id] = entry;
|
|
273
|
+
dirty = true;
|
|
274
|
+
}
|
|
275
|
+
if (surface) surfaced.push({ id: a.id, action: a.action });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Record the probe stamp only when a probe actually ran (non-null result).
|
|
279
|
+
if (probed && probeText != null) {
|
|
280
|
+
rawState._meta = { ...meta, last_probe: today };
|
|
281
|
+
dirty = true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (dirty) {
|
|
285
|
+
try { atomicWrite(statePath, rawState); } catch { /* surfacing still valid; persistence is best-effort */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return surfaced;
|
|
289
|
+
} catch {
|
|
290
|
+
return []; // never throw — must not block session start
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// CLI: print one advisory action per line (used by orient's non-watchtower
|
|
295
|
+
// fallback, which can shell out to the single implementation).
|
|
296
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
297
|
+
let projectPath = process.cwd();
|
|
298
|
+
const args = process.argv.slice(2);
|
|
299
|
+
for (let i = 0; i < args.length; i++) {
|
|
300
|
+
if (args[i] === '--project-path' && args[i + 1]) { projectPath = args[i + 1]; i++; }
|
|
301
|
+
}
|
|
302
|
+
for (const a of runAdvisoryPass({ projectPath })) {
|
|
303
|
+
process.stdout.write(`${a.action}\n`);
|
|
304
|
+
}
|
|
305
|
+
}
|