brainclaw 1.28.2 → 1.28.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 (49) hide show
  1. package/README.md +6 -0
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/commands/harvest.js +67 -25
  4. package/dist/commands/loops-handlers.js +28 -1
  5. package/dist/commands/mcp-catalog.js +25 -4
  6. package/dist/commands/mcp-read-handlers.js +15 -1
  7. package/dist/commands/mcp-schemas.generated.js +13 -0
  8. package/dist/commands/mcp-write-coordination.js +97 -26
  9. package/dist/commands/mcp-write-entities.js +5 -2
  10. package/dist/commands/mcp-write-memory.js +87 -1
  11. package/dist/commands/mcp.js +41 -9
  12. package/dist/core/code-map/aggregate.js +20 -7
  13. package/dist/core/code-map/backend.js +17 -7
  14. package/dist/core/code-map/cascade-jobs.js +174 -0
  15. package/dist/core/code-map/cascade-worker.js +15 -0
  16. package/dist/core/code-map/cascade.js +63 -26
  17. package/dist/core/code-map/query.js +6 -3
  18. package/dist/core/context.js +16 -3
  19. package/dist/core/dispatch-status.js +36 -14
  20. package/dist/core/dispatcher.js +28 -20
  21. package/dist/core/entity-operations.js +80 -8
  22. package/dist/core/entity-registry.js +3 -3
  23. package/dist/core/execution-adapters.js +18 -1
  24. package/dist/core/facade-schema.js +10 -0
  25. package/dist/core/ideation-loop-close.js +3 -1
  26. package/dist/core/lane-result-file.js +72 -0
  27. package/dist/core/loop-turn-dispatch.js +2 -0
  28. package/dist/core/loops/brief-assembly.js +19 -11
  29. package/dist/core/loops/next-expected.js +56 -1
  30. package/dist/core/loops/reconcile-turn.js +8 -0
  31. package/dist/core/loops/result-reducers.js +14 -12
  32. package/dist/core/loops/store.js +4 -0
  33. package/dist/core/loops/types.js +14 -2
  34. package/dist/core/loops/verbs.js +8 -1
  35. package/dist/core/loops/worker-reply-contract.js +1 -1
  36. package/dist/core/protocol-tool-policy.js +1 -0
  37. package/dist/core/review-loop-turn-dispatch.js +1 -0
  38. package/dist/core/schema.js +24 -1
  39. package/dist/core/search.js +3 -2
  40. package/dist/core/spawn-check.js +9 -1
  41. package/dist/core/worktree.js +14 -7
  42. package/dist/facts.js +10 -9
  43. package/dist/facts.json +9 -8
  44. package/docs/cli.md +35 -2
  45. package/docs/code-map.md +20 -9
  46. package/docs/concepts/ideation-loop.md +35 -14
  47. package/docs/integrations/mcp.md +15 -5
  48. package/docs/mcp-schema-changelog.md +72 -6
  49. package/package.json +1 -1
