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.
- package/README.md +6 -10
- package/lib/cli.js +374 -91
- package/lib/copy.js +108 -6
- package/lib/db-setup.js +122 -17
- package/lib/engagement-server-setup.js +34 -9
- package/lib/metadata.js +51 -2
- package/lib/migrate-from-omega.js +13 -1
- package/lib/mux-setup.js +33 -9
- package/lib/settings-merge.js +52 -10
- package/lib/watchtower-setup.js +230 -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/checklist-stats-schema.md +15 -1
- package/templates/cabinet/composition-patterns.md +4 -3
- package/templates/cabinet/memory-lifecycle-contract.md +135 -0
- package/templates/cabinet/pib-db-access.md +13 -0
- package/templates/cabinet/skill-output-conventions.md +35 -1
- package/templates/cabinet/watchtower-contracts.md +503 -1
- package/templates/cabinet/worktree-invocation-contract.md +87 -0
- package/templates/engagement/OVERVIEW.md +12 -0
- package/templates/engagement/__tests__/engagement.test.mjs +177 -2
- package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
- package/templates/engagement/engagement-preview.mjs +100 -0
- package/templates/engagement/engagement-schema.md +240 -0
- package/templates/engagement/engagement.mjs +391 -11
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
- package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
- package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
- package/templates/hooks/action-completion-gate.sh +9 -3
- package/templates/hooks/memory-index-guard.sh +17 -11
- package/templates/hooks/watchtower-session-start.sh +24 -2
- package/templates/mcp/pib-db.json +1 -4
- 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 +289 -56
- package/templates/mux/config/help.txt +1 -0
- package/templates/rules/acknowledge-when-corrected.md +33 -0
- package/templates/rules/maintainability.md +11 -0
- package/templates/rules/memory-capture.md +26 -4
- package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
- package/templates/rules/verify-before-asserting.md +79 -0
- package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
- package/templates/scripts/__tests__/advisories.test.mjs +262 -0
- package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +235 -0
- package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
- package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
- package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
- package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
- package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
- package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
- package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
- package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -3
- package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
- package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
- package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
- package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
- package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
- package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
- package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
- package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
- package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
- package/templates/scripts/__tests__/ring3-dedup.test.mjs +467 -0
- package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
- package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
- package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
- package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
- package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
- package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
- package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
- package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
- package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
- package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
- package/templates/scripts/load-triage-history.js +5 -2
- package/templates/scripts/pib-db-mcp-server.mjs +5 -44
- package/templates/scripts/pib-db-path.mjs +61 -0
- package/templates/scripts/pib-db.mjs +5 -2
- package/templates/scripts/validate-memory.mjs +214 -16
- package/templates/scripts/watchtower-advisories.mjs +309 -0
- package/templates/scripts/watchtower-build-context.mjs +301 -18
- package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
- package/templates/scripts/watchtower-hook-runner.mjs +422 -0
- package/templates/scripts/watchtower-lib.mjs +447 -2
- package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
- package/templates/scripts/watchtower-phase-shim.mjs +171 -0
- package/templates/scripts/watchtower-queue.mjs +484 -1
- package/templates/scripts/watchtower-ring1.mjs +533 -53
- package/templates/scripts/watchtower-ring2.mjs +787 -48
- package/templates/scripts/watchtower-ring3-close.mjs +1357 -99
- package/templates/scripts/watchtower-ring4-runner.sh +85 -0
- package/templates/scripts/watchtower-ring4.mjs +753 -0
- package/templates/scripts/watchtower-routines.mjs +358 -0
- package/templates/scripts/watchtower-snapshot.mjs +452 -0
- package/templates/scripts/watchtower-status.sh +1 -1
- package/templates/scripts/watchtower-sync.mjs +393 -0
- package/templates/skills/audit/SKILL.md +5 -1
- package/templates/skills/briefing/SKILL.md +584 -236
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
- package/templates/skills/cabinet-historian/SKILL.md +14 -11
- package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
- package/templates/skills/cabinet-security/SKILL.md +11 -0
- package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
- package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
- package/templates/skills/catch-up/SKILL.md +113 -0
- package/templates/skills/cc-publish/SKILL.md +148 -25
- package/templates/skills/cc-remember/SKILL.md +45 -0
- package/templates/skills/close/SKILL.md +107 -0
- package/templates/skills/collab-client/SKILL.md +22 -5
- package/templates/skills/collab-consultant/SKILL.md +110 -2
- package/templates/skills/debrief/SKILL.md +148 -16
- package/templates/skills/debrief-classic/SKILL.md +696 -0
- package/templates/skills/debrief-classic/calibration.md +44 -0
- package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
- package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
- package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
- package/templates/skills/debrief-classic/phases/close-work.md +163 -0
- package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
- package/templates/skills/debrief-classic/phases/inventory.md +40 -0
- package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
- package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
- package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
- package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
- package/templates/skills/debrief-classic/phases/report.md +59 -0
- package/templates/skills/debrief-classic/phases/update-state.md +48 -0
- package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
- package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
- package/templates/skills/execute/SKILL.md +77 -6
- package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
- package/templates/skills/execute-group/SKILL.md +23 -26
- package/templates/skills/generate-plan-groups/SKILL.md +20 -0
- package/templates/skills/inbox/SKILL.md +124 -7
- package/templates/skills/memory/SKILL.md +22 -6
- package/templates/skills/orient/SKILL.md +140 -52
- package/templates/skills/orient-classic/SKILL.md +770 -0
- package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
- package/templates/skills/orient-classic/phases/briefing.md +53 -0
- package/templates/skills/orient-classic/phases/cabinet.md +46 -0
- package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
- package/templates/skills/orient-classic/phases/context.md +88 -0
- package/templates/skills/orient-classic/phases/data-sync.md +35 -0
- package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
- package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
- package/templates/skills/orient-classic/phases/health-checks.md +50 -0
- package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
- package/templates/skills/orient-classic/phases/work-scan.md +69 -0
- package/templates/skills/plan/SKILL.md +8 -0
- package/templates/skills/qa-drain/SKILL.md +157 -0
- package/templates/skills/qa-handoff/SKILL.md +287 -46
- package/templates/skills/session-handoff/SKILL.md +175 -6
- package/templates/skills/setup-accounts/SKILL.md +38 -16
- package/templates/skills/threads/SKILL.md +4 -0
- package/templates/skills/triage-audit/SKILL.md +6 -0
- package/templates/skills/validate/phases/validators.md +41 -0
- package/templates/skills/watchtower/SKILL.md +199 -2
- package/templates/verify-runtime/CONVENTIONS.md +9 -0
- package/templates/verify-runtime/README.md +37 -0
- package/templates/watchtower/config.json.template +10 -2
- package/templates/watchtower/queue/items/item.json.schema +1 -1
- 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
|