create-claude-cabinet 0.46.0 → 0.48.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 (189) hide show
  1. package/README.md +3 -6
  2. package/lib/CLAUDE.md +218 -0
  3. package/lib/cli.js +397 -382
  4. package/lib/copy.js +182 -6
  5. package/lib/db-setup.js +122 -17
  6. package/lib/engagement-server-setup.js +1 -17
  7. package/lib/engagement-setup.js +1 -1
  8. package/lib/installer-gate.js +135 -0
  9. package/lib/metadata.js +51 -2
  10. package/lib/modules.js +292 -0
  11. package/lib/mux-setup.js +1 -17
  12. package/lib/project-context.js +16 -96
  13. package/lib/settings-merge.js +194 -13
  14. package/lib/site-audit-setup.js +23 -7
  15. package/lib/verify-setup.js +20 -9
  16. package/lib/watchtower-setup.js +23 -7
  17. package/package.json +1 -1
  18. package/templates/CLAUDE.md +959 -0
  19. package/templates/briefing/_briefing-template.md +6 -11
  20. package/templates/cabinet/advisories-state-schema.md +1 -1
  21. package/templates/cabinet/checklist-stats-schema.md +15 -1
  22. package/templates/cabinet/eval-protocol.md +27 -10
  23. package/templates/cabinet/memory-lifecycle-contract.md +135 -0
  24. package/templates/cabinet/pib-db-access.md +13 -0
  25. package/templates/cabinet/watchtower-contracts.md +453 -0
  26. package/templates/cabinet/worktree-invocation-contract.md +111 -0
  27. package/templates/engagement/OVERVIEW.md +12 -0
  28. package/templates/engagement/__tests__/checklist-visibility.test.mjs +76 -0
  29. package/templates/engagement/__tests__/engagement.test.mjs +177 -2
  30. package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
  31. package/templates/engagement/engagement-checklist.mjs +26 -2
  32. package/templates/engagement/engagement-preview.mjs +100 -0
  33. package/templates/engagement/engagement-schema.md +248 -6
  34. package/templates/engagement/engagement.mjs +391 -11
  35. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +49 -9
  36. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
  37. package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
  38. package/templates/engagement/pib-db-patches/pib-db.mjs +9 -3
  39. package/templates/engagement/sql-constants.mjs +9 -1
  40. package/templates/engagement-server/__tests__/cross-tenant-auth.test.mjs +87 -0
  41. package/templates/engagement-server/server.mjs +65 -40
  42. package/templates/hooks/action-completion-gate.sh +19 -3
  43. package/templates/hooks/action-quality-gate.sh +10 -0
  44. package/templates/hooks/memory-index-guard.sh +17 -11
  45. package/templates/hooks/skill-telemetry.sh +10 -5
  46. package/templates/hooks/skill-tool-telemetry.sh +11 -5
  47. package/templates/hooks/watchtower-session-start.sh +24 -2
  48. package/templates/mcp/pib-db.json +1 -4
  49. package/templates/mux/bin/mux +8 -1
  50. package/templates/mux/config/help.txt +1 -0
  51. package/templates/rules/acknowledge-when-corrected.md +33 -0
  52. package/templates/rules/enforcement-pipeline.md +0 -12
  53. package/templates/rules/maintainability.md +11 -0
  54. package/templates/rules/memory-capture.md +26 -4
  55. package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
  56. package/templates/rules/verify-before-asserting.md +79 -0
  57. package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
  58. package/templates/scripts/__tests__/api-usage-idle-gate.test.mjs +81 -0
  59. package/templates/scripts/__tests__/batch-disposition.test.mjs +98 -0
  60. package/templates/scripts/__tests__/bsql-loader-verify.test.mjs +47 -0
  61. package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
  62. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
  63. package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
  64. package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
  65. package/templates/scripts/__tests__/inbox-assessment.test.mjs +341 -0
  66. package/templates/scripts/__tests__/memory-reachability.test.mjs +150 -0
  67. package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
  68. package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
  69. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
  70. package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
  71. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +120 -0
  72. package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
  73. package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
  74. package/templates/scripts/__tests__/ring1-pib-error-surfacing.test.mjs +69 -0
  75. package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
  76. package/templates/scripts/__tests__/ring1-stale-open-counts.test.mjs +143 -0
  77. package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
  78. package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
  79. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
  80. package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
  81. package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
  82. package/templates/scripts/__tests__/ring3-dedup.test.mjs +92 -12
  83. package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
  84. package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
  85. package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
  86. package/templates/scripts/__tests__/ring3-thread-capture.test.mjs +213 -0
  87. package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
  88. package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
  89. package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
  90. package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
  91. package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
  92. package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
  93. package/templates/scripts/__tests__/watchtower-status-ring4.test.mjs +89 -0
  94. package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
  95. package/templates/scripts/audit-coherence-check.mjs +123 -0
  96. package/templates/scripts/audit-synth.mjs +117 -0
  97. package/templates/scripts/load-triage-history.js +5 -2
  98. package/templates/scripts/merge-findings.js +4 -1
  99. package/templates/scripts/patterns-scope-check.mjs +122 -0
  100. package/templates/scripts/pib-db-lib.mjs +61 -11
  101. package/templates/scripts/pib-db-mcp-server.mjs +7 -45
  102. package/templates/scripts/pib-db-path.mjs +61 -0
  103. package/templates/scripts/pib-db.mjs +7 -3
  104. package/templates/scripts/review-server.mjs +7 -12
  105. package/templates/scripts/triage-server.mjs +7 -8
  106. package/templates/scripts/validate-memory.mjs +214 -16
  107. package/templates/scripts/watchtower-advisories.mjs +7 -3
  108. package/templates/scripts/watchtower-build-context.mjs +192 -8
  109. package/templates/scripts/watchtower-cross-ring-reader.mjs +714 -0
  110. package/templates/scripts/watchtower-hook-runner.mjs +422 -0
  111. package/templates/scripts/watchtower-inbox-assessment.mjs +706 -0
  112. package/templates/scripts/watchtower-lib.mjs +555 -8
  113. package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
  114. package/templates/scripts/watchtower-phase-shim.mjs +171 -0
  115. package/templates/scripts/watchtower-queue.mjs +341 -3
  116. package/templates/scripts/watchtower-ring1.mjs +481 -81
  117. package/templates/scripts/watchtower-ring2.mjs +748 -80
  118. package/templates/scripts/watchtower-ring3-close.mjs +1147 -167
  119. package/templates/scripts/watchtower-ring4-runner.sh +85 -0
  120. package/templates/scripts/watchtower-ring4.mjs +753 -0
  121. package/templates/scripts/watchtower-routines.mjs +1 -1
  122. package/templates/scripts/watchtower-snapshot.mjs +452 -0
  123. package/templates/scripts/watchtower-status.sh +17 -0
  124. package/templates/scripts/watchtower-sync.mjs +393 -0
  125. package/templates/scripts/work-tracker-server.mjs +26 -22
  126. package/templates/skills/audit/SKILL.md +18 -5
  127. package/templates/skills/audit/phases/structural-checks.md +22 -0
  128. package/templates/skills/briefing/SKILL.md +289 -41
  129. package/templates/skills/cabinet-accessibility/SKILL.md +1 -1
  130. package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
  131. package/templates/skills/cabinet-security/SKILL.md +11 -0
  132. package/templates/skills/catch-up/SKILL.md +113 -0
  133. package/templates/skills/cc-publish/SKILL.md +89 -18
  134. package/templates/skills/cc-remember/SKILL.md +45 -0
  135. package/templates/skills/close/SKILL.md +107 -0
  136. package/templates/skills/collab-client/SKILL.md +22 -5
  137. package/templates/skills/collab-consultant/SKILL.md +110 -2
  138. package/templates/skills/debrief/SKILL.md +21 -4
  139. package/templates/skills/debrief/phases/audit-pattern-capture.md +13 -0
  140. package/templates/skills/debrief-classic/SKILL.md +696 -0
  141. package/templates/skills/debrief-classic/calibration.md +44 -0
  142. package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
  143. package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
  144. package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
  145. package/templates/skills/debrief-classic/phases/close-work.md +163 -0
  146. package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
  147. package/templates/skills/debrief-classic/phases/inventory.md +40 -0
  148. package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
  149. package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
  150. package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
  151. package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
  152. package/templates/skills/debrief-classic/phases/report.md +59 -0
  153. package/templates/skills/debrief-classic/phases/update-state.md +48 -0
  154. package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
  155. package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
  156. package/templates/skills/execute/SKILL.md +71 -6
  157. package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
  158. package/templates/skills/execute-group/SKILL.md +23 -26
  159. package/templates/skills/generate-plan-groups/SKILL.md +20 -0
  160. package/templates/skills/inbox/SKILL.md +177 -31
  161. package/templates/skills/memory/SKILL.md +22 -6
  162. package/templates/skills/orient/SKILL.md +75 -5
  163. package/templates/skills/orient-classic/SKILL.md +770 -0
  164. package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
  165. package/templates/skills/orient-classic/phases/briefing.md +53 -0
  166. package/templates/skills/orient-classic/phases/cabinet.md +46 -0
  167. package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
  168. package/templates/skills/orient-classic/phases/context.md +88 -0
  169. package/templates/skills/orient-classic/phases/data-sync.md +35 -0
  170. package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
  171. package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
  172. package/templates/skills/orient-classic/phases/health-checks.md +50 -0
  173. package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
  174. package/templates/skills/orient-classic/phases/work-scan.md +69 -0
  175. package/templates/skills/pulse/SKILL.md +10 -8
  176. package/templates/skills/qa-drain/SKILL.md +38 -0
  177. package/templates/skills/qa-handoff/SKILL.md +287 -46
  178. package/templates/skills/setup-accounts/SKILL.md +38 -16
  179. package/templates/skills/spring-clean/SKILL.md +1 -1
  180. package/templates/skills/spring-clean/phases/execute-decisions.md +1 -1
  181. package/templates/skills/threads/SKILL.md +4 -0
  182. package/templates/skills/validate/phases/validators.md +127 -0
  183. package/templates/skills/watchtower/SKILL.md +174 -30
  184. package/templates/verify-runtime/CONVENTIONS.md +9 -0
  185. package/templates/verify-runtime/README.md +37 -0
  186. package/templates/watchtower/config.json.template +8 -2
  187. package/templates/workflows/deliberative-audit.js +258 -87
  188. package/templates/workflows/execute-group-complete.js +26 -3
  189. package/templates/workflows/execute-group-implement.js +16 -2
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Watchtower narrative corpus — the read-only substrate for the /briefing
4
+ // "decision lineage" drill-down (Plan 12, act:a2efc0ce).
5
+ //
6
+ // It answers, for ONE project: "what decisions does this project's history
7
+ // hold, and which causal edges are RECORDED vs merely associated?" — the raw
8
+ // material the /briefing drill-down synthesizes into the answer-first render
9
+ // ("what still constrains this project / is X reversible").
10
+ //
11
+ // This is a PROJECTION over existing stores, never a fifth store. It writes
12
+ // NOTHING: no state file, no inbox item, no memory write. The read-only
13
+ // invariant is structural — there is no write path in this module, and the
14
+ // hermetic test asserts the filesystem is untouched.
15
+ //
16
+ // Deliberately a SIBLING of watchtower-snapshot.mjs (same reader family, same
17
+ // hermetic-test shape), not folded into any ring. It covers the two
18
+ // watchtower-NATIVE stores — the inbox queue and threads — because those are
19
+ // the ones it can read without the auto-memory-dir resolver (which ships in
20
+ // the memory module and is a documented footgun: project-context.cjs throws on
21
+ // a literal `~`, honors settings.json, dashifies the slug). The memory
22
+ // `decision_*.md` files, methodology, and pib-db are read by the /briefing
23
+ // CONSUMER, which resolves those paths itself (the "punt to the consumer"
24
+ // decision). To keep the EDGE rule testable despite that punt, the consumer
25
+ // hands this module a memory DIRECTORY (already resolved) and this module reads
26
+ // the decision files from it and parses edges with one tested rule
27
+ // (parseDecisionEdges) — so the consumer owns path resolution, this module owns
28
+ // the parsing.
29
+ //
30
+ // CLI modes (additive; reads only):
31
+ // --corpus --project-path <cwd>
32
+ // Resolve the project the cwd belongs to (worktree-aware) and print the
33
+ // inbox+threads corpus JSON.
34
+ // --memory <dir>
35
+ // Read decision_*.md from <dir> (the consumer-resolved memory dir) and
36
+ // print each decision's title/date/edges JSON.
37
+ //
38
+ // Never throws on a missing/partial store — readers degrade to empty.
39
+
40
+ import { readFileSync, existsSync, readdirSync, realpathSync } from 'fs';
41
+ import { join } from 'path';
42
+ import { pathToFileURL } from 'url';
43
+ import {
44
+ loadActiveThreads,
45
+ threadMatchesProject,
46
+ resolveProjectIdentity,
47
+ } from './watchtower-lib.mjs';
48
+ import { listItems } from './watchtower-queue.mjs';
49
+ // The 4-field cursor projection has ONE definition, in the cross-ring reader
50
+ // (a sibling non-ring-loaded file — watchtower-lib is soak-frozen). Both
51
+ // readers project the same shape; this import is what keeps them from
52
+ // drifting.
53
+ import { projectCursorTimeline } from './watchtower-cross-ring-reader.mjs';
54
+
55
+ const WATCHTOWER_DIR = process.env.WATCHTOWER_DIR
56
+ || join(process.env.HOME || '', '.claude-cabinet', 'watchtower');
57
+
58
+ export const NARRATIVE_CORPUS_SCHEMA_VERSION = 1;
59
+
60
+ // Decisions live in the inbox under this category (Ring 3's knowledge
61
+ // extraction files them here). Constraints/lessons/preferences ride the same
62
+ // category, distinguished by evidence.type — the consumer leads with
63
+ // decision+constraint for the "what still constrains this" answer.
64
+ const DECISION_CATEGORY = 'knowledge-extraction';
65
+
66
+ // A resolved knowledge-extraction item with this resolution_type WAS written
67
+ // into a memory decision_*.md on sign-off — reading both the inbox item and the
68
+ // memory file would double-count the SAME decision (and the inbox copy is the
69
+ // stale draft). Excluded here; the memory file is the canonical copy the
70
+ // consumer reads. (data-integrity Finding 1 / Edge Cases.)
71
+ const CAPTURED_TO_MEMORY = 'captured-to-memory';
72
+
73
+ // Inbox statuses worth narrating. `superseded` is INCLUDED deliberately — a
74
+ // decision that was later replaced is exactly what the supersession view shows;
75
+ // excluding it (the obvious ['pending','resolved']) would drop the feature's
76
+ // own subjects. (boundary-man Finding 2 / Edge Cases.)
77
+ const NARRATED_STATUSES = ['pending', 'resolved', 'superseded'];
78
+
79
+ function safeReadJSON(filePath) {
80
+ try {
81
+ if (!existsSync(filePath)) return null;
82
+ return JSON.parse(readFileSync(filePath, 'utf8'));
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ // --- Edge parsing (pure, tested) ---------------------------------------------
89
+
90
+ // parseDecisionEdges — extract causal edges from one memory decision file's
91
+ // markdown, with PROVENANCE. The distinction is load-bearing: a narrative is a
92
+ // trust surface, so a RECORDED edge (an explicit `**Supersedes:**` line) must
93
+ // never be conflated with a mere see-also `[[wikilink]]`. A wikilink is an
94
+ // undirected association, NOT a supersession — rendering it as one fabricates
95
+ // history. Returns { supersedes: [{target, provenance:'recorded'}],
96
+ // seeAlso: [{target, kind:'see-also'}] }. Pure: text in, edges out.
97
+ export function parseDecisionEdges(markdown) {
98
+ const text = typeof markdown === 'string' ? markdown : '';
99
+ const supersedes = [];
100
+ const supersedeTargets = new Set();
101
+
102
+ // `**Supersedes:** [[decision_x]]` or `**Supersedes:** decision_x, decision_y`
103
+ // — the recorded, directional edge. Take the rest of the line after the
104
+ // marker and pull targets. A BRACKETED `[[...]]` target is the author's
105
+ // explicit "this is a link target" signal — accept it verbatim. A BARE token
106
+ // must look like a memory slug or a fid to qualify: the bare branch exists
107
+ // for `**Supersedes:** decision_x`, but a naive whitespace split would also
108
+ // accept prose ("decision_x because it was wrong" → because/it/was/wrong),
109
+ // FABRICATING recorded supersession from English. A narrative is a trust
110
+ // surface, so the bare branch admits only slug/fid-shaped tokens.
111
+ const reSupersede = /\*\*Supersedes:\*\*\s*(.+)/gi;
112
+ let m;
113
+ while ((m = reSupersede.exec(text)) !== null) {
114
+ const tail = m[1];
115
+ const targets = [];
116
+ const bracket = /\[\[([^\]]+)\]\]/g;
117
+ let b;
118
+ while ((b = bracket.exec(tail)) !== null) {
119
+ const t = b[1].trim();
120
+ if (t) targets.push(t); // bracketed = explicit intent (incl. titles)
121
+ }
122
+ if (targets.length === 0) {
123
+ // No bracketed targets — accept ONLY slug/fid-shaped bare tokens.
124
+ for (const tok of tail.split(/[,\s]+/)) {
125
+ const t = tok.trim().replace(/[.,;]+$/, '');
126
+ if (t && looksLikeDecisionTarget(t)) targets.push(t);
127
+ }
128
+ }
129
+ for (const t of targets) {
130
+ if (!supersedeTargets.has(t)) {
131
+ supersedeTargets.add(t);
132
+ supersedes.push({ target: t, provenance: 'recorded' });
133
+ }
134
+ }
135
+ }
136
+
137
+ // All OTHER `[[wikilinks]]` are see-also associations — explicitly NOT
138
+ // supersedes. Exclude any already claimed as a supersede target so the same
139
+ // link isn't double-counted in both buckets. Empty/whitespace targets (a
140
+ // malformed `[[ ]]`) are dropped.
141
+ const seeAlso = [];
142
+ const seen = new Set();
143
+ const reLink = /\[\[([^\]]+)\]\]/g;
144
+ let l;
145
+ while ((l = reLink.exec(text)) !== null) {
146
+ const target = l[1].trim();
147
+ if (!target || supersedeTargets.has(target) || seen.has(target)) continue;
148
+ seen.add(target);
149
+ seeAlso.push({ target, kind: 'see-also' });
150
+ }
151
+
152
+ return { supersedes, seeAlso };
153
+ }
154
+
155
+ // A bare (unbracketed) supersede target must look like a memory slug
156
+ // (`decision_…`, `lesson_…`, etc.) or a fid (`act:abc12345`, `dec-…`) — NOT an
157
+ // English word — so prose after a `**Supersedes:**` marker can't masquerade as
158
+ // a recorded edge.
159
+ function looksLikeDecisionTarget(token) {
160
+ return /^(?:decision|lesson|constraint|preference|pattern)_[a-z0-9][\w-]*$/i.test(token)
161
+ || /^act:[0-9a-f]{8}$/i.test(token)
162
+ || /^dec-[0-9a-z]+$/i.test(token);
163
+ }
164
+
165
+ // --- Memory decisions (resolver-free; consumer hands us the dir) -------------
166
+
167
+ // First markdown heading or the filename, as a display title.
168
+ function decisionTitle(markdown, fallback) {
169
+ const m = /^#\s+(.+)$/m.exec(markdown || '');
170
+ return (m && m[1].trim()) || fallback;
171
+ }
172
+
173
+ // Memory files date themselves in a prose `_Captured: 2026-06-11_` line (not
174
+ // frontmatter, not filename). Missing/garbage dates are tolerated — null, never
175
+ // a NaN that would poison a sort.
176
+ function decisionDate(markdown) {
177
+ const m = /_Captured:\s*([0-9]{4}-[0-9]{2}-[0-9]{2})/.exec(markdown || '');
178
+ return (m && m[1]) || null;
179
+ }
180
+
181
+ // readMemoryDecisions — read decision_*.md from a CONSUMER-RESOLVED directory.
182
+ // No path resolution here (the consumer owns that, per the punt decision); we
183
+ // only read files from a dir we are handed, so this stays hermetic and free of
184
+ // the memory-dir resolver. Returns [] on a missing/unreadable dir — never
185
+ // throws.
186
+ export function readMemoryDecisions(dir) {
187
+ if (!dir || !existsSync(dir)) return [];
188
+ let entries;
189
+ try {
190
+ entries = readdirSync(dir, { withFileTypes: true });
191
+ } catch {
192
+ return [];
193
+ }
194
+ const out = [];
195
+ for (const entry of entries) {
196
+ if (!entry.isFile()) continue;
197
+ if (!/^decision_.*\.md$/.test(entry.name)) continue;
198
+ let markdown = '';
199
+ try {
200
+ markdown = readFileSync(join(dir, entry.name), 'utf8');
201
+ } catch {
202
+ continue;
203
+ }
204
+ const edges = parseDecisionEdges(markdown);
205
+ out.push({
206
+ source: 'memory',
207
+ file: entry.name,
208
+ title: decisionTitle(markdown, entry.name.replace(/\.md$/, '')),
209
+ date: decisionDate(markdown),
210
+ supersedes: edges.supersedes,
211
+ see_also: edges.seeAlso,
212
+ });
213
+ }
214
+ out.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
215
+ return out;
216
+ }
217
+
218
+ // --- Corpus assembly (inbox + threads; current project only) -----------------
219
+
220
+ // assembleNarrativeCorpus — read the project the cwd belongs to (worktree-aware
221
+ // via resolveProjectIdentity) and gather its inbox decisions + thread cursor
222
+ // timelines. Current project ONLY — no free `project` arg, so the five-namespace
223
+ // keying footgun and the broken cross-project case can't arise (boundary-man
224
+ // Finding 1/3). Writes nothing. Degrades to an empty-but-honest corpus when the
225
+ // project can't be resolved, rather than throwing or going blank silently.
226
+ //
227
+ // NOTE on testing: WATCHTOWER_DIR here AND QUEUE_DIR in watchtower-queue.mjs are
228
+ // read at module load, so a hermetic test must set process.env.WATCHTOWER_DIR
229
+ // BEFORE importing (dynamic import). An `env` param can't redirect them.
230
+ export function assembleNarrativeCorpus({ cwd } = {}) {
231
+ const config = safeReadJSON(join(WATCHTOWER_DIR, 'config.json'));
232
+
233
+ let identity = null;
234
+ try {
235
+ if (config && cwd) identity = resolveProjectIdentity(cwd, config);
236
+ } catch {
237
+ identity = null;
238
+ }
239
+
240
+ const empty = (reason) => ({
241
+ schema_version: NARRATIVE_CORPUS_SCHEMA_VERSION,
242
+ project_identity: identity || null,
243
+ reason,
244
+ inbox_decisions: [],
245
+ thread_cursors: [],
246
+ per_store_counts: { inbox_total: 0, inbox_kept: 0, threads_matched: 0 },
247
+ skipped_unresolved: { count: 0, sample: [] },
248
+ time_span: { first: null, last: null },
249
+ });
250
+
251
+ if (!identity || !identity.name) {
252
+ return empty('project-unresolved');
253
+ }
254
+
255
+ // Inbox — knowledge-extraction items for THIS project (exact name match in
256
+ // listItems). Drop captured-to-memory duplicates; keep superseded.
257
+ let rawItems = [];
258
+ try {
259
+ rawItems = listItems({
260
+ project: identity.name,
261
+ category: DECISION_CATEGORY,
262
+ statuses: NARRATED_STATUSES,
263
+ });
264
+ } catch {
265
+ rawItems = [];
266
+ }
267
+ const inboxDecisions = [];
268
+ for (const item of rawItems) {
269
+ if (item.status === 'resolved' && item.resolution_type === CAPTURED_TO_MEMORY) {
270
+ continue; // canonical copy lives in memory; the consumer reads it there
271
+ }
272
+ inboxDecisions.push({
273
+ source: 'inbox',
274
+ id: item.id,
275
+ title: item.title || '(untitled)',
276
+ text: item.summary || '',
277
+ date: item.filed_at || null,
278
+ status: item.status,
279
+ type: (item.evidence && item.evidence.type) || null,
280
+ });
281
+ }
282
+
283
+ // Transparency: knowledge-extraction items repo-wide that the identity
284
+ // resolver could not attribute to a project (filed before the resolver, or
285
+ // under a worktree-basename phantom key). Some may belong HERE. We cannot
286
+ // know which, so we never silently present a complete-looking history — we
287
+ // surface the count + a sample. (data-integrity Finding 4 / no-silent-trunc.)
288
+ let unresolvedSample = [];
289
+ let unresolvedCount = 0;
290
+ try {
291
+ const all = listItems({ category: DECISION_CATEGORY, statuses: NARRATED_STATUSES });
292
+ const orphans = all.filter((i) => i.project_unresolved === true);
293
+ unresolvedCount = orphans.length;
294
+ unresolvedSample = orphans.slice(0, 5).map((i) => ({ id: i.id, title: i.title || '(untitled)' }));
295
+ } catch {
296
+ unresolvedCount = 0;
297
+ }
298
+
299
+ // Threads — active threads belonging to this project (exact slug match), with
300
+ // their full cursor_history timeline (NOT just the current cursor — the
301
+ // evolution is the point).
302
+ let matchedThreads = [];
303
+ try {
304
+ const all = loadActiveThreads(join(WATCHTOWER_DIR, 'state', 'threads'));
305
+ matchedThreads = all.filter((t) => threadMatchesProject(t, identity.slug).match);
306
+ } catch {
307
+ matchedThreads = [];
308
+ }
309
+ const threadCursors = matchedThreads
310
+ .map(projectCursorTimeline)
311
+ .sort((a, b) => (a.thread < b.thread ? -1 : a.thread > b.thread ? 1 : 0));
312
+
313
+ // Time span across whatever dated material we have (inbox + thread cursors).
314
+ const dates = [];
315
+ for (const d of inboxDecisions) if (d.date) dates.push(d.date);
316
+ for (const t of threadCursors) for (const h of t.history) if (h.date) dates.push(h.date);
317
+ dates.sort();
318
+
319
+ return {
320
+ schema_version: NARRATIVE_CORPUS_SCHEMA_VERSION,
321
+ project_identity: { name: identity.name, slug: identity.slug, registered: identity.registered },
322
+ reason: null,
323
+ inbox_decisions: inboxDecisions,
324
+ thread_cursors: threadCursors,
325
+ per_store_counts: {
326
+ inbox_total: rawItems.length,
327
+ inbox_kept: inboxDecisions.length,
328
+ threads_matched: matchedThreads.length,
329
+ },
330
+ skipped_unresolved: { count: unresolvedCount, sample: unresolvedSample },
331
+ time_span: { first: dates[0] || null, last: dates[dates.length - 1] || null },
332
+ };
333
+ }
334
+
335
+ // --- CLI ---------------------------------------------------------------------
336
+
337
+ function parseArgs(argv) {
338
+ const args = argv.slice(2);
339
+ const out = { mode: null, projectPath: null, memoryDir: null };
340
+ for (let i = 0; i < args.length; i++) {
341
+ const a = args[i];
342
+ if (a === '--corpus') out.mode = 'corpus';
343
+ else if (a === '--memory' && args[i + 1]) { out.mode = 'memory'; out.memoryDir = args[++i]; }
344
+ else if (a === '--project-path' && args[i + 1]) out.projectPath = args[++i];
345
+ }
346
+ return out;
347
+ }
348
+
349
+ function main() {
350
+ const { mode, projectPath, memoryDir } = parseArgs(process.argv);
351
+
352
+ if (mode === 'corpus') {
353
+ const corpus = assembleNarrativeCorpus({ cwd: projectPath || process.cwd() });
354
+ process.stdout.write(JSON.stringify(corpus, null, 2) + '\n');
355
+ return;
356
+ }
357
+
358
+ if (mode === 'memory') {
359
+ process.stdout.write(JSON.stringify(readMemoryDecisions(memoryDir), null, 2) + '\n');
360
+ return;
361
+ }
362
+
363
+ process.stderr.write(
364
+ 'usage: watchtower-narrative-corpus.mjs (--corpus --project-path <cwd> | --memory <dir>)\n'
365
+ );
366
+ }
367
+
368
+ // Entry guard — importing must NOT run the CLI (mirrors the ring scripts so
369
+ // tests import the pure functions).
370
+ const isMain = (() => {
371
+ try {
372
+ return process.argv[1]
373
+ && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
374
+ } catch {
375
+ return false;
376
+ }
377
+ })();
378
+
379
+ if (isMain) {
380
+ try {
381
+ main();
382
+ } catch {
383
+ process.exit(0);
384
+ }
385
+ }
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Watchtower phase shim (Plan 9, act:4a6e907c) — the migration adapter that
4
+ // runs an EXISTING non-interactive consumer script as a ring hook, unchanged.
5
+ //
6
+ // Why it exists. The consumer scan found real per-project customs that lived
7
+ // as orient/debrief phases and now need a home at the ring seams: Flow's
8
+ // Railway DB pull and machine-drift check, article-rewriter's reMarkable sync
9
+ // and Sentry check, maginnis's timelog collection. Their non-interactive
10
+ // LOGIC was already a script (or trivially becomes one); what differs is the
11
+ // invocation convention — those scripts expect to run in the project root
12
+ // with an ambient environment, not "JSON on stdin from a ring." The shim is
13
+ // the one place that bridges that gap, so the port is mechanical:
14
+ //
15
+ // config.hooks["ring1-post-collect"] += [
16
+ // 'node ~/.claude-cabinet/watchtower/scripts/watchtower-phase-shim.mjs \
17
+ // --phase scripts/flow-railway-pull.sh'
18
+ // ]
19
+ //
20
+ // The shim reads the ring state on stdin, runs the target script with the
21
+ // project root as cwd and the WATCHTOWER_* contract env exported, passes the
22
+ // state through on stdin, and enforces a timeout. Its stdout is the target's
23
+ // stdout, passed through transparently when the target prints JSON (so the
24
+ // Ring 1 `additional_checks` contract survives a migrated phase), wrapped
25
+ // otherwise. It propagates the target's exit code so failure is visible to
26
+ // whoever runs it — the hook runner (which classifies non-zero as `failed`)
27
+ // or a ring directly (the existing raw config.hooks mechanism).
28
+ //
29
+ // Two ways to wire a migrated phase:
30
+ // 1. Drop the script into <project>/.claude/watchtower/hooks/<seam>/ and let
31
+ // watchtower-hook-runner discover it — best for NEW hook scripts written
32
+ // to the contract.
33
+ // 2. Register the shim in config.hooks[<seam>] pointing at an EXISTING
34
+ // script anywhere in the repo (the path need not move) — best for the
35
+ // MIGRATION case, where the script already exists and speaks the
36
+ // project-cwd/env convention rather than reading stdin.
37
+ //
38
+ // Interactive customs are OUT of scope (owned by routine dispatch,
39
+ // act:c2a55c08). The shim never talks to the operator.
40
+
41
+ import { existsSync, readFileSync } from 'fs';
42
+ import { isAbsolute, resolve as resolvePath } from 'path';
43
+ import {
44
+ resolveInterpreter, buildHookEnv, SEAM_BUDGET_MS,
45
+ } from './watchtower-hook-runner.mjs';
46
+ import { spawnSync } from 'child_process';
47
+
48
+ const SHIM_BASENAME = 'watchtower-phase-shim.mjs';
49
+ const DEFAULT_TIMEOUT_MS = 60_000;
50
+
51
+ function parseArgs(argv) {
52
+ const args = { phase: null, cwd: null, seam: null, timeout: null };
53
+ for (let i = 0; i < argv.length; i++) {
54
+ const a = argv[i];
55
+ if (a === '--phase') args.phase = argv[++i];
56
+ else if (a === '--cwd') args.cwd = argv[++i];
57
+ else if (a === '--seam') args.seam = argv[++i];
58
+ else if (a === '--timeout') args.timeout = Number(argv[++i]);
59
+ }
60
+ return args;
61
+ }
62
+
63
+ function readStdinState() {
64
+ try {
65
+ const data = readFileSync(0, 'utf8');
66
+ if (!data || !data.trim()) return {};
67
+ return JSON.parse(data);
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ // resolveCwd — the project root the phase script expects to run in: explicit
74
+ // --cwd, else the project from the ring state, else the current directory.
75
+ export function resolveCwd(args, state) {
76
+ return args.cwd || (state && (state.path || state.project_path)) || process.cwd();
77
+ }
78
+
79
+ // resolvePhasePath — absolute path to the target script (relative paths are
80
+ // resolved against the project cwd, matching how a phase referenced its own
81
+ // repo files).
82
+ export function resolvePhasePath(phase, cwd) {
83
+ if (!phase) return null;
84
+ return isAbsolute(phase) ? phase : resolvePath(cwd, phase);
85
+ }
86
+
87
+ // runPhase — execute the target script with phase conventions. Returns the
88
+ // raw spawnSync-derived result plus the resolved invocation, never throws.
89
+ export function runPhase({ phasePath, cwd, env, input, timeoutMs }) {
90
+ if (!phasePath || !existsSync(phasePath)) {
91
+ return { ok: false, status: 'failed', error: `phase script not found: ${phasePath || '(none)'}`, exit: 127 };
92
+ }
93
+ const interp = resolveInterpreter(phasePath);
94
+ if (!interp) {
95
+ return { ok: false, status: 'failed', error: `phase script is not runnable (no exec bit, unknown extension): ${phasePath}`, exit: 126 };
96
+ }
97
+ let res;
98
+ try {
99
+ res = spawnSync(interp.cmd, interp.args, {
100
+ cwd, env, input: input || '',
101
+ timeout: Math.max(1, timeoutMs | 0),
102
+ encoding: 'utf8',
103
+ maxBuffer: 10 * 1024 * 1024,
104
+ });
105
+ } catch (e) {
106
+ return { ok: false, status: 'failed', error: e.message, exit: 1 };
107
+ }
108
+ if (res.error) {
109
+ const timedOut = res.error.code === 'ETIMEDOUT' || res.signal === 'SIGTERM';
110
+ return { ok: false, status: timedOut ? 'timeout' : 'failed', error: res.error.message, exit: timedOut ? 124 : 1, stderr: res.stderr };
111
+ }
112
+ if (res.signal === 'SIGTERM') {
113
+ return { ok: false, status: 'timeout', error: `killed after ${timeoutMs}ms`, exit: 124, stderr: res.stderr };
114
+ }
115
+ return {
116
+ ok: res.status === 0,
117
+ status: res.status === 0 ? 'success' : 'failed',
118
+ exit: res.status,
119
+ stdout: res.stdout,
120
+ stderr: res.stderr,
121
+ };
122
+ }
123
+
124
+ async function main() {
125
+ const args = parseArgs(process.argv.slice(2));
126
+ const state = readStdinState();
127
+ const cwd = resolveCwd(args, state);
128
+ const phasePath = resolvePhasePath(args.phase, cwd);
129
+ const seam = args.seam || (state && state.seam) || '';
130
+
131
+ const env = buildHookEnv(state, {
132
+ seam,
133
+ target: { name: (state && (state.name || state.project)) || '', path: cwd },
134
+ });
135
+ env.WATCHTOWER_PHASE = phasePath || '';
136
+ env.WATCHTOWER_HOOK = '1';
137
+
138
+ const timeoutMs = args.timeout
139
+ || Number(process.env.WATCHTOWER_HOOK_BUDGET_MS)
140
+ || SEAM_BUDGET_MS[seam]
141
+ || DEFAULT_TIMEOUT_MS;
142
+
143
+ const result = runPhase({
144
+ phasePath, cwd, env, input: JSON.stringify(state || {}), timeoutMs,
145
+ });
146
+
147
+ if (result.ok) {
148
+ // Pass the target's stdout through transparently when it is valid JSON
149
+ // (preserves the additional_checks contract); otherwise emit a wrapper.
150
+ const out = (result.stdout || '').trim();
151
+ let isJson = false;
152
+ if (out) {
153
+ try { JSON.parse(out); isJson = true; } catch { /* not json */ }
154
+ }
155
+ if (isJson) process.stdout.write(out + '\n');
156
+ else process.stdout.write(JSON.stringify({ phase: phasePath, status: 'success', output: out }) + '\n');
157
+ process.exitCode = 0;
158
+ return;
159
+ }
160
+
161
+ // Failure: emit a JSON error envelope on stdout, echo target stderr to our
162
+ // stderr for the ring/runner log, and propagate a non-zero exit.
163
+ if (result.stderr) process.stderr.write(result.stderr);
164
+ process.stdout.write(JSON.stringify({
165
+ phase: phasePath, status: result.status, error: result.error || 'phase failed', exit: result.exit,
166
+ }) + '\n');
167
+ process.exitCode = result.exit && result.exit !== 0 ? result.exit : 1;
168
+ }
169
+
170
+ const invokedDirectly = process.argv[1] && process.argv[1].endsWith(SHIM_BASENAME);
171
+ if (invokedDirectly) main();