total-recall-brain 3.41.4 → 3.41.6

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 (147) hide show
  1. package/bin/total-recall.mjs +10 -6
  2. package/docs/reference/cli-reference.md +18 -0
  3. package/frontend/dist/assets/index-4tGQfTa2.js +5605 -0
  4. package/frontend/dist/assets/index-DtwsDyFM.css +1 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/package.json +11 -0
  7. package/frontend/src/App.tsx +47 -122
  8. package/frontend/src/components/plugins/plugin-frame.ts +1 -0
  9. package/frontend/src/index.css +152 -86
  10. package/frontend/src/pages/HelpPage.tsx +13 -10
  11. package/frontend/src/pages/MemoryPage.tsx +28 -30
  12. package/frontend/src/pages/NetworkPage.tsx +3 -3
  13. package/frontend/src/pages/PluginsPage.tsx +6 -5
  14. package/frontend/src/pages/RulesPage.tsx +16 -12
  15. package/package.json +4 -3
  16. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
  17. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +4 -1
  18. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
  19. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +4 -1
  20. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3121 -33
  21. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +4 -1
  22. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3122 -34
  23. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +4 -1
  24. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3127 -49
  25. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -2
  26. package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +1 -1
  27. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +136 -7
  28. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +135 -26
  29. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +3121 -33
  30. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +5 -2
  31. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +228 -9
  32. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +24 -30
  33. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +29 -16
  34. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +73 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +182 -365
  36. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +33 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +40 -14
  38. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +4 -4
  39. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/agy.mjs +187 -0
  40. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/claude.mjs +275 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cline.mjs +132 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/codex.mjs +187 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/cursor.mjs +158 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/devin.mjs +157 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/dsh.mjs +183 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/grok.mjs +132 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +173 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +87 -0
  49. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/openclaw.mjs +173 -0
  50. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/pi.mjs +129 -0
  51. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate.mjs +12 -0
  52. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/json-reply.mjs +22 -0
  53. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +72 -70
  54. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/model-catalog.mjs +195 -0
  55. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/paths.mjs +11 -0
  56. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +36 -0
  57. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +169 -0
  58. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +136 -346
  59. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +1 -1
  60. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/ssss-store.mjs +20 -0
  61. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/telemetry.mjs +43 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +156 -0
  63. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +117 -35
  64. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +51 -0
  65. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +398 -57
  66. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +304 -46
  67. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +367 -41
  68. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +5 -2
  69. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +33 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +2 -2
  71. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +151 -4
  76. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +899 -662
  79. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/mermaid-diagrams/SKILL.md +44 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/skills/write-connector/SKILL.md +56 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3097 -81
  83. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +4 -1
  84. package/src/cli/agent.mjs +12 -1
  85. package/src/cli/compact.mjs +108 -0
  86. package/src/cli/connect.mjs +56 -1
  87. package/src/cli/context.mjs +55 -23
  88. package/src/cli/doctor.mjs +5 -2
  89. package/src/cli/forget.mjs +2 -0
  90. package/src/cli/harness.mjs +62 -50
  91. package/src/cli/memory-entry.mjs +2 -2
  92. package/src/cli/preflight.mjs +152 -16
  93. package/src/cli/release.mjs +386 -0
  94. package/src/cli/remember.mjs +19 -1
  95. package/src/cli/rules.mjs +54 -12
  96. package/src/cli/startup.mjs +1 -1
  97. package/src/cli/status.mjs +12 -2
  98. package/src/core/claude-hooks.mjs +41 -3
  99. package/src/core/cli-commands.mjs +2 -0
  100. package/src/core/cli-semantic-index.mjs +3 -2
  101. package/src/core/conflict-detector.mjs +23 -12
  102. package/src/core/context-compactor.mjs +145 -0
  103. package/src/core/context-policy.mjs +20 -0
  104. package/src/core/duplicate-guard.mjs +350 -0
  105. package/src/core/meta-harness.mjs +31 -11
  106. package/src/core/preflight-gate.mjs +2 -2
  107. package/src/core/preflight-receipt.mjs +29 -1
  108. package/src/core/scaffold-plugins.mjs +7 -0
  109. package/src/core/stdio-flush.mjs +23 -0
  110. package/src/core/surface.mjs +33 -10
  111. package/src/server/invoke-durability.mjs +39 -0
  112. package/src/server/invoke-write-child.mjs +25 -0
  113. package/src/server/routes/invoke.mjs +7 -0
  114. package/src/server/routes/plugins.mjs +2 -2
  115. package/frontend/dist/assets/index-BIQmqDKP.css +0 -1
  116. package/frontend/dist/assets/index-CRzjQLu7.js +0 -5631
  117. package/frontend/src/components/DaemonLogsTab.tsx +0 -54
  118. package/frontend/src/components/ResearchAgendaTab.tsx +0 -712
  119. package/frontend/src/components/TaskQueueTab.tsx +0 -193
  120. package/frontend/src/components/VoiceInput.tsx +0 -112
  121. package/frontend/src/pages/ChatPage.tsx +0 -1059
  122. package/frontend/src/pages/CollabPage.tsx +0 -470
  123. package/frontend/src/pages/DesignDocsPage.tsx +0 -621
  124. package/frontend/src/pages/FilesPage.tsx +0 -393
  125. package/frontend/src/pages/HealthPage.tsx +0 -359
  126. package/frontend/src/pages/InboxPage.tsx +0 -83
  127. package/frontend/src/pages/InstructionsPage.tsx +0 -583
  128. package/frontend/src/pages/IntegrationsPage.tsx +0 -761
  129. package/frontend/src/pages/MeshPage.css +0 -58
  130. package/frontend/src/pages/MeshPage.tsx +0 -1219
  131. package/frontend/src/pages/OkfPage.tsx +0 -535
  132. package/frontend/src/pages/SandboxPage.tsx +0 -113
  133. package/frontend/src/pages/SkillsPage.tsx +0 -1994
  134. package/frontend/src/pages/TasksPage.tsx +0 -427
  135. package/frontend/src/pages/UsagePage.tsx +0 -702
  136. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/README.md +0 -65
  137. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/cli.mjs +0 -76
  138. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/package.json +0 -19
  139. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/plugin.json +0 -266
  140. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/adapters/dsh.mjs +0 -446
  141. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/documentation.mjs +0 -152
  142. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/executor.mjs +0 -71
  143. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/runtime.mjs +0 -36
  144. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/setup.mjs +0 -35
  145. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/src/worker.mjs +0 -175
  146. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/DESIGN.md +0 -32
  147. package/scaffold/.agent/skills/total-recall/plugins/cli-agents/ui/card.js +0 -575
