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
@@ -24,10 +24,16 @@
24
24
  */
25
25
  import { spawnSync } from 'node:child_process';
26
26
  import path from 'node:path';
27
+ import { loadAssignment } from '../assignments.js';
27
28
  import { getLoop } from './store.js';
28
29
  import { withLoopLock } from './lock.js';
29
- import { add_artifact } from './verbs.js';
30
+ import { addArtifactWithEvidence } from './verbs.js';
30
31
  import { artifactsInIteration } from './iteration-engine.js';
32
+ import { evidenceDigest } from './evidence.js';
33
+ import { eligibleArtifactsForPurpose } from './gate-policy.js';
34
+ import { captureWorkspaceDigest } from './workspace-digest.js';
35
+ import { findReservationByAssignmentId } from './attempt-reservation.js';
36
+ import { resolveTurnGenerationChain } from './attempt-generations.js';
31
37
  import { VERIFY_DEFAULT_TIMEOUT_MS, LOOP_ARTIFACT_BODY_MAX_BYTES, } from './types.js';
32
38
  /** VerifyReportBodySchema caps stdout_tail/stderr_tail at 1024. */
33
39
  const TAIL_MAX = 1024;
@@ -48,7 +54,15 @@ export const defaultVerifyRunner = (config) => {
48
54
  delete env[k];
49
55
  }
50
56
  const started = Date.now();
