brainclaw 1.16.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +22 -8
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-review.js +2 -2
  4. package/dist/commands/code-map.js +4 -1
  5. package/dist/commands/codev.js +61 -30
  6. package/dist/commands/doctor.js +14 -1
  7. package/dist/commands/harvest.js +241 -25
  8. package/dist/commands/inbox.js +10 -4
  9. package/dist/commands/loop.js +2 -2
  10. package/dist/commands/loops-handlers.js +82 -1
  11. package/dist/commands/mcp-catalog.js +12 -4
  12. package/dist/commands/mcp-read-handlers.js +90 -7
  13. package/dist/commands/mcp-schemas.generated.js +3 -0
  14. package/dist/commands/mcp-write-coordination.js +159 -40
  15. package/dist/commands/mcp.js +11 -2
  16. package/dist/core/agent-capability.js +7 -2
  17. package/dist/core/agent-files.js +53 -2
  18. package/dist/core/agent-integrations.js +1 -0
  19. package/dist/core/agentrun-reconciler.js +171 -7
  20. package/dist/core/agentruns.js +6 -1
  21. package/dist/core/code-map/aggregate.js +473 -0
  22. package/dist/core/code-map/backend.js +36 -10
  23. package/dist/core/code-map/freshness.js +36 -1
  24. package/dist/core/code-map/lang/c/imports.scm +12 -0
  25. package/dist/core/code-map/lang/c/index.js +150 -0
  26. package/dist/core/code-map/lang/c/tags.scm +68 -0
  27. package/dist/core/code-map/lang/cpp/imports.scm +14 -0
  28. package/dist/core/code-map/lang/cpp/index.js +149 -0
  29. package/dist/core/code-map/lang/cpp/tags.scm +87 -0
  30. package/dist/core/code-map/lang/csharp/imports.scm +20 -0
  31. package/dist/core/code-map/lang/csharp/index.js +224 -0
  32. package/dist/core/code-map/lang/csharp/tags.scm +63 -0
  33. package/dist/core/code-map/lang/go/imports.scm +13 -0
  34. package/dist/core/code-map/lang/go/index.js +139 -0
  35. package/dist/core/code-map/lang/go/tags.scm +36 -0
  36. package/dist/core/code-map/lang/providers.js +12 -1
  37. package/dist/core/code-map/lang/ruby/imports.scm +24 -0
  38. package/dist/core/code-map/lang/ruby/index.js +198 -0
  39. package/dist/core/code-map/lang/ruby/tags.scm +49 -0
  40. package/dist/core/code-map/lang/rust/imports.scm +44 -0
  41. package/dist/core/code-map/lang/rust/index.js +136 -0
  42. package/dist/core/code-map/lang/rust/tags.scm +47 -0
  43. package/dist/core/code-map/query.js +229 -80
  44. package/dist/core/code-map/types.js +18 -0
  45. package/dist/core/code-map/work-section.js +8 -7
  46. package/dist/core/codev-responses.js +16 -0
  47. package/dist/core/dispatcher.js +209 -29
  48. package/dist/core/execution-adapters.js +29 -3
  49. package/dist/core/ideation-loop-close.js +124 -0
  50. package/dist/core/loops/artifact-resolver.js +197 -0
  51. package/dist/core/loops/attempt-reservation.js +576 -0
  52. package/dist/core/loops/commit-intent.js +494 -0
  53. package/dist/core/loops/facade-schema.js +48 -0
  54. package/dist/core/loops/impl-bind.js +144 -0
  55. package/dist/core/loops/index.js +1 -1
  56. package/dist/core/loops/iteration-engine.js +29 -0
  57. package/dist/core/loops/lock.js +14 -0
  58. package/dist/core/loops/project-resolution.js +157 -0
  59. package/dist/core/loops/reconcile-turn.js +369 -0
  60. package/dist/core/loops/result-reducers.js +88 -0
  61. package/dist/core/loops/store.js +46 -7
  62. package/dist/core/loops/types.js +139 -11
  63. package/dist/core/loops/verbs.js +9 -3
  64. package/dist/core/loops/verify-command.js +209 -0
  65. package/dist/core/messaging.js +58 -5
  66. package/dist/core/review-loop-close.js +106 -34
  67. package/dist/core/review-loop-turn-dispatch.js +445 -0
  68. package/dist/core/runtime-signals.js +68 -0
  69. package/dist/core/schema.js +34 -0
  70. package/dist/core/worktree.js +240 -22
  71. package/dist/facts.js +10 -10
  72. package/dist/facts.json +9 -9
  73. package/dist/wasm/tree-sitter-c.wasm +0 -0
  74. package/dist/wasm/tree-sitter-c_sharp.wasm +0 -0
  75. package/dist/wasm/tree-sitter-cpp.wasm +0 -0
  76. package/dist/wasm/tree-sitter-go.wasm +0 -0
  77. package/dist/wasm/tree-sitter-ruby.wasm +0 -0
  78. package/dist/wasm/tree-sitter-rust.wasm +0 -0
  79. package/docs/cli.md +1 -1
  80. package/docs/code-map.md +22 -6
  81. package/docs/concepts/loop-engine.md +28 -2
  82. package/docs/concepts/observer-protocol.md +22 -0
  83. package/docs/integrations/codex.md +19 -3
  84. package/docs/mcp-schema-changelog.md +43 -1
  85. package/package.json +1 -1