@@ -0,0 +1,386 @@
1
+ /**
2
+ * total-recall release — publish a package, propagate it, and verify every consumer.
3
+ *
4
+ * The missing capability this fills: publishing to npm and verifying that the
5
+ * published artifact actually reached every registered consumer were manual
6
+ * steps outside any TR command. A release that is only on npm is not released,
7
+ * and a release whose consumers were never verified is a claim, not a fact.
8
+ *
9
+ * Usage:
10
+ * npx total-recall release plan <pkg> [--version X.Y.Z]
11
+ * npx total-recall release publish <pkg> [--version X.Y.Z] [--otp N]
12
+ * npx total-recall release propagate [--apply]
13
+ * npx total-recall release verify <pkg> [--version X.Y.Z]
14
+ * npx total-recall release all <pkg> [--version X.Y.Z] [--apply] [--otp N]
15
+ *
16
+ * Never publishes without --apply. Never trusts the publish command's own
17
+ * report: verification reads the registry and each consumer's resolved
18
+ * installation independently.
19
+ */
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { spawnSync } from 'node:child_process';
24
+ import { BRAIN_PACKAGES, updateAllBrains } from '../core/brain-updates.mjs';
25
+ import { listUpdateRoots, rootsFromEnv } from '../core/package-auto-update.mjs';
26
+
27
+ const DEFAULT_PACKAGES = BRAIN_PACKAGES;
28
+
29
+ /** @param {string[]} args */
30
+ export function parseArgs(args) {
31
+ const opts = {
32
+ action: null,
33
+ packages: [],
34
+ version: null,
35
+ apply: false,
36
+ otp: null,
37
+ json: false,
38
+ help: false,
39
+ root: null,
40
+ timeoutMs: 20 * 60 * 1000,
41
+ };
42
+ const positional = [];
43
+ for (let i = 0; i < args.length; i++) {
44
+ const a = args[i];
45
+ if (a === '--help' || a === '-h') opts.help = true;
46
+ else if (a === '--apply') opts.apply = true;
47
+ else if (a === '--json') opts.json = true;
48
+ else if (a === '--version' && args[i + 1]) opts.version = args[++i];
49
+ else if (a === '--otp' && args[i + 1]) opts.otp = args[++i];
50
+ else if (a === '--root' && args[i + 1]) opts.root = args[++i];
51
+ else if (a === '--timeout' && args[i + 1]) opts.timeoutMs = Number(args[++i]);
52
+ else if (!a.startsWith('-')) positional.push(a);
53
+ }
54
+ opts.action = positional.shift() || null;
55
+ opts.packages = positional;
56
+ return opts;
57
+ }
58
+
59
+ const ACTIONS = ['plan', 'publish', 'propagate', 'verify', 'all'];
60
+
61
+ function printHelp() {
62
+ console.log(`
63
+ total-recall release — publish, propagate and verify a package release
64
+
65
+ Usage:
66
+ total-recall release plan <pkg> [--version X.Y.Z]
67
+ total-recall release publish <pkg> [--version X.Y.Z] [--otp N] --apply
68
+ total-recall release propagate [--apply] [--json]
69
+ total-recall release verify <pkg> [--version X.Y.Z] [--json]
70
+ total-recall release all <pkg> [--version X.Y.Z] [--apply] [--otp N] [--json]
71
+
72
+ Actions:
73
+ plan Resolve the release version and report gate/consumer readiness. No writes.
74
+ publish Run the package's prepublish gate, then npm publish. Requires --apply.
75
+ propagate Pin the published version across every registered consumer (update --all-brains).
76
+ verify Independently confirm the registry artifact and each consumer's resolved install.
77
+ all plan → publish → propagate → verify, stopping at the first failure.
78
+
79
+ Rules enforced here:
80
+ • Publishing requires --apply; plan/verify never write.
81
+ • Verification never trusts the publish or propagate report: it re-reads the
82
+ npm registry and each consumer's own resolved package.json.
83
+ • A version that is only on npm is reported as incomplete, not as released.
84
+
85
+ Options:
86
+ --apply Perform writes (required for publish/propagate).
87
+ --version X.Y.Z Expect or assert a specific version (defaults to the source package.json).
88
+ --otp N npm one-time password, passed through to npm publish.
89
+ --root PATH Source repo for the package (defaults to $PWD).
90
+ --json Machine-readable output.
91
+ `);
92
+ }
93
+
94
+ /** Read a package's source metadata without assuming a monorepo layout. */
95
+ export function readSourcePackage(root, pkg) {
96
+ const candidates = [path.join(root, 'package.json')];
97
+ // A workspace source may live one level down (packages/<dir>/package.json).
98
+ for (const dir of ['packages', 'apps']) {
99
+ const base = path.join(root, dir);
100
+ if (!fs.existsSync(base)) continue;
101
+ for (const entry of fs.readdirSync(base)) {
102
+ candidates.push(path.join(base, entry, 'package.json'));
103
+ }
104
+ }
105
+ for (const file of candidates) {
106
+ if (!fs.existsSync(file)) continue;
107
+ try {
108
+ const pkgJson = JSON.parse(fs.readFileSync(file, 'utf8'));
109
+ if (pkgJson.name === pkg) return { file, dir: path.dirname(file), pkgJson };
110
+ } catch {
111
+ /* ignore malformed candidates rather than failing the whole release */
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** npm registry read. This is the independent source of truth for "published". */
118
+ export function publishedVersion(pkg, { exec = spawnSync } = {}) {
119
+ const res = exec('npm', ['view', pkg, 'version'], { encoding: 'utf8', timeout: 60000 });
120
+ if (res.status !== 0) return { ok: false, version: null, error: (res.stderr || '').trim() || 'npm view failed' };
121
+ return { ok: true, version: (res.stdout || '').trim(), error: null };
122
+ }
123
+
124
+ /** Wait for a just-published version to become readable from the registry. */
125
+ export async function awaitPublished(pkg, version, { attempts = 8, delayMs = 20000, exec = spawnSync, sleep } = {}) {
126
+ const pause = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
127
+ let last = null;
128
+ for (let i = 0; i < attempts; i++) {
129
+ const read = publishedVersion(pkg, { exec });
130
+ last = read;
131
+ if (read.ok && read.version === version) return { ok: true, version: read.version, attempts: i + 1 };
132
+ if (i < attempts - 1) await pause(delayMs);
133
+ }
134
+ return { ok: false, version: last?.version ?? null, error: last?.error ?? `registry still reports ${last?.version}`, attempts };
135
+ }
136
+
137
+ /**
138
+ * Every consumer root TR knows about: the project registry plus TR_SYNC_REPOS.
139
+ * Used by verify so it never depends on the propagate step's own bookkeeping.
140
+ */
141
+ export function consumerRoots(opts = {}) {
142
+ // Explicit roots are authoritative: passing them means "verify exactly these",
143
+ // which is what makes this callable from tests and from --root. Without them,
144
+ // fall back to the whole registered fleet plus TR_SYNC_REPOS.
145
+ const explicit = (opts.roots || []).filter(Boolean);
146
+ const source = explicit.length
147
+ ? explicit
148
+ : [...listUpdateRoots(opts).map((row) => (typeof row === 'string' ? row : row.root)), ...rootsFromEnv()];
149
+ const seen = new Set();
150
+ const out = [];
151
+ for (const root of source) {
152
+ if (!root) continue;
153
+ const resolved = path.resolve(root);
154
+ if (seen.has(resolved)) continue;
155
+ seen.add(resolved);
156
+ out.push(resolved);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** Read the version a consumer actually resolves for a package (declared + installed). */
162
+ export function consumerState(root, pkg, version = null) {
163
+ const declaredFiles = [];
164
+ const pkgFile = path.join(root, 'package.json');
165
+ let declared = null;
166
+ if (fs.existsSync(pkgFile)) {
167
+ try {
168
+ const j = JSON.parse(fs.readFileSync(pkgFile, 'utf8'));
169
+ const all = { ...(j.dependencies || {}), ...(j.devDependencies || {}), ...(j.optionalDependencies || {}) };
170
+ declared = all[pkg] ?? null;
171
+ if (declared) declaredFiles.push(pkgFile);
172
+ } catch {
173
+ /* unreadable package.json is reported as unknown, not as satisfied */
174
+ }
175
+ }
176
+ if (!declared) return { root, declared: null, installed: null, satisfied: false, reason: 'not-declared' };
177
+
178
+ // Resolve the version a spec pins. Handles plain versions ("0.14.4"),
179
+ // npm: aliases ("npm:@scope/pkg@0.14.4") and ranges. A range is satisfied by
180
+ // the installed version rather than by string equality.
181
+ const spec = String(declared).trim();
182
+ let want = null;
183
+ let range = false;
184
+ if (spec.startsWith('npm:')) {
185
+ const afterAt = spec.slice(4);
186
+ const idx = afterAt.lastIndexOf('@');
187
+ want = idx > 0 ? afterAt.slice(idx + 1) : null;
188
+ } else if (/^[~^]|^[<>]=?|\|\||\s-\s|\*|x$/i.test(spec)) {
189
+ range = true;
190
+ } else {
191
+ want = spec;
192
+ }
193
+
194
+ const installedCandidates = [
195
+ path.join(root, 'node_modules', pkg, 'package.json'),
196
+ // npm: aliases install under the alias directory, which is what .bin shims run.
197
+ ...aliasDirs(root, pkg),
198
+ ];
199
+ let installed = null;
200
+ for (const file of installedCandidates) {
201
+ if (!fs.existsSync(file)) continue;
202
+ try {
203
+ installed = JSON.parse(fs.readFileSync(file, 'utf8')).version ?? null;
204
+ if (installed) break;
205
+ } catch {
206
+ /* keep looking */
207
+ }
208
+ }
209
+ const satisfied = range ? Boolean(installed) && installed === version : Boolean(want && installed === want);
210
+ return {
211
+ root,
212
+ declared,
213
+ want,
214
+ range,
215
+ installed,
216
+ satisfied,
217
+ reason: !installed ? 'not-installed' : satisfied ? 'ok' : 'installed-mismatch',
218
+ };
219
+ }
220
+
221
+ /** Alias install dirs for a package, e.g. @ssss/cli -> node_modules/@ssss/cli. */
222
+ function aliasDirs(root, pkg) {
223
+ const dirs = [];
224
+ const nm = path.join(root, 'node_modules');
225
+ if (!fs.existsSync(nm)) return dirs;
226
+ // Known alias pair for the SSSS CLI: the alias dir is what .bin/ssss executes.
227
+ if (pkg === '@gregiteen/ssss-cli') {
228
+ dirs.push(path.join(nm, '@ssss', 'cli', 'package.json'));
229
+ }
230
+ return dirs;
231
+ }
232
+
233
+ export function verifyConsumers(pkg, version, opts = {}) {
234
+ const roots = consumerRoots(opts);
235
+ const states = roots.map((root) => consumerState(root, pkg, version));
236
+ const relevant = states.filter((s) => s.declared);
237
+ const failing = relevant.filter((s) => !s.satisfied);
238
+ return {
239
+ pkg,
240
+ version,
241
+ roots_considered: roots.length,
242
+ consumers: relevant.length,
243
+ failing: failing.length,
244
+ failures: failing.map((s) => ({ root: s.root, declared: s.declared, installed: s.installed, reason: s.reason })),
245
+ };
246
+ }
247
+
248
+ function runPrepublish(dir, pkgJson, { exec = spawnSync, timeoutMs } = {}) {
249
+ if (!pkgJson?.scripts?.prepublishOnly) return { ok: true, skipped: true };
250
+ const manager = fs.existsSync(path.join(dir, 'pnpm-lock.yaml')) ? 'pnpm' : 'npm';
251
+ const res = exec(manager, ['run', 'prepublishOnly'], { cwd: dir, encoding: 'utf8', stdio: 'pipe', timeout: timeoutMs });
252
+ return { ok: res.status === 0, skipped: false, output: `${res.stdout || ''}${res.stderr || ''}`.trim() };
253
+ }
254
+
255
+ function doPublish(dir, { otp, exec = spawnSync, timeoutMs } = {}) {
256
+ const args = ['publish'];
257
+ if (otp) args.push('--otp', otp);
258
+ const res = exec('npm', args, { cwd: dir, encoding: 'utf8', stdio: 'pipe', timeout: timeoutMs });
259
+ return { ok: res.status === 0, output: `${res.stdout || ''}${res.stderr || ''}`.trim() };
260
+ }
261
+
262
+ /**
263
+ * @param {string[]} argv full process argv (bin convention)
264
+ */
265
+ export async function run(argv = process.argv, deps = {}) {
266
+ const opts = parseArgs(argv.slice(3));
267
+ const log = opts.json ? () => {} : (...a) => console.log(...a);
268
+ const emit = (obj) => { if (opts.json) console.log(JSON.stringify(obj, null, 2)); };
269
+
270
+ if (opts.help || !opts.action) {
271
+ printHelp();
272
+ if (!opts.action && !opts.help) process.exitCode = 1;
273
+ return;
274
+ }
275
+ if (!ACTIONS.includes(opts.action)) {
276
+ console.error(`Unknown release action: ${opts.action}. Expected one of: ${ACTIONS.join(', ')}`);
277
+ process.exitCode = 1;
278
+ return;
279
+ }
280
+
281
+ const report = { action: opts.action, packages: {}, ok: false, steps: [] };
282
+ const fail = (message) => {
283
+ report.error = message;
284
+ report.ok = false;
285
+ if (opts.json) emit(report); else console.error(`\n ✗ ${message}\n`);
286
+ process.exitCode = 1;
287
+ };
288
+
289
+ // propagate and verify act on the whole fleet and need no package argument.
290
+ if (opts.action === 'propagate') {
291
+ const dryRun = !opts.apply;
292
+ log(`\n Propagating ${DEFAULT_PACKAGES.join(' + ')} across every registered consumer${dryRun ? ' (plan)' : ''}...`);
293
+ const summary = await (deps.updateAllBrains || updateAllBrains)({ dryRun, force: !dryRun, skipThrottle: !dryRun });
294
+ report.steps.push({ step: 'propagate', dryRun, summary });
295
+ report.ok = (summary.failed || 0) === 0;
296
+ if (opts.json) { emit(report); return; }
297
+ log(`\n roots: ${summary.root_count ?? '—'} consumers: ${summary.consumer_count ?? '—'} failed: ${summary.failed ?? 0}`);
298
+ if (dryRun) log(' Run with --apply to install.\n');
299
+ if (!report.ok) process.exitCode = 1;
300
+ return;
301
+ }
302
+
303
+ const packages = opts.packages.length ? opts.packages : DEFAULT_PACKAGES;
304
+ if (!packages.length) return fail('No package given.');
305
+
306
+ for (const pkg of packages) {
307
+ const root = opts.root || process.cwd();
308
+ const source = readSourcePackage(root, pkg);
309
+ const entry = { pkg, root, source: source?.file ?? null };
310
+ report.packages[pkg] = entry;
311
+
312
+ if (opts.action === 'verify') {
313
+ const version = opts.version || source?.pkgJson?.version;
314
+ if (!version) return fail(`${pkg}: no --version and no source package.json found under ${root}`);
315
+ entry.version = version;
316
+ const reg = await awaitPublished(pkg, version, { attempts: 1, delayMs: 0 });
317
+ entry.registry = reg;
318
+ const consumers = verifyConsumers(pkg, version, { roots: opts.root ? [opts.root] : [] });
319
+ entry.consumers = consumers;
320
+ if (!reg.ok) return fail(`${pkg}@${version} is not readable from the npm registry (${reg.error}).`);
321
+ if (consumers.failing > 0) {
322
+ return fail(`${pkg}@${version}: ${consumers.failing}/${consumers.consumers} consumers not satisfied. First: ${consumers.failures[0].root} (${consumers.failures[0].installed ?? 'not installed'}).`);
323
+ }
324
+ log(` ✓ ${pkg}@${version} verified: registry + ${consumers.consumers}/${consumers.consumers} consumers`);
325
+ continue;
326
+ }
327
+
328
+ if (!source) return fail(`${pkg}: source package.json not found under ${root}. Pass --root <repo>.`);
329
+ const version = opts.version || source.pkgJson.version;
330
+ entry.version = version;
331
+ if (opts.version && source.pkgJson.version !== opts.version) {
332
+ return fail(`${pkg}: source is ${source.pkgJson.version} but --version ${opts.version} was requested. Bump the source first.`);
333
+ }
334
+
335
+ if (opts.action === 'plan') {
336
+ const reg = publishedVersion(pkg);
337
+ entry.registry = reg;
338
+ entry.gates = source.pkgJson.scripts?.prepublishOnly ? 'present' : 'absent';
339
+ entry.alreadyPublished = reg.ok && reg.version === version;
340
+ log(` ${pkg}: source ${version}, registry ${reg.version ?? '—'}${entry.alreadyPublished ? ' (already published)' : ''}, prepublish gate ${entry.gates}`);
341
+ continue;
342
+ }
343
+
344
+ if (opts.action === 'publish' || opts.action === 'all') {
345
+ const reg = publishedVersion(pkg);
346
+ entry.registry_before = reg;
347
+ if (reg.ok && reg.version === version) {
348
+ log(` · ${pkg}@${version} already published — skipping publish`);
349
+ entry.published = 'already';
350
+ } else {
351
+ if (!opts.apply) return fail(`publish requires --apply (would publish ${pkg}@${version}).`);
352
+ const gate = runPrepublish(source.dir, source.pkgJson, { timeoutMs: opts.timeoutMs });
353
+ entry.gate = gate.ok ? 'passed' : 'failed';
354
+ report.steps.push({ step: 'prepublish', pkg, ok: gate.ok, skipped: gate.skipped });
355
+ if (!gate.ok) return fail(`${pkg}: prepublishOnly gate failed.\n${gate.output}`);
356
+ const pub = doPublish(source.dir, { otp: opts.otp, timeoutMs: opts.timeoutMs });
357
+ entry.publish_output = pub.output;
358
+ if (!pub.ok) return fail(`${pkg}: npm publish failed.\n${pub.output}`);
359
+ const confirmed = await (deps.awaitPublished || awaitPublished)(pkg, version, deps);
360
+ entry.registry_after = confirmed;
361
+ if (!confirmed.ok) return fail(`${pkg}@${version} published but the registry still reports ${confirmed.version}. Treat as NOT released.`);
362
+ entry.published = 'yes';
363
+ log(` ✓ ${pkg}@${version} published and confirmed on the registry`);
364
+ }
365
+ if (opts.action === 'publish') continue;
366
+ }
367
+
368
+ if (opts.action === 'all') {
369
+ const dryRun = !opts.apply;
370
+ const summary = await (deps.updateAllBrains || updateAllBrains)({ dryRun, force: !dryRun, skipThrottle: !dryRun });
371
+ report.steps.push({ step: 'propagate', pkg, summary });
372
+ if ((summary.failed || 0) > 0) return fail(`${pkg}: propagation reported ${summary.failed} failure(s).`);
373
+ const consumers = verifyConsumers(pkg, version, {});
374
+ entry.consumers = consumers;
375
+ if (consumers.failing > 0) {
376
+ return fail(`${pkg}@${version}: ${consumers.failing}/${consumers.consumers} consumers still not satisfied after propagation.`);
377
+ }
378
+ log(` ✓ ${pkg}@${version}: registry + ${consumers.consumers}/${consumers.consumers} consumers`);
379
+ }
380
+ }
381
+
382
+ report.ok = true;
383
+ if (opts.json) emit(report);
384
+ else if (opts.action === 'plan') log('\n Plan only — nothing published or changed.\n');
385
+ else log('');
386
+ }
@@ -234,7 +234,7 @@ export default async function remember(args) {
234
234
  i++;
235
235
  }
236
236
  } else if (arg === '--no-dedup') {
237
- // Accepted for compatibility; remember never archives another node.
237
+ // Skips the duplicate guard below; remember never archives another node.
238
238
  } else if (arg === '--allow-long') {
239
239
  allowLong = true;
240
240
  } else if (arg === '--universal') {
@@ -324,6 +324,24 @@ export default async function remember(args) {
324
324
  node.immutable = true;
325
325
  }
326
326
 
327
+ // Hermes-grade fail-closed duplicate guard: prevent writing duplicate rules/invariants
328
+ if (!layerArgs.includes('--force') && !layerArgs.includes('--no-dedup')) {
329
+ try {
330
+ const { assertNoDuplicateRule } = await import('../core/duplicate-guard.mjs');
331
+ assertNoDuplicateRule(node, {
332
+ vaultDir,
333
+ derivedDir,
334
+ globalVaultDir: path.join(getBothBrains().global.brainDir, 'memory-vault'),
335
+ projectRoot: (project && project.projectRoot) ? project.projectRoot : process.cwd(),
336
+ });
337
+ } catch (err) {
338
+ if (err.code === 'ERR_DUPLICATE_RULE') {
339
+ process.exit(1);
340
+ }
341
+ throw err;
342
+ }
343
+ }
344
+
327
345
  // Saving memory never optimizes or archives other canonical documents.
328
346
 
329
347
  const vaultResult = await writeNodeValidatedAsync(node, vaultDir);
package/src/cli/rules.mjs CHANGED
@@ -15,17 +15,49 @@ const POLICY_SLUG = 'context-policy';
15
15
  const ALL_ACTIONS = [...CONTEXT_ACTIONS];
16
16
  const DEFAULT_BUDGET = 4000;
17
17
 
18
+ const explicitBudget = (flags) => {
19
+ const explicit = Number(flags.budget);
20
+ return Number.isFinite(explicit) && explicit > 0 ? explicit : undefined;
21
+ };
22
+
18
23
  /** Budget for capsule checks: --budget, else the context_budget setting, else 4000. */
19
24
  function resolveBudget(flags) {
20
- const explicit = Number(flags.budget);
21
- if (Number.isFinite(explicit) && explicit > 0) return explicit;
25
+ return explicitBudget(flags) ?? configuredBudget() ?? DEFAULT_BUDGET;
26
+ }
27
+
28
+ function configuredBudget() {
22
29
  for (const layer of ['project', 'global']) {
23
30
  try {
24
31
  const value = readConfigValue('context_budget', layer);
25
32
  if (Number.isFinite(value) && value > 0) return value;
26
33
  } catch { /* fall through to the next layer */ }
27
34
  }
28
- return DEFAULT_BUDGET;
35
+ return undefined;
36
+ }
37
+
38
+ /** The budget `context` applies when none is given. */
39
+ async function effectiveBudget() {
40
+ const { CAPSULE_LIMIT } = await import('./context.mjs');
41
+ return Math.min(configuredBudget() ?? CAPSULE_LIMIT, CAPSULE_LIMIT);
42
+ }
43
+
44
+ /** Whether `context` can really serve each capsule: it compacts automatically, so raw size alone is not a failure (CAPSULE-R15). */
45
+ async function checkCompactable(capsules, budget, problems) {
46
+ const { loadContextNodes, compactedHeader } = await import('./context.mjs');
47
+ const { compactNodes } = await import('./compact.mjs');
48
+ const { nodes, skillsDir, derivedDirs } = loadContextNodes();
49
+ const header = compactedHeader('0'.repeat(64), budget);
50
+ for (const capsule of capsules) {
51
+ capsule.compacted = capsule.tokens > budget;
52
+ try {
53
+ await compactNodes({ task: '', actions: capsule.actions.split(','), budget, complete: true, rank: false, nodes, skillsDir, derivedDirs, header });
54
+ capsule.withinBudget = true;
55
+ } catch (error) {
56
+ if (!error.requiredBudget) throw error;
57
+ capsule.withinBudget = false;
58
+ problems.push(`capsule for ${capsule.actions} cannot fit ${budget} tokens even compacted; it needs ${error.requiredBudget}`);
59
+ }
60
+ }
29
61
  }
30
62
 
31
63
  function help() {
@@ -37,7 +69,7 @@ function help() {
37
69
  audit [--budget N] [--top N] [--json] Capsule size per action, largest rules, curation health
38
70
  draft [--all] [--actions a,b] [--out F] Write a policy scaffold (original text as directives) to shorten
39
71
  apply <file> [--dry-run] [--allow-exclude] Validate a policy file and merge it into this repo's policy node
40
- verify [--budget N] [--json] Exit 1 on stale/orphan curation or an action capsule over budget
72
+ verify [--budget N] [--json] Exit 1 on stale/orphan curation or a capsule context cannot serve even compacted; --budget checks raw size
41
73
  prune [--apply] [--archive] [--json] Drop stale policy entries; list (or archive) expired/superseded/duplicate rules
42
74
 
43
75
  Workflow: audit -> draft -> shorten each directive by hand (keep every operative
@@ -107,9 +139,10 @@ function parseFlags(args, valued = []) {
107
139
  return { flags, rest };
108
140
  }
109
141
 
110
- function audit(args) {
142
+ async function audit(args) {
111
143
  const { flags } = parseFlags(args, ['budget', 'top']);
112
- const budget = resolveBudget(flags);
144
+ const explicit = explicitBudget(flags);
145
+ const budget = explicit ?? await effectiveBudget();
113
146
  const state = loadState();
114
147
  const curated = curatedRules(state.nodes);
115
148
  const rows = curated.filter(n => RULE_CATEGORIES.has(n.category) && n.status === 'active').map(n => {
@@ -119,7 +152,12 @@ function audit(args) {
119
152
  return { id, original, current, curated: Boolean(n._directive), excluded: Boolean(n._contextExcluded) };
120
153
  });
121
154
  const sz = sizes(state);
122
- const over = sz.filter(s => s.actions !== '(none)' && s.tokens > budget);
155
+ const capsules = sz.filter(s => s.actions !== '(none)');
156
+ // Without --budget, `over` means what it means to `context`: not servable even compacted.
157
+ const problems = [];
158
+ if (explicit) for (const s of capsules) s.withinBudget = s.tokens <= budget;
159
+ else await checkCompactable(capsules, budget, problems);
160
+ const over = capsules.filter(s => !s.withinBudget);
123
161
  const report = {
124
162
  policy: state.health, rules: rows.length, curated: rows.filter(r => r.curated).length,
125
163
  excluded: rows.filter(r => r.excluded).length, stale: state.stale, orphan: state.orphan,
@@ -131,9 +169,11 @@ function audit(args) {
131
169
  if (state.stale.length) console.log(`Stale curation (rule edited since; full text in use): ${state.stale.join(', ')}`);
132
170
  if (state.orphan.length) console.log(`Orphan curation (rule gone): ${state.orphan.join(', ')}`);
133
171
  console.log(`\nCapsule tokens by action (budget ${budget}):`);
134
- for (const s of sz) console.log(` ${s.actions !== '(none)' && s.tokens > budget ? 'OVER' : 'ok '} ${String(s.tokens).padStart(6)} ${s.actions} (${s.count} rules)`);
172
+ for (const s of sz) console.log(` ${s.withinBudget === false ? 'OVER' : s.compacted ? 'comp' : 'ok '} ${String(s.tokens).padStart(6)} ${s.actions} (${s.count} rules)`);
173
+ if (sz.some(s => s.compacted)) console.log(' comp = larger than the budget; `context` compacts it automatically and every rule still applies');
135
174
  console.log('\nLargest rules (current/original tokens):');
136
175
  for (const r of report.largest) console.log(` ${String(r.current).padStart(5)}/${String(r.original).padEnd(5)} ${r.id}${r.curated ? ' (curated)' : ''}`);
176
+ for (const p of problems) console.log(`\n${p}`);
137
177
  if (over.length) console.log('\nOver budget: run `total-recall rules draft --out policy.json`, shorten the directives, then `rules apply policy.json`.');
138
178
  }
139
179
 
@@ -224,20 +264,22 @@ async function apply(args) {
224
264
  console.log(`Applied ${Object.keys(clean).length} entries (${Object.keys(merged.rules).length} total). Run \`total-recall rules verify\` after the vault recompiles.`);
225
265
  }
226
266
 
227
- function verify(args) {
267
+ async function verify(args) {
228
268
  const { flags } = parseFlags(args, ['budget']);
229
- const budget = resolveBudget(flags);
269
+ const explicit = explicitBudget(flags);
270
+ const budget = explicit ?? await effectiveBudget();
230
271
  const state = loadState();
231
272
  const problems = [];
232
273
  if (state.health === 'ambiguous' || state.health === 'malformed') problems.push(`policy is ${state.health}; curation is ignored`);
233
274
  for (const id of state.stale) problems.push(`stale curation: ${id} (rule edited; full text in use)`);
234
275
  for (const id of state.orphan) problems.push(`orphan curation: ${id} (rule no longer exists)`);
235
276
  const capsules = sizes(state).filter(s => s.actions !== '(none)').map(s => ({ actions: s.actions, tokens: s.tokens, rules: s.count, withinBudget: s.tokens <= budget }));
236
- for (const s of capsules) if (!s.withinBudget) problems.push(`capsule for ${s.actions} is ${s.tokens} tokens > ${budget}`);
277
+ if (explicit) for (const s of capsules) { if (!s.withinBudget) problems.push(`capsule for ${s.actions} is ${s.tokens} tokens > ${budget}`); }
278
+ else await checkCompactable(capsules, budget, problems);
237
279
  const ok = problems.length === 0;
238
280
  if (flags.json) { console.log(JSON.stringify({ ok, health: state.health, budget, capsules, problems }, null, 1)); if (!ok) process.exitCode = 1; return; }
239
281
  if (!ok) { for (const p of problems) console.log(`✖ ${p}`); process.exitCode = 1; return; }
240
- console.log(`✔ policy ${state.health}; every action capsule within ${budget} tokens`);
282
+ console.log(`✔ policy ${state.health}; every action capsule within ${budget} tokens${explicit ? '' : ' (compacted where needed)'}`);
241
283
  }
242
284
 
243
285
  async function prune(args) {
@@ -41,7 +41,7 @@ export default async function startup(args = []) {
41
41
  const availH = harnesses.filter(h => h.available);
42
42
  const readyH = availH.filter(h => h.authed);
43
43
  if (availH.length) {
44
- console.log(`Harnesses: ${readyH.length}/${availH.length} ready (${readyH.map(h => `${h.id}: 5h ${h.rolling5hRemainingPct}`).join(', ')})`);
44
+ console.log(`Harnesses: ${readyH.length}/${availH.length} ready (${readyH.map(h => `${h.id}: 5h ${h.rolling5hRemainingPct} [${h.enforcement || 'enforced'}]`).join(', ')})`);
45
45
  }
46
46
  for (const action of (report.actions || [])) console.log(`Action: ${action.component} ${action.action} (${action.accepted === true ? 'accepted; see observed readiness' : action.reason || 'failed'})`);
47
47
  }
@@ -78,7 +78,16 @@ function projectionsStatus(clients, instructionsMtime) {
78
78
  fresh = projMtime >= instructionsMtime;
79
79
  } catch { fresh = false; }
80
80
  }
81
- return { name, label: info.label, mode: info.mode, connectedAt: info.connectedAt, projectionPath: info.projectionPath, fresh };
81
+ return {
82
+ name,
83
+ label: info.label,
84
+ mode: info.mode,
85
+ enforcement: info.enforcement || 'advisory',
86
+ enforcementReason: info.enforcementReason || null,
87
+ connectedAt: info.connectedAt,
88
+ projectionPath: info.projectionPath,
89
+ fresh
90
+ };
82
91
  });
83
92
  }
84
93
 
@@ -312,7 +321,8 @@ export default async function statusCmd(args) {
312
321
  if (c.mode === 'file' || c.mode === 'symlink') {
313
322
  freshnessTag = c.fresh === null ? ' (no projection)' : c.fresh ? ' ✅ fresh' : ' ⚠️ stale — run: total-recall connect ' + c.name;
314
323
  }
315
- console.log(` ${c.label.padEnd(18)} [${c.mode}]${freshnessTag}`);
324
+ const enfTag = c.enforcement ? ` [${c.enforcement}]` : '';
325
+ console.log(` ${c.label.padEnd(18)} [${c.mode}]${enfTag}${freshnessTag}`);
316
326
  }
317
327
  } else {
318
328
  console.log(' Connected clients: (none — run total-recall connect <client>)');
@@ -21,18 +21,24 @@ const GATE_MATCHER = 'Bash|RemoteTrigger|CronCreate|mcp__scheduled-tasks__.*|mcp
21
21
 
22
22
  const SKILL_COMMAND = 'f="$CLAUDE_PROJECT_DIR/.agent/skills/total-recall/SKILL.md"; [ -f "$f" ] || f="$HOME/.agent/skills/total-recall/SKILL.md"; if [ -f "$f" ]; then echo "MANDATORY CONTEXT: the Total Recall skill ($f), loaded by a SessionStart hook. Follow it; use the total-recall CLI as documented here."; cat "$f"; fi';
23
23
 
24
+ export const PREFLIGHT_GATE_COMMAND = 'total-recall preflight gate --harness claude';
25
+ export const PREFLIGHT_REMIND_COMMAND = 'total-recall preflight remind';
26
+ export const STOPGAP_FILE = 'total-recall-preflight.mjs';
27
+
24
28
  const hasCommand = (entries, needle) => (entries || []).some((e) => (e.hooks || []).some((h) => String(h.command || h.statusMessage || '').includes(needle)));
25
29
 
26
30
  /**
27
31
  * @param {{ home?: string, dryRun?: boolean }} [opts]
28
- * @returns {{ gate: string, settings: string, added: string[] }}
32
+ * @returns {{ gate: string, settings: string, added: string[], removed: string[] }}
29
33
  */
30
34
  export function installClaudeCodeHooks(opts = {}) {
31
35
  const home = opts.home || os.homedir();
32
36
  const claudeDir = path.join(home, '.claude');
33
37
  const hookPath = path.join(claudeDir, 'hooks', GATE_FILE);
38
+ const stopgapPath = path.join(claudeDir, 'hooks', STOPGAP_FILE);
34
39
  const settingsPath = path.join(claudeDir, 'settings.json');
35
40
  const added = [];
41
+ const removed = [];
36
42
 
37
43
  const template = fs.readFileSync(GATE_TEMPLATE, 'utf8');
38
44
  const current = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, 'utf8') : null;
@@ -46,6 +52,28 @@ export function installClaudeCodeHooks(opts = {}) {
46
52
  }
47
53
  }
48
54
  const hooks = settings.hooks || (settings.hooks = {});
55
+
56
+ // Clean up 2026-11-02 stopgap entries from settings and filesystem (ENFORCE-R14)
57
+ for (const event of ['PreToolUse', 'UserPromptSubmit', 'SessionStart', 'PostToolUse']) {
58
+ if (Array.isArray(hooks[event])) {
59
+ const beforeLen = hooks[event].length;
60
+ hooks[event] = hooks[event].filter(entry => {
61
+ const sub = entry.hooks || [];
62
+ return !sub.some(h => String(h.command || '').includes(STOPGAP_FILE));
63
+ });
64
+ if (hooks[event].length < beforeLen) {
65
+ removed.push(`stopgap ${event} hook`);
66
+ }
67
+ }
68
+ }
69
+
70
+ if (!opts.dryRun && fs.existsSync(stopgapPath)) {
71
+ try {
72
+ fs.unlinkSync(stopgapPath);
73
+ removed.push('stopgap hook script');
74
+ } catch { /* ignore */ }
75
+ }
76
+
49
77
  const gateCommand = `node "$HOME/.claude/hooks/${GATE_FILE}"`;
50
78
  if (!hasCommand(hooks.SessionStart, SKILL_MARK) && !hasCommand(hooks.SessionStart, 'total-recall/SKILL.md')) {
51
79
  (hooks.SessionStart ||= []).push({ hooks: [{ type: 'command', command: SKILL_COMMAND, timeout: 10, statusMessage: SKILL_MARK }] });
@@ -60,11 +88,21 @@ export function installClaudeCodeHooks(opts = {}) {
60
88
  added.push('PostToolUse recorder');
61
89
  }
62
90
 
63
- if (!opts.dryRun && added.length) {
91
+ // Managed preflight enforcement hooks (ENFORCE-R1, ENFORCE-R2, ENFORCE-R14)
92
+ if (!hasCommand(hooks.PreToolUse, 'preflight gate --harness claude')) {
93
+ (hooks.PreToolUse ||= []).push({ matcher: '*', hooks: [{ type: 'command', command: PREFLIGHT_GATE_COMMAND, timeout: 5 }] });
94
+ added.push('managed PreToolUse preflight gate');
95
+ }
96
+ if (!hasCommand(hooks.UserPromptSubmit, 'preflight remind')) {
97
+ (hooks.UserPromptSubmit ||= []).push({ hooks: [{ type: 'command', command: PREFLIGHT_REMIND_COMMAND, timeout: 5 }] });
98
+ added.push('managed UserPromptSubmit preflight reminder');
99
+ }
100
+
101
+ if (!opts.dryRun && (added.length || removed.length)) {
64
102
  fs.mkdirSync(path.dirname(hookPath), { recursive: true });
65
103
  if (current !== template) fs.writeFileSync(hookPath, template, { mode: 0o755 });
66
104
  if (fs.existsSync(settingsPath)) fs.copyFileSync(settingsPath, `${settingsPath}.bak-total-recall`);
67
105
  fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
68
106
  }
69
- return { gate: hookPath, settings: settingsPath, added };
107
+ return { gate: hookPath, settings: settingsPath, added, removed };
70
108
  }