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.
- package/README.md +6 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/harvest.js +67 -25
- package/dist/commands/loops-handlers.js +28 -1
- package/dist/commands/mcp-catalog.js +23 -2
- package/dist/commands/mcp-read-handlers.js +15 -1
- package/dist/commands/mcp-schemas.generated.js +13 -0
- package/dist/commands/mcp-write-coordination.js +84 -22
- package/dist/commands/mcp-write-memory.js +87 -1
- package/dist/commands/mcp.js +16 -2
- package/dist/core/context.js +16 -3
- package/dist/core/dispatch-status.js +36 -14
- package/dist/core/dispatcher.js +28 -20
- package/dist/core/entity-operations.js +62 -4
- package/dist/core/entity-registry.js +3 -3
- package/dist/core/execution-adapters.js +10 -0
- package/dist/core/facade-schema.js +10 -0
- package/dist/core/ideation-loop-close.js +3 -1
- package/dist/core/lane-result-file.js +72 -0
- package/dist/core/loop-turn-dispatch.js +2 -0
- package/dist/core/loops/brief-assembly.js +19 -11
- package/dist/core/loops/next-expected.js +56 -1
- package/dist/core/loops/reconcile-turn.js +8 -0
- package/dist/core/loops/result-reducers.js +14 -12
- package/dist/core/loops/store.js +4 -0
- package/dist/core/loops/types.js +14 -2
- package/dist/core/loops/verbs.js +8 -1
- package/dist/core/loops/worker-reply-contract.js +1 -1
- package/dist/core/protocol-tool-policy.js +1 -0
- package/dist/core/review-loop-turn-dispatch.js +1 -0
- package/dist/core/schema.js +24 -1
- package/dist/core/search.js +3 -2
- package/dist/core/worktree.js +14 -7
- package/dist/facts.js +9 -8
- package/dist/facts.json +8 -7
- package/docs/cli.md +33 -0
- package/docs/concepts/ideation-loop.md +35 -14
- package/docs/integrations/mcp.md +13 -3
- package/docs/mcp-schema-changelog.md +42 -6
- 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
|
-
//
|
|
68
|
-
//
|
|
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)
|
|
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 (
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
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 =
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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 ?? [])
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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 }],
|
package/dist/core/loops/store.js
CHANGED
|
@@ -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,
|
package/dist/core/loops/types.js
CHANGED
|
@@ -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
|
-
|
|
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 ≤ ${
|
|
513
|
+
message: `LoopArtifact.body must be ≤ ${bodyLimit} bytes; use a ref for larger content`,
|
|
502
514
|
path: ['body'],
|
|
503
515
|
});
|
|
504
516
|
}
|
package/dist/core/loops/verbs.js
CHANGED
|
@@ -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 ? {
|
|
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):
|
|
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) {
|
|
@@ -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({
|
package/dist/core/schema.js
CHANGED
|
@@ -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(
|
|
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). */
|
package/dist/core/search.js
CHANGED
|
@@ -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}` });
|
package/dist/core/worktree.js
CHANGED
|
@@ -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`
|
|
1037
|
-
//
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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.
|