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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { readFileSync, readdirSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
14
14
|
import { join, resolve, basename } from 'path';
|
|
15
15
|
import { currentCursor, resolveProjectIdentity } from './watchtower-lib.mjs';
|
|
16
|
+
import { runAdvisoryPass } from './watchtower-advisories.mjs';
|
|
16
17
|
|
|
17
18
|
const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
|
|
18
19
|
|| join(process.env.HOME, '.claude-cabinet', 'watchtower');
|
|
@@ -22,6 +23,18 @@ const STALENESS_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
22
23
|
const RINGS_WARNING_THRESHOLD_MS = 48 * 60 * 60 * 1000; // 48 hours
|
|
23
24
|
const PROJECT_STALENESS_DAYS = 7;
|
|
24
25
|
|
|
26
|
+
// Section truncation priority — LOWER survives, HIGHER is dropped first.
|
|
27
|
+
// `assembleSections` removes whole sections (never trims within one) in
|
|
28
|
+
// descending priority order until the output fits MAX_OUTPUT_CHARS;
|
|
29
|
+
// PRIORITY_NEVER is never dropped.
|
|
30
|
+
const PRIORITY_NEVER = 0; // summary — always kept
|
|
31
|
+
const PRIORITY_KEEP = 1; // threads, inbox, advisories — try hard to keep
|
|
32
|
+
const PRIORITY_PROJECT = 2; // per-project state
|
|
33
|
+
const PRIORITY_DOMAIN = 3; // injected domain files
|
|
34
|
+
const PRIORITY_PATTERNS = 4; // enforcement patterns — nice-to-have, drop first
|
|
35
|
+
|
|
36
|
+
const MAX_PATTERN_LINES = 5;
|
|
37
|
+
|
|
25
38
|
// --- Argument parsing ---
|
|
26
39
|
|
|
27
40
|
function parseArgs() {
|
|
@@ -183,6 +196,63 @@ function renderFocalZoom(threads, projectSlug) {
|
|
|
183
196
|
return lines.join('\n');
|
|
184
197
|
}
|
|
185
198
|
|
|
199
|
+
// --- Enforcement patterns (.claude/memory/patterns/) ---
|
|
200
|
+
|
|
201
|
+
// Pull the project-root patterns directory (NOT the ~/.claude/projects/<slug>/
|
|
202
|
+
// memory tree) so captured enforcement lessons keep shaping behavior past
|
|
203
|
+
// orient (act:202e5934). Index-line style only — name + one-line description,
|
|
204
|
+
// budget-capped. A two-field line scan, NOT a YAML parser: there is no shared
|
|
205
|
+
// frontmatter parser in templates/scripts/ and a full YAML parse trips on flow
|
|
206
|
+
// sequences (see MEMORY.md lesson_parsefrontmatter_flow_sequences).
|
|
207
|
+
function extractField(content, field) {
|
|
208
|
+
// Only look inside the leading --- frontmatter block.
|
|
209
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
210
|
+
const block = m ? m[1] : content;
|
|
211
|
+
for (const line of block.split('\n')) {
|
|
212
|
+
const fm = line.match(new RegExp(`^${field}:\\s*(.+)$`));
|
|
213
|
+
if (fm) return fm[1].trim().replace(/^["']|["']$/g, '');
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderPatterns(projectPath) {
|
|
219
|
+
if (!projectPath) return null;
|
|
220
|
+
const patternsDir = join(projectPath, '.claude', 'memory', 'patterns');
|
|
221
|
+
if (!existsSync(patternsDir)) return null;
|
|
222
|
+
|
|
223
|
+
let files;
|
|
224
|
+
try {
|
|
225
|
+
files = readdirSync(patternsDir)
|
|
226
|
+
.filter(f => f.endsWith('.md') && !f.startsWith('_'))
|
|
227
|
+
.sort();
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (files.length === 0) return null;
|
|
232
|
+
|
|
233
|
+
const lines = [];
|
|
234
|
+
for (const f of files.slice(0, MAX_PATTERN_LINES)) {
|
|
235
|
+
let name = null, desc = null;
|
|
236
|
+
try {
|
|
237
|
+
const content = safeReadFile(join(patternsDir, f));
|
|
238
|
+
if (content) {
|
|
239
|
+
name = extractField(content, 'name');
|
|
240
|
+
desc = extractField(content, 'description');
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// fall through to basename fallback
|
|
244
|
+
}
|
|
245
|
+
if (!name) name = f.replace(/\.md$/, '');
|
|
246
|
+
lines.push(desc ? `- ${name} — ${desc}` : `- ${name}`);
|
|
247
|
+
}
|
|
248
|
+
if (lines.length === 0) return null;
|
|
249
|
+
|
|
250
|
+
const more = files.length > MAX_PATTERN_LINES
|
|
251
|
+
? `\n…and ${files.length - MAX_PATTERN_LINES} more`
|
|
252
|
+
: '';
|
|
253
|
+
return `--- Enforcement Patterns ---\n${lines.join('\n')}${more}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
186
256
|
// --- Main ---
|
|
187
257
|
|
|
188
258
|
function main() {
|
|
@@ -234,7 +304,7 @@ function main() {
|
|
|
234
304
|
}
|
|
235
305
|
|
|
236
306
|
// Summary is always included and never truncated
|
|
237
|
-
sections.push({ key: 'summary', content: summarySection, priority:
|
|
307
|
+
sections.push({ key: 'summary', content: summarySection, priority: PRIORITY_NEVER });
|
|
238
308
|
|
|
239
309
|
// Step 3: If project has inject_domains, read each state/<domain>.md
|
|
240
310
|
const domainSections = [];
|
|
@@ -246,7 +316,7 @@ function main() {
|
|
|
246
316
|
domainSections.push({
|
|
247
317
|
key: `domain:${domain}`,
|
|
248
318
|
content: `--- ${domain} ---\n${domainContent.trim()}`,
|
|
249
|
-
priority:
|
|
319
|
+
priority: PRIORITY_DOMAIN,
|
|
250
320
|
});
|
|
251
321
|
}
|
|
252
322
|
}
|
|
@@ -264,7 +334,7 @@ function main() {
|
|
|
264
334
|
sections.push({
|
|
265
335
|
key: `project:${projectSlug}`,
|
|
266
336
|
content: `--- Project: ${projectSlug} ---\n${projectStateContent.trim()}`,
|
|
267
|
-
priority:
|
|
337
|
+
priority: PRIORITY_PROJECT,
|
|
268
338
|
});
|
|
269
339
|
}
|
|
270
340
|
}
|
|
@@ -278,7 +348,7 @@ function main() {
|
|
|
278
348
|
sections.push({
|
|
279
349
|
key: 'threads',
|
|
280
350
|
content: focalZoom,
|
|
281
|
-
priority:
|
|
351
|
+
priority: PRIORITY_KEEP,
|
|
282
352
|
});
|
|
283
353
|
}
|
|
284
354
|
}
|
|
@@ -293,16 +363,44 @@ function main() {
|
|
|
293
363
|
sections.push({
|
|
294
364
|
key: 'queue',
|
|
295
365
|
content: `--- Inbox ---\n${headline}\n${breakdown}`,
|
|
296
|
-
priority:
|
|
366
|
+
priority: PRIORITY_KEEP,
|
|
297
367
|
});
|
|
298
368
|
}
|
|
299
369
|
|
|
370
|
+
// Step 6b: Enforcement patterns from the project-root patterns dir
|
|
371
|
+
const patternsSection = renderPatterns(projectPath);
|
|
372
|
+
if (patternsSection) {
|
|
373
|
+
sections.push({
|
|
374
|
+
key: 'patterns',
|
|
375
|
+
content: patternsSection,
|
|
376
|
+
priority: PRIORITY_PATTERNS,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Step 6c: Environment advisories (LSP / Railway-MCP / hookify / briefing
|
|
381
|
+
// file / registry orphans) with per-project dismissal memory. The module
|
|
382
|
+
// owns all logic + I/O and never throws; the builder only renders. This is
|
|
383
|
+
// orient's advisory home now that orient is being retired (act:f9ea075d).
|
|
384
|
+
try {
|
|
385
|
+
const advisories = runAdvisoryPass({ projectPath });
|
|
386
|
+
if (advisories.length > 0) {
|
|
387
|
+
const body = advisories.map(a => `- ${a.action}`).join('\n');
|
|
388
|
+
sections.push({
|
|
389
|
+
key: 'advisories',
|
|
390
|
+
content: `--- Advisories ---\n${body}`,
|
|
391
|
+
priority: PRIORITY_KEEP,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
} catch {
|
|
395
|
+
// never block session start on advisory rendering
|
|
396
|
+
}
|
|
397
|
+
|
|
300
398
|
// Untracked project mode — if no project match, just add a note
|
|
301
399
|
if (!projectSlug && projectPath) {
|
|
302
400
|
sections.push({
|
|
303
401
|
key: 'untracked',
|
|
304
402
|
content: '(This project is not tracked by watchtower. Only global state is shown.)',
|
|
305
|
-
priority:
|
|
403
|
+
priority: PRIORITY_KEEP,
|
|
306
404
|
});
|
|
307
405
|
}
|
|
308
406
|
|
|
@@ -322,16 +420,17 @@ function assembleSections(sections) {
|
|
|
322
420
|
}
|
|
323
421
|
|
|
324
422
|
// Need to truncate. Remove sections in reverse priority order (highest first).
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
423
|
+
// PRIORITY_PATTERNS (4) = enforcement patterns (truncated first)
|
|
424
|
+
// PRIORITY_DOMAIN (3) = domain files
|
|
425
|
+
// PRIORITY_PROJECT (2) = project file
|
|
426
|
+
// PRIORITY_KEEP (1) = threads/queue/advisories/untracked (try to keep)
|
|
427
|
+
// PRIORITY_NEVER (0) = summary (never truncated)
|
|
329
428
|
|
|
330
429
|
const sortedByTruncPriority = [...sections].sort((a, b) => b.priority - a.priority);
|
|
331
430
|
|
|
332
431
|
let remaining = [...sections];
|
|
333
432
|
for (const section of sortedByTruncPriority) {
|
|
334
|
-
if (section.priority ===
|
|
433
|
+
if (section.priority === PRIORITY_NEVER) break; // Never truncate summary
|
|
335
434
|
|
|
336
435
|
const idx = remaining.findIndex(s => s.key === section.key);
|
|
337
436
|
if (idx === -1) continue;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
readFileSync, writeFileSync, existsSync,
|
|
9
|
-
mkdirSync, renameSync, realpathSync,
|
|
9
|
+
mkdirSync, renameSync, realpathSync, readdirSync,
|
|
10
10
|
} from 'fs';
|
|
11
11
|
import { join, dirname, basename, resolve as resolvePath } from 'path';
|
|
12
12
|
import { homedir } from 'os';
|
|
@@ -68,6 +68,133 @@ export function slugify(text) {
|
|
|
68
68
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// flushFeedbackOutbox — deliver the GLOBAL cc-feedback outbox to the CC repo
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Ring 1 mechanical duty (act:6c3a4763). Ports orient's delivery algorithm:
|
|
75
|
+
// read ~/.claude/cc-feedback-outbox.json, resolve the CC source repo via
|
|
76
|
+
// cc-registry (the entry whose package.json name is create-claude-cabinet),
|
|
77
|
+
// write each undelivered item's body to <cc>/feedback/{date}-{slug}.md with
|
|
78
|
+
// a skip-if-exists guard against feedback/ AND feedback/resolved/ — a name
|
|
79
|
+
// containing the slug, or a delivery-scheme name whose slug is a whole-token
|
|
80
|
+
// prefix/subset of the new one, means a prior session already delivered or
|
|
81
|
+
// resolved it — then atomically rewrite the outbox — [] on a clean pass, only the
|
|
82
|
+
// failed items otherwise. Fail-safe: if the destination cannot be resolved,
|
|
83
|
+
// the outbox is left untouched — an item is never dropped without a file
|
|
84
|
+
// existing for it. Ring 1 is now the SOLE feedback-delivery owner: orient's
|
|
85
|
+
// duplicate flush (incompatible {date}-{slug}-{seq}.md scheme) was retired
|
|
86
|
+
// (act:d53ff509) once this duty was verified in the live runtime.
|
|
87
|
+
|
|
88
|
+
const CC_PACKAGE_NAME = 'create-claude-cabinet';
|
|
89
|
+
|
|
90
|
+
function resolveCcFeedbackDir(registryPath) {
|
|
91
|
+
if (!existsSync(registryPath)) return null;
|
|
92
|
+
let registry;
|
|
93
|
+
try {
|
|
94
|
+
registry = JSON.parse(readFileSync(registryPath, 'utf8'));
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
for (const proj of registry.projects || []) {
|
|
99
|
+
const pkgPath = join(proj.path || '', 'package.json');
|
|
100
|
+
if (!existsSync(pkgPath)) continue;
|
|
101
|
+
try {
|
|
102
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
103
|
+
if (pkg.name === CC_PACKAGE_NAME) return join(proj.path, 'feedback');
|
|
104
|
+
} catch { /* unreadable package.json — not the CC repo */ }
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function feedbackFileExists(feedbackDir, slug) {
|
|
110
|
+
for (const dir of [feedbackDir, join(feedbackDir, 'resolved')]) {
|
|
111
|
+
if (!existsSync(dir)) continue;
|
|
112
|
+
for (const name of readdirSync(dir)) {
|
|
113
|
+
if (name.includes(slug)) return true;
|
|
114
|
+
// Reverse direction (act:ca0aee25): a re-report under a LONGER title
|
|
115
|
+
// must still match its already-delivered shorter twin. Deliberately
|
|
116
|
+
// narrow — only delivery-scheme names ({date}-{slug}[-{seq}].md)
|
|
117
|
+
// carry slug semantics, and the existing slug must sit on whole
|
|
118
|
+
// dash-token boundaries inside the new one. Silent suppression of
|
|
119
|
+
// genuinely new feedback is worse than a duplicate delivery, so no
|
|
120
|
+
// substring fuzz and no participation by hand-named files.
|
|
121
|
+
const m = /^\d{4}-\d{2}-\d{2}-(.+?)(?:-\d+)?\.md$/.exec(name);
|
|
122
|
+
if (m && `-${slug}-`.includes(`-${m[1]}-`)) return true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function flushFeedbackOutbox(opts = {}) {
|
|
129
|
+
const outboxPath = opts.outboxPath
|
|
130
|
+
|| join(homedir(), '.claude', 'cc-feedback-outbox.json');
|
|
131
|
+
const registryPath = opts.registryPath
|
|
132
|
+
|| join(homedir(), '.claude', 'cc-registry.json');
|
|
133
|
+
|
|
134
|
+
const result = { status: 'ok', delivered: 0, skipped: 0, kept: 0, destination: null };
|
|
135
|
+
|
|
136
|
+
if (!existsSync(outboxPath)) {
|
|
137
|
+
result.status = 'no-outbox';
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let outbox;
|
|
142
|
+
try {
|
|
143
|
+
outbox = JSON.parse(readFileSync(outboxPath, 'utf8'));
|
|
144
|
+
} catch {
|
|
145
|
+
// Malformed outbox: per orient's contract, warn and reset to [].
|
|
146
|
+
atomicWrite(outboxPath, '[]\n');
|
|
147
|
+
result.status = 'malformed-reset';
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!Array.isArray(outbox) || outbox.length === 0) {
|
|
152
|
+
result.status = 'empty';
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const undelivered = outbox.filter((item) => item && item.delivered !== true);
|
|
157
|
+
if (undelivered.length === 0) {
|
|
158
|
+
// Only stale delivered:true markers remain — don't accumulate them.
|
|
159
|
+
atomicWrite(outboxPath, '[]\n');
|
|
160
|
+
result.status = 'markers-cleared';
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const feedbackDir = opts.destination || resolveCcFeedbackDir(registryPath);
|
|
165
|
+
if (!feedbackDir) {
|
|
166
|
+
// Cannot resolve where feedback lives. Leave the outbox untouched —
|
|
167
|
+
// never mark/drop an item without a delivered file existing for it.
|
|
168
|
+
result.status = 'no-destination';
|
|
169
|
+
result.kept = outbox.length;
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
result.destination = feedbackDir;
|
|
173
|
+
|
|
174
|
+
const failed = [];
|
|
175
|
+
for (const item of undelivered) {
|
|
176
|
+
try {
|
|
177
|
+
const slug = slugify(item.title || 'untitled') || 'untitled';
|
|
178
|
+
if (feedbackFileExists(feedbackDir, slug)) {
|
|
179
|
+
result.skipped++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const date = item.date || new Date().toISOString().slice(0, 10);
|
|
183
|
+
if (!existsSync(feedbackDir)) mkdirSync(feedbackDir, { recursive: true });
|
|
184
|
+
writeFileSync(join(feedbackDir, `${date}-${slug}.md`), item.body || item.title || '');
|
|
185
|
+
result.delivered++;
|
|
186
|
+
} catch {
|
|
187
|
+
failed.push(item);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Clean pass resets to []; otherwise keep only the failed items.
|
|
192
|
+
atomicWrite(outboxPath, JSON.stringify(failed, null, 2) + '\n');
|
|
193
|
+
result.kept = failed.length;
|
|
194
|
+
if (failed.length > 0) result.status = 'partial';
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
71
198
|
// ---------------------------------------------------------------------------
|
|
72
199
|
// Thread cursor history (schema v2)
|
|
73
200
|
// ---------------------------------------------------------------------------
|
|
@@ -236,6 +363,55 @@ export function preserveRing3LastSession(freshContent, existingContent) {
|
|
|
236
363
|
return freshContent.slice(0, freshIdx) + preserved + '\n' + freshContent.slice(end);
|
|
237
364
|
}
|
|
238
365
|
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// writeProjectStatePreservingRing3 — race-safe project-state rebuild write
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// preserveRing3LastSession is a pure merge; a raw read→merge→atomicWrite
|
|
370
|
+
// around it has a read-then-write race: if Ring 3 writes its fresh Last
|
|
371
|
+
// Session between Ring 1's snapshot read and the rename, the rebuild is
|
|
372
|
+
// computed from a stale snapshot and silently drops the just-authored
|
|
373
|
+
// summary until the NEXT session close. This helper closes that window
|
|
374
|
+
// with a re-read check-and-retry: merge against a snapshot, re-read to
|
|
375
|
+
// verify nothing changed underneath us, and only then write; on change,
|
|
376
|
+
// re-merge against the fresh read (up to maxAttempts). If attempts are
|
|
377
|
+
// exhausted (pathological churn), it merges against the FRESHEST read and
|
|
378
|
+
// writes anyway — the rebuild is never skipped.
|
|
379
|
+
//
|
|
380
|
+
// Residual window: a write landing between the verify read and the rename
|
|
381
|
+
// inside atomicWrite is still theoretically possible — microseconds wide,
|
|
382
|
+
// down from the full merge-computation window. If it ever bites in
|
|
383
|
+
// practice, the structural fix is the Ring 3 sidecar-file design (option c
|
|
384
|
+
// in .claude/plans/watchtower-ring1-race-fix.md).
|
|
385
|
+
//
|
|
386
|
+
// opts._beforeVerifyHook is a TEST-ONLY injection seam, fired between the
|
|
387
|
+
// merge and the verify re-read so tests can deterministically simulate a
|
|
388
|
+
// concurrent Ring 3 write. Production callers must not pass it.
|
|
389
|
+
//
|
|
390
|
+
// Returns { written: true, attempts, exhausted? }.
|
|
391
|
+
export function writeProjectStatePreservingRing3(statePath, freshContent, opts = {}) {
|
|
392
|
+
const maxAttempts = opts.maxAttempts ?? 3;
|
|
393
|
+
const read = () => (existsSync(statePath) ? readFileSync(statePath, 'utf8') : null);
|
|
394
|
+
|
|
395
|
+
let snapshot = read();
|
|
396
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
397
|
+
const merged = preserveRing3LastSession(freshContent, snapshot);
|
|
398
|
+
opts._beforeVerifyHook?.();
|
|
399
|
+
const verify = read();
|
|
400
|
+
if (verify !== snapshot) {
|
|
401
|
+
// Someone (Ring 3) wrote since our read — re-merge against it.
|
|
402
|
+
snapshot = verify;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
atomicWrite(statePath, merged);
|
|
406
|
+
return { written: true, attempts: i + 1 };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Attempts exhausted (pathological churn): merge against the freshest
|
|
410
|
+
// read and write anyway — never skip the rebuild.
|
|
411
|
+
atomicWrite(statePath, preserveRing3LastSession(freshContent, read()));
|
|
412
|
+
return { written: true, attempts: maxAttempts, exhausted: true };
|
|
413
|
+
}
|
|
414
|
+
|
|
239
415
|
// ---------------------------------------------------------------------------
|
|
240
416
|
// resolveProjectIdentity — THE canonical "which project is this?" answer
|
|
241
417
|
// ---------------------------------------------------------------------------
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// All writes use atomic temp+rename per watchtower-contracts.md.
|
|
5
5
|
// Queue uses directory listing, not index files (no-index convention).
|
|
6
6
|
|
|
7
|
-
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, renameSync } from 'fs';
|
|
7
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
import { randomBytes } from 'crypto';
|
|
10
10
|
|
|
@@ -13,6 +13,23 @@ const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
|
|
|
13
13
|
|
|
14
14
|
const QUEUE_DIR = join(WATCHTOWER_DIR, 'queue', 'items');
|
|
15
15
|
|
|
16
|
+
// mux's dispatch queue (bin/mux `qa` verbs — the single desk-dispatch path,
|
|
17
|
+
// carrying qa-handoffs AND routine dispatches). Descriptors are routing
|
|
18
|
+
// convenience keyed <desk>/<item_id>.json (plus <desk>/in-flight/ once
|
|
19
|
+
// drained); the inbox item is the durable record. Terminal exits here must
|
|
20
|
+
// clear the matching descriptor or the two stores drift (act:796fe6dc —
|
|
21
|
+
// resolved ghosts get re-offered by `mux qa drain`). The dir name is legacy.
|
|
22
|
+
const MUX_QA_DIR = process.env.MUX_QA_DIR
|
|
23
|
+
|| join(process.env.HOME, '.local', 'share', 'mux', 'qa-handoff');
|
|
24
|
+
|
|
25
|
+
// Categories whose items are pushed to a desk via the mux dispatch queue.
|
|
26
|
+
// Every terminal exit (resolve / dismiss / supersede / expire) on an item in
|
|
27
|
+
// one of these categories clears its dispatch descriptor(s). Exported so
|
|
28
|
+
// consumers (e.g. the /session-handoff epilogue drain) exclude dispatched
|
|
29
|
+
// items by importing this set rather than re-listing categories in prose —
|
|
30
|
+
// a hand-maintained copy would drift the moment a third category is added.
|
|
31
|
+
export const DISPATCHED_CATEGORIES = new Set(['qa-handoff', 'routine']);
|
|
32
|
+
|
|
16
33
|
// --- Helpers ---
|
|
17
34
|
|
|
18
35
|
function ensureDir(dir) {
|
|
@@ -42,6 +59,27 @@ function generateId() {
|
|
|
42
59
|
return 'dec-' + randomBytes(4).toString('hex');
|
|
43
60
|
}
|
|
44
61
|
|
|
62
|
+
// Best-effort removal of the mux dispatch-queue descriptor(s) for an item —
|
|
63
|
+
// both the queued copy and the in-flight copy, across every desk dir (desk
|
|
64
|
+
// names differ from project names, so we sweep rather than guess). Never
|
|
65
|
+
// throws: a routing-cleanup failure must not block a gate exit. The ·N badge
|
|
66
|
+
// is recomputed by mux on its next mutation/desk-open, so a deletion here is
|
|
67
|
+
// picked up without tmux involvement.
|
|
68
|
+
function clearDispatchEntries(item) {
|
|
69
|
+
try {
|
|
70
|
+
if (!existsSync(MUX_QA_DIR)) return;
|
|
71
|
+
for (const desk of readdirSync(MUX_QA_DIR, { withFileTypes: true })) {
|
|
72
|
+
if (!desk.isDirectory()) continue;
|
|
73
|
+
for (const sub of ['.', 'in-flight']) {
|
|
74
|
+
const fp = join(MUX_QA_DIR, desk.name, sub, `${item.id}.json`);
|
|
75
|
+
try {
|
|
76
|
+
if (existsSync(fp)) unlinkSync(fp);
|
|
77
|
+
} catch { /* best-effort per file */ }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch { /* best-effort overall */ }
|
|
81
|
+
}
|
|
82
|
+
|
|
45
83
|
// Urgency sort order: urgent < normal < low (urgent first)
|
|
46
84
|
const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
|
|
47
85
|
|
|
@@ -57,6 +95,15 @@ const URGENCY_ORDER = { urgent: 0, normal: 1, low: 2 };
|
|
|
57
95
|
// functions below only call into it.
|
|
58
96
|
|
|
59
97
|
const QA_CATEGORY = 'qa-handoff';
|
|
98
|
+
|
|
99
|
+
// Categories whose items carry a structural recipient gate: they may never be
|
|
100
|
+
// included in a batch disposition — each leaves the queue only through its own
|
|
101
|
+
// gate (per-item resolve with a validated verdict, or a typed per-item
|
|
102
|
+
// dismissal). Distinct from DISPATCHED_CATEGORIES: 'routine' is dispatched but
|
|
103
|
+
// NOT gated — stale routines are legal batch fodder. Future gated categories
|
|
104
|
+
// join this set and inherit batch refusal for free.
|
|
105
|
+
export const GATED_CATEGORIES = new Set([QA_CATEGORY]);
|
|
106
|
+
|
|
60
107
|
const QA_TERMINAL_VERDICTS = ['runtime-verified', 'blocked'];
|
|
61
108
|
// The parameterized token. Canonical form uses U+00B7 (·); input tolerates
|
|
62
109
|
// common separator drift (-, –, —, *, •) because sessions retype labels.
|
|
@@ -326,6 +373,7 @@ export function resolveItem(id, { resolution, resolution_notes = null, resolutio
|
|
|
326
373
|
item.resolution_type = resolution_type;
|
|
327
374
|
item.resolution_notes = resolution_notes;
|
|
328
375
|
atomicWrite(fp, item);
|
|
376
|
+
if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
|
|
329
377
|
return item;
|
|
330
378
|
}
|
|
331
379
|
|
|
@@ -349,6 +397,7 @@ export function dismissItem(id, { notes = null, resolution_type = null } = {}) {
|
|
|
349
397
|
item.resolution_type = resolution_type;
|
|
350
398
|
item.resolution_notes = notes;
|
|
351
399
|
atomicWrite(fp, item);
|
|
400
|
+
if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
|
|
352
401
|
return item;
|
|
353
402
|
}
|
|
354
403
|
|
|
@@ -368,6 +417,7 @@ export function supersedeItem(id, { reason = null } = {}) {
|
|
|
368
417
|
item.status = 'superseded';
|
|
369
418
|
item.resolution_notes = reason;
|
|
370
419
|
atomicWrite(fp, item);
|
|
420
|
+
if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
|
|
371
421
|
return item;
|
|
372
422
|
}
|
|
373
423
|
|
|
@@ -386,9 +436,64 @@ export function expireItem(id) {
|
|
|
386
436
|
item.status = 'expired';
|
|
387
437
|
item.resolution_notes = 'Auto-expired by age policy';
|
|
388
438
|
atomicWrite(fp, item);
|
|
439
|
+
if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
|
|
389
440
|
return item;
|
|
390
441
|
}
|
|
391
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Apply ONE disposition to a batch of ungated items — the single bulk path,
|
|
445
|
+
* used by /briefing's batch dispositions and /inbox's bulk triage. A batch is one
|
|
446
|
+
* operator decision applied to many items, so the typed reason is REQUIRED:
|
|
447
|
+
* it lands on every item as its audit trail.
|
|
448
|
+
*
|
|
449
|
+
* All-or-nothing pre-validation: throws BEFORE any write when (a) any id is
|
|
450
|
+
* unknown, (b) any item is in a GATED_CATEGORIES category — gated items never
|
|
451
|
+
* batch; each goes through its own gate — or (c) the typed reason is absent.
|
|
452
|
+
* A batch that would hit a gate fails whole, never half-applies.
|
|
453
|
+
* Non-pending items are not an error (another session may have raced the
|
|
454
|
+
* disposition); they are skipped and reported.
|
|
455
|
+
* @param {string[]} ids
|
|
456
|
+
* @param {object} params
|
|
457
|
+
* @param {'dismiss'|'resolve'} params.disposition
|
|
458
|
+
* @param {string} params.resolution_type - typed reason (e.g. 'stale',
|
|
459
|
+
* 'noise', 'captured-to-memory', 'acted-on') — required
|
|
460
|
+
* @param {string} params.notes - plain-language reason — required
|
|
461
|
+
* @param {string} [params.resolution] - resolve batches: the resolution
|
|
462
|
+
* value stamped on each item (defaults to resolution_type)
|
|
463
|
+
* @returns {{applied: string[], skipped_not_pending: string[]}}
|
|
464
|
+
*/
|
|
465
|
+
export function applyBatch(ids, { disposition, resolution_type, notes, resolution = null } = {}) {
|
|
466
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
467
|
+
throw new Error('applyBatch: ids must be a non-empty array');
|
|
468
|
+
}
|
|
469
|
+
if (disposition !== 'dismiss' && disposition !== 'resolve') {
|
|
470
|
+
throw new Error("applyBatch: disposition must be 'dismiss' or 'resolve'");
|
|
471
|
+
}
|
|
472
|
+
if (typeof resolution_type !== 'string' || !resolution_type.trim()
|
|
473
|
+
|| typeof notes !== 'string' || !notes.trim()) {
|
|
474
|
+
throw new Error('applyBatch: a typed resolution_type AND notes are required — a batch is one decision applied to many items, and the reason lands on every one');
|
|
475
|
+
}
|
|
476
|
+
const items = ids.map((id) => {
|
|
477
|
+
const item = getItem(id);
|
|
478
|
+
if (!item) throw new Error(`applyBatch: unknown item ${id} — batches pre-validate whole; nothing was applied`);
|
|
479
|
+
return item;
|
|
480
|
+
});
|
|
481
|
+
const gated = items.filter((i) => GATED_CATEGORIES.has(i.category));
|
|
482
|
+
if (gated.length > 0) {
|
|
483
|
+
throw new Error(`applyBatch: ${gated.map((i) => `${i.id} (${i.category})`).join(', ')} carr${gated.length === 1 ? 'ies' : 'y'} a recipient gate — gated items never batch; handle each through its own gate. Nothing was applied`);
|
|
484
|
+
}
|
|
485
|
+
const applied = [];
|
|
486
|
+
const skipped_not_pending = [];
|
|
487
|
+
for (const item of items) {
|
|
488
|
+
const result = disposition === 'dismiss'
|
|
489
|
+
? dismissItem(item.id, { notes, resolution_type })
|
|
490
|
+
: resolveItem(item.id, { resolution: resolution ?? resolution_type, resolution_notes: notes, resolution_type });
|
|
491
|
+
if (result) applied.push(item.id);
|
|
492
|
+
else skipped_not_pending.push(item.id);
|
|
493
|
+
}
|
|
494
|
+
return { applied, skipped_not_pending };
|
|
495
|
+
}
|
|
496
|
+
|
|
392
497
|
/**
|
|
393
498
|
* List pending inbox items with optional filters.
|
|
394
499
|
* @param {object} filters
|
|
@@ -425,6 +530,45 @@ export function listPending({ project, category, urgency, maxAge } = {}) {
|
|
|
425
530
|
return items;
|
|
426
531
|
}
|
|
427
532
|
|
|
533
|
+
/**
|
|
534
|
+
* List inbox items across ALL statuses with optional filters — the single
|
|
535
|
+
* source for "read non-pending items". (Ring 2's private readAllQueueItems
|
|
536
|
+
* fork predates this helper; consolidating it is a separate-lane follow-up.)
|
|
537
|
+
* @param {object} filters
|
|
538
|
+
* @param {string} [filters.project] - exact match on item.project
|
|
539
|
+
* @param {string} [filters.category] - exact match on item.category
|
|
540
|
+
* @param {string[]} [filters.statuses] - keep items whose status is in the
|
|
541
|
+
* array (omit = all statuses)
|
|
542
|
+
* @param {string} [filters.since] - ISO date; keep items whose
|
|
543
|
+
* `resolved_at || filed_at` >= since (bounds corpus growth for dedup callers)
|
|
544
|
+
* @returns {Array} items sorted newest first by (resolved_at || filed_at)
|
|
545
|
+
*/
|
|
546
|
+
export function listItems({ project, category, statuses, since } = {}) {
|
|
547
|
+
if (!existsSync(QUEUE_DIR)) return [];
|
|
548
|
+
const sinceMs = since ? new Date(since).getTime() : null;
|
|
549
|
+
const entries = readdirSync(QUEUE_DIR, { withFileTypes: true });
|
|
550
|
+
const items = [];
|
|
551
|
+
for (const entry of entries) {
|
|
552
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
553
|
+
try {
|
|
554
|
+
const item = readItem(join(QUEUE_DIR, entry.name));
|
|
555
|
+
if (project && item.project !== project) continue;
|
|
556
|
+
if (category && item.category !== category) continue;
|
|
557
|
+
if (Array.isArray(statuses) && !statuses.includes(item.status)) continue;
|
|
558
|
+
if (sinceMs != null) {
|
|
559
|
+
const ts = new Date(item.resolved_at || item.filed_at).getTime();
|
|
560
|
+
if (!(ts >= sinceMs)) continue;
|
|
561
|
+
}
|
|
562
|
+
items.push(item);
|
|
563
|
+
} catch {
|
|
564
|
+
// Skip unparseable items
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
items.sort((a, b) =>
|
|
568
|
+
new Date(b.resolved_at || b.filed_at) - new Date(a.resolved_at || a.filed_at));
|
|
569
|
+
return items;
|
|
570
|
+
}
|
|
571
|
+
|
|
428
572
|
/**
|
|
429
573
|
* Get a single inbox item by id.
|
|
430
574
|
* @param {string} id
|
|
@@ -490,6 +634,7 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
|
|
|
490
634
|
item.status = 'expired';
|
|
491
635
|
item.resolution_notes = `Auto-expired after ${expireDays} days. If still relevant, re-file with updated context.`;
|
|
492
636
|
atomicWrite(itemPath(item.id), item);
|
|
637
|
+
if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
|
|
493
638
|
expired.push(item);
|
|
494
639
|
} else if (age >= warnMs) {
|
|
495
640
|
warned.push(item);
|