brainclaw 0.29.2 → 1.5.4

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 (197) hide show
  1. package/LICENSE +21 -74
  2. package/README.md +199 -176
  3. package/dist/brainclaw-vscode.vsix +0 -0
  4. package/dist/cli.js +710 -25
  5. package/dist/commands/accept.js +3 -0
  6. package/dist/commands/add-step.js +11 -26
  7. package/dist/commands/agent-board.js +70 -3
  8. package/dist/commands/audit.js +19 -0
  9. package/dist/commands/check-policy.js +54 -0
  10. package/dist/commands/check-security-mcp.js +145 -0
  11. package/dist/commands/check-security.js +106 -0
  12. package/dist/commands/claim-resource.js +1 -0
  13. package/dist/commands/codev.js +672 -0
  14. package/dist/commands/compact.js +74 -0
  15. package/dist/commands/complete-step.js +16 -26
  16. package/dist/commands/constraint.js +8 -20
  17. package/dist/commands/decision.js +9 -20
  18. package/dist/commands/delete-plan.js +10 -12
  19. package/dist/commands/delete-step.js +16 -0
  20. package/dist/commands/dispatch.js +163 -0
  21. package/dist/commands/doctor.js +1122 -49
  22. package/dist/commands/enable-agent.js +1 -0
  23. package/dist/commands/export.js +280 -22
  24. package/dist/commands/handoff.js +33 -0
  25. package/dist/commands/harvest.js +189 -0
  26. package/dist/commands/hooks.js +82 -25
  27. package/dist/commands/inbox.js +169 -0
  28. package/dist/commands/init.js +38 -31
  29. package/dist/commands/install-hooks.js +71 -44
  30. package/dist/commands/link.js +89 -0
  31. package/dist/commands/list-claims.js +48 -3
  32. package/dist/commands/list-plans.js +129 -25
  33. package/dist/commands/loops-handlers.js +409 -0
  34. package/dist/commands/mcp-read-handlers.js +1628 -0
  35. package/dist/commands/mcp-schemas.generated.js +269 -0
  36. package/dist/commands/mcp.js +4224 -1501
  37. package/dist/commands/plan-resource.js +64 -0
  38. package/dist/commands/plan.js +12 -26
  39. package/dist/commands/prune.js +37 -2
  40. package/dist/commands/reflect.js +20 -7
  41. package/dist/commands/release-claim.js +11 -6
  42. package/dist/commands/release-notes.js +170 -0
  43. package/dist/commands/repair.js +210 -0
  44. package/dist/commands/run-profile.js +57 -0
  45. package/dist/commands/sequence.js +113 -0
  46. package/dist/commands/session-end.js +423 -14
  47. package/dist/commands/session-start.js +214 -41
  48. package/dist/commands/setup-security.js +103 -0
  49. package/dist/commands/setup.js +42 -4
  50. package/dist/commands/stale.js +109 -0
  51. package/dist/commands/switch.js +100 -2
  52. package/dist/commands/trap.js +14 -31
  53. package/dist/commands/update-handoff.js +63 -4
  54. package/dist/commands/update-plan.js +21 -28
  55. package/dist/commands/update-step.js +37 -0
  56. package/dist/commands/upgrade.js +313 -6
  57. package/dist/commands/usage.js +102 -0
  58. package/dist/commands/version.js +20 -0
  59. package/dist/commands/who.js +33 -5
  60. package/dist/commands/worktree.js +105 -0
  61. package/dist/core/actions.js +315 -0
  62. package/dist/core/agent-capability.js +610 -17
  63. package/dist/core/agent-context.js +7 -1
  64. package/dist/core/agent-files.js +1169 -85
  65. package/dist/core/agent-integrations.js +160 -5
  66. package/dist/core/agent-inventory.js +2 -0
  67. package/dist/core/agent-profiles.js +93 -0
  68. package/dist/core/agent-registry.js +162 -30
  69. package/dist/core/agentrun-reconciler.js +345 -0
  70. package/dist/core/agentruns.js +424 -0
  71. package/dist/core/ai-agent-detection.js +31 -10
  72. package/dist/core/archival.js +77 -0
  73. package/dist/core/assignment-sweeper.js +82 -0
  74. package/dist/core/assignments.js +367 -0
  75. package/dist/core/audit.js +30 -0
  76. package/dist/core/brainclaw-version.js +94 -2
  77. package/dist/core/candidates.js +93 -2
  78. package/dist/core/claims.js +419 -0
  79. package/dist/core/codev-metrics.js +77 -0
  80. package/dist/core/codev-personas.js +31 -0
  81. package/dist/core/codev-plan-gen.js +35 -0
  82. package/dist/core/codev-prompts.js +74 -0
  83. package/dist/core/codev-responses.js +62 -0
  84. package/dist/core/codev-rounds.js +218 -0
  85. package/dist/core/config.js +4 -0
  86. package/dist/core/context.js +381 -34
  87. package/dist/core/coordination.js +201 -6
  88. package/dist/core/cross-project.js +230 -16
  89. package/dist/core/default-profiles/doctor.yaml +11 -0
  90. package/dist/core/default-profiles/janitor.yaml +11 -0
  91. package/dist/core/default-profiles/onboarder.yaml +11 -0
  92. package/dist/core/default-profiles/reviewer.yaml +13 -0
  93. package/dist/core/dispatcher.js +1189 -0
  94. package/dist/core/duplicates.js +2 -2
  95. package/dist/core/entity-operations.js +450 -0
  96. package/dist/core/entity-registry.js +344 -0
  97. package/dist/core/events.js +106 -2
  98. package/dist/core/execution-adapters.js +154 -0
  99. package/dist/core/execution-context.js +63 -0
  100. package/dist/core/execution-profile.js +270 -0
  101. package/dist/core/execution.js +255 -0
  102. package/dist/core/facade-schema.js +81 -0
  103. package/dist/core/federation-cloud.js +99 -0
  104. package/dist/core/federation-message.js +52 -0
  105. package/dist/core/federation-transport.js +65 -0
  106. package/dist/core/gc-semantic.js +482 -0
  107. package/dist/core/governance.js +247 -0
  108. package/dist/core/guards.js +19 -0
  109. package/dist/core/ideation.js +72 -0
  110. package/dist/core/identity.js +110 -25
  111. package/dist/core/ids.js +6 -0
  112. package/dist/core/input-validation.js +2 -2
  113. package/dist/core/instruction-templates.js +344 -136
  114. package/dist/core/io.js +90 -11
  115. package/dist/core/lock.js +6 -2
  116. package/dist/core/loops/brief-assembly.js +213 -0
  117. package/dist/core/loops/facade-schema.js +148 -0
  118. package/dist/core/loops/index.js +7 -0
  119. package/dist/core/loops/iteration-engine.js +139 -0
  120. package/dist/core/loops/lock.js +385 -0
  121. package/dist/core/loops/store.js +201 -0
  122. package/dist/core/loops/types.js +403 -0
  123. package/dist/core/loops/verbs.js +534 -0
  124. package/dist/core/markdown.js +15 -3
  125. package/dist/core/memory-compactor.js +432 -0
  126. package/dist/core/memory-git.js +152 -8
  127. package/dist/core/messaging.js +278 -0
  128. package/dist/core/migration.js +32 -1
  129. package/dist/core/mutation-pipeline.js +4 -2
  130. package/dist/core/operations/memory-mutation.js +129 -0
  131. package/dist/core/operations/memory-write.js +78 -0
  132. package/dist/core/operations/plan.js +190 -0
  133. package/dist/core/policy.js +169 -0
  134. package/dist/core/reputation.js +9 -3
  135. package/dist/core/schema.js +491 -6
  136. package/dist/core/search.js +21 -2
  137. package/dist/core/security-cache.js +71 -0
  138. package/dist/core/security-guard.js +152 -0
  139. package/dist/core/security-scoring.js +86 -0
  140. package/dist/core/sequence.js +130 -0
  141. package/dist/core/socket-client.js +113 -0
  142. package/dist/core/staleness.js +246 -0
  143. package/dist/core/state.js +98 -22
  144. package/dist/core/store-resolution.js +43 -11
  145. package/dist/core/toml-writer.js +76 -0
  146. package/dist/core/upgrades/backup.js +232 -0
  147. package/dist/core/upgrades/health-check.js +169 -0
  148. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  149. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  150. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  151. package/dist/core/upgrades/schema-version.js +97 -0
  152. package/dist/core/worktree.js +606 -0
  153. package/dist/facts.js +114 -0
  154. package/dist/facts.json +111 -0
  155. package/docs/architecture/project-refs.md +5 -1
  156. package/docs/cli.md +690 -43
  157. package/docs/concepts/ideation-loop.md +317 -0
  158. package/docs/concepts/loop-engine.md +456 -0
  159. package/docs/concepts/mcp-governance.md +268 -0
  160. package/docs/concepts/memory-staleness.md +122 -0
  161. package/docs/concepts/multi-agent-workflows.md +166 -0
  162. package/docs/concepts/plans-and-claims.md +31 -6
  163. package/docs/concepts/project-md-convention.md +35 -0
  164. package/docs/concepts/troubleshooting.md +220 -0
  165. package/docs/concepts/upgrade-cli.md +202 -0
  166. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  167. package/docs/context-format-changelog.md +2 -2
  168. package/docs/context-format.md +2 -2
  169. package/docs/index.md +68 -0
  170. package/docs/integrations/agents.md +15 -16
  171. package/docs/integrations/cline.md +88 -0
  172. package/docs/integrations/codex.md +75 -23
  173. package/docs/integrations/continue.md +60 -0
  174. package/docs/integrations/copilot.md +67 -9
  175. package/docs/integrations/kilocode.md +72 -0
  176. package/docs/integrations/mcp.md +304 -21
  177. package/docs/integrations/mistral-vibe.md +122 -0
  178. package/docs/integrations/opencode.md +84 -0
  179. package/docs/integrations/overview.md +23 -8
  180. package/docs/integrations/roo.md +74 -0
  181. package/docs/integrations/windsurf.md +83 -0
  182. package/docs/mcp-schema-changelog.md +191 -1
  183. package/docs/playbooks/integration/index.md +121 -0
  184. package/docs/playbooks/productivity/index.md +102 -0
  185. package/docs/playbooks/team/index.md +122 -0
  186. package/docs/product/agent-first-model.md +184 -0
  187. package/docs/product/entity-model-audit.md +462 -0
  188. package/docs/product/positioning.md +10 -10
  189. package/docs/quickstart-existing-project.md +135 -0
  190. package/docs/quickstart.md +124 -37
  191. package/docs/release-maintenance.md +79 -0
  192. package/docs/review.md +2 -0
  193. package/docs/server-operations.md +118 -0
  194. package/package.json +21 -13
  195. package/dist/commands/claude-desktop-extension.js +0 -18
  196. package/dist/commands/diff.js +0 -99
  197. package/dist/core/claude-desktop-extension.js +0 -224
