brainclaw 1.18.0 → 1.19.1

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.
@@ -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
  },
@@ -715,6 +715,28 @@ export const ClaimSchema = z.object({
715
715
  assignment_message_id: z.string().optional(),
716
716
  /** Assignment ID from the Agent SDK runtime protocol. Links claim to its Assignment lifecycle entity. */
717
717
  assignment_id: z.string().optional(),
718
+ /**
719
+ * pln#636 C0-b — commit the claim's work started FROM, recorded at creation.
720
+ *
721
+ * This is the immutable baseline any "what did this claim actually touch?"
722
+ * comparison needs. The design review settled the question by rejecting both
723
+ * options it offered: neither `git diff` against HEAD nor the worktree's dirty
724
+ * set is authoritative, because a lane that commits mid-work moves the ground
725
+ * under both. A fixed point recorded up front is the only honest basis.
726
+ *
727
+ * Optional and never backfilled: the 613 claims that predate this field simply
728
+ * have no baseline, and a conformity check must treat that as `unverifiable`
729
+ * rather than guessing one (see core/claim-scope.ts on the inverted default).
730
+ */
731
+ base_sha: z.string().optional(),
732
+ /**
733
+ * pln#636 C0-b — file footprint the claim DECLARES, when its creator knows it.
734
+ *
735
+ * Raises conformity coverage above what classifying a free-string `scope` can
736
+ * reach (57.6% of the live corpus is path-resolvable). Purely additive: absent
737
+ * means "fall back to classifying `scope`", never "no files allowed".
738
+ */
739
+ paths: z.array(z.string()).optional(),
718
740
  });
719
741
  // --- Assignment schemas (Agent SDK runtime protocol) ---
