brainclaw 1.26.2 → 1.28.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 (88) hide show
  1. package/README.md +13 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-coordination.js +65 -1
  4. package/dist/commands/attempt-authority.js +80 -0
  5. package/dist/commands/harvest.js +140 -61
  6. package/dist/commands/loop.js +34 -0
  7. package/dist/commands/loops-handlers.js +143 -15
  8. package/dist/commands/mcp-catalog.js +52 -18
  9. package/dist/commands/mcp-schemas.generated.js +64 -0
  10. package/dist/commands/mcp-write-claims.js +128 -1
  11. package/dist/commands/mcp-write-coordination.js +149 -76
  12. package/dist/core/agent-capability.js +1 -1
  13. package/dist/core/agentrun-reconciler.js +148 -22
  14. package/dist/core/agentruns.js +254 -29
  15. package/dist/core/assignment-request-schema.js +7 -0
  16. package/dist/core/assignment-sweeper.js +5 -3
  17. package/dist/core/assignments.js +131 -33
  18. package/dist/core/claim-request-schema.js +7 -0
  19. package/dist/core/claims.js +53 -2
  20. package/dist/core/dispatch-status.js +16 -6
  21. package/dist/core/dispatcher.js +51 -51
  22. package/dist/core/entity-operations.js +20 -0
  23. package/dist/core/events.js +4 -0
  24. package/dist/core/execution-adapters.js +189 -14
  25. package/dist/core/execution-contract.js +345 -0
  26. package/dist/core/execution.js +130 -16
  27. package/dist/core/facade-schema.js +3 -0
  28. package/dist/core/harness-adapters/base.js +150 -0
  29. package/dist/core/harness-adapters/claude.js +39 -0
  30. package/dist/core/harness-adapters/codex.js +57 -0
  31. package/dist/core/harness-adapters/harvest.js +109 -0
  32. package/dist/core/harness-adapters/index.js +8 -0
  33. package/dist/core/harness-adapters/prompt-only.js +13 -0
  34. package/dist/core/harness-adapters/registry.js +48 -0
  35. package/dist/core/harness-adapters/result.js +33 -0
  36. package/dist/core/harness-adapters/types.js +2 -0
  37. package/dist/core/ideation-loop-close.js +25 -2
  38. package/dist/core/instruction-templates.js +3 -2
  39. package/dist/core/loop-turn-dispatch.js +235 -0
  40. package/dist/core/loops/artifact-contract.js +11 -0
  41. package/dist/core/loops/attempt-authority.js +496 -0
  42. package/dist/core/loops/attempt-generations.js +509 -0
  43. package/dist/core/loops/attempt-reservation.js +197 -35
  44. package/dist/core/loops/attempt-rollout.js +404 -0
  45. package/dist/core/loops/attempt-takeover.js +155 -0
  46. package/dist/core/loops/bootstrap-acquire.js +7 -3
  47. package/dist/core/loops/brief-assembly.js +21 -4
  48. package/dist/core/loops/evidence.js +188 -0
  49. package/dist/core/loops/facade-schema.js +75 -11
  50. package/dist/core/loops/gate-policy.js +533 -0
  51. package/dist/core/loops/impl-bind.js +91 -81
  52. package/dist/core/loops/index.js +9 -0
  53. package/dist/core/loops/iteration-engine.js +31 -19
  54. package/dist/core/loops/kind-policies.js +90 -0
  55. package/dist/core/loops/lock.js +71 -13
  56. package/dist/core/loops/reconcile-turn.js +237 -18
  57. package/dist/core/loops/result-reducers.js +113 -10
  58. package/dist/core/loops/store.js +34 -3
  59. package/dist/core/loops/turn-execution.js +480 -0
  60. package/dist/core/loops/types.js +127 -3
  61. package/dist/core/loops/verbs.js +335 -99
  62. package/dist/core/loops/verify-command.js +105 -20
  63. package/dist/core/loops/workspace-digest.js +54 -0
  64. package/dist/core/review-loop-close.js +25 -3
  65. package/dist/core/review-loop-turn-dispatch.js +210 -161
  66. package/dist/core/runtime-signals.js +62 -25
  67. package/dist/core/schema.js +40 -0
  68. package/dist/core/spawn-check.js +3 -2
  69. package/dist/core/upgrades/backup.js +27 -4
  70. package/dist/facts.js +9 -8
  71. package/dist/facts.json +8 -7
  72. package/docs/cli.md +49 -1
  73. package/docs/concepts/attempt-authority.md +407 -0
  74. package/docs/concepts/evidence-attestations.md +135 -0
  75. package/docs/concepts/execution-contract.md +166 -0
  76. package/docs/concepts/harness-adapters.md +166 -0
  77. package/docs/concepts/ideation-loop.md +5 -4
  78. package/docs/concepts/loop-engine.md +302 -113
  79. package/docs/index.md +4 -1
  80. package/docs/integrations/codex.md +3 -3
  81. package/docs/integrations/mcp.md +59 -5
  82. package/docs/loops/debug.md +144 -0
  83. package/docs/loops/ideation.md +158 -0
  84. package/docs/loops/implementation.md +174 -0
  85. package/docs/loops/research.md +136 -0
  86. package/docs/loops/review.md +200 -0
  87. package/docs/mcp-schema-changelog.md +18 -5
  88. package/package.json +1 -1
