brainclaw 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -167,6 +167,26 @@ export const NextActionSchema = z.object({
167
167
  /** When this action applies, e.g. "when implementation is complete". */
168
168
  when: z.string().optional(),
169
169
  });
170
+ /**
171
+ * pln#635 — structured warning. ADDITIVE sibling of `warnings: string[]`, which
172
+ * keeps its type and its exact historical contents (the legacy string is
173
+ * derived from this record — see core/warnings.ts). Five handler sites were
174
+ * already encoding structure into a string via JSON.stringify because there was
175
+ * nowhere else to put it; this is that nowhere.
176
+ *
177
+ * `next_actions` is what the string channel could never carry: the recovery
178
+ * path. A warning an agent cannot act on is just noise it learns to skip.
179
+ */
180
+ export const WarningDetailSchema = z.object({
181
+ /** Stable machine-readable identifier, e.g. "scope_already_claimed". */
182
+ code: z.string(),
183
+ /** Human-readable prose. Also the legacy string for non-JSON codes. */
184
+ message: z.string(),
185
+ /** Structured payload (ids, agents, scopes) the prose mentions. */
186
+ data: z.record(z.string(), z.unknown()).optional(),
187
+ /** How to resolve it — same contract as the response-level next_actions. */
188
+ next_actions: z.array(NextActionSchema).optional(),
189
+ });
170
190
  export const FacadeResponseSchema = z.object({
171
191
  status: z.enum(['ok', 'error', 'partial']),
172
192
  intent: z.string(),
@@ -222,6 +242,18 @@ export const FacadeResponseSchema = z.object({
222
242
  * remains for the bootstrap hint; new consumers should read this array.
223
243
  */
224
244
  next_actions: z.array(NextActionSchema).optional(),
245
+ /**
246
+ * pln#635 — structured warnings carrying a stable `code`, the `data` the prose
247
+ * refers to, and the recovery `next_actions`. Optional and additive:
248
+ * `warnings` keeps byte-identical contents, so a consumer ignoring this field
249
+ * is unaffected.
250
+ *
251
+ * This is a structured **SUBSET**, not a mirror — `warnings` remains the
252
+ * complete channel (see core/warnings.ts for why: handlers thread the string
253
+ * array into helpers by reference). Read `warnings` for completeness; read
254
+ * `warning_details` for the codes that carry a recovery path.
255
+ */
256
+ warning_details: z.array(WarningDetailSchema).optional(),
225
257
  /**
226
258
  * Code Map P0 (spec §10): opt-in, present ONLY when the project's Code Map
227
259
  * manifest carries `code_map_enabled: true`. Absent for every project that
@@ -0,0 +1,197 @@
1
+ /**
2
+ * pln#634 PR2 — guidance adherence telemetry.
3
+ *
4
+ * brainclaw emits `next_actions` and `warning_details[].next_actions` on more and
5
+ * more surfaces (PR1, pln#635) but has never measured whether an agent's NEXT
6
+ * call follows the suggestion. Without that number the whole guidance backlog
7
+ * (pln#636/#637/#638) is prioritised on opinion: we cannot tell "the signal is
8
+ * missing" from "the signal is ignored", and those two diagnoses have opposite
9
+ * remedies — add more channels vs. stop adding channels and converge state
10
+ * server-side instead.
11
+ *
12
+ * MECHANISM. `executeMcpToolCall` is the single seam every MCP call passes
13
+ * through. After a response is built we remember which tools it suggested; on
14
+ * the next call in the same session we compare. One observation per
15
+ * suggestion→call pair.
16
+ *
17
+ * WHAT IS RECORDED: tool NAMES and a timestamp. Never arguments, never content,
18
+ * never file paths — the adherence question needs no payload, and a telemetry
19
+ * file that accumulated payloads would become a redaction problem
20
+ * (trp_0d79711e). This is also why it is safe to keep on by default.
21
+ *
22
+ * COST. Observations accumulate in memory and flush in batches, so a session of
23
+ * N calls costs ~N/BATCH writes rather than N. No daemon, no store mutation, no
24
+ * journal noise: the file lives beside the other machine-local runtime
25
+ * artifacts (ack/log sentinels).
26
+ *
27
+ * Opt out with `BRAINCLAW_GUIDANCE_TELEMETRY=0` (also false/off/no).
28
+ *
29
+ * @module
30
+ */
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { MEMORY_DIR } from './io.js';
34
+ const TELEMETRY_FILE = 'guidance-adherence.jsonl';
35
+ /** Flush every N observations — bounds writes without risking much on a crash. */
36
+ const FLUSH_EVERY = 20;
37
+ /** Rotate past this size so the file cannot grow without bound. */
38
+ const MAX_BYTES = 512 * 1024;
39
+ /** Per-session pending suggestion. Process-scoped: one MCP server per connection. */
40
+ const pending = new Map();
41
+ /** Buffered observations awaiting flush, keyed by target store cwd. */
42
+ const buffered = new Map();
43
+ function enabled() {
44
+ const raw = process.env.BRAINCLAW_GUIDANCE_TELEMETRY?.trim().toLowerCase();
45
+ return !(raw === '0' || raw === 'false' || raw === 'off' || raw === 'no');
46
+ }
47
+ function sessionKey(sessionId) {
48
+ return sessionId?.trim() || 'no-session';
49
+ }
50
+ /**
51
+ * Pull suggested tool names out of a built response.
52
+ *
53
+ * Deliberately a SHALLOW scan of the two places affordances actually live —
54
+ * top level (handlers that spread fields into `toolResponse`) and
55
+ * `structuredContent` (facade responses) — plus the per-warning nests. A deep
56
+ * recursive walk would cost more than the signal is worth and would pick up
57
+ * unrelated `next_actions` echoed inside payload data.
58
+ */
59
+ export function extractSuggestedTools(response) {
60
+ if (!response || typeof response !== 'object')
61
+ return [];
62
+ const tools = [];
63
+ const collect = (value) => {
64
+ if (!Array.isArray(value))
65
+ return;
66
+ for (const entry of value) {
67
+ if (entry && typeof entry === 'object' && typeof entry.tool === 'string') {
68
+ tools.push(entry.tool);
69
+ }
70
+ }
71
+ };
72
+ const collectWarningNests = (value) => {
73
+ if (!Array.isArray(value))
74
+ return;
75
+ for (const entry of value) {
76
+ if (entry && typeof entry === 'object')
77
+ collect(entry.next_actions);
78
+ }
79
+ };
80
+ const top = response;
81
+ collect(top.next_actions);
82
+ collectWarningNests(top.warning_details);
83
+ const structured = top.structuredContent;
84
+ if (structured && typeof structured === 'object') {
85
+ const inner = structured;
86
+ collect(inner.next_actions);
87
+ collectWarningNests(inner.warning_details);
88
+ }
89
+ return [...new Set(tools)];
90
+ }
91
+ /**
92
+ * Observe a tool call against the suggestion left by the previous call.
93
+ *
94
+ * Returns the observation (for tests) or undefined when there was nothing
95
+ * pending. Consuming the pending entry is intentional: one suggestion set is
96
+ * judged exactly once, by the call that immediately follows it.
97
+ */
98
+ export function observeToolCall(input) {
99
+ if (!enabled())
100
+ return undefined;
101
+ const key = sessionKey(input.sessionId);
102
+ const prior = pending.get(key);
103
+ if (!prior)
104
+ return undefined;
105
+ pending.delete(key);
106
+ const observation = {
107
+ at: input.now ?? new Date().toISOString(),
108
+ suggested_by: prior.suggestedBy,
109
+ suggested: prior.suggested,
110
+ called: input.tool,
111
+ followed: prior.suggested.includes(input.tool),
112
+ };
113
+ const list = buffered.get(input.cwd) ?? [];
114
+ list.push(observation);
115
+ buffered.set(input.cwd, list);
116
+ if (list.length >= FLUSH_EVERY)
117
+ flushAdherence(input.cwd);
118
+ return observation;
119
+ }
120
+ /** Remember what a response suggested, so the next call can be judged. */
121
+ export function recordSuggestion(input) {
122
+ if (!enabled())
123
+ return;
124
+ const key = sessionKey(input.sessionId);
125
+ if (input.suggested.length === 0) {
126
+ // No suggestion means nothing to judge — clear rather than leave a stale
127
+ // set that a later call would be measured against unfairly.
128
+ pending.delete(key);
129
+ return;
130
+ }
131
+ pending.set(key, { suggestedBy: input.tool, suggested: input.suggested });
132
+ }
133
+ function telemetryPath(cwd) {
134
+ return path.join(cwd, MEMORY_DIR, 'coordination', 'runtime', TELEMETRY_FILE);
135
+ }
136
+ /** Write buffered observations. Best-effort by construction: never throws. */
137
+ export function flushAdherence(cwd) {
138
+ const list = buffered.get(cwd);
139
+ if (!list || list.length === 0)
140
+ return;
141
+ buffered.set(cwd, []);
142
+ try {
143
+ const file = telemetryPath(cwd);
144
+ fs.mkdirSync(path.dirname(file), { recursive: true });
145
+ try {
146
+ if (fs.statSync(file).size > MAX_BYTES) {
147
+ // Keep the newest half; adherence is a trend, not an archive.
148
+ const kept = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
149
+ fs.writeFileSync(file, kept.slice(Math.floor(kept.length / 2)).join('\n') + '\n', 'utf-8');
150
+ }
151
+ }
152
+ catch { /* absent file — nothing to rotate */ }
153
+ fs.appendFileSync(file, list.map((o) => JSON.stringify(o)).join('\n') + '\n', 'utf-8');
154
+ }
155
+ catch {
156
+ /* telemetry must never break a tool call */
157
+ }
158
+ }
159
+ /** Read the recorded observations and summarise. Never throws. */
160
+ export function readAdherence(cwd) {
161
+ let persisted = [];
162
+ try {
163
+ persisted = fs.readFileSync(telemetryPath(cwd), 'utf-8')
164
+ .split('\n')
165
+ .filter(Boolean)
166
+ .map((line) => JSON.parse(line));
167
+ }
168
+ catch {
169
+ /* absent or unreadable — an empty history, not an error */
170
+ }
171
+ // Include anything still buffered so a read right after a call is not stale.
172
+ const observations = [...persisted, ...(buffered.get(cwd) ?? [])];
173
+ const followed = observations.filter((o) => o.followed).length;
174
+ const perTool = new Map();
175
+ for (const o of observations) {
176
+ const entry = perTool.get(o.suggested_by) ?? { total: 0, followed: 0 };
177
+ entry.total += 1;
178
+ if (o.followed)
179
+ entry.followed += 1;
180
+ perTool.set(o.suggested_by, entry);
181
+ }
182
+ return {
183
+ total: observations.length,
184
+ followed,
185
+ ignored: observations.length - followed,
186
+ ...(observations.length > 0 ? { rate: followed / observations.length } : {}),
187
+ by_tool: [...perTool.entries()]
188
+ .map(([tool, v]) => ({ tool, total: v.total, followed: v.followed, rate: v.followed / v.total }))
189
+ .sort((a, b) => a.rate - b.rate),
190
+ };
191
+ }
192
+ /** Test hook — the maps are process-scoped by design. */
193
+ export function __resetAdherenceForTests() {
194
+ pending.clear();
195
+ buffered.clear();
196
+ }
197
+ //# sourceMappingURL=guidance-telemetry.js.map
@@ -103,17 +103,45 @@ export function closeIdeationLoopFromLaneResult(assignment, lane, actor, cwd) {
103
103
  // A critic's LANE-RESULT carries free-form summary/notes (no structured
104
104
  // critiques[] field) → ONE critique artifact. A bare lane with no critique
105
105
  // content FAILS the slot (mirror ideationReducer: no fake gate progress).
106
- const critique = [lane.summary, lane.notes]
106
+ const expectedArtifactType = 'critique';
107
+ // Prefer the typed envelope, but honor the legacy artifacts labels too:
108
+ // coverage_gap used to be silently invisible to a critique gate.
109
+ const reportedArtifactType = lane.artifact_type?.trim()
110
+ ?? lane.artifacts?.find((label) => /^[a-z][a-z0-9_]*$/.test(label) && label !== expectedArtifactType);
111
+ const body = lane.body?.trim();
112
+ const critique = body || [lane.summary, lane.notes]
107
113
  .map((s) => (s ?? '').trim())
108
114
  .filter(Boolean)
109
115
  .join('\n\n')
110
116
  .trim();
111
117
  if (!critique) {
112
118
  complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'failed', failure_reason: 'critic lane produced no critique content (bare summary)' }, cwd);
113
- return { loop_id: loopId, action: 'failed', reason: 'bare critic lane slot failed; critique gate unchanged', loop_status: getLoop(loopId, cwd)?.status };
119
+ return { loop_id: loopId, action: 'failed', reason: reportedArtifactType && reportedArtifactType !== expectedArtifactType
120
+ ? `reported artifact type "${reportedArtifactType}" has no usable body; expected "${expectedArtifactType}"`
121
+ : 'bare critic lane → slot failed; critique gate unchanged',
122
+ loop_status: getLoop(loopId, cwd)?.status };
114
123
  }
115
- complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'done', artifact: { phase: loop.current_phase, type: 'critique', body: capCritique(critique) } }, cwd);
116
- return tryAdvance(true);
124
+ // pln#639 BUG-2 attribute the artifact to the phase the slot was
125
+ // DISPATCHED in, not the loop's phase at close time.
126
+ //
127
+ // `turn()` stamps `slot.phase = current_phase` when the slot is handed
128
+ // out (loops/verbs.ts). Using `loop.current_phase` here instead means a
129
+ // lane that returns AFTER a phase advance has its work filed under the
130
+ // new phase: a critique landing 90 seconds late is recorded in
131
+ // `revision`, where the critique gate cannot see it and where it
132
+ // misrepresents what the agent was asked to do. Reproduced in the
133
+ // pln#638 1a/1b ideation, which advanced ~90s after its last critic.
134
+ //
135
+ // Truthful attribution is also the fix for "don't count it": the gate
136
+ // filters on `artifact.phase === current_phase`, so an out-of-phase
137
+ // artifact stops satisfying the current gate by construction — no
138
+ // separate refusal path, and the content is preserved rather than lost.
139
+ const dispatchPhase = slot.phase ?? loop.current_phase;
140
+ complete_turn({ id: loopId, slot_id: slot.slot_id, actor, outcome: 'done', artifact: { phase: dispatchPhase, type: 'critique', body: capCritique(critique) } }, cwd);
141
+ const advanced = tryAdvance(true);
142
+ return reportedArtifactType && reportedArtifactType !== expectedArtifactType
143
+ ? { ...advanced, reason: `reconciled reported artifact type "${reportedArtifactType}" to expected "${expectedArtifactType}"; ${advanced.reason}` }
144
+ : advanced;
117
145
  },