@@ -0,0 +1,232 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { getInstalledBrainclawVersion } from '../brainclaw-version.js';
5
+ export const BACKUP_DIR_PREFIX = '.brainclaw.bak-';
6
+ export const BACKUP_MANIFEST_FILENAME = 'backup.json';
7
+ export const ROLLBACK_PARKED_PREFIX = '.brainclaw.rollback-';
8
+ export const ROLLBACK_STAGING_PREFIX = '.brainclaw.restoring-';
9
+ export const BackupManifestSchema = z.object({
10
+ schema_version: z.literal(1),
11
+ created_at: z.string().datetime(),
12
+ source_path: z.string(),
13
+ brainclaw_version: z.string(),
14
+ store_schema_version: z.string().nullable(),
15
+ note: z.string().optional(),
16
+ });
17
+ export class BackupError extends Error {
18
+ code;
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.code = code;
22
+ this.name = 'BackupError';
23
+ }
24
+ }
25
+ function isoTimestamp(d) {
26
+ return d.toISOString().replace(/[:.]/g, '-');
27
+ }
28
+ function parentOf(storePath) {
29
+ return path.dirname(storePath);
30
+ }
31
+ /**
32
+ * Atomically copy a `.brainclaw/` tree to a sibling backup directory.
33
+ * Copy happens into a `.partial-*` staging path first, then renamed
34
+ * into place so an interrupted copy never produces a backup that
35
+ * pretends to be complete.
36
+ */
37
+ export function createBackup(options) {
38
+ const { storePath } = options;
39
+ if (!fs.existsSync(storePath)) {
40
+ throw new BackupError('source_missing', `Source store not found: ${storePath}`);
41
+ }
42
+ const stat = fs.statSync(storePath);
43
+ if (!stat.isDirectory()) {
44
+ throw new BackupError('source_not_dir', `Source is not a directory: ${storePath}`);
45
+ }
46
+ const now = (options.now ?? (() => new Date()))();
47
+ const stamp = isoTimestamp(now);
48
+ const parent = parentOf(storePath);
49
+ const finalPath = path.join(parent, `${BACKUP_DIR_PREFIX}${stamp}`);
50
+ const stagingPath = path.join(parent, `${BACKUP_DIR_PREFIX}${stamp}.partial-${process.pid}`);
51
+ if (fs.existsSync(finalPath)) {
52
+ throw new BackupError('backup_exists', `Backup already exists for this timestamp: ${finalPath}`);
53
+ }
54
+ if (fs.existsSync(stagingPath)) {
55
+ fs.rmSync(stagingPath, { recursive: true, force: true });
56
+ }
57
+ try {
58
+ fs.cpSync(storePath, stagingPath, { recursive: true, errorOnExist: false, force: true });
59
+ }
60
+ catch (error) {
61
+ try {
62
+ fs.rmSync(stagingPath, { recursive: true, force: true });
63
+ }
64
+ catch { /* best effort */ }
65
+ throw new BackupError('copy_failed', `Backup copy failed: ${error.message}`);
66
+ }
67
+ const manifest = {
68
+ schema_version: 1,
69
+ created_at: now.toISOString(),
70
+ source_path: path.resolve(storePath),
71
+ brainclaw_version: getInstalledBrainclawVersion(),
72
+ store_schema_version: options.storeSchemaVersion ?? null,
73
+ note: options.note,
74
+ };
75
+ fs.writeFileSync(path.join(stagingPath, BACKUP_MANIFEST_FILENAME), JSON.stringify(manifest, null, 2), 'utf-8');
76
+ try {
77
+ fs.renameSync(stagingPath, finalPath);
78
+ }
79
+ catch (error) {
80
+ try {
81
+ fs.rmSync(stagingPath, { recursive: true, force: true });
82
+ }
83
+ catch { /* best effort */ }
84
+ throw new BackupError('rename_failed', `Could not finalise backup: ${error.message}`);
85
+ }
86
+ return { backupPath: finalPath, manifest };
87
+ }
88
+ /**
89
+ * List sibling backups for a given `.brainclaw/` path, newest first.
90
+ * Backups without a parseable manifest are skipped silently — callers
91
+ * that care can re-read the raw directory listing.
92
+ */
93
+ export function listBackups(storePath) {
94
+ const parent = parentOf(storePath);
95
+ if (!fs.existsSync(parent))
96
+ return [];
97
+ const entries = fs.readdirSync(parent, { withFileTypes: true });
98
+ const backups = [];
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory())
101
+ continue;
102
+ if (!entry.name.startsWith(BACKUP_DIR_PREFIX))
103
+ continue;
104
+ if (entry.name.includes('.partial-'))
105
+ continue;
106
+ const dir = path.join(parent, entry.name);
107
+ const manifest = readManifest(dir);
108
+ if (!manifest)
109
+ continue;
110
+ backups.push({ backupPath: dir, manifest });
111
+ }
112
+ backups.sort((a, b) => b.manifest.created_at.localeCompare(a.manifest.created_at));
113
+ return backups;
114
+ }
115
+ export function readManifest(backupPath) {
116
+ const manifestPath = path.join(backupPath, BACKUP_MANIFEST_FILENAME);
117
+ if (!fs.existsSync(manifestPath))
118
+ return null;
119
+ try {
120
+ const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
121
+ return BackupManifestSchema.parse(raw);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ function cleanupRestoreStaging(stagingPath) {
128
+ try {
129
+ fs.rmSync(stagingPath, { recursive: true, force: true });
130
+ }
131
+ catch {
132
+ // Best effort only.
133
+ }
134
+ }
135
+ /**
136
+ * Restore a backup by staging the new live contents into a sibling
137
+ * directory, then doing a single rename swap. Failure-atomic:
138
+ *
139
+ * 1. Copy backup → staging (slow phase, can be interrupted safely —
140
+ * the live store is still untouched).
141
+ * 2. Strip the manifest from the staging dir (so the restored store
142
+ * does not masquerade as a backup).
143
+ * 3. Park live → parked (atomic rename).
144
+ * 4. Swap staging → live (atomic rename).
145
+ *
146
+ * If step 4 fails after step 3, we un-park so the store is never left
147
+ * missing, and we wipe the staging dir to avoid orphans. If any step
148
+ * before 3 fails, the live store is unchanged.
149
+ */
150
+ export function restoreBackup(options) {
151
+ const { storePath, backupPath } = options;
152
+ if (!fs.existsSync(backupPath)) {
153
+ throw new BackupError('backup_missing', `Backup not found: ${backupPath}`);
154
+ }
155
+ const manifest = readManifest(backupPath);
156
+ if (!manifest) {
157
+ throw new BackupError('manifest_invalid', `Backup has no readable manifest: ${backupPath}`);
158
+ }
159
+ if (options.acceptSchemaVersions && options.acceptSchemaVersions.length > 0) {
160
+ const stored = manifest.store_schema_version;
161
+ if (!stored || !options.acceptSchemaVersions.includes(stored)) {
162
+ throw new BackupError('schema_mismatch', `Backup schema ${stored ?? 'unknown'} is not in the accepted set [${options.acceptSchemaVersions.join(', ')}]`);
163
+ }
164
+ }
165
+ const now = (options.now ?? (() => new Date()))();
166
+ const stamp = isoTimestamp(now);
167
+ const parent = parentOf(storePath);
168
+ const stagingPath = path.join(parent, `${ROLLBACK_STAGING_PREFIX}${stamp}.pid-${process.pid}`);
169
+ const parkedPath = path.join(parent, `${ROLLBACK_PARKED_PREFIX}${stamp}`);
170
+ if (fs.existsSync(stagingPath)) {
171
+ fs.rmSync(stagingPath, { recursive: true, force: true });
172
+ }
173
+ // Step 1: populate staging. Live store untouched on failure.
174
+ try {
175
+ fs.cpSync(backupPath, stagingPath, { recursive: true, errorOnExist: true });
176
+ }
177
+ catch (error) {
178
+ try {
179
+ fs.rmSync(stagingPath, { recursive: true, force: true });
180
+ }
181
+ catch { /* best effort */ }
182
+ throw new BackupError('restore_copy_failed', `Could not stage backup for restore: ${error.message}`);
183
+ }
184
+ // Step 2: remove manifest from the staged copy so the restored live
185
+ // store does not look like a backup.
186
+ const stagedManifest = path.join(stagingPath, BACKUP_MANIFEST_FILENAME);
187
+ if (fs.existsSync(stagedManifest)) {
188
+ try {
189
+ fs.unlinkSync(stagedManifest);
190
+ }
191
+ catch (error) {
192
+ cleanupRestoreStaging(stagingPath);
193
+ throw new BackupError('restore_manifest_strip_failed', `Could not strip backup manifest from staged restore: ${error.message}`);
194
+ }
195
+ }
196
+ // Step 3: park the current live store (if any). From here on we
197
+ // MUST end with a live store in place, either via swap or un-park.
198
+ let parked = false;
199
+ if (fs.existsSync(storePath)) {
200
+ try {
201
+ fs.renameSync(storePath, parkedPath);
202
+ parked = true;
203
+ }
204
+ catch (error) {
205
+ cleanupRestoreStaging(stagingPath);
206
+ throw new BackupError('park_failed', `Could not park live store: ${error.message}`);
207
+ }
208
+ }
209
+ // Step 4: swap staging → live. On failure, un-park so the store
210
+ // is never left missing; staging dir is cleaned.
211
+ try {
212
+ fs.renameSync(stagingPath, storePath);
213
+ }
214
+ catch (error) {
215
+ const swapMessage = error.message;
216
+ if (!parked) {
217
+ cleanupRestoreStaging(stagingPath);
218
+ throw new BackupError('restore_swap_failed', `Could not swap staging into live path: ${swapMessage}`);
219
+ }
220
+ try {
221
+ fs.renameSync(parkedPath, storePath);
222
+ }
223
+ catch (unparkError) {
224
+ throw new BackupError('restore_catastrophic', `Could not swap staging into live path: ${swapMessage}; also failed to restore parked live store: ${unparkError.message}. ` +
225
+ `Recovery paths preserved at parked=${parkedPath} staged=${stagingPath}`);
226
+ }
227
+ cleanupRestoreStaging(stagingPath);
228
+ throw new BackupError('restore_swap_failed', `Could not swap staging into live path: ${swapMessage}`);
229
+ }
230
+ return { parkedPath: parked ? parkedPath : '', restoredFrom: backupPath, manifest };
231
+ }
232
+ //# sourceMappingURL=backup.js.map
@@ -0,0 +1,169 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { PROVENANCE_ENTITY_LAYOUTS } from './patches/provenance-rollout.js';
4
+ import { PENDING_INBOX_SUBPATH } from './patches/candidate-archive.js';
5
+ import { HANDOFFS_SUBPATH } from './patches/handoff-review-strip.js';
6
+ import { V1_TARGET_SCHEMA_VERSION, readSchemaVersion, } from './schema-version.js';
7
+ /**
8
+ * Post-migration health check — invoked via `brainclaw doctor --after-migration`.
9
+ * Verifies the four invariants that `brainclaw upgrade --to=1.0` is meant to
10
+ * leave behind:
11
+ *
12
+ * 1. Every memory record carries a `provenance` field (legacy or v1 kind).
13
+ * 2. No handoff still carries a `review` sub-object.
14
+ * 3. No stray candidate JSON files at the `coordination/inbox/` root
15
+ * (pending candidates must have been archived).
16
+ * 4. `.brainclaw/schema-version.json` exists and `current == V1_TARGET_SCHEMA_VERSION`.
17
+ *
18
+ * Designed to be pure: no mutation, no side effects. Returns a structured
19
+ * report; the CLI decides exit code.
20
+ */
21
+ const V1_PROVENANCE_KINDS = new Set([
22
+ 'agent', 'auto_reflect', 'user', 'loop_artifact', 'federation', 'correction', 'legacy',
23
+ ]);
24
+ export function runPostMigrationHealthCheck(options) {
25
+ const { storePath } = options;
26
+ const findings = [];
27
+ // ---- 1. provenance coverage ----
28
+ let recordsScanned = 0;
29
+ const missingProvenance = [];
30
+ for (const layout of PROVENANCE_ENTITY_LAYOUTS) {
31
+ const dir = path.join(storePath, layout.dir);
32
+ for (const file of listJsonFiles(dir, layout.recursive)) {
33
+ recordsScanned += 1;
34
+ let raw;
35
+ try {
36
+ raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
37
+ }
38
+ catch {
39
+ missingProvenance.push(relFromStore(storePath, file));
40
+ continue;
41
+ }
42
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
43
+ missingProvenance.push(relFromStore(storePath, file));
44
+ continue;
45
+ }
46
+ const provenance = raw.provenance;
47
+ if (!isValidProvenance(provenance)) {
48
+ missingProvenance.push(relFromStore(storePath, file));
49
+ }
50
+ }
51
+ }
52
+ findings.push(missingProvenance.length === 0
53
+ ? { check: 'provenance', status: 'ok', message: `All ${recordsScanned} memory record(s) carry a valid provenance field.` }
54
+ : {
55
+ check: 'provenance',
56
+ status: 'error',
57
+ message: `${missingProvenance.length} of ${recordsScanned} memory record(s) are missing a valid provenance field.`,
58
+ details: { sample: missingProvenance.slice(0, 10), total: missingProvenance.length },
59
+ });
60
+ // ---- 2. handoff review sub-object ----
61
+ const handoffsDir = path.join(storePath, HANDOFFS_SUBPATH);
62
+ const handoffFiles = listJsonFiles(handoffsDir, false);
63
+ const handoffsWithReview = [];
64
+ for (const file of handoffFiles) {
65
+ let raw;
66
+ try {
67
+ raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
68
+ }
69
+ catch {
70
+ continue; // malformed; handled by provenance check or migration test
71
+ }
72
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
73
+ continue;
74
+ if (raw.review !== undefined) {
75
+ handoffsWithReview.push(relFromStore(storePath, file));
76
+ }
77
+ }
78
+ findings.push(handoffsWithReview.length === 0
79
+ ? { check: 'handoff_review', status: 'ok', message: `No handoff carries a \`review\` sub-object (${handoffFiles.length} scanned).` }
80
+ : {
81
+ check: 'handoff_review',
82
+ status: 'error',
83
+ message: `${handoffsWithReview.length} handoff(s) still carry a \`review\` sub-object.`,
84
+ details: { sample: handoffsWithReview.slice(0, 10), total: handoffsWithReview.length },
85
+ });
86
+ // ---- 3. candidate archive — inbox/ root must be empty of JSON files ----
87
+ const inboxRoot = path.join(storePath, PENDING_INBOX_SUBPATH);
88
+ const pendingCandidates = [];
89
+ if (fs.existsSync(inboxRoot)) {
90
+ for (const entry of fs.readdirSync(inboxRoot, { withFileTypes: true })) {
91
+ if (!entry.isFile())
92
+ continue;
93
+ if (!entry.name.endsWith('.json'))
94
+ continue;
95
+ pendingCandidates.push(entry.name);
96
+ }
97
+ }
98
+ findings.push(pendingCandidates.length === 0
99
+ ? { check: 'candidate_archive', status: 'ok', message: 'No pending candidates remain at coordination/inbox/ root.' }
100
+ : {
101
+ check: 'candidate_archive',
102
+ status: 'error',
103
+ message: `${pendingCandidates.length} pending candidate file(s) remain at the inbox root — candidate-archive patch did not complete.`,
104
+ details: { sample: pendingCandidates.slice(0, 10), total: pendingCandidates.length },
105
+ });
106
+ // ---- 4. schema version marker ----
107
+ const schemaState = readSchemaVersion(storePath);
108
+ if (!schemaState.present) {
109
+ findings.push({
110
+ check: 'schema_version',
111
+ status: 'error',
112
+ message: `schema-version.json is missing — upgrade did not reach the version-bump step.`,
113
+ });
114
+ }
115
+ else if (schemaState.current !== V1_TARGET_SCHEMA_VERSION) {
116
+ findings.push({
117
+ check: 'schema_version',
118
+ status: 'error',
119
+ message: `Store is at schema ${schemaState.current}, expected ${V1_TARGET_SCHEMA_VERSION}.`,
120
+ });
121
+ }
122
+ else {
123
+ findings.push({
124
+ check: 'schema_version',
125
+ status: 'ok',
126
+ message: `Store is at schema ${schemaState.current} (history: ${schemaState.history.length} transition(s)).`,
127
+ });
128
+ }
129
+ const ok = findings.every((f) => f.status === 'ok');
130
+ return {
131
+ ok,
132
+ store_path: storePath,
133
+ findings,
134
+ stats: {
135
+ records_scanned: recordsScanned,
136
+ records_missing_provenance: missingProvenance.length,
137
+ handoffs_with_review: handoffsWithReview.length,
138
+ pending_candidates_in_root: pendingCandidates.length,
139
+ current_schema_version: schemaState.present ? schemaState.current : null,
140
+ target_schema_version: V1_TARGET_SCHEMA_VERSION,
141
+ },
142
+ };
143
+ }
144
+ function isValidProvenance(value) {
145
+ if (!value || typeof value !== 'object' || Array.isArray(value))
146
+ return false;
147
+ const kind = value.kind;
148
+ return typeof kind === 'string' && V1_PROVENANCE_KINDS.has(kind);
149
+ }
150
+ function listJsonFiles(dir, recursive) {
151
+ if (!fs.existsSync(dir))
152
+ return [];
153
+ const out = [];
154
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
155
+ const full = path.join(dir, entry.name);
156
+ if (entry.isFile() && entry.name.endsWith('.json')) {
157
+ out.push(full);
158
+ continue;
159
+ }
160
+ if (recursive && entry.isDirectory()) {
161
+ out.push(...listJsonFiles(full, true));
162
+ }
163
+ }
164
+ return out;
165
+ }
166
+ function relFromStore(storePath, file) {
167
+ return path.relative(storePath, file).split(path.sep).join('/');
168
+ }
169
+ //# sourceMappingURL=health-check.js.map
@@ -0,0 +1,145 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { CandidateSchema } from '../../schema.js';
5
+ /**
6
+ * Candidate archive (P6.6) — v1.0 schema migration patch.
7
+ *
8
+ * Pending candidates accumulate because the review queue is rarely
9
+ * used as designed. v1.0 archives them out of the live inbox into
10
+ * `.brainclaw/archive/candidates/<YYYY-MM-DD>/` with a manifest
11
+ * listing what moved and why. Accepted and rejected candidates
12
+ * (already properly triaged) stay put.
13
+ *
14
+ * Live inbox path: `.brainclaw/coordination/inbox/`
15
+ * Archive path: `.brainclaw/archive/candidates/<YYYY-MM-DD>/`
16
+ */
17
+ export const PENDING_INBOX_SUBPATH = path.join('coordination', 'inbox');
18
+ export const ARCHIVE_CANDIDATES_SUBPATH = path.join('archive', 'candidates');
19
+ export const CANDIDATE_ARCHIVE_MANIFEST = 'manifest.json';
20
+ export const CandidateArchiveEntrySchema = z.object({
21
+ id: z.string(),
22
+ short_label: z.string().nullable(),
23
+ type: z.string(),
24
+ status: z.string(),
25
+ created_at: z.string(),
26
+ original_path: z.string(),
27
+ archived_path: z.string(),
28
+ /** Present when the source candidate did not parse cleanly against the
29
+ * current Zod schema. The file is still archived; the manifest records
30
+ * the first Zod error so an operator can inspect the archive later. */
31
+ parse_error: z.string().optional(),
32
+ });
33
+ export const CandidateArchiveManifestSchema = z.object({
34
+ schema_version: z.literal(1),
35
+ archived_at: z.string().datetime(),
36
+ reason: z.string(),
37
+ count: z.number().int().nonnegative(),
38
+ entries: z.array(CandidateArchiveEntrySchema),
39
+ });
40
+ /**
41
+ * Archive pending candidates sitting directly in `coordination/inbox/`.
42
+ * Idempotent — re-running after a successful archive is a no-op.
43
+ */
44
+ export function archivePendingCandidates(options) {
45
+ const pendingDir = path.join(options.storePath, PENDING_INBOX_SUBPATH);
46
+ const now = (options.now ?? (() => new Date()))();
47
+ const dateStamp = now.toISOString().slice(0, 10);
48
+ const archiveDir = path.join(options.storePath, ARCHIVE_CANDIDATES_SUBPATH, dateStamp);
49
+ const files = listPendingCandidateFiles(pendingDir);
50
+ if (files.length === 0) {
51
+ return {
52
+ status: 'noop',
53
+ pendingDir,
54
+ archiveDir: null,
55
+ moved: [],
56
+ manifestPath: null,
57
+ };
58
+ }
59
+ const reason = options.reason ?? 'v1.0 schema migration (P6.6): pending candidate review queue retired';
60
+ const entries = [];
61
+ // First pass: parse + plan. Candidates that do not pass the current Zod
62
+ // schema are still archived — they may be legacy shapes (e.g. status
63
+ // "proposed" from an older enum). We capture the parse failure in the
64
+ // manifest so the archive is self-describing.
65
+ const planned = [];
66
+ for (const file of files) {
67
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
68
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
69
+ throw new Error(`candidate-archive: ${file} is not a JSON object`);
70
+ }
71
+ const rawObj = raw;
72
+ const parsed = CandidateSchema.safeParse(rawObj);
73
+ planned.push({
74
+ source: file,
75
+ target: path.join(archiveDir, path.basename(file)),
76
+ raw: rawObj,
77
+ candidate: parsed.success ? parsed.data : null,
78
+ parseError: parsed.success ? null : parsed.error.issues[0]?.message ?? 'zod parse failed',
79
+ baseName: path.basename(file),
80
+ });
81
+ }
82
+ if (options.dryRun) {
83
+ for (const p of planned) {
84
+ entries.push(makeEntry(p.candidate, p.raw, p.source, p.target, options.storePath, p.parseError));
85
+ }
86
+ return {
87
+ status: 'planned',
88
+ pendingDir,
89
+ archiveDir,
90
+ moved: entries,
91
+ manifestPath: path.join(archiveDir, CANDIDATE_ARCHIVE_MANIFEST),
92
+ };
93
+ }
94
+ fs.mkdirSync(archiveDir, { recursive: true });
95
+ for (const p of planned) {
96
+ fs.renameSync(p.source, p.target);
97
+ entries.push(makeEntry(p.candidate, p.raw, p.source, p.target, options.storePath, p.parseError));
98
+ }
99
+ const manifest = {
100
+ schema_version: 1,
101
+ archived_at: now.toISOString(),
102
+ reason,
103
+ count: entries.length,
104
+ entries,
105
+ };
106
+ const manifestPath = path.join(archiveDir, CANDIDATE_ARCHIVE_MANIFEST);
107
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
108
+ return {
109
+ status: 'archived',
110
+ pendingDir,
111
+ archiveDir,
112
+ moved: entries,
113
+ manifestPath,
114
+ };
115
+ }
116
+ function listPendingCandidateFiles(pendingDir) {
117
+ if (!fs.existsSync(pendingDir))
118
+ return [];
119
+ const out = [];
120
+ for (const entry of fs.readdirSync(pendingDir, { withFileTypes: true })) {
121
+ if (!entry.isFile())
122
+ continue;
123
+ if (!entry.name.endsWith('.json'))
124
+ continue;
125
+ out.push(path.join(pendingDir, entry.name));
126
+ }
127
+ return out;
128
+ }
129
+ function makeEntry(candidate, raw, sourcePath, archivedPath, storePath, parseError) {
130
+ const fallback = (key, dflt) => {
131
+ const v = raw[key];
132
+ return typeof v === 'string' ? v : dflt;
133
+ };
134
+ return {
135
+ id: candidate?.id ?? fallback('id', path.basename(sourcePath, '.json')),
136
+ short_label: candidate?.short_label ?? (typeof raw.short_label === 'string' ? raw.short_label : null),
137
+ type: candidate?.type ?? fallback('type', 'unknown'),
138
+ status: candidate?.status ?? fallback('status', 'unknown'),
139
+ created_at: candidate?.created_at ?? fallback('created_at', '1970-01-01T00:00:00.000Z'),
140
+ original_path: path.relative(storePath, sourcePath).split(path.sep).join('/'),
141
+ archived_path: path.relative(storePath, archivedPath).split(path.sep).join('/'),
142
+ ...(parseError ? { parse_error: parseError } : {}),
143
+ };
144
+ }
145
+ //# sourceMappingURL=candidate-archive.js.map
@@ -0,0 +1,128 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ /**
5
+ * Handoff review-strip (P6.1 groundwork) — v1.0 schema migration patch.
6
+ *
7
+ * Existing handoffs may carry a `review` sub-object (requester,
8
+ * reviewer, verdict, blocking_issues, …). The v1.0 model drops the
9
+ * review sub-flow entirely: handoffs become immutable session-end
10
+ * artefacts and corrections use the tombstone pattern (P6.1) via
11
+ * `bclaw_correct_handoff` (to land in Phase 3, slice 3e).
12
+ *
13
+ * This patch removes the `review` field from every handoff file that
14
+ * still carries one, writes a log listing what was stripped, and is
15
+ * idempotent.
16
+ *
17
+ * Implementation note: handoffs are read/written as raw JSON instead
18
+ * of parsed through Zod. Round-tripping Zod would drop any field not
19
+ * declared in the schema (e.g., fields added by a future branch).
20
+ * The patch's contract is "strip `review`, touch nothing else".
21
+ */
22
+ export const HANDOFFS_SUBPATH = path.join('coordination', 'handoffs');
23
+ export const MIGRATIONS_ARCHIVE_SUBPATH = path.join('archive', 'migrations');
24
+ export const HANDOFF_REVIEW_STRIP_LOG = 'handoff-review-strip.json';
25
+ export const HandoffReviewStripEntrySchema = z.object({
26
+ handoff_id: z.string(),
27
+ short_label: z.string().nullable(),
28
+ handoff_path: z.string(),
29
+ review_fields: z.array(z.string()),
30
+ stripped_at: z.string().datetime(),
31
+ });
32
+ export const HandoffReviewStripLogSchema = z.object({
33
+ schema_version: z.literal(1),
34
+ stripped_at: z.string().datetime(),
35
+ reason: z.string(),
36
+ count: z.number().int().nonnegative(),
37
+ entries: z.array(HandoffReviewStripEntrySchema),
38
+ });
39
+ /**
40
+ * Strip the `review` sub-object from every handoff file that still
41
+ * carries one. Handoffs without `review` are scanned but not
42
+ * rewritten. Idempotent.
43
+ */
44
+ export function stripHandoffReview(options) {
45
+ const { storePath } = options;
46
+ const handoffsDir = path.join(storePath, HANDOFFS_SUBPATH);
47
+ const now = (options.now ?? (() => new Date()))();
48
+ const dateStamp = now.toISOString().slice(0, 10);
49
+ const logDir = path.join(storePath, MIGRATIONS_ARCHIVE_SUBPATH, dateStamp);
50
+ const logPath = path.join(logDir, HANDOFF_REVIEW_STRIP_LOG);
51
+ const reason = options.reason ?? 'v1.0 schema migration (P6.1 groundwork): handoffs become immutable, corrections use tombstones';
52
+ const files = listHandoffFiles(handoffsDir);
53
+ const stripped = [];
54
+ for (const file of files) {
55
+ const raw = JSON.parse(fs.readFileSync(file, 'utf-8'));
56
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
57
+ throw new Error(`handoff-review-strip: ${file} is not a JSON object`);
58
+ }
59
+ const obj = raw;
60
+ const review = obj.review;
61
+ if (review === undefined)
62
+ continue;
63
+ const reviewFields = review && typeof review === 'object' && !Array.isArray(review)
64
+ ? Object.keys(review)
65
+ : [];
66
+ const entry = {
67
+ handoff_id: typeof obj.id === 'string' ? obj.id : path.basename(file, '.json'),
68
+ short_label: typeof obj.short_label === 'string' ? obj.short_label : null,
69
+ handoff_path: path.relative(storePath, file).split(path.sep).join('/'),
70
+ review_fields: reviewFields,
71
+ stripped_at: now.toISOString(),
72
+ };
73
+ stripped.push(entry);
74
+ if (!options.dryRun) {
75
+ const next = { ...obj };
76
+ delete next.review;
77
+ fs.writeFileSync(file, JSON.stringify(next, null, 2), 'utf-8');
78
+ }
79
+ }
80
+ if (stripped.length === 0) {
81
+ return {
82
+ status: 'noop',
83
+ handoffsDir,
84
+ logPath: null,
85
+ stripped: [],
86
+ scanned: files.length,
87
+ };
88
+ }
89
+ if (options.dryRun) {
90
+ return {
91
+ status: 'planned',
92
+ handoffsDir,
93
+ logPath,
94
+ stripped,
95
+ scanned: files.length,
96
+ };
97
+ }
98
+ fs.mkdirSync(logDir, { recursive: true });
99
+ const log = {
100
+ schema_version: 1,
101
+ stripped_at: now.toISOString(),
102
+ reason,
103
+ count: stripped.length,
104
+ entries: stripped,
105
+ };
106
+ fs.writeFileSync(logPath, JSON.stringify(log, null, 2), 'utf-8');
107
+ return {
108
+ status: 'stripped',
109
+ handoffsDir,
110
+ logPath,
111
+ stripped,
112
+ scanned: files.length,
113
+ };
114
+ }
115
+ function listHandoffFiles(handoffsDir) {
116
+ if (!fs.existsSync(handoffsDir))
117
+ return [];
118
+ const out = [];
119
+ for (const entry of fs.readdirSync(handoffsDir, { withFileTypes: true })) {
120
+ if (!entry.isFile())
121
+ continue;
122
+ if (!entry.name.endsWith('.json'))
123
+ continue;
124
+ out.push(path.join(handoffsDir, entry.name));
125
+ }
126
+ return out;
127
+ }
128
+ //# sourceMappingURL=handoff-review-strip.js.map