@@ -0,0 +1,404 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { memoryDir, writeFileAtomic } from '../io.js';
7
+ import { nowISO } from '../ids.js';
8
+ import { fingerprintPublicKeyPem } from '../agent-registry.js';
9
+ import { AuthorityHomeSchema } from './attempt-generations.js';
10
+ /**
11
+ * Two-release activation guard for AttemptAuthority v2.
12
+ *
13
+ * Release A publishes one immutable membership guard and one independently
14
+ * signed ACK per writer. ACKs are deliberately separate cells: writers may
15
+ * prepare/sign/publish them in parallel without contending on a shared JSON
16
+ * document. Release B is enabled by one immutable activation CAS only after all
17
+ * non-revoked participants acknowledged the same guard digest.
18
+ */
19
+ export const ATTEMPT_AUTHORITY_WRITER_VERSION = 2;
20
+ export const ATTEMPT_AUTHORITY_READER_VERSION = 2;
21
+ const ROLLOUT_SCHEMA_VERSION = 1;
22
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
23
+ export const AttemptWriterParticipantSchema = z.object({
24
+ writer_id: z.string().regex(SAFE_ID),
25
+ public_key_pem: z.string().min(1),
26
+ key_fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
27
+ status: z.enum(['active', 'revoked']).default('active'),
28
+ }).strict();
29
+ export const AttemptRolloutGuardSchema = z.object({
30
+ schema_version: z.literal(ROLLOUT_SCHEMA_VERSION),
31
+ document_kind: z.literal('attempt_authority_writer_guard'),
32
+ membership_epoch: z.number().int().positive(),
33
+ minimum_writer_version: z.number().int().positive(),
34
+ minimum_reader_version: z.number().int().positive(),
35
+ authority_home: AuthorityHomeSchema,
36
+ participants: z.array(AttemptWriterParticipantSchema).min(1),
37
+ previous_activation_digest: z.string().regex(/^[a-f0-9]{64}$/).optional(),
38
+ prepared_by: z.string().min(1),
39
+ prepared_at: z.string().min(1),
40
+ }).strict();
41
+ const AttemptRolloutAckUnsignedSchema = z.object({
42
+ schema_version: z.literal(ROLLOUT_SCHEMA_VERSION),
43
+ document_kind: z.literal('attempt_authority_writer_ack'),
44
+ membership_epoch: z.number().int().positive(),
45
+ writer_id: z.string().regex(SAFE_ID),
46
+ writer_version: z.number().int().positive(),
47
+ reader_version: z.number().int().positive(),
48
+ guard_digest: z.string().regex(/^[a-f0-9]{64}$/),
49
+ key_fingerprint: z.string().regex(/^[a-f0-9]{64}$/),
50
+ acknowledged_at: z.string().min(1),
51
+ }).strict();
52
+ export const AttemptRolloutAckSchema = AttemptRolloutAckUnsignedSchema.extend({
53
+ signature: z.string().min(1),
54
+ }).strict();
55
+ export const AttemptRolloutActivationSchema = z.object({
56
+ schema_version: z.literal(ROLLOUT_SCHEMA_VERSION),
57
+ document_kind: z.literal('attempt_authority_activation'),
58
+ membership_epoch: z.number().int().positive(),
59
+ guard_digest: z.string().regex(/^[a-f0-9]{64}$/),
60
+ ack_digests: z.record(z.string().regex(SAFE_ID), z.string().regex(/^[a-f0-9]{64}$/)),
61
+ authority_home: AuthorityHomeSchema,
62
+ activated_by: z.string().min(1),
63
+ activated_at: z.string().min(1),
64
+ }).strict();
65
+ export class AttemptRolloutError extends Error {
66
+ code;
67
+ constructor(code, message) {
68
+ super(message);
69
+ this.code = code;
70
+ this.name = 'AttemptRolloutError';
71
+ }
72
+ }
73
+ function canonical(value) {
74
+ if (Array.isArray(value))
75
+ return value.map(canonical);
76
+ if (!value || typeof value !== 'object')
77
+ return value;
78
+ return Object.fromEntries(Object.entries(value)
79
+ .sort(([left], [right]) => left.localeCompare(right))
80
+ .map(([key, child]) => [key, canonical(child)]));
81
+ }
82
+ function digest(value) {
83
+ return crypto.createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
84
+ }
85
+ export function attemptRolloutGuardDigest(guard) {
86
+ return digest(AttemptRolloutGuardSchema.parse(guard));
87
+ }
88
+ export function attemptRolloutAckDigest(ack) {
89
+ return digest(AttemptRolloutAckSchema.parse(ack));
90
+ }
91
+ export function attemptRolloutActivationDigest(activation) {
92
+ return digest(AttemptRolloutActivationSchema.parse(activation));
93
+ }
94
+ function rolloutRoot(cwd) {
95
+ return path.join(memoryDir(cwd), 'loops', 'attempt-authority-v2', 'rollout');
96
+ }
97
+ function epochDir(cwd, membershipEpoch) {
98
+ return path.join(rolloutRoot(cwd), `epoch-${membershipEpoch}`);
99
+ }
100
+ export function attemptRolloutGuardPath(cwd, membershipEpoch) {
101
+ return path.join(epochDir(cwd, membershipEpoch), 'guard.json');
102
+ }
103
+ export function attemptRolloutAckPath(cwd, membershipEpoch, writerId) {
104
+ if (!SAFE_ID.test(writerId))
105
+ throw new Error(`unsafe writer_id: ${writerId}`);
106
+ return path.join(epochDir(cwd, membershipEpoch), 'acks', `${writerId}.json`);
107
+ }
108
+ export function attemptRolloutActivationPath(cwd, membershipEpoch) {
109
+ return path.join(epochDir(cwd, membershipEpoch), 'active.decision.json');
110
+ }
111
+ const HARDLINK_UNSUPPORTED = new Set(['ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EXDEV', 'EINVAL', 'EPERM', 'EACCES']);
112
+ function readCell(filePath, schema) {
113
+ if (!fs.existsSync(filePath))
114
+ return undefined;
115
+ try {
116
+ return schema.parse(JSON.parse(fs.readFileSync(filePath, 'utf8')));
117
+ }
118
+ catch (error) {
119
+ throw new AttemptRolloutError('corrupt_rollout_cell', `rollout cell is corrupt: ${filePath} (${error instanceof Error ? error.message : String(error)})`);
120
+ }
121
+ }
122
+ function publishCell(filePath, value, schema) {
123
+ const cell = schema.parse(value);
124
+ const dir = path.dirname(filePath);
125
+ fs.mkdirSync(dir, { recursive: true });
126
+ const temp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
127
+ const fd = fs.openSync(temp, 'wx');
128
+ try {
129
+ const bytes = Buffer.from(`${JSON.stringify(cell, null, 2)}\n`, 'utf8');
130
+ let offset = 0;
131
+ while (offset < bytes.length)
132
+ offset += fs.writeSync(fd, bytes, offset, bytes.length - offset);
133
+ fs.fsyncSync(fd);
134
+ }
135
+ finally {
136
+ fs.closeSync(fd);
137
+ }
138
+ try {
139
+ fs.linkSync(temp, filePath);
140
+ try {
141
+ fs.unlinkSync(temp);
142
+ }
143
+ catch { /* orphan temp is non-authoritative */ }
144
+ return { won: true, cell };
145
+ }
146
+ catch (error) {
147
+ try {
148
+ fs.unlinkSync(temp);
149
+ }
150
+ catch { /* best effort */ }
151
+ const code = error.code;
152
+ if (code === 'EEXIST') {
153
+ const incumbent = readCell(filePath, schema);
154
+ if (!incumbent)
155
+ throw new AttemptRolloutError('corrupt_rollout_cell', `rollout incumbent vanished: ${filePath}`);
156
+ return { won: false, cell: incumbent };
157
+ }
158
+ if (code && HARDLINK_UNSUPPORTED.has(code)) {
159
+ throw new AttemptRolloutError('hardlink_unsupported', `filesystem cannot publish rollout cells with hardlink CAS (${code})`);
160
+ }
161
+ throw error;
162
+ }
163
+ }
164
+ function same(left, right) {
165
+ return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
166
+ }
167
+ export function prepareAttemptAuthorityRollout(cwd, input) {
168
+ const participants = input.participants.map((participant) => {
169
+ const parsed = AttemptWriterParticipantSchema.parse(participant);
170
+ if (fingerprintPublicKeyPem(parsed.public_key_pem) !== parsed.key_fingerprint) {
171
+ throw new AttemptRolloutError('bad_signature', `public key fingerprint mismatch for ${parsed.writer_id}`);
172
+ }
173
+ return parsed;
174
+ });
175
+ if (new Set(participants.map((participant) => participant.writer_id)).size !== participants.length) {
176
+ throw new AttemptRolloutError('membership_mismatch', 'rollout participants contain duplicate writer_id values');
177
+ }
178
+ const guard = AttemptRolloutGuardSchema.parse({
179
+ schema_version: ROLLOUT_SCHEMA_VERSION,
180
+ document_kind: 'attempt_authority_writer_guard',
181
+ membership_epoch: input.membership_epoch,
182
+ minimum_writer_version: input.minimum_writer_version ?? ATTEMPT_AUTHORITY_WRITER_VERSION,
183
+ minimum_reader_version: input.minimum_reader_version ?? ATTEMPT_AUTHORITY_READER_VERSION,
184
+ authority_home: input.authority_home,
185
+ participants,
186
+ previous_activation_digest: input.previous_activation_digest,
187
+ prepared_by: input.prepared_by,
188
+ prepared_at: input.prepared_at ?? nowISO(),
189
+ });
190
+ const current = resolveActiveAttemptRollout(cwd);
191
+ if (current) {
192
+ if (guard.membership_epoch <= current.guard.membership_epoch) {
193
+ throw new AttemptRolloutError('membership_mismatch', 'new membership epoch must exceed the active epoch');
194
+ }
195
+ if (guard.previous_activation_digest !== attemptRolloutActivationDigest(current.activation)) {
196
+ throw new AttemptRolloutError('membership_mismatch', 'new guard does not chain from the active rollout');
197
+ }
198
+ if (!same(guard.authority_home, current.guard.authority_home)) {
199
+ throw new AttemptRolloutError('authority_home_mismatch', 'membership rollover cannot change authority_home without an explicit transfer protocol');
200
+ }
201
+ }
202
+ else if (guard.membership_epoch !== 1 || guard.previous_activation_digest !== undefined) {
203
+ throw new AttemptRolloutError('membership_mismatch', 'the first rollout guard must be membership epoch 1 without a predecessor');
204
+ }
205
+ const published = publishCell(attemptRolloutGuardPath(cwd, guard.membership_epoch), guard, AttemptRolloutGuardSchema);
206
+ if (!same(published.cell, guard)) {
207
+ throw new AttemptRolloutError('membership_mismatch', `a different guard already exists for epoch ${guard.membership_epoch}`);
208
+ }
209
+ return published.cell;
210
+ }
211
+ function ackSigningBytes(ack) {
212
+ return Buffer.from(JSON.stringify(canonical(AttemptRolloutAckUnsignedSchema.parse(ack))), 'utf8');
213
+ }
214
+ export function publishAttemptRolloutAck(cwd, input) {
215
+ const guard = readCell(attemptRolloutGuardPath(cwd, input.membership_epoch), AttemptRolloutGuardSchema);
216
+ if (!guard)
217
+ throw new AttemptRolloutError('not_prepared', `rollout epoch ${input.membership_epoch} is not prepared`);
218
+ const participant = guard.participants.find((candidate) => candidate.writer_id === input.writer_id && candidate.status === 'active');
219
+ if (!participant)
220
+ throw new AttemptRolloutError('participant_unknown', `writer ${input.writer_id} is not active in rollout epoch ${input.membership_epoch}`);
221
+ if (input.writer_version < guard.minimum_writer_version) {
222
+ throw new AttemptRolloutError('writer_too_old', `writer ${input.writer_id} version ${input.writer_version} < ${guard.minimum_writer_version}`);
223
+ }
224
+ const readerVersion = input.reader_version ?? ATTEMPT_AUTHORITY_READER_VERSION;
225
+ if (readerVersion < guard.minimum_reader_version) {
226
+ throw new AttemptRolloutError('writer_too_old', `writer ${input.writer_id} reader version ${readerVersion} < ${guard.minimum_reader_version}`);
227
+ }
228
+ const privateKey = crypto.createPrivateKey(input.private_key_pem);
229
+ const publicKeyPem = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString();
230
+ if (fingerprintPublicKeyPem(publicKeyPem) !== participant.key_fingerprint) {
231
+ throw new AttemptRolloutError('bad_signature', `private key does not belong to writer ${input.writer_id}`);
232
+ }
233
+ const unsigned = AttemptRolloutAckUnsignedSchema.parse({
234
+ schema_version: ROLLOUT_SCHEMA_VERSION,
235
+ document_kind: 'attempt_authority_writer_ack',
236
+ membership_epoch: guard.membership_epoch,
237
+ writer_id: input.writer_id,
238
+ writer_version: input.writer_version,
239
+ reader_version: readerVersion,
240
+ guard_digest: attemptRolloutGuardDigest(guard),
241
+ key_fingerprint: participant.key_fingerprint,
242
+ acknowledged_at: input.acknowledged_at ?? nowISO(),
243
+ });
244
+ const ack = AttemptRolloutAckSchema.parse({
245
+ ...unsigned,
246
+ signature: crypto.sign(null, ackSigningBytes(unsigned), privateKey).toString('base64url'),
247
+ });
248
+ const published = publishCell(attemptRolloutAckPath(cwd, guard.membership_epoch, input.writer_id), ack, AttemptRolloutAckSchema);
249
+ if (!same(published.cell, ack)) {
250
+ throw new AttemptRolloutError('membership_mismatch', `writer ${input.writer_id} already acknowledged with different bytes`);
251
+ }
252
+ return published.cell;
253
+ }
254
+ function verifyAck(guard, ack) {
255
+ const participant = guard.participants.find((candidate) => candidate.writer_id === ack.writer_id && candidate.status === 'active');
256
+ if (!participant)
257
+ throw new AttemptRolloutError('participant_unknown', `ack writer ${ack.writer_id} is not active`);
258
+ if (ack.membership_epoch !== guard.membership_epoch || ack.guard_digest !== attemptRolloutGuardDigest(guard)) {
259
+ throw new AttemptRolloutError('membership_mismatch', `ack ${ack.writer_id} targets a different guard`);
260
+ }
261
+ if (ack.writer_version < guard.minimum_writer_version) {
262
+ throw new AttemptRolloutError('writer_too_old', `ack ${ack.writer_id} advertises writer version ${ack.writer_version}`);
263
+ }
264
+ if (ack.reader_version < guard.minimum_reader_version) {
265
+ throw new AttemptRolloutError('writer_too_old', `ack ${ack.writer_id} reader version ${ack.reader_version} < ${guard.minimum_reader_version}`);
266
+ }
267
+ const { signature, ...unsigned } = ack;
268
+ const valid = crypto.verify(null, ackSigningBytes(AttemptRolloutAckUnsignedSchema.parse(unsigned)), crypto.createPublicKey(participant.public_key_pem), Buffer.from(signature, 'base64url'));
269
+ if (!valid || participant.key_fingerprint !== ack.key_fingerprint) {
270
+ throw new AttemptRolloutError('bad_signature', `ack signature is invalid for ${ack.writer_id}`);
271
+ }
272
+ }
273
+ export function activateAttemptAuthorityV2(cwd, membershipEpoch, activatedBy, activatedAt = nowISO()) {
274
+ const guard = readCell(attemptRolloutGuardPath(cwd, membershipEpoch), AttemptRolloutGuardSchema);
275
+ if (!guard)
276
+ throw new AttemptRolloutError('not_prepared', `rollout epoch ${membershipEpoch} is not prepared`);
277
+ const ackDigests = {};
278
+ for (const participant of guard.participants.filter((candidate) => candidate.status === 'active')) {
279
+ const ack = readCell(attemptRolloutAckPath(cwd, membershipEpoch, participant.writer_id), AttemptRolloutAckSchema);
280
+ if (!ack)
281
+ throw new AttemptRolloutError('not_active', `writer ${participant.writer_id} has not acknowledged rollout epoch ${membershipEpoch}`);
282
+ verifyAck(guard, ack);
283
+ ackDigests[participant.writer_id] = attemptRolloutAckDigest(ack);
284
+ }
285
+ const activation = AttemptRolloutActivationSchema.parse({
286
+ schema_version: ROLLOUT_SCHEMA_VERSION,
287
+ document_kind: 'attempt_authority_activation',
288
+ membership_epoch: membershipEpoch,
289
+ guard_digest: attemptRolloutGuardDigest(guard),
290
+ ack_digests: ackDigests,
291
+ authority_home: guard.authority_home,
292
+ activated_by: activatedBy,
293
+ activated_at: activatedAt,
294
+ });
295
+ const published = publishCell(attemptRolloutActivationPath(cwd, membershipEpoch), activation, AttemptRolloutActivationSchema);
296
+ if (!same(published.cell, activation)) {
297
+ throw new AttemptRolloutError('membership_mismatch', `rollout epoch ${membershipEpoch} was activated with different acknowledgements`);
298
+ }
299
+ return published.cell;
300
+ }
301
+ export function resolveActiveAttemptRollout(cwd) {
302
+ const root = rolloutRoot(cwd);
303
+ if (!fs.existsSync(root))
304
+ return undefined;
305
+ const epochs = fs.readdirSync(root)
306
+ .map((name) => /^epoch-(\d+)$/.exec(name)?.[1])
307
+ .filter((value) => value !== undefined)
308
+ .map(Number)
309
+ .filter(Number.isSafeInteger)
310
+ .sort((left, right) => left - right);
311
+ let latest;
312
+ for (const membershipEpoch of epochs) {
313
+ const activation = readCell(attemptRolloutActivationPath(cwd, membershipEpoch), AttemptRolloutActivationSchema);
314
+ if (!activation)
315
+ continue;
316
+ const guard = readCell(attemptRolloutGuardPath(cwd, membershipEpoch), AttemptRolloutGuardSchema);
317
+ if (!guard || activation.guard_digest !== attemptRolloutGuardDigest(guard) || !same(activation.authority_home, guard.authority_home)) {
318
+ throw new AttemptRolloutError('corrupt_rollout_cell', `activation/guard mismatch at epoch ${membershipEpoch}`);
319
+ }
320
+ if (!latest) {
321
+ if (guard.membership_epoch !== 1 || guard.previous_activation_digest !== undefined) {
322
+ throw new AttemptRolloutError('corrupt_rollout_cell', 'activation chain does not start at membership epoch 1');
323
+ }
324
+ }
325
+ else {
326
+ if (guard.previous_activation_digest !== attemptRolloutActivationDigest(latest.activation)) {
327
+ throw new AttemptRolloutError('corrupt_rollout_cell', `activation epoch ${membershipEpoch} is not chained to its predecessor`);
328
+ }
329
+ if (!same(guard.authority_home, latest.guard.authority_home)) {
330
+ throw new AttemptRolloutError('authority_home_mismatch', 'activation chain changes authority_home without transfer');
331
+ }
332
+ }
333
+ for (const participant of guard.participants.filter((candidate) => candidate.status === 'active')) {
334
+ const ack = readCell(attemptRolloutAckPath(cwd, membershipEpoch, participant.writer_id), AttemptRolloutAckSchema);
335
+ if (!ack)
336
+ throw new AttemptRolloutError('corrupt_rollout_cell', `activated writer ${participant.writer_id} has no ACK`);
337
+ verifyAck(guard, ack);
338
+ if (activation.ack_digests[participant.writer_id] !== attemptRolloutAckDigest(ack)) {
339
+ throw new AttemptRolloutError('corrupt_rollout_cell', `activation ACK digest mismatch for ${participant.writer_id}`);
340
+ }
341
+ }
342
+ latest = { guard, activation };
343
+ }
344
+ return latest;
345
+ }
346
+ export function assertAttemptAuthorityV2Writable(cwd, localAuthorityHome, writerVersion = ATTEMPT_AUTHORITY_WRITER_VERSION, writerId, readerVersion = ATTEMPT_AUTHORITY_READER_VERSION) {
347
+ const active = resolveActiveAttemptRollout(cwd);
348
+ if (!active)
349
+ throw new AttemptRolloutError('not_active', 'AttemptAuthority v2 has no activated writer guard');
350
+ if (writerVersion < active.guard.minimum_writer_version) {
351
+ throw new AttemptRolloutError('writer_too_old', `writer version ${writerVersion} < ${active.guard.minimum_writer_version}`);
352
+ }
353
+ if (readerVersion < active.guard.minimum_reader_version) {
354
+ throw new AttemptRolloutError('writer_too_old', `reader version ${readerVersion} < ${active.guard.minimum_reader_version}`);
355
+ }
356
+ const participant = writerId
357
+ ? active.guard.participants.find((candidate) => candidate.writer_id === writerId && candidate.status === 'active')
358
+ : undefined;
359
+ if (!participant || active.activation.ack_digests[participant.writer_id] === undefined) {
360
+ throw new AttemptRolloutError('participant_unknown', `writer ${writerId ?? '(missing)'} is not active in the activated membership`);
361
+ }
362
+ if (!same(AuthorityHomeSchema.parse(localAuthorityHome), active.guard.authority_home)) {
363
+ throw new AttemptRolloutError('authority_home_mismatch', 'this store/device is not the activated authority_home');
364
+ }
365
+ return active;
366
+ }
367
+ function localIdentityPath(cwd, options = {}) {
368
+ const root = options.identity_root
369
+ ?? process.env.BRAINCLAW_AUTHORITY_IDENTITY_ROOT
370
+ ?? path.join(os.homedir(), '.brainclaw', 'store-instances');
371
+ let canonicalPath;
372
+ try {
373
+ canonicalPath = fs.realpathSync.native(cwd);
374
+ }
375
+ catch {
376
+ canonicalPath = path.resolve(cwd);
377
+ }
378
+ const locator = crypto.createHash('sha256').update(process.platform === 'win32' ? canonicalPath.toLowerCase() : canonicalPath).digest('hex');
379
+ return path.join(root, `${locator}.json`);
380
+ }
381
+ export function ensureLocalAuthorityHome(cwd, options = {}) {
382
+ const filePath = localIdentityPath(cwd, options);
383
+ if (fs.existsSync(filePath)) {
384
+ const existing = AuthorityHomeSchema.parse(JSON.parse(fs.readFileSync(filePath, 'utf8')));
385
+ if (options.device_id && existing.device_id !== options.device_id) {
386
+ throw new AttemptRolloutError('authority_home_mismatch', `local authority identity is bound to ${existing.device_id}, not paired device ${options.device_id}`);
387
+ }
388
+ return existing;
389
+ }
390
+ const identity = AuthorityHomeSchema.parse({
391
+ store_instance_id: `sti_${crypto.randomUUID()}`,
392
+ device_id: options.device_id ?? `dev_${crypto.randomUUID()}`,
393
+ });
394
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
395
+ writeFileAtomic(filePath, `${JSON.stringify(identity, null, 2)}\n`);
396
+ return identity;
397
+ }
398
+ export function readLocalAuthorityHome(cwd, options = {}) {
399
+ const filePath = localIdentityPath(cwd, options);
400
+ if (!fs.existsSync(filePath))
401
+ return undefined;
402
+ return AuthorityHomeSchema.parse(JSON.parse(fs.readFileSync(filePath, 'utf8')));
403
+ }
404
+ //# sourceMappingURL=attempt-rollout.js.map
@@ -0,0 +1,155 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import { ensureAgentRunProjection, loadAgentRun, transitionAgentRun } from '../agentruns.js';
4
+ import { loadAssignment } from '../assignments.js';
5
+ import { createRuntimeEvent } from '../events.js';
6
+ import { nowISO } from '../ids.js';
7
+ import { prepareAttemptTakeoverV2 } from './attempt-authority.js';
8
+ import { generationDigest } from './attempt-generations.js';
9
+ import { commitViaIntent } from './commit-intent.js';
10
+ import { evidenceDigest } from './evidence.js';
11
+ import { withLoopLock } from './lock.js';
12
+ import { getReservation } from './attempt-reservation.js';
13
+ import { getLoop, listLoopEvents } from './store.js';
14
+ /**
15
+ * Operator/engine takeover transaction.
16
+ *
17
+ * Under the loop lock it publishes close(epoch) and the causal LoopEvent. The
18
+ * immutable close cell remains the authority; AgentRun/RuntimeEvent/head are
19
+ * replayable projections applied after the lock is released. The successor is
20
+ * ARMED but not crossed — the normal worker dispatch path must project then win
21
+ * launch(next epoch) immediately before spawn.
22
+ */
23
+ export function takeoverLoopAttempt(input) {
24
+ if (!fs.existsSync(input.next_workspace_path) || !fs.statSync(input.next_workspace_path).isDirectory()) {
25
+ throw new Error(`takeover workspace must already exist as an isolated directory: ${input.next_workspace_path}`);
26
+ }
27
+ const authorityActor = input.actor_id ?? input.actor;
28
+ const transaction = withLoopLock({
29
+ cwd: input.cwd,
30
+ intent: 'attempt-takeover',
31
+ agentId: authorityActor,
32
+ scope: { kind: 'loop', loopId: input.loop_id },
33
+ work: () => {
34
+ const loop = getLoop(input.loop_id, input.cwd);
35
+ if (!loop || loop.status !== 'open')
36
+ throw new Error(`loop ${input.loop_id} is not open`);
37
+ if (loop.created_by !== authorityActor) {
38
+ throw new Error(`attempt takeover requires loop coordinator ${loop.created_by}; caller is ${authorityActor}`);
39
+ }
40
+ const slot = loop.slots.find((candidate) => candidate.slot_id === input.slot_id);
41
+ if (!slot)
42
+ throw new Error(`slot ${input.slot_id} not found in loop ${input.loop_id}`);
43
+ const reservation = getReservation(input.turn_id, input.cwd);
44
+ if (!reservation || reservation.loop_id !== loop.id || reservation.slot_id !== slot.slot_id) {
45
+ throw new Error(`turn ${input.turn_id} does not own ${loop.id}/${slot.slot_id}`);
46
+ }
47
+ if (slot.current_turn_id !== input.turn_id || slot.assignment_id !== reservation.child_ids.assignment_id) {
48
+ throw new Error(`slot ${slot.slot_id} is no longer bound to turn ${input.turn_id}`);
49
+ }
50
+ const takeover = prepareAttemptTakeoverV2({ ...input, actor: authorityActor });
51
+ const duplicate = listLoopEvents(loop.id, input.cwd).some((event) => event.kind === 'attempt_generation_changed'
52
+ && event.turn_id === input.turn_id
53
+ && event.to_epoch === takeover.next_generation.attempt_epoch
54
+ && event.to_run_id === takeover.next_generation.run_id);
55
+ if (duplicate)
56
+ return { loop, takeover, reservation };
57
+ const now = nowISO();
58
+ const mutationId = crypto.randomUUID();
59
+ const event = {
60
+ event_id: crypto.randomUUID(),
61
+ loop_id: loop.id,
62
+ seq: loop.version + 1,
63
+ at: now,
64
+ by: authorityActor,
65
+ mutation_id: mutationId,
66
+ kind: 'attempt_generation_changed',
67
+ slot_id: slot.slot_id,
68
+ turn_id: input.turn_id,
69
+ assignment_id: takeover.next_generation.assignment_id,
70
+ from_epoch: takeover.previous_generation.attempt_epoch,
71
+ to_epoch: takeover.next_generation.attempt_epoch,
72
+ from_run_id: takeover.previous_generation.run_id,
73
+ to_run_id: takeover.next_generation.run_id,
74
+ close_digest: evidenceDigest(takeover.close_cell),
75
+ cause: input.cause,
76
+ };
77
+ const next = {
78
+ ...loop,
79
+ version: loop.version + 1,
80
+ mutation_id: mutationId,
81
+ updated_at: now,
82
+ };
83
+ commitViaIntent({ loop_id: loop.id, base_version: loop.version, events: [event], thread_snapshot: next }, input.cwd);
84
+ return { loop: next, takeover, reservation };
85
+ },
86
+ });
87
+ const { takeover, reservation } = transaction;
88
+ const assignment = loadAssignment(takeover.next_generation.assignment_id, input.cwd);
89
+ const previousRun = loadAgentRun(takeover.previous_generation.run_id, input.cwd);
90
+ if (previousRun && !['completed', 'failed', 'cancelled', 'timed_out', 'interrupted'].includes(previousRun.status)) {
91
+ try {
92
+ transitionAgentRun(previousRun.id, 'interrupted', {
93
+ actor: input.actor,
94
+ status_reason: `fenced by ${input.mode ?? 'takeover'} to epoch ${takeover.next_generation.attempt_epoch}`,
95
+ allow_fenced_projection: true,
96
+ }, input.cwd);
97
+ }
98
+ catch { /* immutable close cell already fences the old run */ }
99
+ }
100
+ ensureAgentRunProjection({
101
+ id: takeover.next_generation.run_id,
102
+ short_label: takeover.next_generation.run_id,
103
+ assignment_id: takeover.next_generation.assignment_id,
104
+ claim_id: reservation.claim_id,
105
+ attempt_index: takeover.next_generation.attempt_epoch + 1,
106
+ agent: reservation.agent,
107
+ agent_id: reservation.agent_id,
108
+ transport: 'cli_spawn',
109
+ status: 'created',
110
+ scope: assignment?.scope ?? reservation.execution_contract?.workspace_policy.scope ?? reservation.cwd,
111
+ description: assignment?.description ?? `Attempt generation ${takeover.next_generation.attempt_epoch} for ${input.turn_id}`,
112
+ worktree_path: takeover.next_generation.workspace_path,
113
+ execution_contract_ref: takeover.execution_contract_ref,
114
+ capability_snapshot: reservation.capability_snapshot,
115
+ tags: ['turn-owned', 'loop', 'attempt-takeover', `attempt-generation:${takeover.next_generation.attempt_epoch}`],
116
+ }, input.cwd);
117
+ try {
118
+ createRuntimeEvent({
119
+ agent: input.actor,
120
+ event_type: 'attempt_takeover',
121
+ text: `Attempt ${input.turn_id} moved from epoch ${takeover.previous_generation.attempt_epoch} to ${takeover.next_generation.attempt_epoch}`,
122
+ tags: ['loops', 'attempt-authority-v2', input.mode ?? 'takeover'],
123
+ assignment_id: takeover.next_generation.assignment_id,
124
+ run_id: takeover.next_generation.run_id,
125
+ turn_id: input.turn_id,
126
+ nonce: takeover.next_generation.launch_nonce,
127
+ attempt_epoch: takeover.next_generation.attempt_epoch,
128
+ workspace_digest: takeover.next_generation.workspace_digest,
129
+ status: 'created',
130
+ status_reason: input.cause,
131
+ metadata: {
132
+ previous_run_id: takeover.previous_generation.run_id,
133
+ generation_digest: generationDigest(takeover.next_generation),
134
+ close_digest: evidenceDigest(takeover.close_cell),
135
+ liveness_evidence: input.liveness_evidence,
136
+ external_effect_policy: input.external_effect_policy,
137
+ },
138
+ }, input.cwd);
139
+ }
140
+ catch { /* immutable close cell + LoopEvent are sufficient for recovery */ }
141
+ return {
142
+ loop: transaction.loop,
143
+ turn_id: input.turn_id,
144
+ assignment_id: takeover.next_generation.assignment_id,
145
+ previous_run_id: takeover.previous_generation.run_id,
146
+ run_id: takeover.next_generation.run_id,
147
+ attempt_epoch: takeover.next_generation.attempt_epoch,
148
+ launch_nonce: takeover.next_generation.launch_nonce,
149
+ workspace_digest: takeover.next_generation.workspace_digest,
150
+ execution_contract_hash: takeover.execution_contract_ref.hash,
151
+ won_close: takeover.won,
152
+ spawn_authority: false,
153
+ };
154
+ }
155
+ //# sourceMappingURL=attempt-takeover.js.map
@@ -21,7 +21,7 @@ import { acquireClaimScope, listClaims, releaseClaim } from '../claims.js';
21
21
  import { buildSurveySignalsBaseline } from './hooks/survey-signals-baseline.js';
