create-claude-cabinet 0.45.0 โ†’ 0.47.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 (165) hide show
  1. package/README.md +6 -10
  2. package/lib/cli.js +374 -91
  3. package/lib/copy.js +108 -6
  4. package/lib/db-setup.js +122 -17
  5. package/lib/engagement-server-setup.js +34 -9
  6. package/lib/metadata.js +51 -2
  7. package/lib/migrate-from-omega.js +13 -1
  8. package/lib/mux-setup.js +33 -9
  9. package/lib/settings-merge.js +52 -10
  10. package/lib/watchtower-setup.js +230 -0
  11. package/package.json +5 -1
  12. package/templates/cabinet/_cabinet-member-template.md +8 -3
  13. package/templates/cabinet/advisories-state-schema.md +34 -7
  14. package/templates/cabinet/checklist-stats-schema.md +15 -1
  15. package/templates/cabinet/composition-patterns.md +4 -3
  16. package/templates/cabinet/memory-lifecycle-contract.md +135 -0
  17. package/templates/cabinet/pib-db-access.md +13 -0
  18. package/templates/cabinet/skill-output-conventions.md +35 -1
  19. package/templates/cabinet/watchtower-contracts.md +503 -1
  20. package/templates/cabinet/worktree-invocation-contract.md +87 -0
  21. package/templates/engagement/OVERVIEW.md +12 -0
  22. package/templates/engagement/__tests__/engagement.test.mjs +177 -2
  23. package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
  24. package/templates/engagement/engagement-preview.mjs +100 -0
  25. package/templates/engagement/engagement-schema.md +240 -0
  26. package/templates/engagement/engagement.mjs +391 -11
  27. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
  28. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
  29. package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
  30. package/templates/hooks/action-completion-gate.sh +9 -3
  31. package/templates/hooks/memory-index-guard.sh +17 -11
  32. package/templates/hooks/watchtower-session-start.sh +24 -2
  33. package/templates/mcp/pib-db.json +1 -4
  34. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
  35. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  36. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  37. package/templates/mux/bin/mux +289 -56
  38. package/templates/mux/config/help.txt +1 -0
  39. package/templates/rules/acknowledge-when-corrected.md +33 -0
  40. package/templates/rules/maintainability.md +11 -0
  41. package/templates/rules/memory-capture.md +26 -4
  42. package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
  43. package/templates/rules/verify-before-asserting.md +79 -0
  44. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  45. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  46. package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
  47. package/templates/scripts/__tests__/batch-disposition.test.mjs +235 -0
  48. package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
  49. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
  50. package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
  51. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  52. package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
  53. package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
  54. package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
  55. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
  56. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
  57. package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
  58. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -3
  59. package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
  60. package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
  61. package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
  62. package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
  63. package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
  64. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
  65. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  66. package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
  67. package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
  68. package/templates/scripts/__tests__/ring3-dedup.test.mjs +467 -0
  69. package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
  70. package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
  71. package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
  72. package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
  73. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  74. package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
  75. package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
  76. package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
  77. package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
  78. package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
  79. package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
  80. package/templates/scripts/load-triage-history.js +5 -2
  81. package/templates/scripts/pib-db-mcp-server.mjs +5 -44
  82. package/templates/scripts/pib-db-path.mjs +61 -0
  83. package/templates/scripts/pib-db.mjs +5 -2
  84. package/templates/scripts/validate-memory.mjs +214 -16
  85. package/templates/scripts/watchtower-advisories.mjs +309 -0
  86. package/templates/scripts/watchtower-build-context.mjs +301 -18
  87. package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
  88. package/templates/scripts/watchtower-hook-runner.mjs +422 -0
  89. package/templates/scripts/watchtower-lib.mjs +447 -2
  90. package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
  91. package/templates/scripts/watchtower-phase-shim.mjs +171 -0
  92. package/templates/scripts/watchtower-queue.mjs +484 -1
  93. package/templates/scripts/watchtower-ring1.mjs +533 -53
  94. package/templates/scripts/watchtower-ring2.mjs +787 -48
  95. package/templates/scripts/watchtower-ring3-close.mjs +1357 -99
  96. package/templates/scripts/watchtower-ring4-runner.sh +85 -0
  97. package/templates/scripts/watchtower-ring4.mjs +753 -0
  98. package/templates/scripts/watchtower-routines.mjs +358 -0
  99. package/templates/scripts/watchtower-snapshot.mjs +452 -0
  100. package/templates/scripts/watchtower-status.sh +1 -1
  101. package/templates/scripts/watchtower-sync.mjs +393 -0
  102. package/templates/skills/audit/SKILL.md +5 -1
  103. package/templates/skills/briefing/SKILL.md +584 -236
  104. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  105. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  106. package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
  107. package/templates/skills/cabinet-security/SKILL.md +11 -0
  108. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  109. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  110. package/templates/skills/catch-up/SKILL.md +113 -0
  111. package/templates/skills/cc-publish/SKILL.md +148 -25
  112. package/templates/skills/cc-remember/SKILL.md +45 -0
  113. package/templates/skills/close/SKILL.md +107 -0
  114. package/templates/skills/collab-client/SKILL.md +22 -5
  115. package/templates/skills/collab-consultant/SKILL.md +110 -2
  116. package/templates/skills/debrief/SKILL.md +148 -16
  117. package/templates/skills/debrief-classic/SKILL.md +696 -0
  118. package/templates/skills/debrief-classic/calibration.md +44 -0
  119. package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
  120. package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
  121. package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
  122. package/templates/skills/debrief-classic/phases/close-work.md +163 -0
  123. package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
  124. package/templates/skills/debrief-classic/phases/inventory.md +40 -0
  125. package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
  126. package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
  127. package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
  128. package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
  129. package/templates/skills/debrief-classic/phases/report.md +59 -0
  130. package/templates/skills/debrief-classic/phases/update-state.md +48 -0
  131. package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
  132. package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
  133. package/templates/skills/execute/SKILL.md +77 -6
  134. package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
  135. package/templates/skills/execute-group/SKILL.md +23 -26
  136. package/templates/skills/generate-plan-groups/SKILL.md +20 -0
  137. package/templates/skills/inbox/SKILL.md +124 -7
  138. package/templates/skills/memory/SKILL.md +22 -6
  139. package/templates/skills/orient/SKILL.md +140 -52
  140. package/templates/skills/orient-classic/SKILL.md +770 -0
  141. package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
  142. package/templates/skills/orient-classic/phases/briefing.md +53 -0
  143. package/templates/skills/orient-classic/phases/cabinet.md +46 -0
  144. package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
  145. package/templates/skills/orient-classic/phases/context.md +88 -0
  146. package/templates/skills/orient-classic/phases/data-sync.md +35 -0
  147. package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
  148. package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
  149. package/templates/skills/orient-classic/phases/health-checks.md +50 -0
  150. package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
  151. package/templates/skills/orient-classic/phases/work-scan.md +69 -0
  152. package/templates/skills/plan/SKILL.md +8 -0
  153. package/templates/skills/qa-drain/SKILL.md +157 -0
  154. package/templates/skills/qa-handoff/SKILL.md +287 -46
  155. package/templates/skills/session-handoff/SKILL.md +175 -6
  156. package/templates/skills/setup-accounts/SKILL.md +38 -16
  157. package/templates/skills/threads/SKILL.md +4 -0
  158. package/templates/skills/triage-audit/SKILL.md +6 -0
  159. package/templates/skills/validate/phases/validators.md +41 -0
  160. package/templates/skills/watchtower/SKILL.md +199 -2
  161. package/templates/verify-runtime/CONVENTIONS.md +9 -0
  162. package/templates/verify-runtime/README.md +37 -0
  163. package/templates/watchtower/config.json.template +10 -2
  164. package/templates/watchtower/queue/items/item.json.schema +1 -1
  165. package/templates/workflows/execute-group-complete.js +10 -1
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Watchtower mid-session snapshot + delta.
4
+ //
5
+ // The SessionStart hook injects watchtower state exactly ONCE, at session
6
+ // start. A long session then drifts from that snapshot: other sessions
7
+ // advance threads, Ring 2 enriches and files inbox items, Ring 1 detects new
8
+ // git state, deferred actions come due. This module is the primitive behind
9
+ // the /catch-up skill โ€” it answers "what changed in watchtower since this
10
+ // session started?" without a restart.
11
+ //
12
+ // It is deliberately a SIBLING of watchtower-build-context.mjs, not folded
13
+ // into it: that builder's single job is "assemble the start-of-session
14
+ // injection string and print it," and it runs on every SessionStart. Snapshot
15
+ // capture + diff + plain-English rendering is a separate concern with its own
16
+ // lifecycle. Both the SessionStart hook (baseline capture) and the /catch-up
17
+ // skill (current capture + diff) import the same functions here โ€” single
18
+ // source of truth, builder untouched.
19
+ //
20
+ // State sources are the SAME files the injection summarizes, read at item
21
+ // granularity (NOT through the builder's 9500-char truncation path, so a
22
+ // budget-dropped section can never read as a delta):
23
+ // - inbox : listPending() from watchtower-queue.mjs (full items, with ids)
24
+ // - threads: loadActiveThreads()/currentCursor() from watchtower-lib.mjs
25
+ // - git : state/git-attention.json facts (the structured git signal)
26
+ // - identity: resolveProjectIdentity() (git-aware; a worktree resolves to
27
+ // its main repo's project, so a worktree /catch-up diffs the right state)
28
+ //
29
+ // CLI modes (additive; this file never alters the SessionStart injection):
30
+ // --emit-snapshot <path> --project-path <cwd> [--session-id <id>]
31
+ // Build the current snapshot, write it atomically to <path>, then prune
32
+ // snapshots older than 7 days from <path>'s directory. The SessionStart
33
+ // hook calls this to capture the per-session baseline.
34
+ // --snapshot --project-path <cwd>
35
+ // Print the current snapshot JSON to stdout (used in tests / debugging).
36
+ // --diff <baselinePath> --project-path <cwd>
37
+ // Read the baseline, build the current snapshot, diff, and print the
38
+ // plain-English delta. The /catch-up skill relays this.
39
+ //
40
+ // Never crashes โ€” readers degrade to empty, the diff tolerates missing/partial
41
+ // snapshots, and a corrupt baseline prints an honest "no baseline" line.
42
+
43
+ import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, realpathSync } from 'fs';
44
+ import { join, dirname } from 'path';
45
+ import { pathToFileURL } from 'url';
46
+ import {
47
+ atomicWrite,
48
+ currentCursor,
49
+ loadActiveThreads,
50
+ threadMatchesProject,
51
+ resolveProjectIdentity,
52
+ } from './watchtower-lib.mjs';
53
+ import { listPending } from './watchtower-queue.mjs';
54
+
55
+ const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
56
+ || join(process.env.HOME, '.claude-cabinet', 'watchtower');
57
+
58
+ export const SNAPSHOT_SCHEMA_VERSION = 1;
59
+ const PRUNE_DAYS = 7;
60
+ const PRUNE_MS = PRUNE_DAYS * 24 * 60 * 60 * 1000;
61
+
62
+ // --- Safe readers ---
63
+
64
+ function safeReadJSON(filePath) {
65
+ try {
66
+ if (!existsSync(filePath)) return null;
67
+ return JSON.parse(readFileSync(filePath, 'utf8'));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // --- Snapshot capture ---------------------------------------------------------
74
+
75
+ // buildSnapshot โ€” read the current pollable watchtower state into a structured
76
+ // object. Scoped to the project the cwd resolves to (the operator's "what's new
77
+ // in my project" frame); if the project can't be resolved, falls back to the
78
+ // whole portfolio rather than going blank. Pure-ish: reads state files via the
79
+ // shared helpers, returns a plain object, writes nothing.
80
+ //
81
+ // SILENT-EXCLUSION NOTE (decided, not missed): inbox capture is listPending()
82
+ // only (status === 'pending'). An item that is filed AND resolved entirely
83
+ // between two snapshots appears in neither set, so it is invisible to the
84
+ // delta. That is correct for a "what's actionable now" surface โ€” the operator
85
+ // is shown what is still pending โ€” but it is a deliberate exclusion.
86
+ export function buildSnapshot({ projectPath } = {}) {
87
+ const config = safeReadJSON(join(WATCHTOWER_DIR, 'config.json'));
88
+
89
+ let projectName = null;
90
+ let projectSlug = null;
91
+ if (config && config.projects && projectPath) {
92
+ try {
93
+ const identity = resolveProjectIdentity(projectPath, config);
94
+ if (identity && identity.registered) {
95
+ projectName = identity.name;
96
+ projectSlug = identity.slug;
97
+ }
98
+ } catch { /* unresolved โ†’ portfolio-wide fallback below */ }
99
+ }
100
+
101
+ // Inbox โ€” full pending items, scoped to the project when resolved.
102
+ let pending = [];
103
+ try {
104
+ pending = listPending(projectName ? { project: projectName } : {});
105
+ } catch { pending = []; }
106
+ const items = pending
107
+ .map((i) => ({ id: i.id, category: i.category, title: i.title, urgency: i.urgency }))
108
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
109
+ const byCategory = {};
110
+ let urgent = 0;
111
+ for (const i of items) {
112
+ byCategory[i.category] = (byCategory[i.category] || 0) + 1;
113
+ if (i.urgency === 'urgent') urgent++;
114
+ }
115
+
116
+ // Threads โ€” active threads touching this project (or all active when
117
+ // unresolved), at cursor granularity.
118
+ const allThreads = loadActiveThreads(join(WATCHTOWER_DIR, 'state', 'threads'));
119
+ const scoped = projectSlug
120
+ ? allThreads.filter((t) => threadMatchesProject(t, projectSlug).match)
121
+ : allThreads;
122
+ const threads = scoped
123
+ .map((t) => ({
124
+ thread: t.thread,
125
+ display_name: t.display_name || t.thread,
126
+ last_updated: t.last_updated || null,
127
+ what: currentCursor(t).what || '',
128
+ }))
129
+ .sort((a, b) => (a.thread < b.thread ? -1 : a.thread > b.thread ? 1 : 0));
130
+
131
+ // Git attention โ€” the structured sidecar Ring 1 writes (MERGE-OR-LOSE /
132
+ // diverged-branch lines). The line strings are the comparable signal.
133
+ const sidecar = safeReadJSON(join(WATCHTOWER_DIR, 'state', 'git-attention.json'));
134
+ const gitAttention = Array.isArray(sidecar && sidecar.facts)
135
+ ? sidecar.facts.map((f) => f && f.line).filter(Boolean).sort()
136
+ : [];
137
+
138
+ return {
139
+ schema_version: SNAPSHOT_SCHEMA_VERSION,
140
+ captured_at: new Date().toISOString(),
141
+ session_id: null,
142
+ project: projectName,
143
+ project_slug: projectSlug,
144
+ inbox: { total: items.length, urgent, byCategory, items },
145
+ threads,
146
+ gitAttention,
147
+ };
148
+ }
149
+
150
+ // --- Delta --------------------------------------------------------------------
151
+
152
+ // A thread "advanced" when its last_updated moved forward. Missing/equal/
153
+ // unparseable timestamps on either side => NOT advanced (a decided default, not
154
+ // an accidental one): new Date(undefined) is Invalid Date and every comparison
155
+ // against it is false, which we make explicit here.
156
+ function advancedSince(beforeTs, afterTs) {
157
+ const a = new Date(beforeTs || 0).getTime();
158
+ const b = new Date(afterTs || 0).getTime();
159
+ if (Number.isNaN(a) || Number.isNaN(b)) return false;
160
+ return b > a;
161
+ }
162
+
163
+ // diffSnapshots โ€” structured delta of two snapshots. Tolerates null/partial
164
+ // snapshots (a corrupt or missing field reads as empty). Inbox membership is by
165
+ // item-id SET, not by count, so a file-and-resolve that nets zero total still
166
+ // surfaces the genuinely new item. Threads diff by slug: present-both with a
167
+ // later timestamp => advanced; after-only => new; before-only => went quiet.
168
+ export function diffSnapshots(before, after) {
169
+ const bInbox = (before && before.inbox) || {};
170
+ const aInbox = (after && after.inbox) || {};
171
+ const bItems = Array.isArray(bInbox.items) ? bInbox.items : [];
172
+ const aItems = Array.isArray(aInbox.items) ? aInbox.items : [];
173
+ const bIds = new Set(bItems.map((i) => i.id));
174
+ const aIds = new Set(aItems.map((i) => i.id));
175
+
176
+ const newItems = aItems.filter((i) => !bIds.has(i.id));
177
+ const resolvedItems = bItems.filter((i) => !aIds.has(i.id));
178
+ const newByCategory = {};
179
+ for (const i of newItems) newByCategory[i.category] = (newByCategory[i.category] || 0) + 1;
180
+ const newDeferredTriggers = newItems.filter((i) => i.category === 'deferred-trigger');
181
+
182
+ const bThreads = Array.isArray(before && before.threads) ? before.threads : [];
183
+ const aThreads = Array.isArray(after && after.threads) ? after.threads : [];
184
+ const bByThread = new Map(bThreads.map((t) => [t.thread, t]));
185
+ const aByThread = new Map(aThreads.map((t) => [t.thread, t]));
186
+
187
+ const advanced = [];
188
+ const newThreads = [];
189
+ for (const t of aThreads) {
190
+ const prev = bByThread.get(t.thread);
191
+ if (!prev) {
192
+ newThreads.push(t);
193
+ } else if (advancedSince(prev.last_updated, t.last_updated)) {
194
+ advanced.push(t);
195
+ }
196
+ }
197
+ const closedThreads = bThreads.filter((t) => !aByThread.has(t.thread));
198
+
199
+ const bGit = new Set(Array.isArray(before && before.gitAttention) ? before.gitAttention : []);
200
+ const aGit = new Set(Array.isArray(after && after.gitAttention) ? after.gitAttention : []);
201
+ const gitAdded = [...aGit].filter((l) => !bGit.has(l));
202
+ const gitRemoved = [...bGit].filter((l) => !aGit.has(l));
203
+
204
+ const hasChanges = newItems.length > 0 || resolvedItems.length > 0
205
+ || advanced.length > 0 || newThreads.length > 0 || closedThreads.length > 0
206
+ || gitAdded.length > 0 || gitRemoved.length > 0;
207
+
208
+ return {
209
+ capturedAt: { before: (before && before.captured_at) || null, after: (after && after.captured_at) || null },
210
+ inbox: { newItems, resolvedItems, newByCategory, newDeferredTriggers },
211
+ threads: { advanced, new: newThreads, closed: closedThreads },
212
+ git: { added: gitAdded, removed: gitRemoved },
213
+ hasChanges,
214
+ };
215
+ }
216
+
217
+ // --- Plain-English rendering --------------------------------------------------
218
+
219
+ // Concept-first labels for inbox categories. The item TITLE carries the
220
+ // specifics; this label keeps the sentence in plain words rather than the raw
221
+ // category slug. Unknown categories fall back to a de-slugified phrasing.
222
+ const CATEGORY_LABEL = {
223
+ 'deferred-trigger': 'a deferred task came due',
224
+ 'knowledge-extraction': 'knowledge to review',
225
+ 'completion-review': 'a finished item to confirm',
226
+ 'completion-candidate': 'a finished item to confirm',
227
+ 'qa-handoff': 'a QA handoff',
228
+ 'pattern-promotion': 'a recurring-pattern note',
229
+ 'advisor-finding': 'an advisor finding',
230
+ 'methodology-capture': 'a design note captured',
231
+ 'doc-drift': 'a docs-vs-reality mismatch',
232
+ 'upstream-friction': 'upstream friction reported',
233
+ 'routing-decision': 'a routing decision',
234
+ 'coverage-warning': 'a coverage gap',
235
+ 'verify-backfill': 'a verification backfill',
236
+ 'routine': 'a scheduled routine',
237
+ 'worktree-unmerged': 'an unmerged worktree branch',
238
+ 'branch-diverged': 'a diverged branch',
239
+ 'watchtower-health': 'a watchtower health note',
240
+ 'project-completion': 'a project nearing completion',
241
+ };
242
+
243
+ function categoryLabel(category) {
244
+ if (CATEGORY_LABEL[category]) return CATEGORY_LABEL[category];
245
+ return String(category || 'an item').replace(/[-_]/g, ' ');
246
+ }
247
+
248
+ // Per-section caps keep /catch-up scannable when a burst lands (e.g. Ring 2
249
+ // files many pattern-promotion items at once, or a portfolio gains several
250
+ // diverged branches). Never silently drop โ€” append an explicit "โ€ฆand N more".
251
+ const ITEM_CAP = 10;
252
+ const THREAD_CAP = 5;
253
+ const GIT_CAP = 8;
254
+ const WHAT_MAX = 160;
255
+
256
+ function truncate(s, max) {
257
+ if (!s) return '';
258
+ return s.length > max ? `${s.slice(0, max - 1)}โ€ฆ` : s;
259
+ }
260
+
261
+ // Push up to `cap` rendered lines for `arr`, then a "โ€ฆand N more" line if any
262
+ // were held back. `render` returns the bullet string for one element.
263
+ function pushCapped(lines, arr, cap, render) {
264
+ for (const el of arr.slice(0, cap)) lines.push(render(el));
265
+ if (arr.length > cap) lines.push(` โ€ฆand ${arr.length - cap} more`);
266
+ }
267
+
268
+ // "since HH:MM" / "since session start" framing for the header. Best-effort โ€”
269
+ // a missing timestamp degrades to the generic phrasing.
270
+ function sinceClause(beforeIso) {
271
+ if (!beforeIso) return 'since this session started';
272
+ const d = new Date(beforeIso);
273
+ if (Number.isNaN(d.getTime())) return 'since this session started';
274
+ const hh = String(d.getHours()).padStart(2, '0');
275
+ const mm = String(d.getMinutes()).padStart(2, '0');
276
+ return `since this session started (baseline ${hh}:${mm})`;
277
+ }
278
+
279
+ // renderDelta โ€” plain-English, concept-first briefing of the delta. Phrased as
280
+ // PROJECT-level change ("changed in <project> since your session started"), not
281
+ // personal attribution: in a worktree, a concurrent session's Ring 3 close may
282
+ // have advanced a thread or filed an item, and that legitimately appears here โ€”
283
+ // it is "the project moved," not "you did this."
284
+ export function renderDelta(delta, { projectName } = {}) {
285
+ const where = projectName ? `watchtower for ${projectName}` : 'watchtower';
286
+ if (!delta || !delta.hasChanges) {
287
+ return `Nothing new in ${where} ${sinceClause(delta && delta.capturedAt && delta.capturedAt.before)}.`;
288
+ }
289
+
290
+ const lines = [];
291
+ lines.push(`What's changed in ${where} ${sinceClause(delta.capturedAt && delta.capturedAt.before)}:`);
292
+
293
+ const { newItems, resolvedItems, newDeferredTriggers } = delta.inbox;
294
+
295
+ // Deferred tasks that just came due โ€” called out first; they are the most
296
+ // time-sensitive "act now" signal.
297
+ if (newDeferredTriggers.length > 0) {
298
+ lines.push('');
299
+ const n = newDeferredTriggers.length;
300
+ lines.push(`โฐ ${n} deferred task${n === 1 ? '' : 's'} just came due:`);
301
+ pushCapped(lines, newDeferredTriggers, ITEM_CAP, (i) => ` - ${i.title || '(untitled)'} (${i.id})`);
302
+ }
303
+
304
+ // Other new inbox items (excluding the deferred triggers already shown).
305
+ const otherNew = newItems.filter((i) => i.category !== 'deferred-trigger');
306
+ if (otherNew.length > 0) {
307
+ lines.push('');
308
+ const n = otherNew.length;
309
+ lines.push(`๐Ÿ“ฅ ${n} new inbox item${n === 1 ? '' : 's'} filed:`);
310
+ pushCapped(lines, otherNew, ITEM_CAP,
311
+ (i) => ` - ${i.title || '(untitled)'} โ€” ${categoryLabel(i.category)} (${i.id})`);
312
+ }
313
+
314
+ if (resolvedItems.length > 0) {
315
+ const n = resolvedItems.length;
316
+ lines.push('');
317
+ lines.push(`โœ… ${n} inbox item${n === 1 ? '' : 's'} resolved since you started.`);
318
+ }
319
+
320
+ // Threads.
321
+ const { advanced, new: newThreads, closed } = delta.threads;
322
+ if (newThreads.length > 0) {
323
+ lines.push('');
324
+ pushCapped(lines, newThreads, THREAD_CAP,
325
+ (t) => `๐Ÿงต New work thread "${t.display_name || t.thread}"${t.what ? ` โ€” ${truncate(t.what, WHAT_MAX)}` : ''}.`);
326
+ }
327
+ if (advanced.length > 0) {
328
+ lines.push('');
329
+ pushCapped(lines, advanced, THREAD_CAP,
330
+ (t) => `๐Ÿงต Thread "${t.display_name || t.thread}" advanced${t.what ? `: ${truncate(t.what, WHAT_MAX)}` : ''}.`);
331
+ }
332
+ if (closed.length > 0) {
333
+ lines.push('');
334
+ pushCapped(lines, closed, THREAD_CAP,
335
+ (t) => `๐Ÿงต Thread "${t.display_name || t.thread}" went quiet (no longer active).`);
336
+ }
337
+
338
+ // Git/repo attention.
339
+ if (delta.git.added.length > 0) {
340
+ lines.push('');
341
+ lines.push('๐Ÿ”ง New repo attention:');
342
+ pushCapped(lines, delta.git.added, GIT_CAP, (l) => ` - ${l}`);
343
+ }
344
+ if (delta.git.removed.length > 0) {
345
+ lines.push('');
346
+ lines.push('๐Ÿ”ง Cleared since you started:');
347
+ pushCapped(lines, delta.git.removed, GIT_CAP, (l) => ` - ${l}`);
348
+ }
349
+
350
+ return lines.join('\n');
351
+ }
352
+
353
+ // --- Snapshot directory pruning ----------------------------------------------
354
+
355
+ // pruneSnapshots โ€” best-effort removal of session snapshots older than 7 days
356
+ // from `dir`, by file mtime. Never throws; tolerates a file vanishing mid-prune
357
+ // (a concurrent session pruning the same dir). Keeps state/session-snapshots/
358
+ // bounded without any external cleaner. Returns the count removed.
359
+ export function pruneSnapshots(dir) {
360
+ let removed = 0;
361
+ let entries;
362
+ try {
363
+ entries = readdirSync(dir, { withFileTypes: true });
364
+ } catch {
365
+ return 0;
366
+ }
367
+ const cutoff = Date.now() - PRUNE_MS;
368
+ for (const entry of entries) {
369
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
370
+ const fp = join(dir, entry.name);
371
+ try {
372
+ if (statSync(fp).mtimeMs < cutoff) {
373
+ unlinkSync(fp);
374
+ removed++;
375
+ }
376
+ } catch {
377
+ // file gone / unreadable โ€” tolerate and continue
378
+ }
379
+ }
380
+ return removed;
381
+ }
382
+
383
+ // --- CLI ----------------------------------------------------------------------
384
+
385
+ function parseArgs(argv) {
386
+ const args = argv.slice(2);
387
+ const out = { mode: null, path: null, projectPath: null, sessionId: null };
388
+ for (let i = 0; i < args.length; i++) {
389
+ const a = args[i];
390
+ if (a === '--emit-snapshot' && args[i + 1]) { out.mode = 'emit'; out.path = args[++i]; }
391
+ else if (a === '--diff' && args[i + 1]) { out.mode = 'diff'; out.path = args[++i]; }
392
+ else if (a === '--snapshot') { out.mode = 'snapshot'; }
393
+ else if (a === '--project-path' && args[i + 1]) { out.projectPath = args[++i]; }
394
+ else if (a === '--session-id' && args[i + 1]) { out.sessionId = args[++i]; }
395
+ }
396
+ return out;
397
+ }
398
+
399
+ function main() {
400
+ const { mode, path, projectPath, sessionId } = parseArgs(process.argv);
401
+
402
+ if (mode === 'emit') {
403
+ const snap = buildSnapshot({ projectPath });
404
+ if (sessionId) snap.session_id = sessionId;
405
+ atomicWrite(path, JSON.stringify(snap, null, 2) + '\n');
406
+ pruneSnapshots(dirname(path));
407
+ return;
408
+ }
409
+
410
+ if (mode === 'snapshot') {
411
+ process.stdout.write(JSON.stringify(buildSnapshot({ projectPath }), null, 2) + '\n');
412
+ return;
413
+ }
414
+
415
+ if (mode === 'diff') {
416
+ const baseline = safeReadJSON(path);
417
+ if (!baseline) {
418
+ process.stdout.write(
419
+ 'No valid session-start baseline was found, so there is nothing to compare against yet.\n'
420
+ );
421
+ return;
422
+ }
423
+ const current = buildSnapshot({ projectPath });
424
+ const delta = diffSnapshots(baseline, current);
425
+ process.stdout.write(renderDelta(delta, { projectName: current.project }) + '\n');
426
+ return;
427
+ }
428
+
429
+ // No recognized mode โ€” print usage to stderr, exit 0 (never crash a hook).
430
+ process.stderr.write(
431
+ 'usage: watchtower-snapshot.mjs (--emit-snapshot <path> | --snapshot | --diff <path>) --project-path <cwd> [--session-id <id>]\n'
432
+ );
433
+ }
434
+
435
+ // Entry guard โ€” importing this module must NOT run the CLI (mirrors the ring
436
+ // scripts so tests can import the pure functions).
437
+ const isMain = (() => {
438
+ try {
439
+ return process.argv[1]
440
+ && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
441
+ } catch {
442
+ return false;
443
+ }
444
+ })();
445
+
446
+ if (isMain) {
447
+ try {
448
+ main();
449
+ } catch {
450
+ process.exit(0);
451
+ }
452
+ }
@@ -264,7 +264,7 @@ if [ "$VERBOSE" = "--verbose" ] || [ "$VERBOSE" = "-v" ]; then
264
264
 
265
265
  echo ""
266
266
  echo " Session capture (last session):"
267
- latest_log=$(ls -t "$WATCHTOWER_DIR/logs/ring3-close-"*.log 2>/dev/null | head -1)
267
+ latest_log=$(ls -t "$WATCHTOWER_DIR/logs/ring3-close-"*.log 2>/dev/null | head -1 || true)
268
268
  if [ -n "$latest_log" ]; then
269
269
  tail -8 "$latest_log" | sed 's/^/ /'
270
270
  else