create-claude-cabinet 0.52.0 → 0.53.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/lib/cli.js +2 -2
- package/package.json +1 -1
- package/templates/CLAUDE.md +164 -20
- package/templates/cabinet/watchtower-contracts.md +202 -10
- package/templates/mux/bin/mux +5 -0
- package/templates/mux/config/mux-server.py +7 -5
- package/templates/scripts/__tests__/category-expiry.test.mjs +130 -0
- package/templates/scripts/__tests__/detector-registry.test.mjs +41 -7
- package/templates/scripts/__tests__/extraction-classification.test.mjs +87 -0
- package/templates/scripts/__tests__/friction-actionability.test.mjs +180 -0
- package/templates/scripts/__tests__/memory-budget-debt.test.mjs +264 -0
- package/templates/scripts/__tests__/ring2-pattern-gate.test.mjs +315 -0
- package/templates/scripts/__tests__/ring2-roster-review.test.mjs +75 -16
- package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +167 -105
- package/templates/scripts/__tests__/roster-ambient-render.test.mjs +132 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +29 -1
- package/templates/scripts/__tests__/routine-moment-expiry.test.mjs +192 -0
- package/templates/scripts/skill-validator.sh +7 -4
- package/templates/scripts/watchtower-lib.mjs +58 -5
- package/templates/scripts/watchtower-queue.mjs +54 -7
- package/templates/scripts/watchtower-ring1.mjs +74 -0
- package/templates/scripts/watchtower-ring2.mjs +442 -81
- package/templates/scripts/watchtower-ring3-close.mjs +271 -82
- package/templates/scripts/watchtower-routines.mjs +107 -1
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +35 -2
- package/templates/skills/cabinet-anti-confirmation/SKILL.md +24 -0
- package/templates/skills/cabinet-cc-health/SKILL.md +22 -0
- package/templates/skills/cabinet-data-integrity/SKILL.md +19 -0
- package/templates/skills/cabinet-debugger/SKILL.md +36 -0
- package/templates/skills/cabinet-deployment/SKILL.md +22 -0
- package/templates/skills/cabinet-elegance/SKILL.md +12 -0
- package/templates/skills/cabinet-goal-alignment/SKILL.md +11 -0
- package/templates/skills/cabinet-historian/SKILL.md +33 -0
- package/templates/skills/cabinet-organized-mind/SKILL.md +11 -0
- package/templates/skills/cabinet-process-therapist/SKILL.md +132 -6
- package/templates/skills/cabinet-qa/SKILL.md +63 -0
- package/templates/skills/cabinet-roster-check/SKILL.md +12 -0
- package/templates/skills/cabinet-workflow-cop/SKILL.md +60 -0
|
@@ -241,7 +241,35 @@ test('pending item blocks refiring; stale pending item is superseded and redispa
|
|
|
241
241
|
assert.equal(second.fired.length, 1);
|
|
242
242
|
assert.notEqual(second.fired[0].item_id, firstId);
|
|
243
243
|
assert.equal(q.getItem(firstId).status, 'superseded');
|
|
244
|
-
|
|
244
|
+
// PRECEDENCE (act:ea23b3a5): for a time-of-day routine the moment sweep runs
|
|
245
|
+
// at the top of the tick and reaches the stale item first, so it — not the
|
|
246
|
+
// next-firing path — is what supersedes. The stale_after_hours path still
|
|
247
|
+
// owns interval / path-nonempty / session-close triggers, which have no
|
|
248
|
+
// moment. Same outcome (superseded, refiled), different and more honest
|
|
249
|
+
// reason: the item died because its day ended, not because a newer firing
|
|
250
|
+
// happened to come along.
|
|
251
|
+
assert.match(q.getItem(firstId).resolution_notes, /moment window passed/);
|
|
252
|
+
q.resolveItem(second.fired[0].item_id, { resolution: 'ran', resolution_type: 'acted-on' });
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('the stale-after-hours supersede still owns INTERVAL routines (no moment to expire)', () => {
|
|
256
|
+
const rt = routine({ name: 'interval-digest', trigger: { type: 'interval', minutes: 60 } });
|
|
257
|
+
const t1 = at('2026-06-20T07:00:00');
|
|
258
|
+
const first = r.runRoutinePass({ config: configWith([rt]), event: tick, filedBy: 'ring1', now: t1 });
|
|
259
|
+
assert.equal(first.fired.length, 1);
|
|
260
|
+
const firstId = first.fired[0].item_id;
|
|
261
|
+
|
|
262
|
+
const t2 = at('2026-06-22T07:00:00');
|
|
263
|
+
const fp = join(root, 'queue', 'items', `${firstId}.json`);
|
|
264
|
+
const pendingItem = JSON.parse(readFileSync(fp, 'utf8'));
|
|
265
|
+
pendingItem.filed_at = new Date(t2.getTime() - 25 * 3_600_000).toISOString();
|
|
266
|
+
writeFileSync(fp, JSON.stringify(pendingItem, null, 2));
|
|
267
|
+
|
|
268
|
+
const second = r.runRoutinePass({ config: configWith([rt]), event: tick, filedBy: 'ring1', now: t2 });
|
|
269
|
+
assert.equal(second.fired.length, 1);
|
|
270
|
+
assert.equal(q.getItem(firstId).status, 'superseded');
|
|
271
|
+
assert.match(q.getItem(firstId).resolution_notes, /newer firing/,
|
|
272
|
+
'an interval routine has no moment — the unchanged stale path supersedes it');
|
|
245
273
|
q.resolveItem(second.fired[0].item_id, { resolution: 'ran', resolution_type: 'acted-on' });
|
|
246
274
|
});
|
|
247
275
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Routine moment expiry (act:ea23b3a5).
|
|
2
|
+
//
|
|
3
|
+
// A time-of-day routine only self-superseded at its NEXT firing — 24 hours
|
|
4
|
+
// later for a daily one — so a morning briefing nobody ran held a queue slot
|
|
5
|
+
// and a lit desk badge all day. Six such firings were dismissed in the
|
|
6
|
+
// 2026-07-26 triage.
|
|
7
|
+
//
|
|
8
|
+
// The window is END OF THE FILING DAY, and that is a measured choice. Across
|
|
9
|
+
// six weeks the portfolio's one time-of-day routine (flow/morning-briefing at
|
|
10
|
+
// 08:00) had ten real pickups: eight inside four hours and two after, at 4.4h
|
|
11
|
+
// and 8.6h. A four-hour window would have destroyed two genuine pickups to
|
|
12
|
+
// clear one stale item — negative expected value. End-of-day preserves all ten.
|
|
13
|
+
//
|
|
14
|
+
// Hermetic: every queue dependency is injected.
|
|
15
|
+
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
|
|
22
|
+
const root = mkdtempSync(join(tmpdir(), 'routine-moment-'));
|
|
23
|
+
process.env.WATCHTOWER_DIR = root;
|
|
24
|
+
mkdirSync(join(root, 'queue', 'items'), { recursive: true });
|
|
25
|
+
process.env.WATCHTOWER_MUX_BIN = join(root, 'no-such-mux');
|
|
26
|
+
|
|
27
|
+
const r = await import('../watchtower-routines.mjs');
|
|
28
|
+
|
|
29
|
+
test.after(() => rmSync(root, { recursive: true, force: true }));
|
|
30
|
+
|
|
31
|
+
function item(id, triggerType, filed_at, extra = {}) {
|
|
32
|
+
return {
|
|
33
|
+
id, status: 'pending', category: 'routine', project: 'p', filed_at,
|
|
34
|
+
evidence: { routine_key: `p/${id}`, trigger: { type: triggerType } },
|
|
35
|
+
...extra,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function harness(items) {
|
|
40
|
+
const superseded = [];
|
|
41
|
+
return {
|
|
42
|
+
superseded,
|
|
43
|
+
deps: {
|
|
44
|
+
listPending: () => items,
|
|
45
|
+
supersedeItem: (id, { reason }) => { superseded.push({ id, reason }); return { id }; },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const at = (s) => new Date(s);
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// The window
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
test('a time-of-day item filed YESTERDAY is swept', () => {
|
|
57
|
+
const h = harness([item('dec-a', 'time-of-day', '2026-07-25T08:00:00')]);
|
|
58
|
+
const out = r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps });
|
|
59
|
+
assert.deepEqual(out.swept, ['dec-a']);
|
|
60
|
+
assert.match(h.superseded[0].reason, /moment window passed/);
|
|
61
|
+
assert.match(h.superseded[0].reason, /2026-07-25/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('the SAME day is never swept, however late — 4.4h and 8.6h pickups are real', () => {
|
|
65
|
+
const filed = '2026-07-26T08:00:00';
|
|
66
|
+
for (const hour of ['12:24', '16:36', '23:00', '23:59']) {
|
|
67
|
+
const h = harness([item('dec-b', 'time-of-day', filed)]);
|
|
68
|
+
const out = r.sweepExpiredMoments({ now: at(`2026-07-26T${hour}:00`), deps: h.deps });
|
|
69
|
+
assert.deepEqual(out.swept, [], `${hour} is still the routine's own day`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('one minute past midnight sweeps it', () => {
|
|
74
|
+
const h = harness([item('dec-c', 'time-of-day', '2026-07-26T08:00:00')]);
|
|
75
|
+
const out = r.sweepExpiredMoments({ now: at('2026-07-27T00:01:00'), deps: h.deps });
|
|
76
|
+
assert.deepEqual(out.swept, ['dec-c']);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('a 23:30 routine gets its full day — the window needs no midnight special case', () => {
|
|
80
|
+
// The window is derived from the item's filing DAY, not from clock arithmetic
|
|
81
|
+
// on the declared time, so a late-evening routine cannot wrap.
|
|
82
|
+
const h = harness([item('dec-d', 'time-of-day', '2026-07-26T23:30:00')]);
|
|
83
|
+
assert.deepEqual(
|
|
84
|
+
r.sweepExpiredMoments({ now: at('2026-07-26T23:45:00'), deps: h.deps }).swept, []);
|
|
85
|
+
assert.deepEqual(
|
|
86
|
+
r.sweepExpiredMoments({ now: at('2026-07-27T00:05:00'), deps: h.deps }).swept, ['dec-d']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Scope
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
test('interval, path-nonempty, and session-close routines are never swept', () => {
|
|
94
|
+
const old = '2026-07-01T08:00:00';
|
|
95
|
+
for (const type of ['interval', 'path-nonempty', 'session-close']) {
|
|
96
|
+
const h = harness([item('dec-x', type, old)]);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps }).swept, [],
|
|
99
|
+
`${type} has no moment — stale_after_hours still owns it`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('an item with no persisted trigger is left alone', () => {
|
|
104
|
+
const h = harness([{ id: 'dec-y', status: 'pending', category: 'routine', filed_at: '2026-07-01T08:00:00', evidence: {} }]);
|
|
105
|
+
assert.deepEqual(r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps }).swept, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the trigger is read from the ITEM, so a de-registered routine still classifies', () => {
|
|
109
|
+
// dispatchRoutine persists evidence.trigger at filing. A config lookup would
|
|
110
|
+
// find nothing for a routine the operator has since removed and leave the
|
|
111
|
+
// item pending forever.
|
|
112
|
+
const h = harness([item('dec-z', 'time-of-day', '2026-07-20T08:00:00')]);
|
|
113
|
+
const out = r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps });
|
|
114
|
+
assert.deepEqual(out.swept, ['dec-z'], 'no config was consulted at all');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Failure direction
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
test('an unparseable filed_at abstains loudly instead of sweeping', () => {
|
|
122
|
+
const h = harness([item('dec-bad', 'time-of-day', 'not-a-date')]);
|
|
123
|
+
const out = r.sweepExpiredMoments({ now: at('2026-07-26T09:00:00'), deps: h.deps });
|
|
124
|
+
assert.deepEqual(out.swept, []);
|
|
125
|
+
assert.equal(out.skipped, 1);
|
|
126
|
+
assert.equal(h.superseded.length, 0,
|
|
127
|
+
'the existing stale path treats the same input as stale — one mechanism abstaining loudly beats two disagreeing silently');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a throwing supersede does not abort the rest of the sweep', () => {
|
|
131
|
+
const items = [
|
|
132
|
+
item('dec-1', 'time-of-day', '2026-07-20T08:00:00'),
|
|
133
|
+
item('dec-2', 'time-of-day', '2026-07-20T08:00:00'),
|
|
134
|
+
];
|
|
135
|
+
let calls = 0;
|
|
136
|
+
const out = r.sweepExpiredMoments({
|
|
137
|
+
now: at('2026-07-26T09:00:00'),
|
|
138
|
+
deps: {
|
|
139
|
+
listPending: () => items,
|
|
140
|
+
supersedeItem: (id) => { calls++; if (id === 'dec-1') throw new Error('locked'); return { id }; },
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
assert.equal(calls, 2);
|
|
144
|
+
assert.deepEqual(out.swept, ['dec-2']);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('a throwing listPending returns an empty sweep rather than propagating', () => {
|
|
148
|
+
const out = r.sweepExpiredMoments({
|
|
149
|
+
now: at('2026-07-26T09:00:00'),
|
|
150
|
+
deps: { listPending: () => { throw new Error('queue unreadable'); } },
|
|
151
|
+
});
|
|
152
|
+
assert.deepEqual(out, { swept: [], skipped: 0 });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('endOfFilingDay returns null for garbage and a day-end for a real date', () => {
|
|
156
|
+
assert.equal(r.endOfFilingDay('nonsense'), null);
|
|
157
|
+
assert.equal(r.endOfFilingDay(undefined), null);
|
|
158
|
+
const d = r.endOfFilingDay('2026-07-26T08:00:00');
|
|
159
|
+
assert.equal(d.getHours(), 23);
|
|
160
|
+
assert.equal(d.getMinutes(), 59);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Pass scoping — the blast-radius rule
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
test('a session-close event sweeps NOTHING; only Ring 1 ticks sweep', () => {
|
|
168
|
+
// runRoutinePass is called by Ring 3 with a single-project session-close
|
|
169
|
+
// event. An unscoped sweep there would have every session close in any
|
|
170
|
+
// project superseding every OTHER project's routine items — and during a
|
|
171
|
+
// --reprocess-failed drain, one portfolio sweep per replayed session.
|
|
172
|
+
const stale = [item('dec-other-project', 'time-of-day', '2026-07-01T08:00:00')];
|
|
173
|
+
const h = harness(stale);
|
|
174
|
+
r.runRoutinePass({
|
|
175
|
+
config: { projects: {} },
|
|
176
|
+
event: { type: 'session-close', project: 'someone-else' },
|
|
177
|
+
filedBy: 'ring3-close',
|
|
178
|
+
now: at('2026-07-26T09:00:00'),
|
|
179
|
+
deps: h.deps,
|
|
180
|
+
});
|
|
181
|
+
assert.deepEqual(h.superseded, []);
|
|
182
|
+
|
|
183
|
+
const h2 = harness(stale);
|
|
184
|
+
r.runRoutinePass({
|
|
185
|
+
config: { projects: {} },
|
|
186
|
+
event: { type: 'tick' },
|
|
187
|
+
filedBy: 'ring1',
|
|
188
|
+
now: at('2026-07-26T09:00:00'),
|
|
189
|
+
deps: h2.deps,
|
|
190
|
+
});
|
|
191
|
+
assert.deepEqual(h2.superseded.map((s) => s.id), ['dec-other-project']);
|
|
192
|
+
});
|
|
@@ -72,9 +72,9 @@ field_value() {
|
|
|
72
72
|
printf '%s\n' "$fm" | awk -v f="$field" '
|
|
73
73
|
$0 ~ "^" f ":" {
|
|
74
74
|
sub("^" f ": *", "")
|
|
75
|
-
# If the value is a block scalar indicator
|
|
76
|
-
# caller falls through to field_block_scalar.
|
|
77
|
-
if ($0
|
|
75
|
+
# If the value is a block scalar indicator (any of > | >- |- >+ |+),
|
|
76
|
+
# return empty so the caller falls through to field_block_scalar.
|
|
77
|
+
if ($0 ~ /^[>|][-+]?$/) { print ""; exit }
|
|
78
78
|
gsub(/^["'\'']|["'\'']$/, "")
|
|
79
79
|
print
|
|
80
80
|
exit
|
|
@@ -91,7 +91,10 @@ field_block_scalar() {
|
|
|
91
91
|
$0 ~ "^" f ":" {
|
|
92
92
|
line = $0
|
|
93
93
|
sub("^" f ": *", "", line)
|
|
94
|
-
|
|
94
|
+
# Any block-scalar indicator (> | >- |- >+ |+) starts a block; the
|
|
95
|
+
# old bare-marker test returned ">-" itself as the field value, so
|
|
96
|
+
# every folded description counted 0 sentences (feedback 2026-07-26).
|
|
97
|
+
if (line ~ /^[>|][-+]?$/) {
|
|
95
98
|
in_block = 1
|
|
96
99
|
next
|
|
97
100
|
}
|
|
@@ -104,8 +104,20 @@ export const INBOX_DETECTOR_REGISTRY = {
|
|
|
104
104
|
'completion-review': {
|
|
105
105
|
reconciler: 'autoReconcileCompletionReviews (ring1): resolves when the referenced '
|
|
106
106
|
+ 'action (plan_fid || evidence.fid) is verifiably closed in its OWN project db; '
|
|
107
|
-
+ 'fid-not-found keeps (flow vestigial-db + misattribution protection)
|
|
108
|
-
+ '
|
|
107
|
+
+ 'fid-not-found keeps (flow vestigial-db + misattribution protection). Filing '
|
|
108
|
+
+ 'exclusions (act:ea23b3a5, all at ring3 Phase 2c): (a) confidence \'high\' is '
|
|
109
|
+
+ 'SUPPRESSED — measured anti-predictive over all 223 items ever filed (high 30% '
|
|
110
|
+
+ 'precision, medium+low 60%); (b) act:9eebbac4\'s create-vs-complete day-window '
|
|
111
|
+
+ 'filter is DELETED — it exempted \'high\' from the born-this-session skip, so it '
|
|
112
|
+
+ 'suppressed the best cohort (same-day + medium/low, 74%) and passed the worst, '
|
|
113
|
+
+ 'and the category filed 0 true positives in the 23 items after it shipped; (c) '
|
|
114
|
+
+ 'per-fid dedup across pending + resolved/dismissed/EXPIRED/SUPERSEDED. Category '
|
|
115
|
+
+ 'expiry is 14d (expiryDaysFor), which is why the last two statuses are '
|
|
116
|
+
+ 'load-bearing: the expiry equals COMPLETION_REVIEW_DEDUP_DAYS, so without them a '
|
|
117
|
+
+ 'fid leaves every corpus exactly when its item expires and refiles forever. A '
|
|
118
|
+
+ 'quoted-completion-evidence check runs as INSTRUMENTATION ONLY (evidence.'
|
|
119
|
+
+ 'quote_verified) and gates nothing — the transcript haystack is JSON-serialized, '
|
|
120
|
+
+ 'so a substring gate rejects real prose and accepts serialized tool arguments.',
|
|
109
121
|
},
|
|
110
122
|
// ring3-filed
|
|
111
123
|
'coverage-warning': {
|
|
@@ -133,7 +145,12 @@ export const INBOX_DETECTOR_REGISTRY = {
|
|
|
133
145
|
'advisor-finding': {
|
|
134
146
|
exempt: 'advisory observation from the session advisor pass (ring2 briefing panel + '
|
|
135
147
|
+ 'ring3 Phase 2m); capped per member per session and deduped vs pending + '
|
|
136
|
-
+ 'resolution corpora at filing; no mechanical truth condition.'
|
|
148
|
+
+ 'resolution corpora at filing; no mechanical truth condition. The ring2 '
|
|
149
|
+
+ 'cabinet-roster review files only ONE kind here now (act:ea23b3a5): '
|
|
150
|
+
+ 'uncovered-tech, which proposes a cabinet seat and is therefore a decision. Its '
|
|
151
|
+
+ 'siblings dormant-skill and stale-briefing were pure measurements and moved to '
|
|
152
|
+
+ 'ambient state (state/roster-review.json → Ring 1 Standing Issues, rendered on '
|
|
153
|
+
+ 'change); retireRosterFindingItems supersedes any that were still pending.',
|
|
137
154
|
},
|
|
138
155
|
'raised-unhandled': {
|
|
139
156
|
exempt: 'session-historical fact — a loose end the session raised but neither did '
|
|
@@ -150,8 +167,44 @@ export const INBOX_DETECTOR_REGISTRY = {
|
|
|
150
167
|
+ 'reconciler: the referenced deferred action closed.',
|
|
151
168
|
},
|
|
152
169
|
'watchtower-health': {
|
|
153
|
-
|
|
154
|
-
+ '
|
|
170
|
+
reconciler: 'retractClearedMemoryBudgetItems (ring2 slow, act:ea23b3a5): the '
|
|
171
|
+
+ 'memory-budget alarm retracts on the SAME pass that measures it — '
|
|
172
|
+
+ 'runMemoryHygiene already shells every project\'s validate-memory (exit 0 = '
|
|
173
|
+
+ 'pass) and already computes passProjects, so the retraction condition is in '
|
|
174
|
+
+ 'hand. Deliberately NOT in Ring 1: a second memory-health producer is forbidden '
|
|
175
|
+
+ 'by name in this doc, and at a 30-minute throttle against ring2\'s 30-minute '
|
|
176
|
+
+ 'cadence it would buy zero freshness. Fails toward KEEPING — only a project '
|
|
177
|
+
+ 'positively classified `pass` retracts; any nonzero exit maps to `violations`. '
|
|
178
|
+
+ 'The selector matches BOTH the new evidence.debt_class and the legacy '
|
|
179
|
+
+ '{violations, streak} title shape, because every item on disk when this shipped '
|
|
180
|
+
+ 'carried the latter. Filing side: ONE accumulating item per project, keyed by '
|
|
181
|
+
+ 'CALENDAR DAY (a per-run key would count ~48 cron ticks a day), appended via '
|
|
182
|
+
+ 'annotateItemEvidence so filed_at is never bumped. Other watchtower-health '
|
|
183
|
+
+ 'filings (stale/failing ring windows) remain exempt — recovery is visible in the '
|
|
184
|
+
+ 'ring health sidecars.',
|
|
185
|
+
},
|
|
186
|
+
// ring4-filed (in the census since act:ea23b3a5; ring4 was outside it before)
|
|
187
|
+
'doc-drift': {
|
|
188
|
+
exempt: 'a documentary claim that no longer matches the tree, filed one item per '
|
|
189
|
+
+ '(project, drift_key). Deliberately NOT aggregated into a per-project standing '
|
|
190
|
+
+ 'debt: each item carries its own mutually-exclusive fix-doc / fix-code decision, '
|
|
191
|
+
+ 'so one accumulated item would pose a question the operator cannot answer. No '
|
|
192
|
+
+ 'mechanical retraction yet either — the weekly pass covers only 3 of 7 projects '
|
|
193
|
+
+ 'per run, so "not reproduced this run" is not evidence a claim cleared, and a '
|
|
194
|
+
+ 'reconciler built on it would mass-retract every item in the unscanned projects. '
|
|
195
|
+
+ 'Candidate future reconciler: re-verify on the next pass that ACTUALLY scanned '
|
|
196
|
+
+ 'that project, scoped to items with filed_by ring4. NOTE a second producer '
|
|
197
|
+
+ 'exists outside the census — a consumer Plan-9 hook files doc-drift with no '
|
|
198
|
+
+ 'drift_key at all (live: flow ring2-slow-post/10-pulse-mechanical.mjs).',
|
|
199
|
+
},
|
|
200
|
+
// routines-filed (in the census since act:ea23b3a5)
|
|
201
|
+
'routine': {
|
|
202
|
+
exempt: 'a declared interactive routine that fired. The dispatch engine owns the '
|
|
203
|
+
+ 'whole lifecycle, so there is nothing for a Ring 1 reconciler to re-check: a '
|
|
204
|
+
+ 'pending item blocks refiring, a stale one is superseded at the next due firing, '
|
|
205
|
+
+ 'and since act:ea23b3a5 a time-of-day routine self-supersedes at END OF ITS '
|
|
206
|
+
+ 'FILING DAY on Ring 1\'s tick — its moment is over. Terminal exits clear the mux '
|
|
207
|
+
+ 'dispatch descriptor via DISPATCHED_CATEGORIES, so the desk badge follows.',
|
|
155
208
|
},
|
|
156
209
|
'stale-project': {
|
|
157
210
|
exempt: 'staleness notification. Candidate future reconciler: project activity '
|
|
@@ -149,7 +149,7 @@ const KNOWLEDGE_CATEGORY = 'knowledge-extraction';
|
|
|
149
149
|
// is ALWAYS filed (never dropped) — home becomes the recomputation itself,
|
|
150
150
|
// not a written record.
|
|
151
151
|
export const KNOWLEDGE_EXTRACTION_TYPES = ['decision', 'constraint', 'lesson', 'preference', 'unclassifiable'];
|
|
152
|
-
export const KNOWLEDGE_EXTRACTION_HOMES = ['memory', 'claude-md', 'pib-db-trigger', 'upstream-feedback', 'derivation'];
|
|
152
|
+
export const KNOWLEDGE_EXTRACTION_HOMES = ['memory', 'claude-md', 'pib-db-trigger', 'upstream-feedback', 'derivation', 'session-record'];
|
|
153
153
|
|
|
154
154
|
// Categories whose items carry a structural recipient gate: they may never be
|
|
155
155
|
// included in a batch disposition — each leaves the queue only through its own
|
|
@@ -917,6 +917,13 @@ export function createItem({
|
|
|
917
917
|
if (evidence.derivable === true && (typeof evidence.derivation !== 'string' || !evidence.derivation.trim())) {
|
|
918
918
|
throw new Error('createItem: derivable:true requires evidence.derivation naming how to recompute it — a derivable fact is routed to its derivation, never filed as an opaque flag');
|
|
919
919
|
}
|
|
920
|
+
// The perishable twin (act:a69c21f8): a fact that is true at a moment and
|
|
921
|
+
// will become false, with no live source to recompute it from. perishes_when
|
|
922
|
+
// is to perishable what derivation is to derivable — the flag is never
|
|
923
|
+
// filed opaque.
|
|
924
|
+
if (evidence.perishable === true && (typeof evidence.perishes_when !== 'string' || !evidence.perishes_when.trim())) {
|
|
925
|
+
throw new Error('createItem: perishable:true requires evidence.perishes_when naming what event or time makes it false — a perishable fact is filed as a dated snapshot, never as an opaque flag');
|
|
926
|
+
}
|
|
920
927
|
}
|
|
921
928
|
// A 'merged' handoff must reference work actually on main. merge_state
|
|
922
929
|
// replaced the old hand-flagged `merged_into: "PENDING — not yet merged"`
|
|
@@ -1161,11 +1168,46 @@ export function releaseHold(id) {
|
|
|
1161
1168
|
* @param {string} id
|
|
1162
1169
|
* @returns {object} The updated item
|
|
1163
1170
|
*/
|
|
1171
|
+
// ---------------------------------------------------------------------------
|
|
1172
|
+
// Category expiry — the ONE age policy (act:ea23b3a5)
|
|
1173
|
+
// ---------------------------------------------------------------------------
|
|
1174
|
+
//
|
|
1175
|
+
// There are TWO expiry engines: Ring 2's `escalateQueueItems` (the 5-minute
|
|
1176
|
+
// cron) and `runExpiry` below (called by /inbox and /briefing). Before this,
|
|
1177
|
+
// each hardcoded 30 days and each inlined its own qa-handoff carve-out — so a
|
|
1178
|
+
// per-category policy set in one would be silently violated by whichever engine
|
|
1179
|
+
// reached the item first. Both now delegate here.
|
|
1180
|
+
//
|
|
1181
|
+
// `categoryNeverExpires` lives beside `expireItem`'s throw deliberately: the
|
|
1182
|
+
// carve-out is STRUCTURAL, not a preference. `expireItem` throws on a
|
|
1183
|
+
// qa-handoff, so returning anything but null for it would crash the caller's
|
|
1184
|
+
// loop. One fact, one place, no drift.
|
|
1185
|
+
export const DEFAULT_EXPIRE_DAYS = 30;
|
|
1186
|
+
|
|
1187
|
+
// Per-category overrides. completion-review at 14d: the category's whole value
|
|
1188
|
+
// is time-bound — a completion candidate nobody confirmed in two weeks is not
|
|
1189
|
+
// going to be confirmed, and a real completion re-detects when the action is
|
|
1190
|
+
// next touched. NOTE the coupling to COMPLETION_REVIEW_DEDUP_DAYS (also 14) in
|
|
1191
|
+
// ring3-close: because the two are equal, that phase's dedup corpus MUST
|
|
1192
|
+
// include `expired` and `superseded`, or a fid leaves every corpus at exactly
|
|
1193
|
+
// the moment its item expires and refiles forever.
|
|
1194
|
+
export const CATEGORY_EXPIRE_DAYS = { 'completion-review': 14 };
|
|
1195
|
+
|
|
1196
|
+
export function categoryNeverExpires(category) {
|
|
1197
|
+
return category === QA_CATEGORY;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Days before an item of this category expires; null = never. */
|
|
1201
|
+
export function expiryDaysFor(category) {
|
|
1202
|
+
if (categoryNeverExpires(category)) return null;
|
|
1203
|
+
return CATEGORY_EXPIRE_DAYS[category] ?? DEFAULT_EXPIRE_DAYS;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1164
1206
|
export function expireItem(id) {
|
|
1165
1207
|
const fp = itemPath(id);
|
|
1166
1208
|
const item = readItem(fp);
|
|
1167
1209
|
if (item.status !== 'pending') return null;
|
|
1168
|
-
if (item.category
|
|
1210
|
+
if (categoryNeverExpires(item.category)) {
|
|
1169
1211
|
throw gateError(item, 'qa-handoff items never expire — QA debt stays surfaced until a stamped verdict resolves it (or a typed dismissal waives it)');
|
|
1170
1212
|
}
|
|
1171
1213
|
item.status = 'expired';
|
|
@@ -1359,10 +1401,12 @@ export function getEnrichment(id) {
|
|
|
1359
1401
|
* @param {object} params
|
|
1360
1402
|
* @returns {object} { warned: [], expired: [] }
|
|
1361
1403
|
*/
|
|
1362
|
-
|
|
1404
|
+
// `expireDays` is the DEFAULT, not a blanket: per-category overrides come from
|
|
1405
|
+
// expiryDaysFor (act:ea23b3a5), so this engine and Ring 2's cron cannot give
|
|
1406
|
+
// two different answers for the same item.
|
|
1407
|
+
export function runExpiry({ warnDays = 14, expireDays = DEFAULT_EXPIRE_DAYS } = {}) {
|
|
1363
1408
|
const now = Date.now();
|
|
1364
1409
|
const warnMs = warnDays * 24 * 60 * 60 * 1000;
|
|
1365
|
-
const expireMs = expireDays * 24 * 60 * 60 * 1000;
|
|
1366
1410
|
const pending = listPending();
|
|
1367
1411
|
const warned = [];
|
|
1368
1412
|
const expired = [];
|
|
@@ -1371,13 +1415,16 @@ export function runExpiry({ warnDays = 14, expireDays = 30 } = {}) {
|
|
|
1371
1415
|
const age = now - new Date(item.filed_at).getTime();
|
|
1372
1416
|
// qa-handoff items never auto-expire: an expired handoff is exactly the
|
|
1373
1417
|
// silent QA gap the recipient gate forbids. They warn (by item) instead.
|
|
1374
|
-
|
|
1418
|
+
const days = categoryNeverExpires(item.category)
|
|
1419
|
+
? null
|
|
1420
|
+
: (CATEGORY_EXPIRE_DAYS[item.category] ?? expireDays);
|
|
1421
|
+
if (days === null) {
|
|
1375
1422
|
if (age >= warnMs) warned.push(item);
|
|
1376
1423
|
continue;
|
|
1377
1424
|
}
|
|
1378
|
-
if (age >=
|
|
1425
|
+
if (age >= days * 24 * 60 * 60 * 1000) {
|
|
1379
1426
|
item.status = 'expired';
|
|
1380
|
-
item.resolution_notes = `Auto-expired after ${
|
|
1427
|
+
item.resolution_notes = `Auto-expired after ${days} days. If still relevant, re-file with updated context.`;
|
|
1381
1428
|
atomicWrite(itemPath(item.id), item);
|
|
1382
1429
|
if (DISPATCHED_CATEGORIES.has(item.category)) clearDispatchEntries(item);
|
|
1383
1430
|
expired.push(item);
|
|
@@ -1645,6 +1645,72 @@ function readRecallCanary() {
|
|
|
1645
1645
|
} catch { return {}; }
|
|
1646
1646
|
}
|
|
1647
1647
|
|
|
1648
|
+
// readRosterMetrics — the Ring 2 cabinet-roster measurement sidecar
|
|
1649
|
+
// (act:ea23b3a5). Same shape of relationship as the recall canary: the
|
|
1650
|
+
// measurement is Ring 2's, the render is Ring 1's, because Ring 1 owns the
|
|
1651
|
+
// per-project state file. Absent or corrupt yields {} — a project with no
|
|
1652
|
+
// entry renders nothing at all.
|
|
1653
|
+
function readRosterMetrics() {
|
|
1654
|
+
const path = join(WATCHTOWER_DIR, 'state', 'roster-review.json');
|
|
1655
|
+
if (!existsSync(path)) return {};
|
|
1656
|
+
try {
|
|
1657
|
+
return JSON.parse(readFileSync(path, 'utf8')).metrics || {};
|
|
1658
|
+
} catch { return {}; }
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Cap on skill names listed inline, matching FLAGGED_RENDER_CAP's discipline:
|
|
1662
|
+
// the COUNT is always true, only the enumeration is bounded.
|
|
1663
|
+
export const ROSTER_NAME_RENDER_CAP = 4;
|
|
1664
|
+
|
|
1665
|
+
// renderRosterEntries — the ambient Standing Issues lines for a project's
|
|
1666
|
+
// roster measurement. Returns [] when there is nothing worth a line.
|
|
1667
|
+
//
|
|
1668
|
+
// RENDERS ON CHANGE ONLY. An unchanging number in a file the operator reads at
|
|
1669
|
+
// every briefing is furniture by the third read — the recall canary set this
|
|
1670
|
+
// precedent by rendering only on alert. The count still lives in
|
|
1671
|
+
// state/roster-review.json for anyone who goes looking; what earns a line here
|
|
1672
|
+
// is movement. (First measurement counts as movement: `previous` is null.)
|
|
1673
|
+
//
|
|
1674
|
+
// Pure (no I/O) for hermetic testing.
|
|
1675
|
+
export function renderRosterEntries(metrics, cap = ROSTER_NAME_RENDER_CAP) {
|
|
1676
|
+
const lines = [];
|
|
1677
|
+
if (!metrics || typeof metrics !== 'object') return lines;
|
|
1678
|
+
const asOf = String(metrics.measured_at || '').slice(0, 10);
|
|
1679
|
+
const stamp = asOf ? ` (measured ${asOf})` : '';
|
|
1680
|
+
|
|
1681
|
+
const d = metrics.dormant;
|
|
1682
|
+
if (d && (d.dead_count > 0 || d.stale_count > 0)) {
|
|
1683
|
+
const prev = metrics.previous;
|
|
1684
|
+
const changed = !prev
|
|
1685
|
+
|| prev.dead_count !== d.dead_count
|
|
1686
|
+
|| prev.stale_count !== d.stale_count;
|
|
1687
|
+
if (changed) {
|
|
1688
|
+
const names = (Array.isArray(d.dead) ? d.dead : []).slice(0, cap);
|
|
1689
|
+
const more = (d.dead_count || 0) - names.length;
|
|
1690
|
+
const nameList = names.length
|
|
1691
|
+
? ` — ${names.join(', ')}${more > 0 ? `, +${more} more` : ''}`
|
|
1692
|
+
: '';
|
|
1693
|
+
const wasNote = prev ? ` (was ${prev.dead_count})` : '';
|
|
1694
|
+
lines.push(
|
|
1695
|
+
`dormant skills: ${d.dead_count} never invoked${wasNote}, `
|
|
1696
|
+
+ `${d.stale_count} stale >${d.days || 30}d${nameList}${stamp}`
|
|
1697
|
+
);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
const briefings = metrics.stale_briefings;
|
|
1702
|
+
if (Array.isArray(briefings) && briefings.length > 0) {
|
|
1703
|
+
const oldest = briefings.reduce((a, b) =>
|
|
1704
|
+
(b.ageDaysBehind || 0) > (a.ageDaysBehind || 0) ? b : a);
|
|
1705
|
+
lines.push(
|
|
1706
|
+
`stale cabinet briefings: ${briefings.length}, `
|
|
1707
|
+
+ `oldest ~${oldest.ageDaysBehind}d behind the project${stamp}`
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
return lines;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1648
1714
|
function assembleProjectState(ps) {
|
|
1649
1715
|
const now = new Date().toISOString();
|
|
1650
1716
|
const lines = [];
|
|
@@ -1727,6 +1793,10 @@ function assembleProjectState(ps) {
|
|
|
1727
1793
|
`sample (over-suppression only; recall-canary.json / see /briefing)`
|
|
1728
1794
|
);
|
|
1729
1795
|
}
|
|
1796
|
+
// Cabinet-roster measurements (act:ea23b3a5) — ambient state, not queue
|
|
1797
|
+
// items: a count describes a condition, it doesn't ask the operator to
|
|
1798
|
+
// decide anything. Rendered on CHANGE only; see renderRosterEntries.
|
|
1799
|
+
for (const line of renderRosterEntries(ps.roster)) issues.push(line);
|
|
1730
1800
|
if (ps.divergedBranches && ps.divergedBranches.length > 0) {
|
|
1731
1801
|
issues.push(`Diverged branches: ${ps.divergedBranches.join(', ')}`);
|
|
1732
1802
|
}
|
|
@@ -1815,6 +1885,9 @@ function main() {
|
|
|
1815
1885
|
// Ring 2 slow writes the recall-canary sidecar; Ring 1 renders an alerting
|
|
1816
1886
|
// project's entry into its Standing Issues (the canary's named reader).
|
|
1817
1887
|
const recallCanaryProjects = readRecallCanary();
|
|
1888
|
+
// Ring 2's weekly cabinet-roster measurement, rendered by Ring 1 (which
|
|
1889
|
+
// owns the per-project state file) — the recall-canary relationship.
|
|
1890
|
+
const rosterMetrics = readRosterMetrics();
|
|
1818
1891
|
|
|
1819
1892
|
// Branch-diverged reconciliation inputs, resolved ONCE per tick: the
|
|
1820
1893
|
// exclusion matcher (config-driven) and one pending-items snapshot —
|
|
@@ -1845,6 +1918,7 @@ function main() {
|
|
|
1845
1918
|
ccFeedbackArrival: checkCcFeedbackArrival(projectPath),
|
|
1846
1919
|
memoryIntegrity: checkMemoryIntegrity(projectPath),
|
|
1847
1920
|
recall: recallCanaryProjects[name] || null,
|
|
1921
|
+
roster: rosterMetrics[name] || null,
|
|
1848
1922
|
divergedBranches: [],
|
|
1849
1923
|
hookResults: [],
|
|
1850
1924
|
};
|