51
- const r = spawnSync(config.command[0], config.command.slice(1), {
57
+ const requestedExecutable = config.command[0];
58
+ const npmCli = process.platform === 'win32' && (requestedExecutable === 'npm' || requestedExecutable === 'npx')
59
+ ? path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', `${requestedExecutable}-cli.js`)
60
+ : undefined;
61
+ // Node cannot spawn .cmd shims with shell:false on Windows. Invoke npm's JS
62
+ // entrypoint through the current Node binary so the no-shell security contract holds.
63
+ const executable = npmCli ? process.execPath : requestedExecutable;
64
+ const commandArgs = npmCli ? [npmCli, ...config.command.slice(1)] : config.command.slice(1);
65
+ const r = spawnSync(executable, commandArgs, {
52
66
  cwd: config.cwd,
53
67
  env,
54
68
  shell: false,
@@ -76,19 +90,52 @@ export const defaultVerifyRunner = (config) => {
76
90
  };
77
91
  };
78
92
  /**
79
- * Resolve the verify command for a loop. PR1: the loop-PROJECT cwd only (the
80
- * lane-worktree cwd for a sequenced impl loop is a follow-up). Returns `unconfigured`
81
- * when the loop opted out (no `protocol.verify`).
93
+ * Resolve the verify command for a loop. Bound implementation lanes run only
94
+ * in their assignment worktree; legacy/unbound loops retain the project cwd.
95
+ * Returns `unconfigured` when the loop opted out (no `protocol.verify`).
82
96
  */
83
- export function resolveVerifyCommand(thread, cwd) {
97
+ function assignmentWorktree(assignmentId, cwd) {
98
+ const assignment = loadAssignment(assignmentId, cwd);
99
+ const reservation = findReservationByAssignmentId(assignmentId, cwd);
100
+ const generation = reservation
101
+ ? resolveTurnGenerationChain(cwd ?? reservation.store_root, reservation.turn_id)?.latest_generation
102
+ : undefined;
103
+ return generation?.workspace_path ?? assignment?.worktree_path;
104
+ }
105
+ export function resolveVerifyCommand(thread, cwd, slotId) {
84
106
  const cfg = thread.protocol?.verify;
85
107
  if (!cfg)
86
108
  return { kind: 'unconfigured' };
109
+ let verifyCwd = path.resolve(cwd ?? process.cwd());
110
+ if (thread.kind === 'implementation') {
111
+ const selected = slotId ? thread.slots.find((slot) => slot.slot_id === slotId) : undefined;
112
+ if (slotId && !selected)
113
+ throw new Error(`verify: slot ${slotId} not found on loop ${thread.id}`);
114
+ const candidates = (selected ? [selected] : thread.slots)
115
+ .filter((slot) => slot.assignment_id)
116
+ .map((slot) => ({ slot, worktree_path: assignmentWorktree(slot.assignment_id, cwd) }))
117
+ .filter((entry) => entry.worktree_path);
118
+ if (!selected && candidates.length > 1) {
119
+ throw new Error(`verify: implementation loop ${thread.id} has multiple bound worktrees; pass slot_id to verify one lane deterministically`);
120
+ }
121
+ if (!selected && thread.slots.some((slot) => slot.lane) && candidates.length === 0) {
122
+ throw new Error(`verify: implementation loop ${thread.id} has bound lanes but no assignment worktree; dispatch and settle the execute turn first`);
123
+ }
124
+ if (selected?.lane && !selected.assignment_id) {
125
+ throw new Error(`verify: slot ${selected.slot_id} is bound to lane ${selected.lane} but has no assignment worktree; dispatch and settle the execute turn first`);
126
+ }
127
+ const candidate = candidates[0];
128
+ if (selected?.assignment_id && !candidate?.worktree_path) {
129
+ throw new Error(`verify: slot ${selected.slot_id} assignment ${selected.assignment_id} has no worktree_path`);
130
+ }
131
+ if (candidate?.worktree_path)
132
+ verifyCwd = path.resolve(candidate.worktree_path);
133
+ }
87
134
  return {
88
135
  kind: 'ok',
89
136
  config: {
90
137
  command: cfg.command,
91
- cwd: path.resolve(cwd ?? process.cwd()),
138
+ cwd: verifyCwd,
92
139
  timeout_ms: cfg.timeout_ms ?? VERIFY_DEFAULT_TIMEOUT_MS,
93
140
  },
94
141
  };
@@ -115,9 +162,10 @@ function fitBody(report) {
115
162
  }
116
163
  return { ...report, stdout_tail: undefined, stderr_tail: undefined };
117
164
  }
118
- export function buildVerifyReportBody(config, result) {
165
+ export function buildVerifyReportBody(config, result, bindings) {
119
166
  return fitBody({
120
167
  command: config.command.join(' '),
168
+ command_argv: config.command,
121
169
  exit_code: result.exit_code,
122
170
  passed: result.passed,
123
171
  duration_ms: result.duration_ms,
@@ -125,11 +173,24 @@ export function buildVerifyReportBody(config, result) {
125
173
  timed_out: result.timed_out,
126
174
  stdout_tail: result.stdout_tail || undefined,
127
175
  stderr_tail: result.stderr_tail || undefined,
176
+ ...bindings,
128
177
  });
129
178
  }
130
- /** True when a verify_report already exists in this iteration (idempotency key = loop+iteration). */
131
- function hasVerifyReportForIteration(thread, iteration) {
132
- return artifactsInIteration(thread, iteration).some((a) => a.type === 'verify_report');
179
+ /** True when an authoritative, still-fresh engine report exists for this iteration. */
180
+ function hasVerifyReportForIteration(thread, iteration, lane) {
181
+ const reports = artifactsInIteration(thread, iteration).filter((artifact) => {
182
+ if (artifact.type !== 'verify_report')
183
+ return false;
184
+ if (!lane)
185
+ return true;
186
+ try {
187
+ return JSON.parse(artifact.body ?? '{}').lane === lane;
188
+ }
189
+ catch {
190
+ return false;
191
+ }
192
+ });
193
+ return eligibleArtifactsForPurpose(thread, reports, 'command_green').eligible.length > 0;
133
194
  }
134
195
  /**
135
196
  * Run the configured verify command and record a deterministic `verify_report` for the
@@ -152,16 +213,23 @@ export function runVerify(input, cwd) {
152
213
  const thread = getLoop(input.loop_id, cwd);
153
214
  if (!thread)
154
215
  throw new Error(`loop ${input.loop_id} not found`);
155
- const resolved = resolveVerifyCommand(thread, cwd);
216
+ const inferredSlots = thread.kind === 'implementation' && !input.slot_id
217
+ ? thread.slots.filter((slot) => slot.assignment_id && assignmentWorktree(slot.assignment_id, cwd))
218
+ : [];
219
+ const selectedSlot = input.slot_id
220
+ ? thread.slots.find((slot) => slot.slot_id === input.slot_id)
221
+ : inferredSlots.length === 1 ? inferredSlots[0] : undefined;
222
+ const lane = selectedSlot?.lane;
223
+ const resolved = resolveVerifyCommand(thread, cwd, selectedSlot?.slot_id ?? input.slot_id);
156
224
  if (resolved.kind === 'unconfigured')
157
225
  return { state: 'unconfigured', thread };
158
226
  const iteration = thread.iteration_count;
159
- if (hasVerifyReportForIteration(thread, iteration))
227
+ if (hasVerifyReportForIteration(thread, iteration, lane))
160
228
  return { state: 'deduped', thread };
161
229
  // Snapshot the iteration + phase we are about to verify. The command tests THIS
162
230
  // iteration's working tree; the report must be attributed to it even if a
163
231
  // concurrent advance bumps the loop's iteration while we spawn (review F1).
164
- return { state: 'run', thread, config: resolved.config, iteration, phase: thread.current_phase };
232
+ return { state: 'run', thread, config: resolved.config, iteration, phase: thread.current_phase, lane };
165
233
  },
166
234
  });
167
235
  if (snapshot.state === 'unconfigured')
@@ -169,8 +237,13 @@ export function runVerify(input, cwd) {
169
237
  if (snapshot.state === 'deduped')
170
238
  return { thread: snapshot.thread, deduped: true };
171
239
  // --- OUT OF LOCK: run the command (may take minutes). ---
172
- const { config, iteration, phase } = snapshot;
173
- const report = buildVerifyReportBody(config, runner(config));
240
+ const { config, iteration, phase, lane } = snapshot;
241
+ const command_digest = evidenceDigest({ command: config.command });
242
+ const workspaceBefore = captureWorkspaceDigest(config.cwd);
243
+ const runResult = runner(config);
244
+ const workspaceAfter = captureWorkspaceDigest(config.cwd);
245
+ const workspace_stable = workspaceBefore === workspaceAfter;
246
+ const reportAfterRun = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && workspace_stable }, { command_digest, workspace_digest: workspaceAfter, workspace_stable, lane });
174
247
  // --- Lock scope 2: re-check idempotency (by SNAPSHOT iteration), then append. ---
175
248
  return withLoopLock({
176
249
  cwd,
@@ -184,12 +257,25 @@ export function runVerify(input, cwd) {
184
257
  // Dedup on the SNAPSHOT iteration — a report for the iteration we verified already
185
258
  // landed (a concurrent verify won). Checking the snapshot (not the current)
186
259
  // iteration is what makes this correct after a concurrent advance (review F1).
187
- if (hasVerifyReportForIteration(thread, iteration)) {
188
- return { thread, report, deduped: true };
260
+ if (hasVerifyReportForIteration(thread, iteration, lane)) {
261
+ return { thread, report: reportAfterRun, deduped: true };
189
262
  }
190
- const updated = add_artifact({
263
+ // Close the final out-of-lock race: the bytes verified above must still be the
264
+ // bytes present at the evidence commit boundary. A later gate independently
265
+ // repeats this freshness check so a post-commit mutation also fails closed.
266
+ const workspaceAtCommit = captureWorkspaceDigest(config.cwd);
267
+ const commitStable = workspace_stable && workspaceAtCommit === workspaceAfter;
268
+ const report = buildVerifyReportBody(config, { ...runResult, passed: runResult.passed && commitStable }, { command_digest, workspace_digest: workspaceAtCommit, workspace_stable: commitStable, lane });
269
+ const updated = addArtifactWithEvidence({
191
270
  id: input.loop_id,
192
271
  actor: input.actor,
272
+ evidence_context: {
273
+ channel: 'verify_command',
274
+ producer_kind: 'engine',
275
+ producer_id: 'brainclaw:verify-command',
276
+ command_digest,
277
+ workspace_digest: workspaceAtCommit,
278
+ },
193
279
  artifact: {
194
280
  // Stamp the SNAPSHOT phase + iteration so the report is attributed to the
195
281
  // iteration whose code it actually tested — never a later iteration a
@@ -198,7 +284,6 @@ export function runVerify(input, cwd) {
198
284
  iteration,
199
285
  type: 'verify_report',
200
286
  body: JSON.stringify(report),
201
- produced_by: 'engine',
202
287
  },
203
288
  }, cwd);
204
289
  const art = updated.artifacts[updated.artifacts.length - 1];
@@ -0,0 +1,54 @@
1
+ import crypto from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ function fallbackFiles(root, current = root) {
6
+ const files = [];
7
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
8
+ if (entry.name === '.brainclaw' || entry.name === '.git' || entry.name === 'node_modules')
9
+ continue;
10
+ const absolute = path.join(current, entry.name);
11
+ if (entry.isDirectory())
12
+ files.push(...fallbackFiles(root, absolute));
13
+ else if (entry.isFile())
14
+ files.push(path.relative(root, absolute));
15
+ }
16
+ return files;
17
+ }
18
+ /** Hash the exact tracked/untracked workspace bytes at a gate boundary. */
19
+ export function captureWorkspaceDigest(cwd) {
20
+ const rootResult = spawnSync('git', ['-C', cwd, 'rev-parse', '--show-toplevel'], {
21
+ encoding: 'utf8', shell: false, maxBuffer: 1024 * 1024,
22
+ });
23
+ const gitRoot = rootResult.status === 0 ? rootResult.stdout.trim() : undefined;
24
+ let files;
25
+ let root;
26
+ if (gitRoot) {
27
+ root = path.resolve(gitRoot);
28
+ const listed = spawnSync('git', ['-C', root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
29
+ encoding: 'buffer', shell: false, maxBuffer: 32 * 1024 * 1024,
30
+ });
31
+ files = listed.status === 0
32
+ ? listed.stdout.toString('utf8').split('\0').filter(Boolean)
33
+ : fallbackFiles(root);
34
+ }
35
+ else {
36
+ root = path.resolve(cwd);
37
+ files = fallbackFiles(root);
38
+ }
39
+ const hash = crypto.createHash('sha256').update(root.normalize('NFC'));
40
+ for (const relative of [...new Set(files)].sort()) {
41
+ const absolute = path.resolve(root, relative);
42
+ try {
43
+ const stat = fs.lstatSync(absolute);
44
+ if (!stat.isFile())
45
+ continue;
46
+ hash.update('\0').update(relative.normalize('NFC')).update('\0').update(fs.readFileSync(absolute));
47
+ }
48
+ catch {
49
+ hash.update('\0missing\0').update(relative.normalize('NFC'));
50
+ }
51
+ }
52
+ return hash.digest('hex');
53
+ }
54
+ //# sourceMappingURL=workspace-digest.js.map
@@ -1,5 +1,5 @@
1
1
  import { getLoop } from './loops/store.js';
2
- import { complete_turn, advance } from './loops/verbs.js';
2
+ import { completeTurnWithEvidence, advance } from './loops/verbs.js';
3
3
  import { withLoopLock } from './loops/lock.js';
4
4
  import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './loops/types.js';
5
5
  /** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
@@ -93,8 +93,19 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
93
93
  if (verdict === 'approve') {
94
94
  if (slot) {
95
95
  // isVerdictAccepted fires reviewer_green ONLY on an "accepted…" body.
96
- complete_turn({
96
+ completeTurnWithEvidence({
97
97
  id: loopId, slot_id: slot.slot_id, actor,
98
+ evidence_context: {
99
+ channel: 'complete_turn',
100
+ producer_kind: 'slot',
101
+ producer_id: slot.slot_id,
102
+ agent_id: slot.agent_id,
103
+ slot_id: slot.slot_id,
104
+ slot_role: slot.role,
105
+ turn_id: slot.current_turn_id,
106
+ assignment_id: slot.assignment_id ?? assignment.id,
107
+ claim_id: slot.claim_id,
108
+ },
98
109
  // pln#639 BUG-2 — the phase the slot was DISPATCHED in, not the
99
110
  // loop's phase at close time. Same defect as the ideation closer;
100
111
  // fixed here too because this is the far more travelled path.
@@ -145,8 +156,19 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
145
156
  // is a planned follow-up, so a human drives it. (No re-dispatch means no
146
157
  // worktree reuse, so the claim is released by harvest as usual.)
147
158
  const symmetric = loop.protocol?.review_mode === 'symmetric';
148
- complete_turn({
159
+ completeTurnWithEvidence({
149
160
  id: loopId, slot_id: slot.slot_id, actor,
161
+ evidence_context: {
162
+ channel: 'complete_turn',
163
+ producer_kind: 'slot',
164
+ producer_id: slot.slot_id,
165
+ agent_id: slot.agent_id,
166
+ slot_id: slot.slot_id,
167
+ slot_role: slot.role,
168
+ turn_id: slot.current_turn_id,
169
+ assignment_id: slot.assignment_id ?? assignment.id,
170
+ claim_id: slot.claim_id,
171
+ },
150
172
  // pln#639 BUG-2 — dispatch phase, not close-time phase (see above).
151
173
  artifact: { phase: slot.phase ?? loop.current_phase, type: 'verdict', body: capVerdictBody('changes-requested', detail) },
152
174
  }, cwd);