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
@@ -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
- preserveRing3LastSession,
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 mainBranch = safeExec('git rev-parse --verify main 2>/dev/null && echo main || echo master', { cwd });
97
- if (mainBranch) {
98
- const branchList = safeExec('git branch --no-merged ' + mainBranch + ' 2>/dev/null', { cwd });
99
- if (branchList) {
100
- for (const line of branchList.split('\n')) {
101
- const b = line.trim().replace(/^\* /, '');
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 count (user-prioritized, still-open actions)
139
- let flaggedCount = 0;
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
- flaggedCount = db.prepare(
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 main..${branch} in ${projectPath}`,
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 (/\s\.claude$/.test(l) || /\s\.claude\//.test(l)) return false;
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 stale —
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
- const ahead = safeExec(`git log --oneline main..${ev.branch}`, { cwd: projectPath });
470
- const aheadCount = ahead ? ahead.split('\n').filter(l => l.trim()).length : 0;
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 (aheadCount === 0 && uncommittedCount === 0) {
475
- staleReason = '0 commits ahead of main, no uncommitted changes (session artifacts excluded)';
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
- // Check if branch has unmerged commits
528
- const mainRef = safeExec('git rev-parse --verify main', { cwd: projectPath });
529
- if (!mainRef) continue;
530
-
531
- const ahead = safeExec(`git log --oneline main..${wt.branch}`, { cwd: projectPath });
532
- const aheadCount = ahead ? ahead.split('\n').filter(l => l.trim()).length : 0;
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 (aheadCount === 0 && uncommittedCount === 0) continue;
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: aheadCount,
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 main..${wt.branch} in ${wt.path}`,
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
- function assembleSummary(projectStates, config) {
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
- issues.push(`${ps.pib.flaggedCount} flagged action(s)`);
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
- let projectMd = assembleProjectState(ps);
933
- if (existsSync(statePath)) {
934
- try {
935
- projectMd = preserveRing3LastSession(projectMd, readFileSync(statePath, 'utf8'));
936
- } catch (e) {
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
- try {
964
- main();
965
- } catch (e) {
966
- logError(`fatal: ${e.message}`);
967
- process.exit(1);
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
  }