118
146
  });
119
147
  }
@@ -217,10 +217,18 @@ function renderHeader(input) {
217
217
  `> Regenerate: brainclaw export --format ${formatForAgent(input.profile.name)} --write`,
218
218
  ].join('\n');
219
219
  }
220
- function renderLiveHeader(_input) {
220
+ function renderLiveHeader(input) {
221
+ // pln#638 volet 2a — HONESTY FIX. This header used to say "auto-refreshed",
222
+ // but regeneration is EXPLICIT: it happens on session-end, handoff, and
223
+ // `export --write`. An agent tier that never fires those events (no hooks, no
224
+ // MCP) read a file claiming to be fresh while being arbitrarily stale. A claim
225
+ // that is false for half the tiers is worse than no claim, so the header now
226
+ // names the actual triggers and tells the reader how to force a refresh.
227
+ // Guarded by tests/unit/guidance-engine-consistency.test.ts.
221
228
  return [
222
- `> Brainclaw live state — auto-refreshed, do not edit.`,
223
- `> Last updated: ${new Date().toISOString().slice(0, 19)}`,
229
+ `> Brainclaw live state — do not edit. Regenerated on: session-end, handoff, \`brainclaw export --write\`.`,
230
+ `> Written by brainclaw v${input.brainclawVersion} at ${new Date().toISOString().slice(0, 19)}`,
231
+ `> Older than your last session? It is stale — run \`brainclaw export --write\` to refresh.`,
224
232
  ].join('\n');
225
233
  }
226
234
  // Kept deliberately small (pln#542): entry point + grammar + escalation
@@ -39,6 +39,31 @@ function isVerdictAccepted(artifact) {
39
39
  const body = (artifact.body ?? '').trim().toLowerCase();
40
40
  return /^accepted(?:\b|[:\s])/.test(body);
41
41
  }
42
+ /**
43
+ * pln#639 BUG-1 — does this artifact carry anything a reader could USE?
44
+ *
45
+ * `body` is optional in both the input schema (loops/facade-schema.ts) and
46
+ * `LoopArtifactSchema`, so `{phase, type}` alone is schema-valid. Without this
47
+ * predicate such an artifact counted toward `min_artifacts_by_type`, which means
48
+ * a phase gate — the mechanism whose entire job is to prove the phase produced
49
+ * real work — could be opened by producing nothing at all.
50
+ *
51
+ * THE INVARIANT ALREADY EXISTED, one layer too low. `ideationReducer` states it
52
+ * verbatim: "a bare summary with no critique body → slot failed, gate stays shut
53
+ * (correct: no fake progress from a lane that produced no critiques)". That guard
54
+ * only covers the LANE-RESULT reducer path; a direct `add_artifact` /
55
+ * `complete_turn` MCP call bypassed it entirely. Enforcing it in the evaluator
56
+ * makes it hold for every entry path.
57
+ *
58
+ * A `ref` counts as content: ref-based artifacts legitimately carry no inline
59
+ * body (the payload lives in the referenced entity), so the rule is "no usable
60
+ * content" — NOT "body required", which would break them.
61
+ */
62
+ function hasUsableContent(artifact) {
63
+ if ((artifact.body ?? '').trim().length > 0)
64
+ return true;
65
+ return artifact.ref !== undefined;
66
+ }
42
67
  export function evaluateStopCondition(thread, condition) {
43
68
  if (!condition)
44
69
  return false;
@@ -65,6 +90,9 @@ export function evaluateStopCondition(thread, condition) {
65
90
  const matches = thread.artifacts.filter((artifact) => {
66
91
  if (artifact.type !== condition.type)
67
92
  return false;
93
+ // pln#639 BUG-1 — an artifact with no usable content never counts.
94
+ if (!hasUsableContent(artifact))
95
+ return false;
68
96
  if (condition.scope === 'phase') {
69
97
  if (artifact.phase !== thread.current_phase)
70
98
  return false;
@@ -125,6 +153,11 @@ function describeUnmetGate(thread, gate) {
125
153
  const matches = thread.artifacts.filter((artifact) => {
126
154
  if (artifact.type !== gate.type)
127
155
  return false;
156
+ // pln#639 BUG-1 — same content filter as the evaluator, for the same
157
+ // reason the iteration filter is mirrored here: a message reporting a
158
+ // count the evaluator never saw sends the operator hunting a phantom.
159
+ if (!hasUsableContent(artifact))
160
+ return false;
128
161
  if (gate.scope === 'phase') {
129
162
  if (artifact.phase !== thread.current_phase)
130
163
  return false;
@@ -137,7 +170,13 @@ function describeUnmetGate(thread, gate) {
137
170
  }
138
171
  return true;
139
172
  });
140
- return `min_artifacts_by_type unmet: ${gate.scope}-scope count of type "${gate.type}" = ${matches.length} < n=${gate.n}`;
173
+ // Name the empty-artifact case explicitly: "count = 2 < n = 3" is baffling
174
+ // when the operator can see three artifacts of the right type in the thread.
175
+ const emptyOfType = thread.artifacts.filter((a) => a.type === gate.type && !hasUsableContent(a)).length;
176
+ const emptyNote = emptyOfType > 0
177
+ ? ` (${emptyOfType} artifact(s) of this type carry no usable content and do not count)`
178
+ : '';
179
+ return `min_artifacts_by_type unmet: ${gate.scope}-scope count of type "${gate.type}" = ${matches.length} < n=${gate.n}${emptyNote}`;
141
180
  }
142
181
  case 'phase_reached':
143
182
  return `phase_reached unmet: current_phase="${thread.current_phase}" expected="${gate.phase}"`;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Verifying a spawned worker: always `bclaw_dispatch_status`, never
3
+ * `bclaw_find(agent_run)` + a pid check. On Windows an ack-wrapped spawn runs
4
+ * under cmd.exe, so `agent_run.pid` is the wrapper (which exits by design) and
5
+ * reads dead while the worker is alive (trp_7fc3e3c4). `dispatch_status`
6
+ * returns a sentinel-based verdict instead.
7
+ */
8
+ export function verifyDispatchAction(targetId, note) {
9
+ return {
10
+ tool: 'bclaw_dispatch_status',
11
+ args: { target_id: targetId },
12
+ when: note
13
+ ? `${note} — sentinel-based liveness verdict (do NOT judge from agent_run.pid)`
14
+ : 'verify the spawned worker is actually alive — sentinel-based verdict (do NOT judge from agent_run.pid)',
15
+ };
16
+ }
17
+ /** Cap on repeated per-target actions, so a wide fan-out cannot flood the field. */
18
+ const FANOUT_CAP = 3;
19
+ function verifyActions(targetIds, note) {
20
+ const shown = targetIds.slice(0, FANOUT_CAP);
21
+ const actions = shown.map((id) => verifyDispatchAction(id, note));
22
+ if (targetIds.length > shown.length) {
23
+ // Say what was dropped rather than silently truncating.
24
+ actions.push({
25
+ tool: 'bclaw_dispatch_status',
26
+ args: { target_id: '<one of the remaining targets>' },
27
+ when: `${targetIds.length - shown.length} further target(s) were dispatched — verify each one the same way`,
28
+ });
29
+ }
30
+ return actions;
31
+ }
32
+ /**
33
+ * After a release, the follow-up depends entirely on what the cascade decided:
34
+ * a blocked plan transition needs the other claim holders inspected, a
35
+ * completed plan is ready for review, and a plain release has no next step at
36
+ * all.
37
+ */
38
+ export function releaseClaimNextActions(outcome) {
39
+ const actions = [];
40
+ if (outcome.planWarning && outcome.planId) {
41
+ // The cascade refused: other claims still hold the plan. Both the diagnosis
42
+ // and the eventual manual transition are real MCP calls.
43
+ actions.push({
44
+ tool: 'bclaw_find',
45
+ args: { entity: 'claim', filter: { plan_id: outcome.planId, status: 'active' } },
46
+ when: 'the plan was NOT transitioned because other claims are still active — see who else holds it',
47
+ });
48
+ actions.push({
49
+ tool: 'bclaw_transition',
50
+ args: { entity: 'plan', id: outcome.planId, to: outcome.requestedPlanStatus ?? 'done' },
51
+ when: 'once the other claims are released, transition the plan yourself',
52
+ });
53
+ return actions;
54
+ }
55
+ if (outcome.planTransitioned && outcome.planId) {
56
+ // Documented workflow: implement → release → review.
57
+ actions.push({
58
+ tool: 'bclaw_coordinate',
59
+ args: {
60
+ intent: 'review',
61
+ task: `Review the work delivered under plan ${outcome.planId}`,
62
+ open_loop: true,
63
+ },
64
+ when: 'the plan is done — the next workflow stage is review',
65
+ });
66
+ }
67
+ return actions;
68
+ }
69
+ /**
70
+ * Only two transitions imply an unambiguous next call. Everything else
71
+ * (candidate accepted, plan done, trap retired, …) is terminal for the caller,
72
+ * so it returns nothing rather than inventing busywork.
73
+ */
74
+ export function transitionNextActions(outcome) {
75
+ if (outcome.entity === 'plan' && outcome.to === 'in_progress') {
76
+ return [{
77
+ tool: 'bclaw_work',
78
+ args: { intent: 'execute', planId: outcome.id, scope: '<scope you are about to edit>' },
79
+ when: 'the plan is in progress — claim the scope before editing',
80
+ }];
81
+ }
82
+ if (outcome.entity === 'plan' && outcome.to === 'blocked') {
83
+ return [{
84
+ tool: 'bclaw_quick_capture',
85
+ args: { text: '<what blocks this plan>', type: 'trap' },
86
+ when: 'record WHY it is blocked so the next agent does not rediscover it',
87
+ }];
88
+ }
89
+ return [];
90
+ }
91
+ /**
92
+ * Coordinate's follow-up is driven by whether anything actually spawned, not by
93
+ * the intent alone: the same `intent='assign'` needs verification when it
94
+ * spawned and nothing MCP-callable when it produced manual commands.
95
+ */
96
+ export function coordinateNextActions(outcome) {
97
+ const actions = [];
98
+ const spawned = outcome.executionStatus === 'delivered_and_started';
99
+ if (spawned && outcome.assignmentIds.length > 0) {
100
+ actions.push(...verifyActions(outcome.assignmentIds));
101
+ }
102
+ if (outcome.loopId) {
103
+ actions.push({
104
+ tool: 'bclaw_loop',
105
+ args: { intent: 'get', loop_id: outcome.loopId },
106
+ when: spawned
107
+ ? 'inspect loop state — its `next_expected` names the turn the loop is waiting on'
108
+ : 'the loop is open but nothing spawned — inspect it and drive the turn yourself',
109
+ });
110
+ }
111
+ // Manual-handoff spawning intents: the launch commands are in the text body
112
+ // (not MCP-callable), so the only real MCP follow-up is verification AFTER
113
+ // the operator runs them.
114
+ if (!spawned && outcome.executionStatus === 'command_ready_manual' && outcome.assignmentIds.length > 0) {
115
+ actions.push(verifyActions(outcome.assignmentIds, 'once you have run the launch command(s) printed above')[0]);
116
+ }
117
+ return actions;
118
+ }
119
+ export function dispatchNextActions(outcome) {
120
+ if (outcome.dryRun) {
121
+ return [{
122
+ tool: 'bclaw_dispatch',
123
+ args: { intent: 'execute' },
124
+ when: 'this was a dry run — nothing was dispatched; re-run without dryRun to actually spawn',
125
+ }];
126
+ }
127
+ const actions = [];
128
+ if (outcome.spawnedTargets.length > 0) {
129
+ actions.push(...verifyActions(outcome.spawnedTargets));
130
+ }
131
+ if (outcome.blockedCount > 0) {
132
+ actions.push({
133
+ tool: 'bclaw_dispatch',
134
+ args: { intent: 'analysis' },
135
+ when: `${outcome.blockedCount} lane(s) are blocked — analysis explains which gate holds each one`,
136
+ });
137
+ }
138
+ return actions;
139
+ }
140
+ export function createEntityNextActions(outcome) {
141
+ if (outcome.entity === 'plan') {
142
+ return [{
143
+ tool: 'bclaw_add_step',
144
+ args: { planId: outcome.id, data: { text: '<first unit of work>' } },
145
+ when: 'break the plan into steps so progress is trackable',
146
+ }];
147
+ }
148
+ if (outcome.entity === 'sequence') {
149
+ return [{
150
+ tool: 'bclaw_dispatch',
151
+ args: { intent: 'analysis' },
152
+ when: 'inspect lane readiness before dispatching the sequence',
153
+ }];
154
+ }
155
+ return [];
156
+ }
157
+ //# sourceMappingURL=next-actions.js.map
@@ -1,9 +1,19 @@
1
1
  import { getLoop } from './loops/store.js';
2
2
  import { complete_turn, advance } from './loops/verbs.js';
3
3
  import { withLoopLock } from './loops/lock.js';
4
+ import { LOOP_ARTIFACT_BODY_MAX_BYTES } from './loops/types.js';
4
5
  /** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
5
6
  const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
6
7
  const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
8
+ /** Keep the loop-facing verdict valid while the full worker body stays durable in harvest metadata. */
9
+ function capVerdictBody(prefix, detail) {
10
+ const full = `${prefix}${detail ? `: ${detail}` : ''}`;
11
+ if (Buffer.byteLength(full, 'utf8') <= LOOP_ARTIFACT_BODY_MAX_BYTES)
12
+ return full;
13
+ const marker = '…[truncated; full body retained in lane harvest event]';
14
+ const room = LOOP_ARTIFACT_BODY_MAX_BYTES - Buffer.byteLength(prefix, 'utf8') - Buffer.byteLength(': ', 'utf8') - Buffer.byteLength(marker, 'utf8');
15
+ return `${prefix}: ${Buffer.from(detail, 'utf8').subarray(0, Math.max(0, room)).toString('utf8').replace(/�+$/, '')}${marker}`;
16
+ }
7
17
  /** Build the fix+re-review brief for a request_changes cycle turn (symmetric).
8
18
  * Exported so the turn-owned reconcile path (pln#630 PR3b) reuses the identical
9
19
  * wording — the reviewer contract must not drift between the legacy and turn-owned
@@ -78,14 +88,21 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
78
88
  return noop(`loop already ${loop.status}`, loop.status);
79
89
  const slot = resolveReviewerSlot(loop, assignment);
80
90
  const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
81
- const summary = (lane.review_summary ?? '').trim();
91
+ const detail = (lane.body ?? lane.review_summary ?? '').trim();
82
92
  // ── approve → close on reviewer_green ───────────────────────────────
83
93
  if (verdict === 'approve') {
84
94
  if (slot) {
85
95
  // isVerdictAccepted fires reviewer_green ONLY on an "accepted…" body.
86
96
  complete_turn({
87
97
  id: loopId, slot_id: slot.slot_id, actor,
88
- artifact: { phase: loop.current_phase, type: 'verdict', body: `accepted${summary ? `: ${summary}` : ''}` },
98
+ // pln#639 BUG-2 the phase the slot was DISPATCHED in, not the
99
+ // loop's phase at close time. Same defect as the ideation closer;
100
+ // fixed here too because this is the far more travelled path.
101
+ // Safe for the approve flow: `reviewer_green` scans every artifact
102
+ // via isVerdictAccepted regardless of phase, and no gate in the
103
+ // engine keys on `type: 'verdict'` — so this changes attribution
104
+ // truth without changing a single gate outcome.
105
+ artifact: { phase: slot.phase ?? loop.current_phase, type: 'verdict', body: capVerdictBody('accepted', detail) },
89
106
  }, cwd);
90
107
  }
91
108
  else if (!acceptedVerdictExists) {
@@ -130,7 +147,8 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
130
147
  const symmetric = loop.protocol?.review_mode === 'symmetric';
131
148
  complete_turn({
132
149
  id: loopId, slot_id: slot.slot_id, actor,
133
- artifact: { phase: loop.current_phase, type: 'verdict', body: `changes-requested${summary ? `: ${summary}` : ''}` },
150
+ // pln#639 BUG-2 dispatch phase, not close-time phase (see above).
151
+ artifact: { phase: slot.phase ?? loop.current_phase, type: 'verdict', body: capVerdictBody('changes-requested', detail) },
134
152
  }, cwd);
135
153
  if (!symmetric) {
136
154
  const advancedAsym = advance({ id: loopId, actor }, cwd);
@@ -174,7 +192,7 @@ export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd, opti
174
192
  agent_id: slot.agent_id,
175
193
  phase: advanced.loop.current_phase,
176
194
  iteration: advanced.loop.iteration_count,
177
- task: buildFixCycleTask(summary, advanced.loop.iteration_count),
195
+ task: buildFixCycleTask(detail, advanced.loop.iteration_count),
178
196
  },
179
197
  };
180
198
  },