brainclaw 1.28.3 → 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 (40) 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 +23 -2
  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 +84 -22
  9. package/dist/commands/mcp-write-memory.js +87 -1
  10. package/dist/commands/mcp.js +16 -2
  11. package/dist/core/context.js +16 -3
  12. package/dist/core/dispatch-status.js +36 -14
  13. package/dist/core/dispatcher.js +28 -20
  14. package/dist/core/entity-operations.js +62 -4
  15. package/dist/core/entity-registry.js +3 -3
  16. package/dist/core/execution-adapters.js +10 -0
  17. package/dist/core/facade-schema.js +10 -0
  18. package/dist/core/ideation-loop-close.js +3 -1
  19. package/dist/core/lane-result-file.js +72 -0
  20. package/dist/core/loop-turn-dispatch.js +2 -0
  21. package/dist/core/loops/brief-assembly.js +19 -11
  22. package/dist/core/loops/next-expected.js +56 -1
  23. package/dist/core/loops/reconcile-turn.js +8 -0
  24. package/dist/core/loops/result-reducers.js +14 -12
  25. package/dist/core/loops/store.js +4 -0
  26. package/dist/core/loops/types.js +14 -2
  27. package/dist/core/loops/verbs.js +8 -1
  28. package/dist/core/loops/worker-reply-contract.js +1 -1
  29. package/dist/core/protocol-tool-policy.js +1 -0
  30. package/dist/core/review-loop-turn-dispatch.js +1 -0
  31. package/dist/core/schema.js +24 -1
  32. package/dist/core/search.js +3 -2
  33. package/dist/core/worktree.js +14 -7
  34. package/dist/facts.js +9 -8
  35. package/dist/facts.json +8 -7
  36. package/docs/cli.md +33 -0
  37. package/docs/concepts/ideation-loop.md +35 -14
  38. package/docs/integrations/mcp.md +13 -3
  39. package/docs/mcp-schema-changelog.md +42 -6
  40. package/package.json +1 -1
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { LANE_RESULT_BODY_MAX_BYTES, LaneResultSchema, } from './schema.js';
4
+ export const LANE_RESULT_FILENAME = 'LANE-RESULT.json';
5
+ const MAX_RESULT_FILE_BYTES = LANE_RESULT_BODY_MAX_BYTES + 16 * 1024;
6
+ function parseLaneResultFile(file) {
7
+ const stat = fs.statSync(file);
8
+ if (!stat.isFile() || stat.size > MAX_RESULT_FILE_BYTES)
9
+ return undefined;
10
+ return LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
11
+ }
12
+ /**
13
+ * Resolve a worker's terminal result without trusting arbitrary paths.
14
+ *
15
+ * The protocol filename is exact and remains authoritative. When an agent
16
+ * nevertheless renames it, recover only a UNIQUE schema-valid JSON file at the
17
+ * worktree root (never recursively, never through a symlink), optionally bound
18
+ * to the requested assignment id. Ambiguity is surfaced instead of guessed.
19
+ */
20
+ export function resolveLaneResultFile(worktreePath, assignmentId) {
21
+ const canonicalPath = path.join(worktreePath, LANE_RESULT_FILENAME);
22
+ let foreignCanonical;
23
+ if (fs.existsSync(canonicalPath)) {
24
+ try {
25
+ const lane = parseLaneResultFile(canonicalPath);
26
+ if (!lane) {
27
+ return { kind: 'invalid', path: canonicalPath, error: 'file is not a regular bounded lane-result file' };
28
+ }
29
+ const found = { kind: 'found', path: canonicalPath, lane, canonical: true };
30
+ if (!assignmentId || lane.assignment_id === assignmentId)
31
+ return found;
32
+ foreignCanonical = found;
33
+ }
34
+ catch (err) {
35
+ return {
36
+ kind: 'invalid',
37
+ path: canonicalPath,
38
+ error: err instanceof Error ? err.message : String(err),
39
+ };
40
+ }
41
+ }
42
+ let entries;
43
+ try {
44
+ entries = fs.readdirSync(worktreePath, { withFileTypes: true });
45
+ }
46
+ catch {
47
+ return foreignCanonical ?? { kind: 'absent' };
48
+ }
49
+ const recovered = [];
50
+ for (const entry of entries) {
51
+ if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.json') || entry.name === LANE_RESULT_FILENAME)
52
+ continue;
53
+ const candidatePath = path.join(worktreePath, entry.name);
54
+ try {
55
+ const lane = parseLaneResultFile(candidatePath);
56
+ if (lane && (!assignmentId || lane.assignment_id === assignmentId)) {
57
+ recovered.push({ path: candidatePath, lane });
58
+ }
59
+ }
60
+ catch {
61
+ // Ordinary project JSON and malformed non-canonical files are not lane results.
62
+ }
63
+ }
64
+ if (recovered.length === 1) {
65
+ return { kind: 'found', ...recovered[0], canonical: false };
66
+ }
67
+ if (recovered.length > 1) {
68
+ return { kind: 'ambiguous', paths: recovered.map((item) => item.path).sort() };
69
+ }
70
+ return foreignCanonical ?? { kind: 'absent' };
71
+ }
72
+ //# sourceMappingURL=lane-result-file.js.map
@@ -82,6 +82,7 @@ export async function dispatchLoopTurn(input) {
82
82
  const phaseBrief = buildIdeationBrief({
83
83
  thread: loop,
84
84
  slotRole: slot.role,
85
+ slotPerspective: slot.perspective,
85
86
  memoryProvider: provider,
86
87
  seedText: input.task,
87
88
  scopeHints: slot.scope_hint ? slot.scope_hint.split(',').map((value) => value.trim()) : [],
@@ -180,6 +181,7 @@ export async function dispatchLoopTurn(input) {
180
181
  attempt_epoch: prepared.attempt_epoch,
181
182
  workspace_digest: prepared.workspace_digest,
182
183
  } : undefined,
184
+ artifactType: policy.expected_artifacts?.[0]?.loop_artifact_type,
183
185
  cwd: input.cwd,
184
186
  });
185
187
  const message = sendMessage({
@@ -45,7 +45,7 @@ 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, seedText, scopeHints = [], } = input;
48
+ const { thread, slotRole, slotPerspective, memoryProvider, maxChars = DEFAULT_MAX_CHARS, topKPerCategory = DEFAULT_TOP_K_PER_CATEGORY, seedText, scopeHints = [], } = input;
49
49
  const proposal = findProposalArtifact(thread);
50
50
  const proposalText = seedText?.trim() || proposal?.body?.trim() || '(no proposal seed found)';
51
51
  // Resolve which memory categories the current phase wants. If the
@@ -64,13 +64,15 @@ export function buildIdeationBrief(input) {
64
64
  }
65
65
  }
66
66
  // Loop-internal categories: pulled from thread.artifacts directly.
67
- // critique_history all critique artifacts in iterations < current.
68
- // revision_history all revision artifacts in iterations < current.
67
+ // Critique history includes contributions already made in the current
68
+ // round. Sequential ideation depends on this: participant B challenges A,
69
+ // then C sees both, instead of producing isolated first impressions.
70
+ // Revision history similarly includes the latest available revision.
69
71
  // synthesis_artifact → the most recent synthesis output (if any).
70
72
  const priorArtifactsBlock = includesLoopInternal
71
73
  ? renderPriorArtifactsBlock(thread, requestedCategories)
72
74
  : '';
73
- const header = renderHeader(thread, slotRole, currentPhaseDef?.name ?? thread.current_phase);
75
+ const header = renderHeader(thread, slotRole, currentPhaseDef?.name ?? thread.current_phase, slotPerspective);
74
76
  const proposalBlock = renderProposalBlock(proposalText);
75
77
  const memoryBlock = renderMemoryBlock(fetchedItemsByCategory);
76
78
  const closing = renderClosingInstructions(slotRole, thread.current_phase);
@@ -114,7 +116,7 @@ function expandUserFacingCategories(requested) {
114
116
  // Drop loop-internal categories — they're handled separately.
115
117
  return requested.filter((c) => !LOOP_INTERNAL_CATEGORIES.has(c) && c !== '*');
116
118
  }
117
- function renderHeader(thread, slotRole, phase) {
119
+ function renderHeader(thread, slotRole, phase, perspective) {
118
120
  const lines = [
119
121
  `# ${thread.kind}_loop brief`,
120
122
  `loop: ${thread.id}`,
@@ -125,6 +127,8 @@ function renderHeader(thread, slotRole, phase) {
125
127
  ];
126
128
  if (thread.goal)
127
129
  lines.push(`goal: ${thread.goal}`);
130
+ if (perspective)
131
+ lines.push(`perspective: ${perspective}`);
128
132
  return lines.join('\n');
129
133
  }
130
134
  function normalizeScope(value) {
@@ -168,9 +172,9 @@ function renderPriorArtifactsBlock(thread, requested) {
168
172
  const wantsSynthesis = requested.includes('*') || requested.includes('synthesis_artifact');
169
173
  const sections = [];
170
174
  if (wantsCritique) {
171
- const priorCritique = thread.artifacts.filter((a) => a.type === 'critique' && (a.iteration ?? 0) < thread.iteration_count);
175
+ const priorCritique = thread.artifacts.filter((a) => a.type === 'critique' && (a.iteration ?? 0) <= thread.iteration_count);
172
176
  if (priorCritique.length > 0) {
173
- const lines = ['### critique_history (prior iterations)'];
177
+ const lines = ['### critique_history (conversation so far)'];
174
178
  for (const a of priorCritique) {
175
179
  lines.push(`- [${a.artifact_id}] (iter ${a.iteration ?? 0}) ${truncateLine(a.body)}`);
176
180
  }
@@ -178,7 +182,7 @@ function renderPriorArtifactsBlock(thread, requested) {
178
182
  }
179
183
  }
180
184
  if (wantsRevision) {
181
- const priorRevision = thread.artifacts.filter((a) => a.phase === 'revision' && (a.iteration ?? 0) < thread.iteration_count);
185
+ const priorRevision = thread.artifacts.filter((a) => a.phase === 'revision' && (a.iteration ?? 0) <= thread.iteration_count);
182
186
  if (priorRevision.length > 0) {
183
187
  const lines = ['### revision_history (prior iterations)'];
184
188
  for (const a of priorRevision) {
@@ -200,12 +204,16 @@ function renderPriorArtifactsBlock(thread, requested) {
200
204
  return ['## prior loop artifacts', ...sections].join('\n\n');
201
205
  }
202
206
  function renderClosingInstructions(slotRole, phase) {
203
- return [
207
+ const lines = [
204
208
  `## what to produce`,
205
209
  `- Phase "${phase}" expects you to act in role "${slotRole}".`,
206
210
  `- Emit findings as LoopArtifacts via bclaw_loop intent='complete_turn' or 'add_artifact'.`,
207
- `- Cite the memory ids you relied on so the synthesis can audit coverage.`,
208
- ].join('\n');
211
+ ];
212
+ if (phase === 'critique') {
213
+ lines.push(`- Treat memory items as investigation leads, never as proof that the current worktree still behaves that way.`, `- Verify every finding about the current implementation against the worktree. Cite at least one concrete file path plus a line, symbol, assertion, or test/command result.`, `- If you cannot verify a memory-backed concern in the worktree, label it as an unverified question instead of reporting it as a finding.`);
214
+ }
215
+ lines.push(`- Cite the memory ids you relied on so the synthesis can audit coverage.`);
216
+ return lines.join('\n');
209
217
  }
210
218
  function truncateLine(s, maxLen = 200) {
211
219
  if (!s)
@@ -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}` });
@@ -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.