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.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/lib/cli.js +26 -0
  3. package/lib/engagement-server-setup.js +34 -9
  4. package/lib/migrate-from-omega.js +13 -1
  5. package/lib/mux-setup.js +33 -9
  6. package/lib/watchtower-setup.js +210 -0
  7. package/package.json +5 -1
  8. package/templates/cabinet/_cabinet-member-template.md +8 -3
  9. package/templates/cabinet/advisories-state-schema.md +34 -7
  10. package/templates/cabinet/composition-patterns.md +4 -3
  11. package/templates/cabinet/skill-output-conventions.md +35 -1
  12. package/templates/cabinet/watchtower-contracts.md +89 -1
  13. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
  14. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
  15. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  16. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  17. package/templates/mux/bin/mux +281 -55
  18. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  19. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  20. package/templates/scripts/__tests__/batch-disposition.test.mjs +137 -0
  21. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  22. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
  23. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +108 -3
  24. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  25. package/templates/scripts/__tests__/ring3-dedup.test.mjs +387 -0
  26. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  27. package/templates/scripts/watchtower-advisories.mjs +305 -0
  28. package/templates/scripts/watchtower-build-context.mjs +110 -11
  29. package/templates/scripts/watchtower-lib.mjs +177 -1
  30. package/templates/scripts/watchtower-queue.mjs +146 -1
  31. package/templates/scripts/watchtower-ring1.mjs +129 -9
  32. package/templates/scripts/watchtower-ring2.mjs +118 -21
  33. package/templates/scripts/watchtower-ring3-close.mjs +466 -49
  34. package/templates/scripts/watchtower-routines.mjs +358 -0
  35. package/templates/scripts/watchtower-status.sh +1 -1
  36. package/templates/skills/audit/SKILL.md +5 -1
  37. package/templates/skills/briefing/SKILL.md +342 -234
  38. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  39. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  40. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  41. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  42. package/templates/skills/cc-publish/SKILL.md +105 -19
  43. package/templates/skills/debrief/SKILL.md +127 -12
  44. package/templates/skills/execute/SKILL.md +6 -0
  45. package/templates/skills/inbox/SKILL.md +67 -6
  46. package/templates/skills/orient/SKILL.md +69 -47
  47. package/templates/skills/plan/SKILL.md +8 -0
  48. package/templates/skills/qa-drain/SKILL.md +119 -0
  49. package/templates/skills/session-handoff/SKILL.md +175 -6
  50. package/templates/skills/triage-audit/SKILL.md +6 -0
  51. package/templates/skills/watchtower/SKILL.md +46 -1
  52. package/templates/watchtower/config.json.template +3 -1
  53. 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
+ }