22
22
  import { BOOTSTRAP_PRESET } from './presets/bootstrap.js';
23
23
  import { listLoops, openLoop } from './store.js';
24
- import { add_artifact } from './verbs.js';
24
+ import { addArtifactWithEvidence } from './verbs.js';
25
25
  export class BootstrapCoordinationInProgressError extends Error {
26
26
  blockingClaimId;
27
27
  constructor(blockingClaimId) {
@@ -192,14 +192,18 @@ export function acquireBootstrapLoop(opts, cwd) {
192
192
  // failure must never block opening the loop.
193
193
  try {
194
194
  const baseline = buildSurveySignalsBaseline(cwd ?? process.cwd());
195
- loop = add_artifact({
195
+ loop = addArtifactWithEvidence({
196
196
  id: loop.id,
197
197
  actor: opts.created_by ?? opts.agent_id ?? opts.actor,
198
+ evidence_context: {
199
+ channel: 'system_hook',
200
+ producer_kind: 'engine',
201
+ producer_id: 'brainclaw-scanner',
202
+ },
198
203
  artifact: {
199
204
  phase: 'survey',
200
205
  type: 'signals_baseline',
201
206
  body: JSON.stringify(baseline),
202
- produced_by: 'brainclaw-scanner',
203
207
  },
204
208
  }, cwd);
205
209
  warnings.push('survey baseline attached (artifact type signals_baseline, produced by the deterministic scanner) — enrich it into your signals_report instead of re-scanning the repo from scratch.');
@@ -45,9 +45,9 @@ const LOOP_INTERNAL_CATEGORIES = new Set([
45
45
  'synthesis_artifact',
46
46
  ]);
47
47
  export function buildIdeationBrief(input) {
48
- const { thread, slotRole, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, } = input;
48
+ const { thread, slotRole, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, seedText, scopeHints = [], } = input;
49
49
  const proposal = findProposalArtifact(thread);
50
- const proposalText = proposal?.body?.trim() ?? '(no proposal seed found)';
50
+ const proposalText = seedText?.trim() || proposal?.body?.trim() || '(no proposal seed found)';
51
51
  // Resolve which memory categories the current phase wants. If the
52
52
  // current phase has no context_filter, fall back to '*' (full bundle).
53
53
  const currentPhaseDef = thread.phases.find((p) => p.name === thread.current_phase);
@@ -57,7 +57,7 @@ export function buildIdeationBrief(input) {
57
57
  const fetchedItemsByCategory = new Map();
58
58
  const categoriesUsed = [];
59
59
  for (const category of userFacingCategories) {
60
- const items = memoryProvider.fetch(category, proposalText, topKPerCategory);
60
+ const items = scopeMemoryItems(memoryProvider.fetch(category, `${proposalText} ${scopeHints.join(' ')}`.trim(), topKPerCategory), scopeHints);
61
61
  if (items.length > 0) {
62
62
  fetchedItemsByCategory.set(category, items);
63
63
  categoriesUsed.push(category);
@@ -116,7 +116,7 @@ function expandUserFacingCategories(requested) {
116
116
  }
117
117
  function renderHeader(thread, slotRole, phase) {
118
118
  const lines = [
119
- `# ideation_loop brief`,
119
+ `# ${thread.kind}_loop brief`,
120
120
  `loop: ${thread.id}`,
121
121
  `phase: ${phase}`,
122
122
  `iteration: ${thread.iteration_count}`,
@@ -127,6 +127,23 @@ function renderHeader(thread, slotRole, phase) {
127
127
  lines.push(`goal: ${thread.goal}`);
128
128
  return lines.join('\n');
129
129
  }
130
+ function normalizeScope(value) {
131
+ return value.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
132
+ }
133
+ /** Keep project-wide memories and memories whose related_paths overlap this lane. */
134
+ function scopeMemoryItems(items, scopeHints) {
135
+ const scopes = scopeHints.map(normalizeScope).filter(Boolean);
136
+ if (scopes.length === 0)
137
+ return items;
138
+ return items.filter((item) => {
139
+ if (!item.relatedPaths || item.relatedPaths.length === 0)
140
+ return true;
141
+ return item.relatedPaths.some((related) => {
142
+ const path = normalizeScope(related);
143
+ return scopes.some((scope) => path.startsWith(scope) || scope.startsWith(path));
144
+ });
145
+ });
146
+ }
130
147
  function renderProposalBlock(proposalText) {
131
148
  return `## proposal\n\n${proposalText}`;
132
149
  }