@@ -1,3 +1,18 @@
1
+ import { evaluatePhaseAdvanceGate } from './verbs.js';
2
+ /** Participants eligible to speak in the current phase. Ideation reuses the
3
+ * same durable slots across rounds: critics converse during critique, while
4
+ * the champion revises and synthesizes. Other protocols retain their legacy
5
+ * phase binding semantics. */
6
+ function eligiblePhaseSlots(loop) {
7
+ if (loop.kind === 'ideation') {
8
+ if (loop.current_phase === 'critique')
9
+ return loop.slots.filter((slot) => slot.role === 'critic');
10
+ if (loop.current_phase === 'proposal' || loop.current_phase === 'revision' || loop.current_phase === 'synthesis') {
11
+ return loop.slots.filter((slot) => slot.role === 'champion');
12
+ }
13
+ }
14
+ return loop.slots.filter((slot) => (slot.phase ?? loop.current_phase) === loop.current_phase);
15
+ }
1
16
  export function computeNextExpected(loop) {
2
17
  if (loop.status === 'completed' || loop.status === 'cancelled' || loop.status === 'blocked') {
3
18
  return null;
@@ -18,7 +33,7 @@ export function computeNextExpected(loop) {
18
33
  if (loop.status === 'paused') {
19
34
  return null;
20
35
  }
21
- const currentPhaseSlots = loop.slots.filter((s) => (s.phase ?? loop.current_phase) === loop.current_phase);
36
+ const currentPhaseSlots = eligiblePhaseSlots(loop);
22
37
  const openSlots = currentPhaseSlots.filter((s) => s.status === 'open');
23
38
  if (openSlots.length > 0) {
24
39
  const first = openSlots[0];
@@ -42,6 +57,46 @@ export function computeNextExpected(loop) {
42
57
  blocking_on: assignedOrWorking.map((s) => s.slot_id),
43
58
  };
44
59
  }
60
+ // A completed ideation slot is reusable in the next phase/iteration. Pick
61
+ // the first participant that has not yet contributed to THIS round. This is
62
+ // what turns critique A → critique B → critique C → champion revision
63
+ // into an actual conversation instead of replaying slot A forever.
64
+ if (loop.kind === 'ideation') {
65
+ const awaitingRound = currentPhaseSlots.find((slot) => slot.status === 'done'
66
+ && (slot.last_completed_phase !== loop.current_phase
67
+ || slot.last_completed_iteration !== loop.iteration_count));
68
+ if (awaitingRound) {
69
+ return {
70
+ action: 'turn',
71
+ intent: 'bclaw_loop.turn',
72
+ reason: 'next sequential participant in the current ideation round',
73
+ phase: loop.current_phase,
74
+ slot_id: awaitingRound.slot_id,
75
+ role: awaitingRound.role,
76
+ blocking_on: [awaitingRound.slot_id],
77
+ };
78
+ }
79
+ }
80
+ const currentPhase = loop.phases.find((phase) => phase.name === loop.current_phase);
81
+ const gate = evaluatePhaseAdvanceGate(loop, currentPhase?.advance_gate);
82
+ if (!gate.advance) {
83
+ // A failed/malformed worker result must lead back to a real evidence-bearing
84
+ // turn. Under strict evidence policy, add_artifact is audit-only and cannot
85
+ // satisfy the gate, so never suggest the champion/advance path here.
86
+ const replayable = currentPhaseSlots.filter((slot) => slot.status === 'failed');
87
+ const target = replayable[0] ?? currentPhaseSlots[0];
88
+ if (target) {
89
+ return {
90
+ action: 'turn',
91
+ intent: 'bclaw_loop.turn',
92
+ reason: `phase_gate_unmet: ${gate.gate_reason ?? 'required evidence is missing'}; replay a real slot turn (manual add_artifact does not count under strict evidence)`,
93
+ phase: loop.current_phase,
94
+ slot_id: target.slot_id,
95
+ role: target.role,
96
+ blocking_on: replayable.length > 0 ? replayable.map((slot) => slot.slot_id) : [target.slot_id],
97
+ };
98
+ }
99
+ }
45
100
  const phaseNames = loop.phases.map((p) => p.name);
46
101
  const currentIndex = phaseNames.indexOf(loop.current_phase);
47
102
  if (currentIndex >= 0 && currentIndex + 1 < phaseNames.length) {
@@ -506,6 +506,14 @@ function convergeLockedTurn(reservation, input, actor, cwd) {
506
506
  // ── §6 reducer: validated result → loop artifacts + slot outcome. ──
507
507
  const reducerInput = { lane, phase: reservation.phase, critiques: input.critiques };
508
508
  const reduced = reducerForKind(loop.kind)(reducerInput, reservation);
509
+ if (reduced.slot_outcome === 'retryable') {
510
+ return {
511
+ reconciled: false,
512
+ reason: `repairable worker result: ${reduced.failure_reason ?? 'result does not satisfy the phase artifact contract'}; slot remains assigned and may be re-harvested or replayed`,
513
+ artifacts_added: 0,
514
+ loop_status: loop.status,
515
+ };
516
+ }
509
517
  artifacts_added = reduced.artifacts.length;
510
518
  slot_outcome = reduced.slot_outcome;
511
519
  // Record artifacts + complete the turn (crash-atomic WAL via complete_turn).
@@ -28,11 +28,11 @@ export const reviewReducer = (input, attempt) => {
28
28
  }
29
29
  if (phase === 'author_response') {
30
30
  if (lane.artifact_type !== 'author_response') {
31
- return { artifacts: [], slot_outcome: 'failed', failure_reason: "review author_response requires artifact_type 'author_response'" };
31
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: "review author_response requires artifact_type 'author_response'" };
32
32
  }
33
33
  const response = (lane.body ?? '').trim();
34
34
  if (!response) {
35
- return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review author_response produced no body' };
35
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: 'review author_response produced no body' };
36
36
  }
37
37
  return {
38
38
  artifacts: [{ phase, type: 'author_response', body: capLoopArtifactBody(response), produced_by: attempt.agent }],
@@ -43,7 +43,7 @@ export const reviewReducer = (input, attempt) => {
43
43
  return { artifacts: [], slot_outcome: 'failed', failure_reason: `review phase '${phase}' has no worker-result contract` };
44
44
  }
45
45
  if (!lane.review_verdict) {
46
- return { artifacts: [], slot_outcome: 'failed', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
46
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: 'review lane completed without a review_verdict — cannot converge the loop' };
47
47
  }
48
48
  const summary = (lane.review_summary ?? '').trim();
49
49
  const body = capLoopArtifactBody(lane.review_verdict === 'approve'
@@ -71,24 +71,26 @@ export const ideationReducer = (input, attempt) => {
71
71
  return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation phase '${phase}' has no result contract` };
72
72
  }
73
73
  if (lane.artifact_type !== artifactType) {
74
- return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation phase '${phase}' expected artifact_type '${artifactType}', got '${lane.artifact_type}'` };
74
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: `ideation phase '${phase}' expected artifact_type '${artifactType}', got '${lane.artifact_type}'` };
75
75
  }
76
76
  const body = (lane.body ?? lane.summary).trim();
77
77
  if (!body)
78
- return { artifacts: [], slot_outcome: 'failed', failure_reason: `ideation ${phase} produced no body` };
78
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: `ideation ${phase} produced no body` };
79
79
  if (artifactType === 'plan_draft') {
80
80
  const addresses = [
81
- ...(lane.artifacts ?? []).filter((id) => /^art_[0-9a-z]+$/.test(id)),
81
+ ...(lane.artifacts ?? [])
82
+ .map((item) => typeof item === 'string' ? item : item.ref)
83
+ .filter((id) => /^art_[0-9a-z]+$/.test(id)),
82
84
  ...(critiques ?? []).flatMap((c) => c.addresses_critique ?? []),
83
85
  ];
84
86
  const uniqueAddresses = [...new Set(addresses)];
85
87
  if (uniqueAddresses.length === 0) {
86
- return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation synthesis must cite critique artifact ids in lane.artifacts' };
88
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: 'ideation synthesis must cite critique artifact ids in lane.artifacts' };
87
89
  }
88
90
  if (!lane.implementation_verify) {
89
91
  return {
90
92
  artifacts: [],
91
- slot_outcome: 'failed',
93
+ slot_outcome: 'retryable',
92
94
  failure_reason: 'ideation synthesis must declare implementation_verify for deterministic downstream verification',
93
95
  };
94
96
  }
@@ -107,10 +109,10 @@ export const ideationReducer = (input, attempt) => {
107
109
  return { artifacts: [{ phase, type: artifactType, body: capLoopArtifactBody(body), produced_by: attempt.agent }], slot_outcome: 'done' };
108
110
  }
109
111
  if (lane.artifact_type !== 'critique') {
110
- return { artifacts: [], slot_outcome: 'failed', failure_reason: "ideation critique requires artifact_type 'critique'" };
112
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: "ideation critique requires artifact_type 'critique'" };
111
113
  }
112
114
  if (!critiques || critiques.length === 0) {
113
- return { artifacts: [], slot_outcome: 'failed', failure_reason: 'ideation critique lane produced no critiques (bare summary) — gate stays shut' };
115
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: 'ideation critique lane produced no critiques (bare summary) — correct the lane result or replay this slot; gate stays shut' };
114
116
  }
115
117
  return {
116
118
  artifacts: critiques.map((c) => ({
@@ -148,7 +150,7 @@ function typedPhaseReducer(kind, artifactByPhase) {
148
150
  if (lane.artifact_type !== expectedType) {
149
151
  return {
150
152
  artifacts: [],
151
- slot_outcome: 'failed',
153
+ slot_outcome: 'retryable',
152
154
  failure_reason: `${kind} phase '${phase}' expected artifact_type '${expectedType}', got '${lane.artifact_type}'`,
153
155
  };
154
156
  }
@@ -156,7 +158,7 @@ function typedPhaseReducer(kind, artifactByPhase) {
156
158
  // summary can never masquerade as a gate-driving repro or verify report.
157
159
  const body = (lane.body ?? lane.summary).trim();
158
160
  if (!body) {
159
- return { artifacts: [], slot_outcome: 'failed', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
161
+ return { artifacts: [], slot_outcome: 'retryable', failure_reason: `${kind} phase '${phase}' produced no artifact body` };
160
162
  }
161
163
  return {
162
164
  artifacts: [{ phase, type: expectedType, body: capLoopArtifactBody(body), produced_by: attempt.agent }],
@@ -68,6 +68,7 @@ function buildSlot(partial) {
68
68
  role: partial.role,
69
69
  agent: partial.agent,
70
70
  agent_id: partial.agent_id,
71
+ perspective: partial.perspective,
71
72
  assignment_id: partial.assignment_id,
72
73
  claim_id: partial.claim_id,
73
74
  phase: partial.phase,
@@ -76,6 +77,9 @@ function buildSlot(partial) {
76
77
  plan_ids: partial.plan_ids,
77
78
  step_ids: partial.step_ids,
78
79
  status: partial.status ?? 'open',
80
+ current_turn_id: partial.current_turn_id,
81
+ last_completed_phase: partial.last_completed_phase,
82
+ last_completed_iteration: partial.last_completed_iteration,
79
83
  };
80
84
  }
81
85
  export function appendEvent(loopId, event, cwd,
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  export const LOOP_ARTIFACT_BODY_MAX_BYTES = 4096;
3
+ /** Ideation proposals are caller-authored task contracts, not worker summaries. */
4
+ export const LOOP_PROPOSAL_BODY_MAX_BYTES = 32 * 1024;
3
5
  export const LOOP_KINDS = ['review', 'ideation', 'implementation', 'research', 'debug'];
4
6
  export const LOOP_STATUSES = ['open', 'paused', 'completed', 'blocked', 'cancelled'];
5
7
  export const REVIEW_MODES = ['asymmetric', 'symmetric'];
@@ -130,6 +132,8 @@ export const LoopVerifyConfigSchema = z.object({
130
132
  });
131
133
  export const LoopProtocolConfigSchema = z.object({
132
134
  review_mode: z.enum(REVIEW_MODES).optional(),
135
+ /** Whether ideation participants take ordered turns or fan out together. */
136
+ ideation_schedule: z.enum(['sequential', 'parallel']).optional(),
133
137
  iteration: LoopIterationSchema.optional(),
134
138
  /** pln#632 — engine-run verify command (opener-provided; makes command_green real). */
135
139
  verify: LoopVerifyConfigSchema.optional(),
@@ -160,6 +164,8 @@ export const LoopSlotSchema = z.object({
160
164
  role: z.string().min(1),
161
165
  agent: z.string().optional(),
162
166
  agent_id: z.string().optional(),
167
+ /** Stable point of view/instruction for this participant across rounds. */
168
+ perspective: z.string().min(1).max(1000).optional(),
163
169
  assignment_id: z.string().optional(),
164
170
  claim_id: z.string().optional(),
165
171
  phase: z.string().optional(),
@@ -178,6 +184,9 @@ export const LoopSlotSchema = z.object({
178
184
  * reusable slot. Additive; wired onto the dispatch path in a later PR.
179
185
  */
180
186
  current_turn_id: z.string().optional(),
187
+ /** Last successful contribution, used to rotate reusable slots each round. */
188
+ last_completed_phase: z.string().optional(),
189
+ last_completed_iteration: z.number().int().nonnegative().optional(),
181
190
  });
182
191
  // ───────────────────────────────────────────────────────────────────────
183
192
  // pln#508 step 1 — bootstrap loop foundation: operator-interaction schemas
@@ -495,10 +504,13 @@ export const LoopArtifactSchema = z
495
504
  evidence: EvidenceEnvelopeSchema.optional(),
496
505
  })
497
506
  .superRefine((artifact, ctx) => {
498
- if (artifact.body !== undefined && Buffer.byteLength(artifact.body, 'utf8') > LOOP_ARTIFACT_BODY_MAX_BYTES) {
507
+ const bodyLimit = artifact.type === 'proposal'
508
+ ? LOOP_PROPOSAL_BODY_MAX_BYTES
509
+ : LOOP_ARTIFACT_BODY_MAX_BYTES;
510
+ if (artifact.body !== undefined && Buffer.byteLength(artifact.body, 'utf8') > bodyLimit) {
499
511
  ctx.addIssue({
500
512
  code: z.ZodIssueCode.custom,
501
- message: `LoopArtifact.body must be ≤ ${LOOP_ARTIFACT_BODY_MAX_BYTES} bytes; use a ref for larger content`,
513
+ message: `LoopArtifact.body must be ≤ ${bodyLimit} bytes; use a ref for larger content`,
502
514
  path: ['body'],
503
515
  });
504
516
  }
@@ -662,7 +662,14 @@ function completeTurnCommit(input, cwd) {
662
662
  // Map outcome → terminal slot.status so observers reading the thread can
663
663
  // distinguish done/failed/cancelled without replaying the event journal.
664
664
  const terminalStatus = outcome;
665
- const updatedSlots = current.slots.map((s) => s.slot_id === slot.slot_id ? { ...s, status: terminalStatus } : s);
665
+ const updatedSlots = current.slots.map((s) => s.slot_id === slot.slot_id ? {
666
+ ...s,
667
+ status: terminalStatus,
668
+ ...(terminalStatus === 'done' ? {
669
+ last_completed_phase: slot.phase ?? current.current_phase,
670
+ last_completed_iteration: current.iteration_count,
671
+ } : {}),
672
+ } : s);
666
673
  // A negative convergence signal is only valid after the critic window has
667
674
  // causally closed. The last trusted critic completion records that boundary;
668
675
  // mere absence of critique artifacts while a turn is open is never enough.
@@ -81,7 +81,7 @@ export function renderWorkerReplyProse(contract) {
81
81
  `- MCP path: call \`${action.tool}\` with ${JSON.stringify(action.args)}`,
82
82
  `- The body must be NON-EMPTY: an artifact without usable content does not count toward the gate.`,
83
83
  `- Body cap: ${contract.body_max_bytes} bytes. If your output is larger, write the full version to a markdown file in your worktree and put a dense summary plus the file path in the body.`,
84
- `- File fallback (no MCP): in LANE-RESULT.json set "artifact_type":"${contract.requirements[0].type}" and put your full output in "body" — the harvester records it under this contract.`,
84
+ `- File fallback (no MCP): write the worktree-root filename exactly as \`LANE-RESULT.json\` (uppercase, hyphen, no suffix or subdirectory); set "artifact_type":"${contract.requirements[0].type}" and put your full output in "body" — the harvester records it under this contract.`,
85
85
  `- This contract is FROZEN for loop version ${contract.loop_version}, phase "${contract.phase}". If your submit reports a version conflict or the loop has advanced, your work is still recorded under phase "${contract.phase}" — do not re-target a newer phase.`,
86
86
  ];
87
87
  if (contract.other_conditions.length > 0) {
@@ -69,6 +69,7 @@ export const MCP_HEADLESS_AUTO_TOOL_NAMES = [
69
69
  'bclaw_assignment_update',
70
70
  'bclaw_assignment_action',
71
71
  'bclaw_harvest_candidates',
72
+ 'bclaw_harvest',
72
73
  'bclaw_find',
73
74
  'bclaw_get',
74
75
  ];
@@ -402,6 +402,7 @@ export async function dispatchReviewLoopTurn(input) {
402
402
  attempt_epoch: turnEcho.attempt_epoch,
403
403
  workspace_digest: turnEcho.workspace_digest,
404
404
  } : undefined,
405
+ artifactType: phase === 'author_response' ? 'author_response' : 'verdict',
405
406
  cwd, // pln#638 PR-6b — the context envelope reads the store
406
407
  });
407
408
  const msg = sendMessage({
@@ -144,6 +144,16 @@ export const ProvenanceSchema = z.discriminatedUnion('kind', [
144
144
  * entity-operations.ts which enforces the typed shape.
145
145
  */
146
146
  export const ProvenancePassthroughSchema = z.unknown().optional();
147
+ /** Replayable empirical assertion attached to a perishable memory claim. */
148
+ export const MemoryVerificationSchema = z.object({
149
+ kind: z.enum(['command', 'query']),
150
+ input: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
151
+ expected: z.string().min(1),
152
+ observed: z.string().optional(),
153
+ verified_at: z.string().datetime(),
154
+ outcome: z.enum(['pass', 'fail']),
155
+ max_age_days: z.number().positive().optional(),
156
+ });
147
157
  export const ConstraintSchema = z.object({
148
158
  schema_version: z.number().int().positive().optional(),
149
159
  id: z.string(),
@@ -163,6 +173,8 @@ export const ConstraintSchema = z.object({
163
173
  related_paths: z.array(z.string()).optional(),
164
174
  plan_id: z.string().optional(),
165
175
  expires_at: z.string().optional(),
176
+ verified_at: z.string().optional(),
177
+ verify_cmd: z.string().optional(),
166
178
  // pln#544 — memory-lifecycle (confirm/decay/reinforce). Symmetric across
167
179
  // constraint/decision/trap. `verified_at` (pln#530 perishable-fact
168
180
  // re-verification) is kept as a narrower legacy signal alongside.
@@ -175,6 +187,7 @@ export const ConstraintSchema = z.object({
175
187
  /** Bounded event log (most recent N) — older events are dropped, the
176
188
  * counts remain accurate. Empty / absent means "never confirmed". */
177
189
  confirmations: z.array(MemoryConfirmationEventSchema).optional(),
190
+ verification: MemoryVerificationSchema.optional(),
178
191
  provenance: ProvenancePassthroughSchema,
179
192
  });
180
193
  export const DecisionSchema = z.object({
@@ -207,6 +220,7 @@ export const DecisionSchema = z.object({
207
220
  saved_me_count: z.number().int().nonnegative().optional(),
208
221
  misled_me_count: z.number().int().nonnegative().optional(),
209
222
  confirmations: z.array(MemoryConfirmationEventSchema).optional(),
223
+ verification: MemoryVerificationSchema.optional(),
210
224
  provenance: ProvenancePassthroughSchema,
211
225
  });
212
226
  export const TrapSchema = z.object({
@@ -243,6 +257,7 @@ export const TrapSchema = z.object({
243
257
  saved_me_count: z.number().int().nonnegative().optional(),
244
258
  misled_me_count: z.number().int().nonnegative().optional(),
245
259
  confirmations: z.array(MemoryConfirmationEventSchema).optional(),
260
+ verification: MemoryVerificationSchema.optional(),
246
261
  provenance: ProvenancePassthroughSchema,
247
262
  });
248
263
  export const HandoffContractSchema = z.object({
@@ -1068,6 +1083,14 @@ export const RuntimeEventTypeSchema = z.enum([
1068
1083
  * durable runtime event before a loop closer applies its smaller display cap.
1069
1084
  */
1070
1085
  export const LANE_RESULT_BODY_MAX_BYTES = 64 * 1024;
1086
+ export const LaneResultArtifactSchema = z.union([
1087
+ z.string().min(1),
1088
+ z.object({
1089
+ type: z.string().min(1),
1090
+ ref: z.string().min(1),
1091
+ description: z.string().optional(),
1092
+ }),
1093
+ ]);
1071
1094
  export const LaneResultSchema = z.object({
1072
1095
  assignment_id: z.string(),
1073
1096
  /**
@@ -1089,7 +1112,7 @@ export const LaneResultSchema = z.object({
1089
1112
  status: z.enum(['completed', 'blocked', 'failed']),
1090
1113
  summary: z.string(),
1091
1114
  /** Paths or refs the worker produced (commits, files, docs). */
1092
- artifacts: z.array(z.string()).optional(),
1115
+ artifacts: z.array(LaneResultArtifactSchema).optional(),
1093
1116
  /** Files the worker changed in the worktree. */
1094
1117
  files_changed: z.array(z.string()).optional(),
1095
1118
  /** Free-form notes (blockers, follow-ups). */
@@ -1,6 +1,7 @@
1
1
  import { loadState } from './state.js';
2
2
  import { listCandidates } from './candidates.js';
3
3
  import { listSequences } from './sequence.js';
4
+ import { isTrapActive } from './traps.js';
4
5
  const K1 = 1.5;
5
6
  const B = 0.75;
6
7
  function tokenize(text) {
@@ -23,11 +24,11 @@ function buildCorpus(state, includePending, cwd, includeLegacy = false) {
23
24
  const textParts = [item.text, item.author ?? '', ...(item.tags ?? []), ...(item.related_paths ?? [])];
24
25
  docs.push({ ...item, section, terms: tokenize(textParts.join(' ')) });
25
26
  };
26
- for (const c of state.active_constraints)
27
+ for (const c of state.active_constraints.filter((item) => item.status === 'active'))
27
28
  add('constraints', c);
28
29
  for (const d of state.recent_decisions)
29
30
  add('decisions', d);
30
- for (const t of state.known_traps)
31
+ for (const t of state.known_traps.filter((item) => isTrapActive(item)))
31
32
  add('traps', { ...t, text: t.text });
32
33
  for (const h of state.open_handoffs)
33
34
  add('handoffs', { ...h, text: `${h.from} -> ${h.to}: ${h.text}` });
@@ -96,7 +96,15 @@ export async function checkAgentSpawn(agent, options = {}) {
96
96
  const stderrRaw = readLogTail(root, assignmentId, 'stderr', 800).trim();
97
97
  const stderrTail = stderrRaw ? stderrRaw.split(/\r?\n/).filter(Boolean) : undefined;
98
98
  if (completed) {
99
- return { agent, binary, status: 'ok', delivered, completed: true, duration_ms, detail: 'ack + completed round-trip' };
99
+ return {
100
+ agent,
101
+ binary,
102
+ status: 'ok',
103
+ delivered,
104
+ completed: true,
105
+ duration_ms,
106
+ detail: `validation probe: ack + completed round-trip (${invoke.promptDelivery}, ${invoke.promptText?.length ?? 0} prompt bytes)`,
107
+ };
100
108
  }
101
109
  if (failed) {
102
110
  const tail = stderrRaw || readLogTail(root, assignmentId, 'stdout', 400).trim();
@@ -968,11 +968,9 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
968
968
  // the dispatch brief can tell the worker the truth. A failed install/copy is
969
969
  // best-effort (non-fatal) but the worker must then install itself — the brief
970
970
  // must NOT claim "node_modules is real, do not reinstall" over a failure.
971
- let depsProvisioned;
972
971
  if (provisionDeps) {
973
972
  const provisionWarnings = provisionWorktreeDeps(depsMode, mainWorktreePath, targetPath, nodeModulesPaths);
974
973
  symlinkWarnings.push(...provisionWarnings);
975
- depsProvisioned = provisionWarnings.length === 0;
976
974
  }
977
975
  else if (depsMode === 'link') {
978
976
  // trp_37b05a15 (field report, Next.js 16 / Turbopack) — the node_modules link
@@ -992,6 +990,15 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
992
990
  logger.warn(`[worktree] ${msg}`);
993
991
  }
994
992
  }
993
+ // Record observed availability, not only the requested provisioning mode.
994
+ // This is deliberately evaluated after link/install/copy so dispatch briefs
995
+ // cannot claim a main-repo dependency tree that never existed.
996
+ const depsPaths = [...new Set([
997
+ ...requested.filter(isNodeModulesPath),
998
+ ...detectWorkspaceNodeModules(targetPath),
999
+ ])]
1000
+ .filter((relativePath) => fs.existsSync(path.join(targetPath, relativePath)));
1001
+ const depsProvisioned = depsMode === 'none' ? false : depsPaths.length > 0;
995
1002
  // NOTE: .brainclaw/ is intentionally NOT symlinked.
996
1003
  // Symlinking .brainclaw/ causes hooks and session_start to trigger on the
997
1004
  // shared store, creating session conflicts and potentially blocking agents
@@ -1033,11 +1040,11 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
1033
1040
  // trp_37b05a15: how JS deps were provisioned (link junction / real install /
1034
1041
  // copy / none) — non-default modes are recorded so a worker/supervisor knows
1035
1042
  // whether node_modules is an out-of-root link (dev-server caveat) or in-root.
1036
- // `deps_provisioned` (install/copy only) records whether the in-root
1037
- // provisioning actually succeeded false means best-effort failed and the
1038
- // worker must install itself (Codex review P1).
1039
- ...(depsMode !== 'link' ? { deps_mode: depsMode } : {}),
1040
- ...(depsProvisioned !== undefined ? { deps_provisioned: depsProvisioned } : {}),
1043
+ // `deps_provisioned` and `deps_paths` record observed availability. They are
1044
+ // emitted for link mode too: requested capacity is not executable capacity.
1045
+ deps_mode: depsMode,
1046
+ deps_provisioned: depsProvisioned,
1047
+ deps_paths: depsPaths,
1041
1048
  // pln#523: surface any shared-path link failures (e.g. node_modules junction
1042
1049
  // that could not be created) so the worker / supervisor can see why a build
1043
1050
  // might fail, instead of an invisible degradation.
package/dist/facts.js CHANGED
@@ -1,11 +1,11 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.28.2 on 2026-08-25T18:22:09.950Z
2
+ // Source: brainclaw v1.28.4 on 2026-08-28T07:25:30.486Z
3
3
  export const FACTS = {
4
- "version": "1.28.2",
5
- "generated_at": "2026-08-25T18:22:09.950Z",
4
+ "version": "1.28.4",
5
+ "generated_at": "2026-08-28T07:25:30.486Z",
6
6
  "tools": {
7
- "count": 70,
8
- "published_count": 68,
7
+ "count": 71,
8
+ "published_count": 69,
9
9
  "names": [
10
10
  "bclaw_bootstrap",
11
11
  "bclaw_release_notes",
@@ -70,6 +70,7 @@ export const FACTS = {
70
70
  "bclaw_assignment_update",
71
71
  "bclaw_assignment_action",
72
72
  "bclaw_harvest_candidates",
73
+ "bclaw_harvest",
73
74
  "bclaw_find",
74
75
  "bclaw_get",
75
76
  "bclaw_create",
@@ -478,7 +479,7 @@ export const FACTS = {
478
479
  },
479
480
  "bench": {
480
481
  "schema": "brainclaw.bench.v1",
481
- "generated_at": "2026-08-25T18:22:07.761Z",
482
+ "generated_at": "2026-08-28T07:25:28.367Z",
482
483
  "node_version": "v24.19.0",
483
484
  "platform": "linux-x64",
484
485
  "repeats": 3,
@@ -487,7 +488,7 @@ export const FACTS = {
487
488
  "name": "cold_onboard",
488
489
  "volume": "empty",
489
490
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
490
- "duration_ms_median": 82,
491
+ "duration_ms_median": 80,
491
492
  "payload_chars_median": 1640,
492
493
  "payload_tokens_est_median": 410
493
494
  },
@@ -495,7 +496,7 @@ export const FACTS = {
495
496
  "name": "warm_work",
496
497
  "volume": "medium",
497
498
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
498
- "duration_ms_median": 132,
499
+ "duration_ms_median": 124,
499
500
  "payload_chars_median": 2626,
500
501
  "payload_tokens_est_median": 657
501
502
  },
@@ -503,7 +504,7 @@ export const FACTS = {
503
504
  "name": "first_edit",
504
505
  "volume": "medium",
505
506
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
506
- "duration_ms_median": 13,
507
+ "duration_ms_median": 11,
507
508
  "payload_chars_median": 1629,
508
509
  "payload_tokens_est_median": 407
509
510
  }
package/dist/facts.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.28.2",
3
- "generated_at": "2026-08-25T18:22:09.950Z",
2
+ "version": "1.28.4",
3
+ "generated_at": "2026-08-28T07:25:30.486Z",
4
4
  "tools": {
5
- "count": 70,
6
- "published_count": 68,
5
+ "count": 71,
6
+ "published_count": 69,
7
7
  "names": [
8
8
  "bclaw_bootstrap",
9
9
  "bclaw_release_notes",
@@ -68,6 +68,7 @@
68
68
  "bclaw_assignment_update",
69
69
  "bclaw_assignment_action",
70
70
  "bclaw_harvest_candidates",
71
+ "bclaw_harvest",
71
72
  "bclaw_find",
72
73
  "bclaw_get",
73
74
  "bclaw_create",
@@ -476,7 +477,7 @@
476
477
  },
477
478
  "bench": {
478
479
  "schema": "brainclaw.bench.v1",
479
- "generated_at": "2026-08-25T18:22:07.761Z",
480
+ "generated_at": "2026-08-28T07:25:28.367Z",
480
481
  "node_version": "v24.19.0",
481
482
  "platform": "linux-x64",
482
483
  "repeats": 3,
@@ -485,7 +486,7 @@
485
486
  "name": "cold_onboard",
486
487
  "volume": "empty",
487
488
  "description": "fresh machine → init → first useful context. Baseline for time-to-first-value.",
488
- "duration_ms_median": 82,
489
+ "duration_ms_median": 80,
489
490
  "payload_chars_median": 1640,
490
491
  "payload_tokens_est_median": 410
491
492
  },
@@ -493,7 +494,7 @@
493
494
  "name": "warm_work",
494
495
  "volume": "medium",
495
496
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
496
- "duration_ms_median": 132,
497
+ "duration_ms_median": 124,
497
498
  "payload_chars_median": 2626,
498
499
  "payload_tokens_est_median": 657
499
500
  },
@@ -501,7 +502,7 @@
501
502
  "name": "first_edit",
502
503
  "volume": "medium",
503
504
  "description": "code_find + code_brief on the fresh-agent path (missing index, first touch).",
504
- "duration_ms_median": 13,
505
+ "duration_ms_median": 11,
505
506
  "payload_chars_median": 1629,
506
507
  "payload_tokens_est_median": 407
507
508
  }
package/docs/cli.md CHANGED
@@ -639,11 +639,11 @@ Full reference (freshness model, supported languages, WASM bundling): [docs/code
639
639
 
640
640
  ### `brainclaw code-map status [--cascade]`
641
641
 
642
- Store presence, freshness badge (`fresh` / `stale_changed_files` / `stale_extractor` / `stale_grammar` / `partial` / `missing_index`), and index stats (files, nodes, edges). Read-only. In a multi-project workspace, `--cascade` adds a per-child recap (which nested projects have a built index vs `missing_index`, plus an aggregate count).
642
+ Store presence, freshness badge (`fresh` / `stale_changed_files` / `stale_extractor` / `stale_grammar` / `partial` / `missing_index`), and index stats (files, nodes, edges). Read-only. In a multi-project workspace, `--cascade` adds compact coverage counts and names only non-fresh projects. On the MCP surface, the equivalent `bclaw_code_status(cascade=true)` also follows the latest durable cascade job.
643
643
 
644
644
  ### `brainclaw code-map refresh [--all|--changed] [--cascade]`
645
645
 
646
- Build or update the index. `--changed` (default) re-parses only touched files; `--all` does a full re-index. Run this when status shows `missing_index` or a stale badge. Fails fast (never blocks) if another writer holds the project lock. In a multi-project workspace, `--cascade` refreshes **every nested project** into its own store plus a root store scoped to the files no child owns (zero double-indexing) one command at the root indexes the whole monorepo per-project. See [docs/code-map.md](code-map.md#cascading-a-multi-project-workspace---cascade).
646
+ Build or update the index. `--changed` (default) re-parses only touched files; `--all` does a full re-index. Run this when status shows `missing_index` or a stale badge. Fails fast (never blocks) if another writer holds the project lock. In a multi-project workspace, `--cascade` synchronously refreshes each discovered project into its own store plus a root store scoped to files no child owns (zero double-indexing). The MCP equivalent starts a durable background job instead; follow it with `bclaw_code_status(cascade=true)`. See [docs/code-map.md](code-map.md#cascading-a-multi-project-workspace---cascade).
647
647
 
648
648
  ### `brainclaw code-map find <query> [--limit <n>]`
649
649
 
@@ -1185,6 +1185,39 @@ brainclaw update-handoff hnd_001 --review-verdict request_changes --reviewed-by
1185
1185
 
1186
1186
  ---
1187
1187
 
1188
+ ## Worker result harvest
1189
+
1190
+ ### `brainclaw harvest [assignment_id]`
1191
+
1192
+ Ingest a worker's `LANE-RESULT.json`, reconcile any bound loop turn, and report
1193
+ the exact continuation. Pass one assignment id or use `--all`; this is distinct
1194
+ from `harvest-candidates`, which imports proposed memory items.
1195
+
1196
+ | Option | Description |
1197
+ |---|---|
1198
+ | `--all` | Scan every managed worktree |
1199
+ | `--integrate` | Also commit a sandboxed worker's worktree diff and settle its lifecycle |
1200
+ | `--orphaned` | Recover a dead worker that left no lane result, without deleting/resetting work |
1201
+ | `--base <ref>` | Base ref used by orphan recovery (default `master`) |
1202
+ | `--dry-run` | Preview without writing events or markers |
1203
+ | `--worktree <path>` | Explicit worktree to scan; repeatable |
1204
+ | `--json` | Return lane results, warnings, and loop continuations as JSON |
1205
+
1206
+ ```bash
1207
+ brainclaw harvest asgn_123
1208
+ brainclaw harvest --all --json
1209
+ brainclaw harvest --integrate asgn_123
1210
+ ```
1211
+
1212
+ The exact filename is `LANE-RESULT.json`. A unique root-level JSON file whose
1213
+ schema and `assignment_id` match can be recovered when a worker chose the wrong
1214
+ name; Brainclaw refuses ambiguous candidates. `artifacts` accepts either string
1215
+ refs or `{type, ref, description?}` objects, while loop workers must also emit
1216
+ the `artifact_type` named in their brief. A repairable schema/contract error
1217
+ keeps the real turn replayable and eligible for corrected re-harvest.
1218
+
1219
+ ---
1220
+
1188
1221
  ## Dispatch
1189
1222
 
1190
1223
  The `dispatch` command group manages the local agent dispatcher: it analyzes the active sequence for lane readiness and assigns work to available agents.