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.
- package/README.md +3 -6
- package/lib/CLAUDE.md +218 -0
- package/lib/cli.js +397 -382
- package/lib/copy.js +182 -6
- package/lib/db-setup.js +122 -17
- package/lib/engagement-server-setup.js +1 -17
- package/lib/engagement-setup.js +1 -1
- package/lib/installer-gate.js +135 -0
- package/lib/metadata.js +51 -2
- package/lib/modules.js +292 -0
- package/lib/mux-setup.js +1 -17
- package/lib/project-context.js +16 -96
- package/lib/settings-merge.js +194 -13
- package/lib/site-audit-setup.js +23 -7
- package/lib/verify-setup.js +20 -9
- package/lib/watchtower-setup.js +23 -7
- package/package.json +1 -1
- package/templates/CLAUDE.md +959 -0
- package/templates/briefing/_briefing-template.md +6 -11
- package/templates/cabinet/advisories-state-schema.md +1 -1
- package/templates/cabinet/checklist-stats-schema.md +15 -1
- package/templates/cabinet/eval-protocol.md +27 -10
- package/templates/cabinet/memory-lifecycle-contract.md +135 -0
- package/templates/cabinet/pib-db-access.md +13 -0
- package/templates/cabinet/watchtower-contracts.md +453 -0
- package/templates/cabinet/worktree-invocation-contract.md +111 -0
- package/templates/engagement/OVERVIEW.md +12 -0
- package/templates/engagement/__tests__/checklist-visibility.test.mjs +76 -0
- package/templates/engagement/__tests__/engagement.test.mjs +177 -2
- package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
- package/templates/engagement/engagement-checklist.mjs +26 -2
- package/templates/engagement/engagement-preview.mjs +100 -0
- package/templates/engagement/engagement-schema.md +248 -6
- package/templates/engagement/engagement.mjs +391 -11
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +49 -9
- package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
- package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
- package/templates/engagement/pib-db-patches/pib-db.mjs +9 -3
- package/templates/engagement/sql-constants.mjs +9 -1
- package/templates/engagement-server/__tests__/cross-tenant-auth.test.mjs +87 -0
- package/templates/engagement-server/server.mjs +65 -40
- package/templates/hooks/action-completion-gate.sh +19 -3
- package/templates/hooks/action-quality-gate.sh +10 -0
- package/templates/hooks/memory-index-guard.sh +17 -11
- package/templates/hooks/skill-telemetry.sh +10 -5
- package/templates/hooks/skill-tool-telemetry.sh +11 -5
- package/templates/hooks/watchtower-session-start.sh +24 -2
- package/templates/mcp/pib-db.json +1 -4
- package/templates/mux/bin/mux +8 -1
- package/templates/mux/config/help.txt +1 -0
- package/templates/rules/acknowledge-when-corrected.md +33 -0
- package/templates/rules/enforcement-pipeline.md +0 -12
- package/templates/rules/maintainability.md +11 -0
- package/templates/rules/memory-capture.md +26 -4
- package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
- package/templates/rules/verify-before-asserting.md +79 -0
- package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
- package/templates/scripts/__tests__/api-usage-idle-gate.test.mjs +81 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +98 -0
- package/templates/scripts/__tests__/bsql-loader-verify.test.mjs +47 -0
- package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
- package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
- package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
- package/templates/scripts/__tests__/inbox-assessment.test.mjs +341 -0
- package/templates/scripts/__tests__/memory-reachability.test.mjs +150 -0
- package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
- package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
- package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
- package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +120 -0
- package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
- package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
- package/templates/scripts/__tests__/ring1-pib-error-surfacing.test.mjs +69 -0
- package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
- package/templates/scripts/__tests__/ring1-stale-open-counts.test.mjs +143 -0
- package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
- package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
- package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
- package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
- package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
- package/templates/scripts/__tests__/ring3-dedup.test.mjs +92 -12
- package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
- package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
- package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
- package/templates/scripts/__tests__/ring3-thread-capture.test.mjs +213 -0
- package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
- package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
- package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
- package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
- package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
- package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
- package/templates/scripts/__tests__/watchtower-status-ring4.test.mjs +89 -0
- package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
- package/templates/scripts/audit-coherence-check.mjs +123 -0
- package/templates/scripts/audit-synth.mjs +117 -0
- package/templates/scripts/load-triage-history.js +5 -2
- package/templates/scripts/merge-findings.js +4 -1
- package/templates/scripts/patterns-scope-check.mjs +122 -0
- package/templates/scripts/pib-db-lib.mjs +61 -11
- package/templates/scripts/pib-db-mcp-server.mjs +7 -45
- package/templates/scripts/pib-db-path.mjs +61 -0
- package/templates/scripts/pib-db.mjs +7 -3
- package/templates/scripts/review-server.mjs +7 -12
- package/templates/scripts/triage-server.mjs +7 -8
- package/templates/scripts/validate-memory.mjs +214 -16
- package/templates/scripts/watchtower-advisories.mjs +7 -3
- package/templates/scripts/watchtower-build-context.mjs +192 -8
- package/templates/scripts/watchtower-cross-ring-reader.mjs +714 -0
- package/templates/scripts/watchtower-hook-runner.mjs +422 -0
- package/templates/scripts/watchtower-inbox-assessment.mjs +706 -0
- package/templates/scripts/watchtower-lib.mjs +555 -8
- package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
- package/templates/scripts/watchtower-phase-shim.mjs +171 -0
- package/templates/scripts/watchtower-queue.mjs +341 -3
- package/templates/scripts/watchtower-ring1.mjs +481 -81
- package/templates/scripts/watchtower-ring2.mjs +748 -80
- package/templates/scripts/watchtower-ring3-close.mjs +1147 -167
- package/templates/scripts/watchtower-ring4-runner.sh +85 -0
- package/templates/scripts/watchtower-ring4.mjs +753 -0
- package/templates/scripts/watchtower-routines.mjs +1 -1
- package/templates/scripts/watchtower-snapshot.mjs +452 -0
- package/templates/scripts/watchtower-status.sh +17 -0
- package/templates/scripts/watchtower-sync.mjs +393 -0
- package/templates/scripts/work-tracker-server.mjs +26 -22
- package/templates/skills/audit/SKILL.md +18 -5
- package/templates/skills/audit/phases/structural-checks.md +22 -0
- package/templates/skills/briefing/SKILL.md +289 -41
- package/templates/skills/cabinet-accessibility/SKILL.md +1 -1
- package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
- package/templates/skills/cabinet-security/SKILL.md +11 -0
- package/templates/skills/catch-up/SKILL.md +113 -0
- package/templates/skills/cc-publish/SKILL.md +89 -18
- package/templates/skills/cc-remember/SKILL.md +45 -0
- package/templates/skills/close/SKILL.md +107 -0
- package/templates/skills/collab-client/SKILL.md +22 -5
- package/templates/skills/collab-consultant/SKILL.md +110 -2
- package/templates/skills/debrief/SKILL.md +21 -4
- package/templates/skills/debrief/phases/audit-pattern-capture.md +13 -0
- package/templates/skills/debrief-classic/SKILL.md +696 -0
- package/templates/skills/debrief-classic/calibration.md +44 -0
- package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
- package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
- package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
- package/templates/skills/debrief-classic/phases/close-work.md +163 -0
- package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
- package/templates/skills/debrief-classic/phases/inventory.md +40 -0
- package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
- package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
- package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
- package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
- package/templates/skills/debrief-classic/phases/report.md +59 -0
- package/templates/skills/debrief-classic/phases/update-state.md +48 -0
- package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
- package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
- package/templates/skills/execute/SKILL.md +71 -6
- package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
- package/templates/skills/execute-group/SKILL.md +23 -26
- package/templates/skills/generate-plan-groups/SKILL.md +20 -0
- package/templates/skills/inbox/SKILL.md +177 -31
- package/templates/skills/memory/SKILL.md +22 -6
- package/templates/skills/orient/SKILL.md +75 -5
- package/templates/skills/orient-classic/SKILL.md +770 -0
- package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
- package/templates/skills/orient-classic/phases/briefing.md +53 -0
- package/templates/skills/orient-classic/phases/cabinet.md +46 -0
- package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
- package/templates/skills/orient-classic/phases/context.md +88 -0
- package/templates/skills/orient-classic/phases/data-sync.md +35 -0
- package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
- package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
- package/templates/skills/orient-classic/phases/health-checks.md +50 -0
- package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
- package/templates/skills/orient-classic/phases/work-scan.md +69 -0
- package/templates/skills/pulse/SKILL.md +10 -8
- package/templates/skills/qa-drain/SKILL.md +38 -0
- package/templates/skills/qa-handoff/SKILL.md +287 -46
- package/templates/skills/setup-accounts/SKILL.md +38 -16
- package/templates/skills/spring-clean/SKILL.md +1 -1
- package/templates/skills/spring-clean/phases/execute-decisions.md +1 -1
- package/templates/skills/threads/SKILL.md +4 -0
- package/templates/skills/validate/phases/validators.md +127 -0
- package/templates/skills/watchtower/SKILL.md +174 -30
- package/templates/verify-runtime/CONVENTIONS.md +9 -0
- package/templates/verify-runtime/README.md +37 -0
- package/templates/watchtower/config.json.template +8 -2
- package/templates/workflows/deliberative-audit.js +258 -87
- package/templates/workflows/execute-group-complete.js +26 -3
- 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();
|