720
742
  export const AssignmentStatusSchema = z.enum([
@@ -974,6 +996,12 @@ export const RuntimeEventTypeSchema = z.enum([
974
996
  * environment, e.g. a genuinely MCP-less agent). The coordinator ingests it with
975
997
  * `brainclaw harvest <assignment_id>`.
976
998
  */
999
+ /**
1000
+ * Largest inline worker body accepted in a LANE-RESULT. This is deliberately
1001
+ * larger than a loop artifact body: harvest persists the original body in its
1002
+ * durable runtime event before a loop closer applies its smaller display cap.
1003
+ */
1004
+ export const LANE_RESULT_BODY_MAX_BYTES = 64 * 1024;
977
1005
  export const LaneResultSchema = z.object({
978
1006
  assignment_id: z.string(),
979
1007
  /**
@@ -994,6 +1022,18 @@ export const LaneResultSchema = z.object({
994
1022
  files_changed: z.array(z.string()).optional(),
995
1023
  /** Free-form notes (blockers, follow-ups). */
996
1024
  notes: z.string().optional(),
1025
+ /**
1026
+ * Full worker reasoning or review content. Unlike `summary`, this is the
1027
+ * durable handoff payload and is copied into the coordinator-side harvest
1028
+ * event, so it survives worktree cleanup. Optional for legacy workers.
1029
+ */
1030
+ body: z.string().refine((body) => Buffer.byteLength(body, 'utf8') <= LANE_RESULT_BODY_MAX_BYTES, `LANE-RESULT.body must be ≤ ${LANE_RESULT_BODY_MAX_BYTES} bytes`).optional(),
1031
+ /**
1032
+ * Type the worker associated with `body`. Optional because legacy
1033
+ * `artifacts` remains a list of opaque labels/refs. A loop harvester may
1034
+ * reconcile this to its phase's required artifact type.
1035
+ */
1036
+ artifact_type: z.string().min(1).optional(),
997
1037
  /**
998
1038
  * pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
999
1039
  * sets this to signal whether the change is good to merge (`approve`) or needs
@@ -0,0 +1,150 @@
1
+ /**
2
+ * pln#638 volet 2b — lazy freshness reconcile for generated guidance surfaces.
3
+ *
4
+ * WHY THIS EXISTS. 2a made the live header HONEST: it stopped claiming
5
+ * "auto-refreshed" and started naming its real triggers (session-end, handoff,
6
+ * `export --write`) plus the version and timestamp that wrote it. Honesty alone
7
+ * does not help an agent tier that never fires any of those triggers, though — it
8
+ * just tells that tier, truthfully, that the file might be arbitrarily old. 2b
9
+ * closes the loop by USING the stamp: compare it against the running version and
10
+ * say so, once, at a path we already visit.
11
+ *
12
+ * NO DAEMON, NO WATCHER — the validated lazy-reconcile pattern. The check is a
13
+ * pure comparison plus a directory scan of a registry that already exists
14
+ * (`AGENT_EXPORT_REGISTRY` / `LIVE_COMPANION_EXPORT_REGISTRY`), so it is DERIVED
15
+ * rather than enumerated. That is review finding F1 applied here: a hand-kept
16
+ * list of generated surfaces would itself be an unguarded generated surface, and
17
+ * would reproduce the exact defect this plan exists to fix.
18
+ *
19
+ * ADVISORY, AND SILENT ON DOUBT. A surface with no stamp is not stale — it is
20
+ * unknown (it may predate the stamp, or be hand-written by the operator). Only a
21
+ * stamp that PARSES and names a DIFFERENT version is reported. Nothing here
22
+ * rewrites a file: regeneration stays the explicit act it always was.
23
+ *
24
+ * @module
25
+ */
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { AGENT_EXPORT_REGISTRY, LIVE_COMPANION_EXPORT_REGISTRY } from './agent-files.js';
29
+ /**
30
+ * Matches the provenance line emitted by `renderLiveHeader`
31
+ * (instruction-templates.ts) and by the protocol-skill front-matter.
32
+ *
33
+ * Deliberately tolerant about what follows the version: the timestamp format is
34
+ * not what this parser is for, and a stricter pattern would go stale the first
35
+ * time the header gains a field.
36
+ */
37
+ const PROVENANCE_RE = /Written by brainclaw v(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)/;
38
+ /** `brainclaw_version: X` in a generated SKILL.md front-matter. */
39
+ const SKILL_PROVENANCE_RE = /^\s*brainclaw_version:\s*v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\s*$/m;
40
+ /** Read the provenance stamp out of a generated surface's content. Never throws. */
41
+ export function parseSurfaceProvenance(content) {
42
+ const header = PROVENANCE_RE.exec(content);
43
+ if (header?.[1])
44
+ return { version: header[1] };
45
+ const skill = SKILL_PROVENANCE_RE.exec(content);
46
+ if (skill?.[1])
47
+ return { version: skill[1] };
48
+ return {};
49
+ }
50
+ /**
51
+ * Compare one surface's stamp against the running version.
52
+ *
53
+ * An UNKNOWN stamp is never reported as stale. Treating "no stamp" as "out of
54
+ * date" would fire on every hand-written AGENTS.md in every project that ever
55
+ * adopted brainclaw — the false-positive failure mode that teaches agents to
56
+ * ignore a channel.
57
+ */
58
+ export function assessSurfaceFreshness(content, currentVersion) {
59
+ const { version } = parseSurfaceProvenance(content);
60
+ if (!version)
61
+ return { kind: 'unknown', reason: 'no brainclaw provenance stamp' };
62
+ if (version === currentVersion)
63
+ return { kind: 'fresh', version };
64
+ return { kind: 'stale', stampedVersion: version, currentVersion };
65
+ }
66
+ /**
67
+ * The set of surfaces this project could have on disk, derived from the export
68
+ * registries rather than listed here. Deduplicated because several agents share
69
+ * a target (four of them write AGENTS.md).
70
+ */
71
+ function candidateSurfacePaths() {
72
+ return [...new Set([
73
+ ...AGENT_EXPORT_REGISTRY.map((t) => t.relativePath),
74
+ ...LIVE_COMPANION_EXPORT_REGISTRY.map((t) => t.relativePath),
75
+ ])];
76
+ }
77
+ /**
78
+ * Scan the project's generated surfaces and report the ones stamped with a
79
+ * different brainclaw version.
80
+ *
81
+ * Cheap by construction: it only stats/reads files the registries name (~25
82
+ * paths, most absent in any given project), and reads at most the head of each —
83
+ * the stamp is in the header, so there is no reason to pull a whole file into
84
+ * memory. Never throws; an unreadable file is simply not reported.
85
+ */
86
+ export function reconcileSurfaceFreshness(cwd, currentVersion) {
87
+ const result = { stale: [], freshCount: 0, unknownCount: 0 };
88
+ for (const relativePath of candidateSurfacePaths()) {
89
+ const full = path.join(cwd, relativePath);
90
+ let head;
91
+ try {
92
+ if (!fs.existsSync(full))
93
+ continue;
94
+ // The stamp lives in the header; 4KB covers it with room to spare.
95
+ const fd = fs.openSync(full, 'r');
96
+ try {
97
+ const buf = Buffer.alloc(4096);
98
+ const read = fs.readSync(fd, buf, 0, buf.length, 0);
99
+ head = buf.subarray(0, read).toString('utf-8');
100
+ }
101
+ finally {
102
+ fs.closeSync(fd);
103
+ }
104
+ }
105
+ catch {
106
+ continue; // unreadable → not reported, never a crash
107
+ }
108
+ const verdict = assessSurfaceFreshness(head, currentVersion);
109
+ if (verdict.kind === 'stale')
110
+ result.stale.push({ relativePath, stampedVersion: verdict.stampedVersion });
111
+ else if (verdict.kind === 'fresh')
112
+ result.freshCount += 1;
113
+ else
114
+ result.unknownCount += 1;
115
+ }
116
+ return result;
117
+ }
118
+ /**
119
+ * Build the advisory for a stale-surface scan, or `undefined` when there is
120
+ * nothing to say.
121
+ *
122
+ * NO `next_actions`, deliberately. The recovery is `brainclaw export --write`,
123
+ * and there is no MCP tool that performs it — `bclaw_setup` is the onboarding
124
+ * wizard and takes no write flag. Pointing at it anyway would ship a next_action
125
+ * whose args the engine rejects, which is the precise class of drift this plan
126
+ * exists to eliminate; and per pln#634's own rule, a builder with no genuine
127
+ * follow-up returns nothing rather than inventing one. The command therefore
128
+ * travels in the message, where it is true.
129
+ */
130
+ export function staleSurfaceWarning(result, currentVersion) {
131
+ if (result.stale.length === 0)
132
+ return undefined;
133
+ const shown = result.stale.slice(0, 8);
134
+ const overflow = result.stale.length - shown.length;
135
+ return {
136
+ code: 'generated_surfaces_stale',
137
+ message: `${result.stale.length} generated guidance surface(s) were written by an older brainclaw than v${currentVersion}: `
138
+ + shown.map((s) => `${s.relativePath} (v${s.stampedVersion})`).join(', ')
139
+ + (overflow > 0 ? ` (+${overflow} more)` : '')
140
+ + '. An agent tier that never triggers a regeneration is reading them as-is.'
141
+ + ' Run `brainclaw export --write` to refresh them.',
142
+ data: {
143
+ current_version: currentVersion,
144
+ stale_surfaces: shown.map((s) => ({ path: s.relativePath, stamped_version: s.stampedVersion })),
145
+ ...(overflow > 0 ? { stale_surfaces_omitted: overflow } : {}),
146
+ refresh_command: 'brainclaw export --write',
147
+ },
148
+ };
149
+ }
150
+ //# sourceMappingURL=surface-freshness.js.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Codes that historically shipped as a JSON blob keep shipping that exact blob,
3
+ * so no existing consumer sees a changed string. The set is enumerated rather
4
+ * than inferred so a NEW code cannot accidentally start emitting JSON at a
5
+ * consumer that only ever saw prose.
6
+ */
7
+ const LEGACY_JSON_CODES = new Set([
8
+ 'agent_validation_failed',
9
+ 'plan_already_assigned',
10
+ 'scope_already_claimed',
11
+ ]);
12
+ /** Derive the legacy `warnings` string for a structured warning. */
13
+ export function renderLegacyWarning(detail) {
14
+ if (LEGACY_JSON_CODES.has(detail.code)) {
15
+ return JSON.stringify({ warning: detail.code, ...(detail.data ?? {}) });
16
+ }
17
+ return detail.message;
18
+ }
19
+ /**
20
+ * Build the structured record without touching any legacy channel.
21
+ *
22
+ * Used by surfaces that have NO historical `warnings: string[]` to stay
23
+ * compatible with — a field introduced already-structured (pln#636 C2's
24
+ * `LaneHarvestResult.warnings`, for one) should not have to invent a throwaway
25
+ * string array just to reach this shape.
26
+ */
27
+ export function toWarningDetail(input) {
28
+ return {
29
+ code: input.code,
30
+ message: input.message,
31
+ ...(input.data ? { data: input.data } : {}),
32
+ ...(input.next_actions?.length ? { next_actions: input.next_actions } : {}),
33
+ };
34
+ }
35
+ /**
36
+ * Record a structured warning into BOTH channels at once.
37
+ *
38
+ * Taking the two arrays as parameters (rather than owning them) is what keeps
39
+ * this additive: the caller's `warnings: string[]` stays the same object it
40
+ * already passes by reference to its own helpers.
41
+ */
42
+ export function pushStructuredWarning(warnings, details, input) {
43
+ const detail = toWarningDetail(input);
44
+ details.push(detail);
45
+ warnings.push(renderLegacyWarning(detail));
46
+ }
47
+ // ── Builders for the migrated sites ─────────────────────────────────────────
48
+ // Each owns its recovery path, which is the entire point of the structured
49
+ // channel: `scope_already_claimed` used to be a dead-end string; now it names
50
+ // the two calls that resolve it.
51
+ export function agentValidationFailedWarning(input) {
52
+ return {
53
+ code: 'agent_validation_failed',
54
+ message: `Agent '${input.agent}' cannot be dispatched to${input.reason ? `: ${input.reason}` : ''}.`,
55
+ data: { agent: input.agent, code: input.code, reason: input.reason },
56
+ next_actions: [{
57
+ tool: 'bclaw_find',
58
+ args: { entity: 'agent', filter: { scope: 'global' } },
59
+ when: 'list the dispatchable agents and pick a target that is actually spawnable',
60
+ }],
61
+ };
62
+ }
63
+ export function planAlreadyAssignedWarning(input) {
64
+ return {
65
+ code: 'plan_already_assigned',
66
+ message: `'${input.planId}' already has an active assignment for ${input.existingAgent} — this call adds a second one.`,
67
+ data: { plan_id: input.planId, existing_agent: input.existingAgent },
68
+ next_actions: [{
69
+ tool: 'bclaw_find',
70
+ args: { entity: 'assignment', filter: { agent: input.existingAgent, status: 'offered' } },
71
+ when: 'inspect the existing assignment before letting two agents work the same scope',
72
+ }],
73
+ };
74
+ }
75
+ export function scopeAlreadyClaimedWarning(input) {
76
+ return {
77
+ code: 'scope_already_claimed',
78
+ message: `Scope '${input.scope}' is already claimed by ${input.existingAgent} (${input.existingClaimId}).`,
79
+ data: {
80
+ scope: input.scope,
81
+ existing_agent: input.existingAgent,
82
+ existing_claim_id: input.existingClaimId,
83
+ },
84
+ next_actions: [
85
+ {
86
+ tool: 'bclaw_get',
87
+ args: { entity: 'claim', id: input.existingClaimId },
88
+ when: 'see who holds the scope and since when before creating a second claim on it',
89
+ },
90
+ {
91
+ tool: 'bclaw_coordinate',
92
+ args: { intent: 'reroute', task: `Reassign work on ${input.scope}`, scope: input.scope },
93
+ when: 'hand the existing claim to another agent instead of double-claiming the scope',
94
+ },
95
+ ],
96
+ };
97
+ }
98
+ //# sourceMappingURL=warnings.js.map
package/dist/facts.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
2
- // Source: brainclaw v1.18.0 on 2026-07-31T14:51:44.969Z
2
+ // Source: brainclaw v1.19.1 on 2026-08-02T07:16:39.143Z
3
3
  export const FACTS = {
4
- "version": "1.18.0",
5
- "generated_at": "2026-07-31T14:51:44.969Z",
4
+ "version": "1.19.1",
5
+ "generated_at": "2026-08-02T07:16:39.143Z",
6
6
  "tools": {
7
7
  "count": 67,
8
8
  "published_count": 65,
@@ -474,7 +474,7 @@ export const FACTS = {
474
474
  },
475
475
  "bench": {
476
476
  "schema": "brainclaw.bench.v1",
477
- "generated_at": "2026-07-31T14:51:42.799Z",
477
+ "generated_at": "2026-08-02T07:16:37.042Z",
478
478
  "node_version": "v24.18.0",
479
479
  "platform": "linux-x64",
480
480
  "repeats": 3,
@@ -491,7 +491,7 @@ export const FACTS = {
491
491
  "name": "warm_work",
492
492
  "volume": "medium",
493
493
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
494
- "duration_ms_median": 142,
494
+ "duration_ms_median": 123,
495
495
  "payload_chars_median": 2626,
496
496
  "payload_tokens_est_median": 657
497
497
  },
package/dist/facts.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.18.0",
3
- "generated_at": "2026-07-31T14:51:44.969Z",
2
+ "version": "1.19.1",
3
+ "generated_at": "2026-08-02T07:16:39.143Z",
4
4
  "tools": {
5
5
  "count": 67,
6
6
  "published_count": 65,
@@ -472,7 +472,7 @@
472
472
  },
473
473
  "bench": {
474
474
  "schema": "brainclaw.bench.v1",
475
- "generated_at": "2026-07-31T14:51:42.799Z",
475
+ "generated_at": "2026-08-02T07:16:37.042Z",
476
476
  "node_version": "v24.18.0",
477
477
  "platform": "linux-x64",
478
478
  "repeats": 3,
@@ -489,7 +489,7 @@
489
489
  "name": "warm_work",
490
490
  "volume": "medium",
491
491
  "description": "bclaw_work consult over a real-shaped store (~200 plans / 500 handoffs / 450 claims).",
492
- "duration_ms_median": 142,
492
+ "duration_ms_median": 123,
493
493
  "payload_chars_median": 2626,
494
494
  "payload_tokens_est_median": 657
495
495
  },
@@ -166,6 +166,63 @@ Without claims, multiple agents can easily touch the same area at once and gener
166
166
  Claims are not necessarily hard file locks.
167
167
  They are a shared coordination signal.
168
168
 
169
+ ### Scope grammar and conformity (v1.19.0+)
170
+
171
+ A claim's `scope` is a free string, and in practice it is used three ways. Measured
172
+ over the 613 real claims in the dogfood store:
173
+
174
+ | Shape | Share | Example |
175
+ |---|---|---|
176
+ | Path-like | 57.6% | `src/core/auth.ts`, `docs/` |
177
+ | Loop reference | 22.8% | `review-loop:lop_…`, `ideate-loop:lop_…:lsl_…` |
178
+ | Free prose | 19.6% | `Loop engine residuals #1-4` |
179
+
180
+ So **42.4% of real scopes cannot be matched to a file path at all** — and the
181
+ non-matchable share is *growing*, because coordinator-created lane claims are the
182
+ ones being minted. Any check built naively on path matching would false-accuse on
183
+ nearly one claim in two.
184
+
185
+ brainclaw therefore classifies a scope into `paths` / `loop_ref` / `prose` / `empty`
186
+ and reports conformity as `in_scope`, `out_of_scope`, or **`unverifiable`** — a
187
+ first-class verdict that every consumer renders as **silence**. Only a
188
+ path-resolvable scope with concrete stray files can ever produce an accusation.
189
+ `.brainclaw/` and `.git/` are never counted as out of scope: every brainclaw call
190
+ rewrites them, so counting them would accuse every agent on every claim.
191
+
192
+ The reserved loop prefixes are **enumerated**, not inferred from shape — so
193
+ `project-resolution: the gate` reads as prose, and a Windows absolute path
194
+ (`C:/Users/…`) stays a path rather than being read as a `C:` prefix.
195
+
196
+ ### `base_sha` and declared `paths[]`
197
+
198
+ A new claim records **`base_sha`**, the commit its work started from, resolved once
199
+ at creation and never moved. This is the baseline any "what did this claim actually
200
+ touch?" comparison needs: neither `git diff HEAD` nor the worktree's dirty set is
201
+ authoritative, because a lane that commits mid-work moves the ground under both —
202
+ each would report "touched nothing" the instant it committed.
203
+
204
+ Optionally a creator can declare **`paths[]`**, a machine-readable footprint that
205
+ raises conformity coverage above what classifying a free-string `scope` can reach.
206
+
207
+ Both are additive and never backfilled. A claim with no baseline is `unverifiable`,
208
+ never guessed, and acquiring a claim **never fails or blocks** because a baseline
209
+ could not be computed — outside a git repo the claim is simply created without one.
210
+
211
+ > `paths[]` is currently settable through the core and the CLI, but is not yet
212
+ > exposed in `bclaw_claim`'s published MCP inputSchema.
213
+
214
+ ### Liveness: file evidence, not just a session
215
+
216
+ A spawned sandboxed worker cannot reach MCP, so it cannot maintain any server-side
217
+ liveness record — which is why proof-of-life lives in filesystem sentinels the
218
+ worker writes into its own worktree. Since v1.19.0 claim liveness reads that same
219
+ evidence (worktree/project heartbeat plus filesystem activity) **before** consulting
220
+ any session record, with the same 30-minute freshness window an assignment gets.
221
+
222
+ Previously a demonstrably-alive worker kept its *assignment* while its *claim* aged
223
+ out on wall-clock alone — and a coordinator-created claim, which carries no
224
+ `session_id`, fell straight through to `never-adopted`.
225
+
169
226
  ## Policy checks
170
227
 
171
228
  Before editing a scope, agents can verify governance compliance using `check-policy`: