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
|
@@ -13,27 +13,44 @@
|
|
|
13
13
|
// and per-project state/projects/<slug>.md files.
|
|
14
14
|
// Writes ring1-health.json with last-run timestamp.
|
|
15
15
|
//
|
|
16
|
+
// Also delivers the global CC feedback outbox (~/.claude/
|
|
17
|
+
// cc-feedback-outbox.json) to the CC repo's feedback/ dir every tick —
|
|
18
|
+
// deterministic file IO belongs at the mechanical layer (act:6c3a4763).
|
|
19
|
+
//
|
|
16
20
|
// Consumer hooks: reads config.json hooks.ring1-post-collect, spawns
|
|
17
21
|
// each with 30s timeout, passes project state JSON on stdin.
|
|
18
22
|
|
|
19
23
|
import {
|
|
20
24
|
readFileSync, readdirSync, existsSync, statSync,
|
|
21
|
-
mkdirSync,
|
|
25
|
+
mkdirSync, realpathSync,
|
|
22
26
|
} from 'fs';
|
|
27
|
+
import { pathToFileURL } from 'url';
|
|
23
28
|
import { join, basename } from 'path';
|
|
24
29
|
import { execSync } from 'child_process';
|
|
25
30
|
import { homedir } from 'os';
|
|
26
31
|
import {
|
|
27
32
|
atomicWrite, loadConfig, slugify, log as _log, logError as _logError,
|
|
28
33
|
getWatchtowerDir, createItem, listPending, resolveItem, loadBetterSqlite3,
|
|
29
|
-
|
|
34
|
+
writeProjectStatePreservingRing3, flushFeedbackOutbox, resolveCcSourceRepo,
|
|
35
|
+
authoredClaudeDirs, claudeChurnIsDisposable,
|
|
30
36
|
} from './watchtower-lib.mjs';
|
|
37
|
+
import { runRoutinePass } from './watchtower-routines.mjs';
|
|
38
|
+
import { analyze, resolveRoots } from './watchtower-sync.mjs';
|
|
31
39
|
|
|
32
40
|
const WATCHTOWER_DIR = getWatchtowerDir();
|
|
33
41
|
|
|
34
42
|
const CLAUDE_HOME = join(homedir(), '.claude');
|
|
35
43
|
const STALE_DAYS = 14;
|
|
36
44
|
const ACTIVE_SESSION_THRESHOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
45
|
+
|
|
46
|
+
// The flagged-actions query (act:b1b21a15). Exported so the hermetic test
|
|
47
|
+
// asserts the SAME SQL collectPibState runs — one source for the filter, no
|
|
48
|
+
// divergent copy. Scope: active work only (open/in-progress/blocked),
|
|
49
|
+
// deliberately excluding flagged *deferred* actions (the documented narrowing
|
|
50
|
+
// from orient's `completed = 0`) and excluding soft-deleted rows.
|
|
51
|
+
export const FLAGGED_ACTIONS_SQL =
|
|
52
|
+
"SELECT fid, text FROM actions WHERE flagged = 1 " +
|
|
53
|
+
"AND status IN ('open', 'in-progress', 'blocked') AND deleted_at IS NULL";
|
|
37
54
|
const DEPLOY_MARKERS = ['railway.toml', 'fly.toml', 'vercel.json'];
|
|
38
55
|
const HOOK_TIMEOUT_MS = 30_000;
|
|
39
56
|
|
|
@@ -69,6 +86,109 @@ function safeExec(cmd, opts = {}) {
|
|
|
69
86
|
}
|
|
70
87
|
}
|
|
71
88
|
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Default-branch / origin comparison helpers
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
//
|
|
93
|
+
// The unmerged-branch ahead-check (act:6f36cbe2) compares against
|
|
94
|
+
// origin/<main>, NEVER the stale local <main> ref. A worktree is often cut
|
|
95
|
+
// from a non-main HEAD, and the local main ref can lag origin/main by many
|
|
96
|
+
// commits — so `git log main..<branch>` reported the branch as carrying all
|
|
97
|
+
// of main's drift as its own work (the recurring "2 commits look like 80
|
|
98
|
+
// files of baggage" trap that produced false MERGE-OR-LOSE / N-commits-ahead
|
|
99
|
+
// banners). Comparing against origin/<main> is the WHAT fix; build-context's
|
|
100
|
+
// re-verify-at-read is the WHEN fix (act:a136b362).
|
|
101
|
+
//
|
|
102
|
+
// resolveMainRef takes an injectable exec (the inline call sites pass a
|
|
103
|
+
// safeExec bound to a cwd) so the logic is hermetically testable against a
|
|
104
|
+
// temp git repo. It:
|
|
105
|
+
// - resolves the default branch's *name* from `origin/HEAD` (fall back to
|
|
106
|
+
// 'main', then 'master'),
|
|
107
|
+
// - fetches origin first so origin/<main> is current — but TOLERATES fetch
|
|
108
|
+
// failure (Ring 1's launchd cron PATH/network is constrained) and falls
|
|
109
|
+
// back to whatever origin/<main> ref already exists locally,
|
|
110
|
+
// - picks compareRef = origin/<main> when that remote-tracking ref exists,
|
|
111
|
+
// else the local <main> (a repo with no remote still works), and
|
|
112
|
+
// - flags localLagsRemote when the local <main> is a strict ancestor of
|
|
113
|
+
// origin/<main> (a stale local main is itself worth surfacing).
|
|
114
|
+
function resolveMainRef(exec, { fetch = true } = {}) {
|
|
115
|
+
if (fetch) {
|
|
116
|
+
// Best-effort refresh; never let a failed/blocked fetch error the check.
|
|
117
|
+
exec('git fetch origin --quiet');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Default branch name from origin/HEAD, e.g. "origin/main" → "main".
|
|
121
|
+
let mainName = null;
|
|
122
|
+
const headRef = exec('git rev-parse --abbrev-ref origin/HEAD');
|
|
123
|
+
if (headRef) mainName = headRef.replace(/^origin\//, '').trim() || null;
|
|
124
|
+
if (!mainName) {
|
|
125
|
+
// No origin/HEAD (no remote, or never set) — probe local refs.
|
|
126
|
+
if (exec('git rev-parse --verify --quiet main')) mainName = 'main';
|
|
127
|
+
else if (exec('git rev-parse --verify --quiet master')) mainName = 'master';
|
|
128
|
+
else mainName = 'main';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const remoteRef = `origin/${mainName}`;
|
|
132
|
+
const remoteSha = exec(`git rev-parse --verify --quiet ${remoteRef}`);
|
|
133
|
+
const localSha = exec(`git rev-parse --verify --quiet ${mainName}`);
|
|
134
|
+
|
|
135
|
+
// Prefer the remote-tracking ref; fall back to local main when there is no
|
|
136
|
+
// remote (a brand-new repo, or origin unreachable and never fetched).
|
|
137
|
+
const compareRef = remoteSha ? remoteRef : mainName;
|
|
138
|
+
|
|
139
|
+
// localLagsRemote: local main is behind its remote (strict ancestor, not
|
|
140
|
+
// equal). Only meaningful when both refs resolve and we're comparing
|
|
141
|
+
// against the remote. This is the stale-local-main signal worth a NOTE.
|
|
142
|
+
let localLagsRemote = false;
|
|
143
|
+
if (remoteSha && localSha && remoteSha !== localSha) {
|
|
144
|
+
localLagsRemote = exec(`git merge-base --is-ancestor ${mainName} ${remoteRef}`) !== null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { mainName, compareRef, localLagsRemote, hasRemote: !!remoteSha };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Commits on `branch` not yet in `compareRef` (origin/<main>). Uses
|
|
151
|
+
// `git log <branch> --not <compareRef>` — the work range that is correct for
|
|
152
|
+
// both pre- and post-merge states (see qa-handoff Step 1). Returns a count.
|
|
153
|
+
function aheadCount(exec, branch, compareRef) {
|
|
154
|
+
const out = exec(`git log --oneline ${branch} --not ${compareRef}`);
|
|
155
|
+
return out ? out.split('\n').filter(l => l.trim()).length : 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// True when `branch`'s tip is already an ancestor of `compareRef` (merged).
|
|
159
|
+
function isMergedInto(exec, branch, compareRef) {
|
|
160
|
+
return exec(`git merge-base --is-ancestor ${branch} ${compareRef}`) !== null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// True when `branch` carries content not already present in `compareRef`.
|
|
164
|
+
// SQUASH-ROBUST and direction-aware, unlike the ancestry/ahead-count gates it
|
|
165
|
+
// replaced (act:a152cf6c): a squash-merge leaves `branch` a NON-ancestor of
|
|
166
|
+
// main with different commit hashes but an IDENTICAL tree, so `isMergedInto`
|
|
167
|
+
// (ancestry) and `aheadCount` both report phantom unmerged work — permanently,
|
|
168
|
+
// since main never gains `branch` as an ancestor. ~40 of 48 maginnis worktree
|
|
169
|
+
// false positives were squash-merged branches that no ahead-check could clear.
|
|
170
|
+
//
|
|
171
|
+
// A two-dot `compareRef..branch` diff is NOT a valid content test: it is a
|
|
172
|
+
// symmetric tip-vs-tip comparison, so once `compareRef` advances past the
|
|
173
|
+
// squash point (true for every aging item) it reports compareRef's OWN newer
|
|
174
|
+
// files as differences (verified empirically). The correct test is a trial
|
|
175
|
+
// in-memory merge: if merging `branch` into `compareRef` changes nothing
|
|
176
|
+
// (result tree == compareRef's tree), `branch` contributes no unmerged
|
|
177
|
+
// content. `git merge-tree --write-tree` (git >= 2.38) touches no working tree
|
|
178
|
+
// or index. It exits non-zero on a merge conflict (→ null here) — a conflict
|
|
179
|
+
// means real divergent content, so we fail toward flagging. Likewise a missing
|
|
180
|
+
// ref, an old git without --write-tree, or any git error fails toward flagging,
|
|
181
|
+
// so real work is never silently suppressed.
|
|
182
|
+
function hasUnmergedContent(exec, branch, compareRef) {
|
|
183
|
+
// Fast path: a true ancestor is unambiguously merged (and cheaper to test).
|
|
184
|
+
if (isMergedInto(exec, branch, compareRef)) return false;
|
|
185
|
+
const baseTree = exec(`git rev-parse --verify --quiet ${compareRef}^{tree}`);
|
|
186
|
+
const mergedOut = exec(`git merge-tree --write-tree ${compareRef} ${branch}`);
|
|
187
|
+
if (baseTree === null || mergedOut === null) return true; // error/conflict → flag
|
|
188
|
+
const mergedTree = mergedOut.split('\n')[0].trim();
|
|
189
|
+
return mergedTree !== baseTree;
|
|
190
|
+
}
|
|
191
|
+
|
|
72
192
|
// ---------------------------------------------------------------------------
|
|
73
193
|
// Git state collection
|
|
74
194
|
// ---------------------------------------------------------------------------
|
|
@@ -79,6 +199,7 @@ function collectGitState(projectPath) {
|
|
|
79
199
|
}
|
|
80
200
|
|
|
81
201
|
const cwd = projectPath;
|
|
202
|
+
const exec = (cmd) => safeExec(cmd, { cwd });
|
|
82
203
|
const branch = safeExec('git rev-parse --abbrev-ref HEAD', { cwd });
|
|
83
204
|
const lastCommitRaw = safeExec(
|
|
84
205
|
'git log -1 --format="%H%x00%s%x00%aI"',
|
|
@@ -91,20 +212,25 @@ function collectGitState(projectPath) {
|
|
|
91
212
|
lastCommit = { hash, message, timestamp };
|
|
92
213
|
}
|
|
93
214
|
|
|
94
|
-
// Branches ahead of main
|
|
215
|
+
// Branches ahead of origin/<main> — compared against the remote-tracking
|
|
216
|
+
// ref, not the local <main> ref (act:6f36cbe2). A branch counts as "ahead"
|
|
217
|
+
// only when it carries CONTENT not yet in origin/<main> — squash-merged
|
|
218
|
+
// branches (non-ancestor, different hashes, identical tree) are NOT ahead
|
|
219
|
+
// (act:a152cf6c). hasUnmergedContent short-circuits on the ancestry fast
|
|
220
|
+
// path, so this is no more expensive than the old isMergedInto check for the
|
|
221
|
+
// common already-merged case.
|
|
222
|
+
const { mainName, compareRef, localLagsRemote } = resolveMainRef(exec);
|
|
95
223
|
const branchesAhead = [];
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (b && b !== mainBranch) branchesAhead.push(b);
|
|
103
|
-
}
|
|
224
|
+
const branchList = safeExec(`git for-each-ref --format='%(refname:short)' refs/heads/`, { cwd });
|
|
225
|
+
if (branchList) {
|
|
226
|
+
for (const line of branchList.split('\n')) {
|
|
227
|
+
const b = line.trim().replace(/^\* /, '');
|
|
228
|
+
if (!b || b === mainName) continue;
|
|
229
|
+
if (hasUnmergedContent(exec, b, compareRef)) branchesAhead.push(b);
|
|
104
230
|
}
|
|
105
231
|
}
|
|
106
232
|
|
|
107
|
-
return { branch, lastCommit, branchesAhead, mainBranch };
|
|
233
|
+
return { branch, lastCommit, branchesAhead, mainBranch: mainName, compareRef, localLagsRemote };
|
|
108
234
|
}
|
|
109
235
|
|
|
110
236
|
// ---------------------------------------------------------------------------
|
|
@@ -135,15 +261,21 @@ function collectPibState(projectPath) {
|
|
|
135
261
|
"SELECT COUNT(*) as count FROM actions WHERE status IN ('open', 'in-progress', 'blocked')"
|
|
136
262
|
).get();
|
|
137
263
|
|
|
138
|
-
// Flagged
|
|
139
|
-
|
|
264
|
+
// Flagged actions (user-prioritized, still-open work). The LIST — not
|
|
265
|
+
// just a count — so the per-project state file surfaces WHICH actions are
|
|
266
|
+
// flagged, at parity with orient's work-scan (act:b1b21a15). flaggedCount
|
|
267
|
+
// is derived from the list: one query, one source (mirrors overdueActions
|
|
268
|
+
// below). Scope is Ring 1's standard "active work" set
|
|
269
|
+
// (open/in-progress/blocked) — deliberately NARROWER than orient's
|
|
270
|
+
// `completed = 0`, which also surfaced flagged *deferred* actions; that
|
|
271
|
+
// narrowing is recorded in the orient→watchtower coverage ledger.
|
|
272
|
+
let flaggedActions = [];
|
|
140
273
|
try {
|
|
141
|
-
|
|
142
|
-
"SELECT COUNT(*) as count FROM actions WHERE flagged = 1 AND status IN ('open', 'in-progress', 'blocked') AND deleted_at IS NULL"
|
|
143
|
-
).get().count;
|
|
274
|
+
flaggedActions = db.prepare(FLAGGED_ACTIONS_SQL).all();
|
|
144
275
|
} catch {
|
|
145
276
|
// flagged column may not exist in older schemas
|
|
146
277
|
}
|
|
278
|
+
const flaggedCount = flaggedActions.length;
|
|
147
279
|
|
|
148
280
|
// Deferred actions waiting on triggers — standing state, not attention.
|
|
149
281
|
// Orient's deferred-check evaluates these every session; reported in the
|
|
@@ -208,12 +340,33 @@ function collectPibState(projectPath) {
|
|
|
208
340
|
// best-effort
|
|
209
341
|
}
|
|
210
342
|
|
|
343
|
+
// Overdue actions (due date is in the past). The `due` column is
|
|
344
|
+
// unconstrained free text (TEXT, no CHECK), so a malformed value like
|
|
345
|
+
// "06/18/2026" would string-compare as overdue — the GLOB guard skips
|
|
346
|
+
// anything that isn't a well-formed YYYY-MM-DD. `<= date('now')` (not
|
|
347
|
+
// `<`) matches pib-db-lib.mjs's existing overdue convention, the single
|
|
348
|
+
// source of truth; due-today counts as overdue.
|
|
349
|
+
let overdueActions = [];
|
|
350
|
+
try {
|
|
351
|
+
overdueActions = db.prepare(
|
|
352
|
+
`SELECT fid, text, due FROM actions
|
|
353
|
+
WHERE status IN ('open','in-progress','blocked')
|
|
354
|
+
AND due GLOB '????-??-??' AND due <= date('now')
|
|
355
|
+
AND deleted_at IS NULL`
|
|
356
|
+
).all();
|
|
357
|
+
} catch {
|
|
358
|
+
// due column may not exist in older schemas
|
|
359
|
+
}
|
|
360
|
+
|
|
211
361
|
return {
|
|
212
362
|
openActions: openActions.count,
|
|
213
363
|
flaggedCount,
|
|
364
|
+
flaggedActions,
|
|
214
365
|
deferredTriggerCount,
|
|
215
366
|
staleProjects,
|
|
216
367
|
completionCandidates,
|
|
368
|
+
overdueActions,
|
|
369
|
+
overdueCount: overdueActions.length,
|
|
217
370
|
projectBreakdown,
|
|
218
371
|
};
|
|
219
372
|
} finally {
|
|
@@ -289,6 +442,39 @@ function detectActiveSessions(projectPath) {
|
|
|
289
442
|
// Deployment detection (cached per config cycle)
|
|
290
443
|
// ---------------------------------------------------------------------------
|
|
291
444
|
|
|
445
|
+
// CC-repo feedback-arrival check (act:b08efbc2). Only the CC SOURCE repo
|
|
446
|
+
// (package.json name === 'create-claude-cabinet') has a feedback/ root and a
|
|
447
|
+
// proposals/ dir; everywhere else this returns null at near-zero cost.
|
|
448
|
+
// feedback/ root files are untriaged BY DEFINITION (triage-at-arrival moves
|
|
449
|
+
// them to feedback/resolved/), and proposals/ holds pending extraction
|
|
450
|
+
// proposals. Any count > 0 raises a LOUD attention line in summary.md —
|
|
451
|
+
// recomputed every tick, so it cannot expire while the files remain. The
|
|
452
|
+
// blocking triage rule itself lives in the CC repo's always-loaded
|
|
453
|
+
// instructions (CLAUDE.md), not here: Ring 1 surfaces, the session acts.
|
|
454
|
+
function checkCcFeedbackArrival(projectPath) {
|
|
455
|
+
try {
|
|
456
|
+
const pkgPath = join(projectPath, 'package.json');
|
|
457
|
+
if (!existsSync(pkgPath)) return null;
|
|
458
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
459
|
+
if (pkg.name !== 'create-claude-cabinet') return null;
|
|
460
|
+
const countMd = (dir) => {
|
|
461
|
+
try {
|
|
462
|
+
return readdirSync(join(projectPath, dir), { withFileTypes: true })
|
|
463
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('.'))
|
|
464
|
+
.length;
|
|
465
|
+
} catch {
|
|
466
|
+
return 0;
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
const feedbackCount = countMd('feedback');
|
|
470
|
+
const proposalCount = countMd('proposals');
|
|
471
|
+
if (feedbackCount === 0 && proposalCount === 0) return null;
|
|
472
|
+
return { feedbackCount, proposalCount };
|
|
473
|
+
} catch {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
292
478
|
function detectDeployment(projectPath) {
|
|
293
479
|
const found = [];
|
|
294
480
|
for (const marker of DEPLOY_MARKERS) {
|
|
@@ -360,8 +546,8 @@ function createBranchDivergedItem(projectName, projectPath, branch) {
|
|
|
360
546
|
category: 'branch-diverged',
|
|
361
547
|
urgency: 'normal',
|
|
362
548
|
title: `Branch "${branch}" diverged from main`,
|
|
363
|
-
summary: `Branch "${branch}" in ${projectName} is ahead of main with no active session on it. Consider merging or cleaning up.`,
|
|
364
|
-
context_anchor: `git log
|
|
549
|
+
summary: `Branch "${branch}" in ${projectName} is ahead of origin/main with no active session on it. Consider merging or cleaning up.`,
|
|
550
|
+
context_anchor: `git log ${branch} --not origin/main in ${projectPath}`,
|
|
365
551
|
evidence: { branch, project_path: projectPath },
|
|
366
552
|
});
|
|
367
553
|
} catch (e) {
|
|
@@ -387,9 +573,25 @@ function createBranchDivergedItem(projectName, projectPath, branch) {
|
|
|
387
573
|
function countRealUncommitted(wtPath) {
|
|
388
574
|
const uncommitted = safeExec('git status --porcelain', { cwd: wtPath });
|
|
389
575
|
if (!uncommitted) return 0;
|
|
576
|
+
// Authored .claude/ subtrees (plans, methodology, rules, …) are real work,
|
|
577
|
+
// not disposable mux infra — re-included per the canonical exclusion
|
|
578
|
+
// contract (act:e91fdfcf, claudeChurnIsDisposable in watchtower-lib).
|
|
579
|
+
const authoredDirs = authoredClaudeDirs(wtPath, safeExec);
|
|
390
580
|
return uncommitted.split('\n').filter(l => {
|
|
391
581
|
if (!l.trim()) return false;
|
|
392
|
-
if (
|
|
582
|
+
if (claudeChurnIsDisposable(l, authoredDirs)) return false;
|
|
583
|
+
// Disposable runtime state written by the watchtower/verify harnesses
|
|
584
|
+
// (act:a152cf6c). These churn in every worktree without representing work
|
|
585
|
+
// to lose. Gitignored in this repo (so invisible to porcelain here), but a
|
|
586
|
+
// consumer's .claude/ gitignore differs and DOES surface them — the
|
|
587
|
+
// flow/maginnis false positives. Scoped to UNTRACKED (`??`) lines so a
|
|
588
|
+
// deliberately-tracked file of the same name (e.g. a committed
|
|
589
|
+
// .claude/verification/*.json) still counts as authored work, never
|
|
590
|
+
// silently dropped. Runs AFTER claudeChurnIsDisposable so it overrides the
|
|
591
|
+
// dir-level authored re-inclusion (.claude/cabinet/ is authored here).
|
|
592
|
+
if (/^\?\?\s+\.claude\/verification\//.test(l)) return false;
|
|
593
|
+
if (/^\?\?\s+\.claude\/cabinet\/advisories-state\.json$/.test(l)) return false;
|
|
594
|
+
if (/^\?\?\s+e2e\/\.verify-progress\.jsonl$/.test(l)) return false;
|
|
393
595
|
if (/\s\.mcp\.json$/.test(l)) return false;
|
|
394
596
|
if (/\snode_modules$/.test(l) || /\snode_modules\//.test(l)) return false;
|
|
395
597
|
if (/(?:^|[\s/])package-lock\.json$/.test(l)) return false;
|
|
@@ -441,8 +643,8 @@ function itemBelongsToProject(item, projectPath) {
|
|
|
441
643
|
|
|
442
644
|
// Auto-resolve pending worktree-unmerged items whose branch is now clean.
|
|
443
645
|
// Mechanical truth-checking, not judgment: if the branch has 0 commits
|
|
444
|
-
// ahead of main and no real uncommitted changes, the alarm is
|
|
445
|
-
// and it will never self-heal otherwise, because dedup suppresses
|
|
646
|
+
// ahead of origin/<main> and no real uncommitted changes, the alarm is
|
|
647
|
+
// stale — and it will never self-heal otherwise, because dedup suppresses
|
|
446
648
|
// refiling but nothing retracts the original. Stale alarms train the
|
|
447
649
|
// operator to ignore real ones.
|
|
448
650
|
function autoResolveWorktreeItems(projectName, projectPath) {
|
|
@@ -453,6 +655,9 @@ function autoResolveWorktreeItems(projectName, projectPath) {
|
|
|
453
655
|
return;
|
|
454
656
|
}
|
|
455
657
|
|
|
658
|
+
const exec = (cmd) => safeExec(cmd, { cwd: projectPath });
|
|
659
|
+
const { compareRef } = resolveMainRef(exec);
|
|
660
|
+
|
|
456
661
|
for (const item of pending) {
|
|
457
662
|
if (!itemBelongsToProject(item, projectPath)) continue;
|
|
458
663
|
const ev = item.evidence || {};
|
|
@@ -466,13 +671,17 @@ function autoResolveWorktreeItems(projectName, projectPath) {
|
|
|
466
671
|
// either way there is nothing left to lose.
|
|
467
672
|
staleReason = `branch "${ev.branch}" no longer exists`;
|
|
468
673
|
} else {
|
|
469
|
-
|
|
470
|
-
|
|
674
|
+
// Content-based, squash-aware retraction: an item whose branch carries no
|
|
675
|
+
// unmerged content vs origin/<main> and no real uncommitted work is a
|
|
676
|
+
// stale alarm — this is what finally retracts the ~40 squash-merged
|
|
677
|
+
// false positives that the old ahead===0 test never cleared
|
|
678
|
+
// (act:a152cf6c).
|
|
679
|
+
const unmerged = hasUnmergedContent(exec, ev.branch, compareRef);
|
|
471
680
|
const uncommittedCount = (ev.worktree_path && existsSync(ev.worktree_path))
|
|
472
681
|
? countRealUncommitted(ev.worktree_path)
|
|
473
682
|
: 0;
|
|
474
|
-
if (
|
|
475
|
-
staleReason =
|
|
683
|
+
if (!unmerged && uncommittedCount === 0) {
|
|
684
|
+
staleReason = `no unmerged content vs ${compareRef} (squash/merge-aware), no uncommitted changes (session artifacts excluded)`;
|
|
476
685
|
}
|
|
477
686
|
}
|
|
478
687
|
|
|
@@ -520,22 +729,34 @@ function scanWorktrees(projectName, projectPath) {
|
|
|
520
729
|
const muxDir = join(homedir(), '.mux', 'worktrees');
|
|
521
730
|
const orphaned = [];
|
|
522
731
|
|
|
732
|
+
// Resolve the comparison ref once for this project — origin/<main>, never
|
|
733
|
+
// the stale local main (act:6f36cbe2).
|
|
734
|
+
const exec = (cmd) => safeExec(cmd, { cwd: projectPath });
|
|
735
|
+
const { compareRef, hasRemote } = resolveMainRef(exec);
|
|
736
|
+
if (!hasRemote && !safeExec(`git rev-parse --verify --quiet ${compareRef}`, { cwd: projectPath })) {
|
|
737
|
+
return []; // No comparison ref at all — nothing to measure against.
|
|
738
|
+
}
|
|
739
|
+
|
|
523
740
|
for (const wt of worktrees) {
|
|
524
741
|
if (wt.bare || !wt.path.startsWith(muxDir)) continue;
|
|
525
742
|
if (wt.path === projectPath) continue;
|
|
526
|
-
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
if (!
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
743
|
+
// Detached / branch-less worktree — no branch to content-compare. Skip
|
|
744
|
+
// rather than let the merge-tree call fail toward flagging an
|
|
745
|
+
// "undefined"-branch worktree (act:a152cf6c).
|
|
746
|
+
if (!wt.branch) continue;
|
|
747
|
+
|
|
748
|
+
// Flag on CONTENT, not ahead-count: a squash-merged branch is a
|
|
749
|
+
// non-ancestor with phantom "ahead" commits but no real unmerged content
|
|
750
|
+
// (act:a152cf6c). aheadCount is retained only for the human-readable
|
|
751
|
+
// "N unmerged commit(s)" detail line, which is shown only when we flag.
|
|
752
|
+
const unmerged = hasUnmergedContent(exec, wt.branch, compareRef);
|
|
753
|
+
const ahead = aheadCount(exec, wt.branch, compareRef);
|
|
533
754
|
|
|
534
755
|
// Also check for uncommitted changes in the worktree
|
|
535
756
|
// (artifact exclusions live in countRealUncommitted)
|
|
536
757
|
const uncommittedCount = countRealUncommitted(wt.path);
|
|
537
758
|
|
|
538
|
-
if (
|
|
759
|
+
if (!unmerged && uncommittedCount === 0) continue;
|
|
539
760
|
|
|
540
761
|
// Check if there's an active tmux window for this worktree
|
|
541
762
|
const windowName = basename(wt.path).replace(/^[^-]+-/, '');
|
|
@@ -547,7 +768,7 @@ function scanWorktrees(projectName, projectPath) {
|
|
|
547
768
|
orphaned.push({
|
|
548
769
|
path: wt.path,
|
|
549
770
|
branch: wt.branch,
|
|
550
|
-
ahead
|
|
771
|
+
ahead,
|
|
551
772
|
uncommitted: uncommittedCount,
|
|
552
773
|
});
|
|
553
774
|
}
|
|
@@ -574,7 +795,7 @@ function scanWorktrees(projectName, projectPath) {
|
|
|
574
795
|
urgency: 'urgent',
|
|
575
796
|
title: `Orphaned worktree "${wt.branch}" has unmerged work`,
|
|
576
797
|
summary: `Worktree at ${wt.path} has ${detail.join(' and ')} with no active tmux window. Merge to main or the work may be lost.`,
|
|
577
|
-
context_anchor: `git log
|
|
798
|
+
context_anchor: `git log ${wt.branch} --not origin/main in ${wt.path}`,
|
|
578
799
|
evidence: { branch: wt.branch, worktree_path: wt.path, ahead: wt.ahead, uncommitted: wt.uncommitted },
|
|
579
800
|
options: [
|
|
580
801
|
{ key: 'merge', label: 'Merge to main now' },
|
|
@@ -594,7 +815,94 @@ function scanWorktrees(projectName, projectPath) {
|
|
|
594
815
|
// Summary assembly (30-line hard cap)
|
|
595
816
|
// ---------------------------------------------------------------------------
|
|
596
817
|
|
|
597
|
-
|
|
818
|
+
// Structured, re-verifiable form of the git-derived attention facts (the
|
|
819
|
+
// MERGE-OR-LOSE worktree lines and the diverged-branch lines). The build
|
|
820
|
+
// context re-checks each entry against live git before relaying the matching
|
|
821
|
+
// summary line, so a stale cached banner is dropped/stamped rather than
|
|
822
|
+
// asserted as a live fact (act:a136b362). Each fact carries everything needed
|
|
823
|
+
// to re-run the same origin/<main> ahead-check at read time.
|
|
824
|
+
function buildGitAttentionSidecar(projectStates) {
|
|
825
|
+
const generatedAt = new Date().toISOString();
|
|
826
|
+
const facts = [];
|
|
827
|
+
for (const ps of projectStates) {
|
|
828
|
+
const compareRef = ps.git?.compareRef || 'origin/main';
|
|
829
|
+
if (ps.orphanedWorktrees) {
|
|
830
|
+
for (const wt of ps.orphanedWorktrees) {
|
|
831
|
+
facts.push({
|
|
832
|
+
kind: 'worktree-unmerged',
|
|
833
|
+
project: ps.name,
|
|
834
|
+
project_path: ps.path,
|
|
835
|
+
branch: wt.branch,
|
|
836
|
+
worktree_path: wt.path,
|
|
837
|
+
compare_ref: compareRef,
|
|
838
|
+
// The cached banner — what build-context relays only if re-verified.
|
|
839
|
+
line: `⚠ ${ps.name}: worktree "${wt.branch}" has unmerged work — MERGE OR LOSE`,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
if (ps.divergedBranches) {
|
|
844
|
+
for (const b of ps.divergedBranches) {
|
|
845
|
+
facts.push({
|
|
846
|
+
kind: 'diverged-branch',
|
|
847
|
+
project: ps.name,
|
|
848
|
+
project_path: ps.path,
|
|
849
|
+
branch: b,
|
|
850
|
+
compare_ref: compareRef,
|
|
851
|
+
line: `${ps.name}: branch "${b}" diverged from main`,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return { generated_at: generatedAt, facts };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// Runtime-script drift check (act:e81fe82f). The watchtower runtime that
|
|
860
|
+
// launchd/cron executes lives at ~/.claude-cabinet/watchtower/; a one-file
|
|
861
|
+
// fix is durable only when that runtime matches the CC source templates.
|
|
862
|
+
// Surfaces a stale runtime as an ambient attention line so the operator
|
|
863
|
+
// doesn't have to run `/watchtower status` to notice it. READ-ONLY from a
|
|
864
|
+
// cron context — never auto-heals. Stays SILENT (skipped) whenever there is
|
|
865
|
+
// no authoritative source to compare against (no CC repo in cc-registry, no
|
|
866
|
+
// templates/tracked tier) — a false "drift" nag would be the worse failure,
|
|
867
|
+
// exactly the cry-wolf decay act:a136b362 just removed.
|
|
868
|
+
function checkRuntimeScriptDrift(opts = {}) {
|
|
869
|
+
const registryPath = opts.registryPath
|
|
870
|
+
|| join(homedir(), '.claude', 'cc-registry.json');
|
|
871
|
+
const runtimeDir = opts.runtimeDir || WATCHTOWER_DIR;
|
|
872
|
+
try {
|
|
873
|
+
const ccRepo = resolveCcSourceRepo(registryPath);
|
|
874
|
+
if (!ccRepo) return { skipped: true, reason: 'no-cc-source' };
|
|
875
|
+
const roots = resolveRoots({ cwd: ccRepo, runtime: runtimeDir });
|
|
876
|
+
// No authoritative tier (no templates/ and no tracked scripts/) or no
|
|
877
|
+
// runtime on disk → nothing meaningful to diff. Stay silent.
|
|
878
|
+
if (!roots.template && !roots.tracked) return { skipped: true, reason: 'no-source-tier' };
|
|
879
|
+
if (!roots.runtime) return { skipped: true, reason: 'no-runtime' };
|
|
880
|
+
const analysis = analyze(roots);
|
|
881
|
+
// Count only files whose RUNTIME copy needs a heal (drift/missing) plus
|
|
882
|
+
// runtime-resident orphans — the precise "live runtime is stale" signal.
|
|
883
|
+
let driftCount = 0;
|
|
884
|
+
let orphanCount = 0;
|
|
885
|
+
for (const f of analysis.files) {
|
|
886
|
+
if (f.healTargets?.some((h) => h.tier === 'runtime')) driftCount++;
|
|
887
|
+
else if (f.status === 'orphan' && f.tiers?.runtime?.present) orphanCount++;
|
|
888
|
+
}
|
|
889
|
+
return { skipped: false, driftCount, orphanCount, total: analysis.summary.total };
|
|
890
|
+
} catch (e) {
|
|
891
|
+
return { skipped: true, reason: `error: ${e.message}` };
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Build the global runtime-drift attention line, or null when in sync/skipped.
|
|
896
|
+
function runtimeDriftAttentionLine(drift) {
|
|
897
|
+
if (!drift || drift.skipped) return null;
|
|
898
|
+
if (!drift.driftCount && !drift.orphanCount) return null;
|
|
899
|
+
const parts = [];
|
|
900
|
+
if (drift.driftCount) parts.push(`${drift.driftCount} script(s) differ from the CC source templates`);
|
|
901
|
+
if (drift.orphanCount) parts.push(`${drift.orphanCount} runtime orphan(s)`);
|
|
902
|
+
return `⚠ watchtower runtime drift: ${parts.join(' + ')} — run \`/watchtower sync\``;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function assembleSummary(projectStates, config, extraAttention = []) {
|
|
598
906
|
const now = new Date().toISOString();
|
|
599
907
|
const lines = [];
|
|
600
908
|
|
|
@@ -634,11 +942,14 @@ function assembleSummary(projectStates, config) {
|
|
|
634
942
|
|
|
635
943
|
// --- What Needs Attention ---
|
|
636
944
|
lines.push('## What Needs Attention');
|
|
637
|
-
const attention = [];
|
|
945
|
+
const attention = [...extraAttention];
|
|
638
946
|
for (const ps of projectStates) {
|
|
639
947
|
if (ps.pib && ps.pib.flaggedCount > 0) {
|
|
640
948
|
attention.push(`${ps.name}: ${ps.pib.flaggedCount} flagged action(s)`);
|
|
641
949
|
}
|
|
950
|
+
if (ps.pib && ps.pib.overdueCount > 0) {
|
|
951
|
+
attention.push(`${ps.name}: ${ps.pib.overdueCount} overdue action(s)`);
|
|
952
|
+
}
|
|
642
953
|
if (ps.pib && ps.pib.staleProjects && ps.pib.staleProjects.length > 0) {
|
|
643
954
|
for (const sp of ps.pib.staleProjects) {
|
|
644
955
|
attention.push(`${ps.name}/${sp.name}: stale (no activity in ${STALE_DAYS}d)`);
|
|
@@ -649,6 +960,9 @@ function assembleSummary(projectStates, config) {
|
|
|
649
960
|
attention.push(`${ps.name}: branch "${b}" diverged from main`);
|
|
650
961
|
}
|
|
651
962
|
}
|
|
963
|
+
if (ps.git && ps.git.localLagsRemote) {
|
|
964
|
+
attention.push(`${ps.name}: local ${ps.git.mainBranch} lags origin/${ps.git.mainBranch} — run \`git fetch && git merge\` (comparisons use origin/${ps.git.mainBranch})`);
|
|
965
|
+
}
|
|
652
966
|
if (ps.pib && ps.pib.completionCandidates && ps.pib.completionCandidates.length > 0) {
|
|
653
967
|
for (const c of ps.pib.completionCandidates) {
|
|
654
968
|
attention.push(`${ps.name}/${c.name}: all actions done — close?`);
|
|
@@ -659,6 +973,13 @@ function assembleSummary(projectStates, config) {
|
|
|
659
973
|
attention.unshift(`⚠ ${ps.name}: worktree "${wt.branch}" has unmerged work — MERGE OR LOSE`);
|
|
660
974
|
}
|
|
661
975
|
}
|
|
976
|
+
if (ps.ccFeedbackArrival) {
|
|
977
|
+
const { feedbackCount, proposalCount } = ps.ccFeedbackArrival;
|
|
978
|
+
const parts = [];
|
|
979
|
+
if (feedbackCount > 0) parts.push(`${feedbackCount} untriaged feedback file(s) in feedback/ root`);
|
|
980
|
+
if (proposalCount > 0) parts.push(`${proposalCount} pending extraction proposal(s) in proposals/`);
|
|
981
|
+
attention.unshift(`⚠ ${ps.name}: ${parts.join(' + ')} — TRIAGE AT ARRIVAL (file to pib-db or decline, then stamp + move to feedback/resolved/)`);
|
|
982
|
+
}
|
|
662
983
|
if (ps.memoryIntegrity) {
|
|
663
984
|
const mi = ps.memoryIntegrity;
|
|
664
985
|
if (mi.orphans.length > 0) {
|
|
@@ -775,6 +1096,46 @@ function assembleSummary(projectStates, config) {
|
|
|
775
1096
|
// Per-project state file
|
|
776
1097
|
// ---------------------------------------------------------------------------
|
|
777
1098
|
|
|
1099
|
+
// Cap on how many flagged actions are listed by fid in Standing Issues. Every
|
|
1100
|
+
// other Standing Issues entry is count-only; an uncapped list would dump every
|
|
1101
|
+
// flagged row into the file on each 5-min tick. The full count is always shown
|
|
1102
|
+
// in the header line, so nothing is hidden — only the per-action detail is
|
|
1103
|
+
// bounded (the same discipline as the inline-capped missed-routine section).
|
|
1104
|
+
export const FLAGGED_RENDER_CAP = 5;
|
|
1105
|
+
|
|
1106
|
+
// Render the flagged-action Standing Issues entry — the count header plus a
|
|
1107
|
+
// capped sub-list of WHICH actions are flagged (fid + one-line text), at
|
|
1108
|
+
// parity with orient's work-scan (act:b1b21a15). Pass the FULL flagged list;
|
|
1109
|
+
// the count is derived from it. Returns null when nothing is flagged so the
|
|
1110
|
+
// caller can fall back / skip. Pure (no I/O) for hermetic testing.
|
|
1111
|
+
export function renderFlaggedEntry(flaggedActions, cap = FLAGGED_RENDER_CAP) {
|
|
1112
|
+
const list = Array.isArray(flaggedActions) ? flaggedActions : [];
|
|
1113
|
+
const count = list.length;
|
|
1114
|
+
if (count === 0) return null;
|
|
1115
|
+
const out = [`${count} flagged action(s)`];
|
|
1116
|
+
for (const a of list.slice(0, cap)) {
|
|
1117
|
+
const text = String(a && a.text != null ? a.text : '').replace(/\s+/g, ' ').trim();
|
|
1118
|
+
const shown = text.length > 80 ? text.slice(0, 79) + '…' : text;
|
|
1119
|
+
const fid = a && a.fid ? a.fid : '(no fid)';
|
|
1120
|
+
out.push(` - ${fid}${shown ? `: ${shown}` : ''}`);
|
|
1121
|
+
}
|
|
1122
|
+
if (count > cap) out.push(` - …and ${count - cap} more`);
|
|
1123
|
+
return out.join('\n');
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// readRecallCanary — load the Ring 2 slow over-suppression canary sidecar
|
|
1127
|
+
// (M5, act:6354a9db). Returns the per-project map (or {} if absent/corrupt).
|
|
1128
|
+
// Ring 1 renders an alerting project's entry into its Standing Issues so the
|
|
1129
|
+
// signal reaches /briefing — the canary is Ring 2's, the render is Ring 1's
|
|
1130
|
+
// (Ring 1 owns the per-project state file).
|
|
1131
|
+
function readRecallCanary() {
|
|
1132
|
+
const path = join(WATCHTOWER_DIR, 'state', 'recall-canary.json');
|
|
1133
|
+
if (!existsSync(path)) return {};
|
|
1134
|
+
try {
|
|
1135
|
+
return JSON.parse(readFileSync(path, 'utf8')).projects || {};
|
|
1136
|
+
} catch { return {}; }
|
|
1137
|
+
}
|
|
1138
|
+
|
|
778
1139
|
function assembleProjectState(ps) {
|
|
779
1140
|
const now = new Date().toISOString();
|
|
780
1141
|
const lines = [];
|
|
@@ -813,14 +1174,46 @@ function assembleProjectState(ps) {
|
|
|
813
1174
|
lines.push('## Standing Issues');
|
|
814
1175
|
const issues = [];
|
|
815
1176
|
if (ps.pib && ps.pib.flaggedCount > 0) {
|
|
816
|
-
|
|
1177
|
+
// Surface WHICH actions are flagged (capped), not just the count — orient
|
|
1178
|
+
// parity (act:b1b21a15). Falls back to the count line if the list is
|
|
1179
|
+
// somehow absent (older state shape).
|
|
1180
|
+
issues.push(
|
|
1181
|
+
renderFlaggedEntry(ps.pib.flaggedActions) ||
|
|
1182
|
+
`${ps.pib.flaggedCount} flagged action(s)`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
if (ps.pib && ps.pib.overdueCount > 0) {
|
|
1186
|
+
issues.push(`${ps.pib.overdueCount} overdue action(s)`);
|
|
817
1187
|
}
|
|
818
1188
|
if (ps.pib && ps.pib.deferredTriggerCount > 0) {
|
|
819
1189
|
issues.push(`${ps.pib.deferredTriggerCount} deferred action(s) waiting on triggers`);
|
|
820
1190
|
}
|
|
1191
|
+
// Stale + completion-candidate counts are the data feed for /briefing's
|
|
1192
|
+
// backlog-hygiene nudge (act:5e8a9e89) — surfaced here in the per-project
|
|
1193
|
+
// deep file so the nudge reads one source instead of re-querying pib.
|
|
1194
|
+
if (ps.pib && ps.pib.staleProjects && ps.pib.staleProjects.length > 0) {
|
|
1195
|
+
issues.push(`${ps.pib.staleProjects.length} stale project(s)`);
|
|
1196
|
+
}
|
|
1197
|
+
if (ps.pib && ps.pib.completionCandidates && ps.pib.completionCandidates.length > 0) {
|
|
1198
|
+
issues.push(`${ps.pib.completionCandidates.length} completion candidate(s)`);
|
|
1199
|
+
}
|
|
1200
|
+
// Recall over-suppression canary (M5, act:6354a9db) — render only on alert
|
|
1201
|
+
// (the data feed for /briefing's State-file-flags reader). Over-suppression
|
|
1202
|
+
// only; the operator eyeballs the sample in recall-canary.json.
|
|
1203
|
+
if (ps.recall && ps.recall.alert) {
|
|
1204
|
+
const pct = (n) => `${Math.round((n || 0) * 100)}%`;
|
|
1205
|
+
issues.push(
|
|
1206
|
+
`recall canary: ${ps.recall.suppressed} dedup suppression(s), rate ` +
|
|
1207
|
+
`${pct(ps.recall.rate)} vs baseline ${pct(ps.recall.baseline)} — review the ` +
|
|
1208
|
+
`sample (over-suppression only; recall-canary.json / see /briefing)`
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
821
1211
|
if (ps.divergedBranches && ps.divergedBranches.length > 0) {
|
|
822
1212
|
issues.push(`Diverged branches: ${ps.divergedBranches.join(', ')}`);
|
|
823
1213
|
}
|
|
1214
|
+
if (ps.git && ps.git.localLagsRemote) {
|
|
1215
|
+
issues.push(`Local ${ps.git.mainBranch} lags origin/${ps.git.mainBranch} (comparisons use origin/${ps.git.mainBranch})`);
|
|
1216
|
+
}
|
|
824
1217
|
if (issues.length === 0) {
|
|
825
1218
|
lines.push('None.');
|
|
826
1219
|
} else {
|
|
@@ -865,7 +1258,44 @@ function main() {
|
|
|
865
1258
|
status = 'no-projects';
|
|
866
1259
|
}
|
|
867
1260
|
|
|
1261
|
+
// Feedback outbox delivery — global duty, not per-project. Failure
|
|
1262
|
+
// must not kill the state-collection pass.
|
|
1263
|
+
try {
|
|
1264
|
+
const flush = flushFeedbackOutbox();
|
|
1265
|
+
if (flush.delivered || flush.skipped) {
|
|
1266
|
+
log(`feedback outbox: ${flush.delivered} delivered, ${flush.skipped} already-present → ${flush.destination}`);
|
|
1267
|
+
}
|
|
1268
|
+
if (flush.status === 'no-destination') {
|
|
1269
|
+
logError(`feedback outbox has ${flush.kept} item(s) but the CC repo could not be resolved from cc-registry — items kept`);
|
|
1270
|
+
} else if (flush.status === 'malformed-reset') {
|
|
1271
|
+
logError('feedback outbox was malformed JSON — reset to []');
|
|
1272
|
+
} else if (flush.status === 'partial') {
|
|
1273
|
+
logError(`feedback outbox: ${flush.kept} item(s) failed delivery and were kept`);
|
|
1274
|
+
}
|
|
1275
|
+
} catch (e) {
|
|
1276
|
+
logError(`feedback outbox flush failed: ${e.message}`);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Routine tick — evaluate declared interactive routines' mechanical
|
|
1280
|
+
// triggers (time-of-day / interval / path-nonempty) and dispatch any
|
|
1281
|
+
// that fire to their desk's main session (act:c2a55c08). The engine
|
|
1282
|
+
// never throws, but belt-and-suspenders: a routine failure must not
|
|
1283
|
+
// kill the state-collection pass.
|
|
1284
|
+
if (config.defaults?.routine_dispatch !== false) {
|
|
1285
|
+
try {
|
|
1286
|
+
const pass = runRoutinePass({ config, event: { type: 'tick' }, filedBy: 'ring1' });
|
|
1287
|
+
if (pass.fired.length > 0) {
|
|
1288
|
+
log(`routines: fired ${pass.fired.map((f) => `${f.key} (${f.status})`).join(', ')}`);
|
|
1289
|
+
}
|
|
1290
|
+
} catch (e) {
|
|
1291
|
+
logError(`routine tick failed: ${e.message}`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
868
1295
|
const projectStates = [];
|
|
1296
|
+
// Ring 2 slow writes the recall-canary sidecar; Ring 1 renders an alerting
|
|
1297
|
+
// project's entry into its Standing Issues (the canary's named reader).
|
|
1298
|
+
const recallCanaryProjects = readRecallCanary();
|
|
869
1299
|
|
|
870
1300
|
for (const name of projectNames) {
|
|
871
1301
|
const projectPath = projects[name].path || projects[name];
|
|
@@ -881,7 +1311,9 @@ function main() {
|
|
|
881
1311
|
pib: collectPibState(projectPath),
|
|
882
1312
|
activeSessions: detectActiveSessions(projectPath),
|
|
883
1313
|
deployment: detectDeployment(projectPath),
|
|
1314
|
+
ccFeedbackArrival: checkCcFeedbackArrival(projectPath),
|
|
884
1315
|
memoryIntegrity: checkMemoryIntegrity(projectPath),
|
|
1316
|
+
recall: recallCanaryProjects[name] || null,
|
|
885
1317
|
divergedBranches: [],
|
|
886
1318
|
hookResults: [],
|
|
887
1319
|
};
|
|
@@ -914,10 +1346,36 @@ function main() {
|
|
|
914
1346
|
const projectsDir = join(stateDir, 'projects');
|
|
915
1347
|
mkdirSync(projectsDir, { recursive: true });
|
|
916
1348
|
|
|
1349
|
+
// Runtime-script drift — a global (portfolio-level) check that the live
|
|
1350
|
+
// runtime matches the CC source templates (act:e81fe82f). Read-only;
|
|
1351
|
+
// surfaced as one ambient attention line. Failure must not kill the pass.
|
|
1352
|
+
const extraAttention = [];
|
|
1353
|
+
if (config.defaults?.script_sync_check !== false) {
|
|
1354
|
+
try {
|
|
1355
|
+
const drift = checkRuntimeScriptDrift();
|
|
1356
|
+
const line = runtimeDriftAttentionLine(drift);
|
|
1357
|
+
if (line) extraAttention.push(line);
|
|
1358
|
+
} catch (e) {
|
|
1359
|
+
logError(`runtime script-drift check failed: ${e.message}`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
917
1363
|
// Write summary.md
|
|
918
|
-
const summary = assembleSummary(projectStates, config);
|
|
1364
|
+
const summary = assembleSummary(projectStates, config, extraAttention);
|
|
919
1365
|
atomicWrite(join(stateDir, 'summary.md'), summary);
|
|
920
1366
|
|
|
1367
|
+
// Write the git-attention sidecar — the structured, re-verifiable form of
|
|
1368
|
+
// the git-derived attention lines (worktree-unmerged + diverged-branch).
|
|
1369
|
+
// Ring 1's summary is a cached snapshot; the SessionStart context builder
|
|
1370
|
+
// re-verifies each fact against current git reality before relaying it,
|
|
1371
|
+
// so a banner can't assert "MERGE OR LOSE" about a branch that has since
|
|
1372
|
+
// merged (act:a136b362). Prose lines are the human surface; this sidecar
|
|
1373
|
+
// is the machine-verifiable join key.
|
|
1374
|
+
atomicWrite(
|
|
1375
|
+
join(stateDir, 'git-attention.json'),
|
|
1376
|
+
JSON.stringify(buildGitAttentionSidecar(projectStates), null, 2)
|
|
1377
|
+
);
|
|
1378
|
+
|
|
921
1379
|
// Write per-project state files.
|
|
922
1380
|
// Section ownership (watchtower-contracts.md §Project State Section
|
|
923
1381
|
// Ownership): Ring 3 owns "## Last Session" once it has authored a rich
|
|
@@ -925,19 +1383,24 @@ function main() {
|
|
|
925
1383
|
// attribution line). Ring 1 rebuilds every OTHER section from scratch,
|
|
926
1384
|
// but must carry a Ring 3-authored Last Session forward verbatim —
|
|
927
1385
|
// otherwise this rebuild deterministically clobbers Ring 3's summary
|
|
928
|
-
// within one cron tick.
|
|
1386
|
+
// within one cron tick. The rebuild goes through the lib's re-read
|
|
1387
|
+
// check-and-retry helper so a Ring 3 write landing mid-merge is
|
|
1388
|
+
// re-merged instead of silently dropped.
|
|
929
1389
|
for (const ps of projectStates) {
|
|
930
1390
|
const slug = slugify(ps.name);
|
|
931
1391
|
const statePath = join(projectsDir, `${slug}.md`);
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
logError(`could not merge existing state for ${slug}: ${e.message} — writing fresh`);
|
|
1392
|
+
const projectMd = assembleProjectState(ps);
|
|
1393
|
+
try {
|
|
1394
|
+
const res = writeProjectStatePreservingRing3(statePath, projectMd);
|
|
1395
|
+
if (res.exhausted) {
|
|
1396
|
+
log(`state merge for ${slug} exhausted retries — merged against freshest snapshot`);
|
|
938
1397
|
}
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
// Best-effort: a failed merge/write for one project must not kill
|
|
1400
|
+
// the whole Ring 1 pass.
|
|
1401
|
+
logError(`could not write state for ${slug}: ${e.message} — writing fresh`);
|
|
1402
|
+
atomicWrite(statePath, projectMd);
|
|
939
1403
|
}
|
|
940
|
-
atomicWrite(statePath, projectMd);
|
|
941
1404
|
}
|
|
942
1405
|
|
|
943
1406
|
log(`collected state for ${projectStates.length} project(s)`);
|
|
@@ -960,9 +1423,26 @@ function main() {
|
|
|
960
1423
|
atomicWrite(healthPath, JSON.stringify(health, null, 2));
|
|
961
1424
|
}
|
|
962
1425
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1426
|
+
// Exports for hermetic tests (and re-use by the context builder). These are
|
|
1427
|
+
// pure given an injectable `exec` — the inline call sites bind safeExec to a
|
|
1428
|
+
// cwd; the tests bind a runner against a temp git repo (act:6f36cbe2,
|
|
1429
|
+
// act:a136b362).
|
|
1430
|
+
export { resolveMainRef, aheadCount, isMergedInto, hasUnmergedContent, countRealUncommitted, buildGitAttentionSidecar, checkRuntimeScriptDrift, runtimeDriftAttentionLine };
|
|
1431
|
+
|
|
1432
|
+
// Entry guard so tests (and other modules) can import this file's pure
|
|
1433
|
+
// helpers without executing main(). realpathSync matters: node
|
|
1434
|
+
// realpath-resolves the main module for import.meta.url while argv[1]
|
|
1435
|
+
// keeps the given path — a symlinked cron invocation would otherwise
|
|
1436
|
+
// make main() silently never run. Matches watchtower-ring2.mjs. (act:141a1c2b)
|
|
1437
|
+
const isMain = (() => {
|
|
1438
|
+
try { return process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; }
|
|
1439
|
+
catch { return false; }
|
|
1440
|
+
})();
|
|
1441
|
+
if (isMain) {
|
|
1442
|
+
try {
|
|
1443
|
+
main();
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
logError(`fatal: ${e.message}`);
|
|
1446
|
+
process.exit(1);
|
|
1447
|
+
}
|
|
968
1448
|
}
|