@@ -0,0 +1,494 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ import { memoryDir } from '../io.js';
6
+ import { nowISO } from '../ids.js';
7
+ import { logger } from '../logger.js';
8
+ import { LoopEventSchema, LoopThreadSchema } from './types.js';
9
+ /**
10
+ * WAL commit-intent for atomic, crash-consistent, idempotent loop turn
11
+ * completion (pln#630 PR1b, dec#137 — converged in ideation loop
12
+ * lop_96d5324865a4c580; hardened per the symmetric review of PR #102).
13
+ *
14
+ * Stage the whole mutation as ONE durable intent BEFORE touching journal/thread.
15
+ * The intent is the source of truth until fully applied. Apply is idempotent by
16
+ * IDENTITY — each planned event must occupy its planned seq with its planned
17
+ * event_id (not merely "present somewhere", not "seq > max"). fsync order is
18
+ * intent → journal → projection → marker, and directory publication is fsynced
19
+ * up the parent chain, so a projection is never trusted ahead of the durable
20
+ * journal (trp_8b17c2d0) and a freshly-created commits/ dir is durable at the
21
+ * after_intent crash point.
22
+ *
23
+ * RECOVERY runs at the loop-lock entry boundary and FAILS CLOSED: any error
24
+ * other than a quarantined conflict propagates so a mutation never proceeds
25
+ * against a torn/unreadable journal. A torn trailing journal record (partial
26
+ * append at power loss) is truncated under the lock before replay.
27
+ */
28
+ export const COMMIT_INTENT_KIND = 'complete_turn';
29
+ /**
30
+ * Strict schema for a PERSISTED intent (PR #102 review round 2, HIGH). A
31
+ * JSON-valid file is not enough: recovery must reject a structurally-invalid or
32
+ * cross-loop intent before replaying it. `loadValidatedIntent` additionally
33
+ * binds every embedded loop_id / thread id to the directory loop.
34
+ */
35
+ export const LoopCommitIntentSchema = z.object({
36
+ intent_id: z.string().min(1),
37
+ loop_id: z.string().min(1),
38
+ kind: z.literal(COMMIT_INTENT_KIND),
39
+ base_version: z.number().int().nonnegative(),
40
+ events: z.array(LoopEventSchema).min(1),
41
+ thread_snapshot: LoopThreadSchema,
42
+ created_at: z.string().min(1),
43
+ });
44
+ export class IntentConflictError extends Error {
45
+ intent_id;
46
+ loop_id;
47
+ constructor(intent_id, loop_id, message) {
48
+ super(message);
49
+ this.intent_id = intent_id;
50
+ this.loop_id = loop_id;
51
+ this.name = 'IntentConflictError';
52
+ }
53
+ }
54
+ class SimulatedCrash extends Error {
55
+ at;
56
+ constructor(at) {
57
+ super(`simulated crash at ${at}`);
58
+ this.at = at;
59
+ this.name = 'SimulatedCrash';
60
+ }
61
+ }
62
+ /* ============================ paths ====================================== */
63
+ function loopsDir(cwd) {
64
+ return path.join(memoryDir(cwd ?? process.cwd()), 'loops');
65
+ }
66
+ function commitsDir(loopId, cwd) {
67
+ return path.join(loopsDir(cwd), 'commits', loopId);
68
+ }
69
+ function threadPath(loopId, cwd) {
70
+ return path.join(loopsDir(cwd), 'threads', `${loopId}.json`);
71
+ }
72
+ function eventsPath(loopId, cwd) {
73
+ return path.join(loopsDir(cwd), 'events', `${loopId}.jsonl`);
74
+ }
75
+ function intentPath(loopId, intentId, cwd) {
76
+ return path.join(commitsDir(loopId, cwd), `${intentId}.intent.json`);
77
+ }
78
+ function appliedPath(loopId, intentId, cwd) {
79
+ return path.join(commitsDir(loopId, cwd), `${intentId}.applied.json`);
80
+ }
81
+ function conflictPath(loopId, intentId, cwd) {
82
+ return path.join(commitsDir(loopId, cwd), `${intentId}.conflict.json`);
83
+ }
84
+ function corruptPath(loopId, intentId, cwd) {
85
+ return path.join(commitsDir(loopId, cwd), `${intentId}.corrupt.json`);
86
+ }
87
+ /* ============================ fsync helpers ============================== */
88
+ // Directory fsync is a POSIX durability primitive; Windows does not support it
89
+ // (and does not need it for our atomic-rename visibility). On POSIX a genuine
90
+ // I/O error must FAIL CLOSED — swallowing it was the review's CRITICAL finding.
91
+ const DIR_FSYNC_SUPPORTED = process.platform !== 'win32';
92
+ function fsyncDirOne(dir) {
93
+ if (!DIR_FSYNC_SUPPORTED)
94
+ return;
95
+ let fd;
96
+ try {
97
+ fd = fs.openSync(dir, 'r');
98
+ fs.fsyncSync(fd);
99
+ }
100
+ catch (err) {
101
+ const code = err.code;
102
+ // Benign only: raced-away dir, or a filesystem that cannot fsync a dir.
103
+ if (code === 'ENOENT' || code === 'EINVAL' || code === 'ENOTSUP' || code === 'EPERM' || code === 'EISDIR' || code === 'EACCES')
104
+ return;
105
+ throw err; // EIO / ENOSPC / … → fail closed
106
+ }
107
+ finally {
108
+ if (fd !== undefined) {
109
+ try {
110
+ fs.closeSync(fd);
111
+ }
112
+ catch { /* ignore */ }
113
+ }
114
+ }
115
+ }
116
+ /** Durably publish `dir` and every ancestor up to (and including) the loops
117
+ * root, so a newly-created commits/<loop> directory survives a crash. */
118
+ function fsyncDirChain(dir, cwd) {
119
+ const root = loopsDir(cwd);
120
+ let cur = dir;
121
+ for (let i = 0; i < 24; i++) {
122
+ fsyncDirOne(cur);
123
+ if (cur === root)
124
+ break;
125
+ const parent = path.dirname(cur);
126
+ if (parent === cur)
127
+ break;
128
+ cur = parent;
129
+ }
130
+ }
131
+ /** Write a file durably: temp write + fsync(file) + atomic rename + fsync(dir chain). */
132
+ function writeFileDurable(filePath, contents, cwd) {
133
+ const dir = path.dirname(filePath);
134
+ fs.mkdirSync(dir, { recursive: true });
135
+ const tmp = `${filePath}.${crypto.randomBytes(6).toString('hex')}.tmp`;
136
+ const buf = Buffer.from(contents, 'utf8');
137
+ const fd = fs.openSync(tmp, 'w');
138
+ try {
139
+ let off = 0;
140
+ while (off < buf.length)
141
+ off += fs.writeSync(fd, buf, off, buf.length - off);
142
+ fs.fsyncSync(fd);
143
+ }
144
+ finally {
145
+ fs.closeSync(fd);
146
+ }
147
+ fs.renameSync(tmp, filePath);
148
+ fsyncDirChain(dir, cwd);
149
+ }
150
+ /** Append newline-delimited lines to the journal durably, looping each write to
151
+ * completion (a short/torn write is never left behind), then fsync the fd. */
152
+ function appendJournalDurable(loopId, lines, cwd) {
153
+ const p = eventsPath(loopId, cwd);
154
+ fs.mkdirSync(path.dirname(p), { recursive: true });
155
+ const fd = fs.openSync(p, 'a');
156
+ try {
157
+ for (const line of lines) {
158
+ const buf = Buffer.from(`${line}\n`, 'utf8');
159
+ let off = 0;
160
+ while (off < buf.length)
161
+ off += fs.writeSync(fd, buf, off, buf.length - off);
162
+ }
163
+ fs.fsyncSync(fd);
164
+ }
165
+ finally {
166
+ fs.closeSync(fd);
167
+ }
168
+ }
169
+ /**
170
+ * Tolerant journal read. Parses complete newline-delimited records; a non-empty
171
+ * UNPARSEABLE FINAL line is treated as an uncommitted torn-append fragment and
172
+ * dropped (never a mid-file parse — a torn record can only ever be the tail of
173
+ * an append). Any earlier unparseable line is a real corruption and throws.
174
+ */
175
+ function readJournalTolerant(loopId, cwd) {
176
+ const p = eventsPath(loopId, cwd);
177
+ if (!fs.existsSync(p))
178
+ return { events: [], tornTail: false };
179
+ const raw = fs.readFileSync(p, 'utf8');
180
+ const lines = raw.split('\n');
181
+ // A trailing '\n' yields a final '' element; the last real line is at len-2
182
+ // when complete. Track the last NON-empty line index to detect a torn tail.
183
+ const nonEmpty = [];
184
+ for (let i = 0; i < lines.length; i++) {
185
+ if (lines[i].length > 0)
186
+ nonEmpty.push({ idx: i, text: lines[i] });
187
+ }
188
+ const events = [];
189
+ let tornTail = false;
190
+ for (let k = 0; k < nonEmpty.length; k++) {
191
+ const { text } = nonEmpty[k];
192
+ const isLast = k === nonEmpty.length - 1;
193
+ try {
194
+ events.push(LoopEventSchema.parse(JSON.parse(text)));
195
+ }
196
+ catch (err) {
197
+ if (isLast) {
198
+ tornTail = true;
199
+ break;
200
+ }
201
+ throw new Error(`readJournalTolerant: corrupt journal record (not the tail) in ${p}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
202
+ }
203
+ }
204
+ return { events, tornTail };
205
+ }
206
+ /** Truncate a torn trailing record from the journal, durably, under the caller's
207
+ * lock. Rewrites the file to only its complete records + trailing newline. */
208
+ function repairTornJournalTail(loopId, cwd) {
209
+ const { events } = readJournalTolerant(loopId, cwd);
210
+ const rebuilt = events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '');
211
+ const p = eventsPath(loopId, cwd);
212
+ const tmp = `${p}.${crypto.randomBytes(6).toString('hex')}.tmp`;
213
+ const buf = Buffer.from(rebuilt, 'utf8');
214
+ const fd = fs.openSync(tmp, 'w');
215
+ try {
216
+ let off = 0;
217
+ while (off < buf.length)
218
+ off += fs.writeSync(fd, buf, off, buf.length - off);
219
+ fs.fsyncSync(fd);
220
+ }
221
+ finally {
222
+ fs.closeSync(fd);
223
+ }
224
+ fs.renameSync(tmp, p);
225
+ fsyncDirChain(path.dirname(p), cwd);
226
+ }
227
+ function readThreadOnDisk(loopId, cwd) {
228
+ const p = threadPath(loopId, cwd);
229
+ if (!fs.existsSync(p))
230
+ return undefined;
231
+ try {
232
+ return LoopThreadSchema.parse(JSON.parse(fs.readFileSync(p, 'utf8')));
233
+ }
234
+ catch {
235
+ return undefined;
236
+ }
237
+ }
238
+ /* ============================ intent lifecycle =========================== */
239
+ export function writeIntent(input, cwd) {
240
+ const intent = {
241
+ intent_id: input.intent_id ?? crypto.randomUUID(),
242
+ loop_id: input.loop_id,
243
+ kind: COMMIT_INTENT_KIND,
244
+ base_version: input.base_version,
245
+ events: input.events.map((e) => LoopEventSchema.parse(e)),
246
+ thread_snapshot: LoopThreadSchema.parse(input.thread_snapshot),
247
+ created_at: nowISO(),
248
+ };
249
+ writeFileDurable(intentPath(intent.loop_id, intent.intent_id, cwd), `${JSON.stringify(intent, null, 2)}\n`, cwd);
250
+ return intent;
251
+ }
252
+ /**
253
+ * Apply a staged intent idempotently, by IDENTITY. Ordering: (repair torn tail)
254
+ * → journal append (fsync) → thread projection (fsync) → `.intent → .applied`.
255
+ *
256
+ * Identity rules (review MEDIUM):
257
+ * - a planned event counts as present only if the journal has its event_id AT
258
+ * its planned seq;
259
+ * - all planned present → journal already applied;
260
+ * - none present + journal max seq === first planned seq - 1 → clean contiguous
261
+ * append; otherwise → CONFLICT (foreign occupant / hole), quarantined;
262
+ * - the thread projection is written unless the on-disk thread already carries
263
+ * the snapshot's mutation_id; an equal/newer version with a DIFFERENT
264
+ * mutation_id is a CONFLICT (divergent projection), not a silent skip.
265
+ */
266
+ export function applyIntent(intent, cwd, faultAt) {
267
+ // Repair a torn trailing record before we reason about the journal (review HIGH).
268
+ const firstRead = readJournalTolerant(intent.loop_id, cwd);
269
+ if (firstRead.tornTail)
270
+ repairTornJournalTail(intent.loop_id, cwd);
271
+ const journal = readJournalTolerant(intent.loop_id, cwd).events;
272
+ const seqOfId = new Map();
273
+ for (const e of journal)
274
+ seqOfId.set(e.event_id, e.seq);
275
+ const idAtSeq = new Map();
276
+ for (const e of journal)
277
+ idAtSeq.set(e.seq, e.event_id);
278
+ const maxSeq = journal.reduce((m, e) => Math.max(m, e.seq), 0);
279
+ const planned = intent.events;
280
+ const presentAtPlannedSeq = (e) => seqOfId.get(e.event_id) === e.seq;
281
+ const allPresent = planned.every(presentAtPlannedSeq);
282
+ const quarantine = (why) => {
283
+ const ip = intentPath(intent.loop_id, intent.intent_id, cwd);
284
+ if (fs.existsSync(ip)) {
285
+ fs.renameSync(ip, conflictPath(intent.loop_id, intent.intent_id, cwd));
286
+ fsyncDirChain(commitsDir(intent.loop_id, cwd), cwd);
287
+ }
288
+ throw new IntentConflictError(intent.intent_id, intent.loop_id, `applyIntent: ${why} — intent superseded`);
289
+ };
290
+ if (!allPresent) {
291
+ const missing = planned.filter((e) => !presentAtPlannedSeq(e));
292
+ // A planned seq already occupied by a DIFFERENT event_id → foreign overlap.
293
+ for (const e of missing) {
294
+ const occupant = idAtSeq.get(e.seq);
295
+ if (occupant !== undefined && occupant !== e.event_id) {
296
+ quarantine(`planned seq ${e.seq} occupied by foreign event ${occupant}`);
297
+ }
298
+ }
299
+ const firstMissingSeq = missing[0]?.seq;
300
+ if (firstMissingSeq === undefined) {
301
+ // nothing to append (defensive; shouldn't happen)
302
+ }
303
+ else if (maxSeq === firstMissingSeq - 1) {
304
+ appendJournalDurable(intent.loop_id, missing.map((e) => JSON.stringify(e)), cwd);
305
+ }
306
+ else {
307
+ quarantine(`missing planned seq ${firstMissingSeq} is not a contiguous tail (journal max ${maxSeq})`);
308
+ }
309
+ }
310
+ if (faultAt === 'after_journal')
311
+ throw new SimulatedCrash('after_journal');
312
+ // Thread projection — validate by identity, not bare version (review MEDIUM).
313
+ const onDisk = readThreadOnDisk(intent.loop_id, cwd);
314
+ if (onDisk?.mutation_id === intent.thread_snapshot.mutation_id) {
315
+ // already applied — skip
316
+ }
317
+ else if (onDisk && onDisk.version >= intent.thread_snapshot.version) {
318
+ quarantine(`on-disk thread version ${onDisk.version} (mutation ${onDisk.mutation_id}) diverges from staged snapshot version ${intent.thread_snapshot.version} (mutation ${intent.thread_snapshot.mutation_id})`);
319
+ }
320
+ else {
321
+ writeFileDurable(threadPath(intent.loop_id, cwd), `${JSON.stringify(intent.thread_snapshot, null, 2)}\n`, cwd);
322
+ }
323
+ if (faultAt === 'after_thread')
324
+ throw new SimulatedCrash('after_thread');
325
+ if (faultAt === 'before_marker')
326
+ throw new SimulatedCrash('before_marker');
327
+ const ip = intentPath(intent.loop_id, intent.intent_id, cwd);
328
+ if (fs.existsSync(ip)) {
329
+ fs.renameSync(ip, appliedPath(intent.loop_id, intent.intent_id, cwd));
330
+ fsyncDirChain(commitsDir(intent.loop_id, cwd), cwd);
331
+ }
332
+ // Bounded audit-marker GC so the commits dir does not grow without bound
333
+ // (review LOW): drop markers older than 1h AND cap to the newest 20 so a busy
334
+ // hour cannot make each getLoop directory scan O(marker count).
335
+ gcCommitMarkers(intent.loop_id, 60 * 60 * 1000, cwd, 20);
336
+ }
337
+ export function commitViaIntent(input, cwd, faultAt) {
338
+ const intent = writeIntent(input, cwd);
339
+ if (faultAt === 'after_intent')
340
+ throw new SimulatedCrash('after_intent');
341
+ applyIntent(intent, cwd, faultAt);
342
+ return intent;
343
+ }
344
+ /* ============================ recovery =================================== */
345
+ function listPendingIntentFiles(loopId, cwd) {
346
+ const dir = commitsDir(loopId, cwd);
347
+ if (!fs.existsSync(dir))
348
+ return [];
349
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.intent.json')).sort();
350
+ }
351
+ /** Quarantine a bad pending intent to `.corrupt`. FAILS CLOSED on genuine I/O
352
+ * (PR #102 review round 2): only a benign ENOENT race is tolerated; an
353
+ * EIO/ENOSPC during the quarantine rename/fsync propagates and aborts recovery
354
+ * rather than being miscounted as handled. */
355
+ function quarantineCorruptIntent(loopId, file, why, cwd) {
356
+ const intentId = file.replace(/\.intent\.json$/, '');
357
+ const full = path.join(commitsDir(loopId, cwd), file);
358
+ try {
359
+ fs.renameSync(full, corruptPath(loopId, intentId, cwd));
360
+ fsyncDirChain(commitsDir(loopId, cwd), cwd);
361
+ }
362
+ catch (mvErr) {
363
+ if (mvErr.code !== 'ENOENT')
364
+ throw mvErr; // genuine I/O → fail closed
365
+ }
366
+ logger.warn(`recoverPendingIntents: quarantined intent ${file} for loop ${loopId}: ${why}`);
367
+ }
368
+ /** Read + STRICTLY validate a persisted intent and bind it to its directory
369
+ * loop (PR #102 review round 2, HIGH): a JSON-valid intent for a different loop
370
+ * must never be applied. Throws on schema failure or any cross-loop id mismatch. */
371
+ function loadValidatedIntent(loopId, file, cwd) {
372
+ const full = path.join(commitsDir(loopId, cwd), file);
373
+ const intent = LoopCommitIntentSchema.parse(JSON.parse(fs.readFileSync(full, 'utf8')));
374
+ if (intent.loop_id !== loopId)
375
+ throw new Error(`intent.loop_id ${intent.loop_id} != dir loop ${loopId}`);
376
+ if (intent.thread_snapshot.id !== loopId)
377
+ throw new Error(`intent.thread_snapshot.id ${intent.thread_snapshot.id} != dir loop ${loopId}`);
378
+ for (const e of intent.events) {
379
+ if (e.loop_id !== loopId)
380
+ throw new Error(`intent event ${e.event_id} loop_id ${e.loop_id} != dir loop ${loopId}`);
381
+ }
382
+ return intent;
383
+ }
384
+ /**
385
+ * Recover any pending intents for a loop. MUST run at loop-lock entry, before
386
+ * load/version-check/seq-allocation. First repairs a torn trailing journal
387
+ * record UNCONDITIONALLY (an ordinary appendEvent crash can leave one with NO
388
+ * pending intent — PR #102 review round 2, HIGH). FAILS CLOSED: a genuine apply
389
+ * or quarantine I/O error propagates so the mutation never proceeds against
390
+ * corrupt state; a quarantined conflict is counted, not thrown; a malformed or
391
+ * cross-loop intent is quarantined to `.corrupt` (visible). Returns counts.
392
+ */
393
+ export function recoverPendingIntents(loopId, cwd) {
394
+ if (readJournalTolerant(loopId, cwd).tornTail)
395
+ repairTornJournalTail(loopId, cwd);
396
+ let applied = 0;
397
+ let conflicted = 0;
398
+ let corrupt = 0;
399
+ for (const file of listPendingIntentFiles(loopId, cwd)) {
400
+ let intent;
401
+ try {
402
+ intent = loadValidatedIntent(loopId, file, cwd);
403
+ }
404
+ catch (err) {
405
+ quarantineCorruptIntent(loopId, file, err instanceof Error ? err.message : String(err), cwd);
406
+ corrupt += 1;
407
+ continue;
408
+ }
409
+ try {
410
+ applyIntent(intent, cwd);
411
+ applied += 1;
412
+ }
413
+ catch (e) {
414
+ if (e instanceof IntentConflictError) {
415
+ conflicted += 1;
416
+ continue;
417
+ }
418
+ throw e; // fail closed on genuine apply errors
419
+ }
420
+ }
421
+ return { applied, conflicted, corrupt };
422
+ }
423
+ /**
424
+ * Non-persisting consistent read: if a pending intent exists whose snapshot is
425
+ * ahead of the on-disk thread, return the snapshot. Used by read paths that do
426
+ * not hold the loop lock. Ignores conflicted/corrupt intents.
427
+ */
428
+ export function reconstructConsistentThread(loopId, onDisk, cwd) {
429
+ let best = onDisk;
430
+ for (const file of listPendingIntentFiles(loopId, cwd)) {
431
+ try {
432
+ // Strict validation + loop-binding: a malformed or cross-loop intent must
433
+ // never be returned as this loop's consistent view (recovery quarantines
434
+ // it at the next lock entry). Best-effort here — never throws on a read.
435
+ const intent = loadValidatedIntent(loopId, file, cwd);
436
+ const snap = intent.thread_snapshot;
437
+ if (!best || snap.version > best.version)
438
+ best = snap;
439
+ }
440
+ catch {
441
+ /* skip malformed/cross-loop — recovery quarantines it at the next lock entry */
442
+ }
443
+ }
444
+ return best;
445
+ }
446
+ export function hasPendingIntent(loopId, cwd) {
447
+ return listPendingIntentFiles(loopId, cwd).length > 0;
448
+ }
449
+ /** Bounded GC of `.applied` / `.conflict` / `.corrupt` markers: removes those
450
+ * older than maxAgeMs, then — when `maxCount` is given — keeps only the newest
451
+ * `maxCount` survivors (a cardinality bound so a busy hour cannot make each
452
+ * getLoop's directory scan O(marker count); PR #102 review round 2, LOW). */
453
+ export function gcCommitMarkers(loopId, maxAgeMs, cwd, maxCount) {
454
+ const dir = commitsDir(loopId, cwd);
455
+ if (!fs.existsSync(dir))
456
+ return 0;
457
+ const cutoff = Date.now() - maxAgeMs;
458
+ const markers = [];
459
+ for (const file of fs.readdirSync(dir)) {
460
+ if (!/\.(applied|conflict|corrupt)\.json$/.test(file))
461
+ continue;
462
+ const fp = path.join(dir, file);
463
+ try {
464
+ markers.push({ fp, mtime: fs.statSync(fp).mtimeMs });
465
+ }
466
+ catch { /* racing — skip */ }
467
+ }
468
+ let removed = 0;
469
+ const survivors = [];
470
+ for (const m of markers) {
471
+ if (m.mtime < cutoff) {
472
+ try {
473
+ fs.unlinkSync(m.fp);
474
+ removed += 1;
475
+ }
476
+ catch { /* racing */ }
477
+ }
478
+ else {
479
+ survivors.push(m);
480
+ }
481
+ }
482
+ if (typeof maxCount === 'number' && survivors.length > maxCount) {
483
+ survivors.sort((a, b) => b.mtime - a.mtime); // newest first
484
+ for (const m of survivors.slice(maxCount)) {
485
+ try {
486
+ fs.unlinkSync(m.fp);
487
+ removed += 1;
488
+ }
489
+ catch { /* racing */ }
490
+ }
491
+ }
492
+ return removed;
493
+ }
494
+ //# sourceMappingURL=commit-intent.js.map
@@ -30,6 +30,14 @@ export const BclawLoopOpenSchema = z.object({
30
30
  linked: LoopLinksSchema.optional(),
31
31
  stop_condition: StopConditionSchema.optional(),
32
32
  mode: z.enum(REVIEW_MODES).optional(),
33
+ /** pln#632 — engine-run verify command (opener-provided). Argv array, run shell:false;
34
+ * set once at open, drives the deterministic command_green gate via bclaw_loop(verify). */
35
+ verify: z
36
+ .object({
37
+ command: z.array(z.string().min(1)).min(1),
38
+ timeout_ms: z.number().int().positive().optional(),
39
+ })
40
+ .optional(),
33
41
  // Opt-in acknowledgement that the caller will drive dispatch manually.
34
42
  // Absent (or false) → handler rejects with a pointer to bclaw_coordinate,
35
43
  // because a loop opened without a follow-up turn/claim/inbox never runs.
@@ -128,6 +136,42 @@ export const BclawLoopCloseSchema = z.object({
128
136
  expected_version: z.number().int().nonnegative().optional(),
129
137
  ...CallerEnvelopeFields,
130
138
  });
139
+ /**
140
+ * pln#632 — `bclaw_loop(intent='verify')`: run the loop's opener-configured verify
141
+ * command (tests/build/lint) and record a deterministic `verify_report` for the current
142
+ * iteration. Does NOT advance. No command in the request — provenance is the loop's
143
+ * `protocol.verify`, never the caller (the determinism guarantee).
144
+ */
145
+ export const BclawLoopVerifySchema = z.object({
146
+ intent: z.literal('verify'),
147
+ loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
148
+ // No expected_version: runVerify is idempotent by (loop, iteration) via its own
149
+ // two-lock re-check, not optimistic-concurrency CAS (review F3).
150
+ ...CallerEnvelopeFields,
151
+ });
152
+ /**
153
+ * pln#632 — `bclaw_loop(intent='bind')`: the ENGINE action for an implementation loop's
154
+ * `bind` phase. Dispatches the loop's linked sequence (by id, no active-sequence hijack)
155
+ * and advances `bind → execute`. Idempotent (a loop past `bind` → noop). `dry_run`
156
+ * previews what would dispatch without spawning or advancing. Implementation loops only —
157
+ * review/ideation loops dispatch via bclaw_coordinate.
158
+ */
159
+ export const BclawLoopBindSchema = z.object({
160
+ intent: z.literal('bind'),
161
+ loop_id: z.string().regex(/^lop_[0-9a-z]+$/),
162
+ /** Analyze + report what would dispatch; no spawn, no advance. */
163
+ dry_run: z.boolean().optional(),
164
+ /** Restrict the bind dispatch to specific lanes. */
165
+ lanes: z.array(z.string().min(1)).optional(),
166
+ /** Deliver briefs without spawning (→ manual launch commands). */
167
+ auto_execute: z.boolean().optional(),
168
+ /** Model override for the dispatched agents. */
169
+ model: z.string().min(1).optional(),
170
+ /** Cap assignments made in this bind. */
171
+ max_assignments: z.number().int().positive().optional(),
172
+ // No expected_version: bind is idempotent by loop phase (past `bind` → noop), not CAS.
173
+ ...CallerEnvelopeFields,
174
+ });
131
175
  /**
132
176
  * pln#508 step 2 — `bclaw_loop(intent='request_input')`.
133
177
  *
@@ -197,6 +241,8 @@ export const BclawLoopRequestSchema = z.discriminatedUnion('intent', [
197
241
  BclawLoopPauseSchema,
198
242
  BclawLoopResumeSchema,
199
243
  BclawLoopCloseSchema,
244
+ BclawLoopVerifySchema,
245
+ BclawLoopBindSchema,
200
246
  BclawLoopRequestInputSchema,
201
247
  BclawLoopProvideInputSchema,
202
248
  ]);
@@ -211,6 +257,8 @@ export const BCLAW_LOOP_INTENTS = [
211
257
  'pause',
212
258
  'resume',
213
259
  'close',
260
+ 'verify',
261
+ 'bind',
214
262
  'request_input',
215
263
  'provide_input',
216
264
  ];