brainclaw 1.15.0 → 1.16.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.
- package/README.md +10 -3
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-capture.js +209 -0
- package/dist/cli/register-code-map.js +19 -0
- package/dist/cli/register-coordination.js +472 -0
- package/dist/cli/register-federation.js +258 -0
- package/dist/cli/register-lifecycle.js +436 -0
- package/dist/cli/register-memory-context.js +502 -0
- package/dist/cli/register-planning.js +167 -0
- package/dist/cli/register-review.js +149 -0
- package/dist/cli/shared.js +5 -0
- package/dist/cli.js +212 -2183
- package/dist/commands/dispatch-watch.js +25 -2
- package/dist/commands/harvest.js +31 -6
- package/dist/commands/mcp-catalog.js +1438 -0
- package/dist/commands/mcp-contract.js +33 -0
- package/dist/commands/mcp-presentation.js +27 -0
- package/dist/commands/mcp-read-handlers.js +72 -36
- package/dist/commands/mcp-write-admin.js +328 -0
- package/dist/commands/mcp-write-claims.js +864 -0
- package/dist/commands/mcp-write-coordination.js +1825 -0
- package/dist/commands/mcp-write-entities.js +620 -0
- package/dist/commands/mcp-write-memory.js +451 -0
- package/dist/commands/mcp-write-sequences.js +116 -0
- package/dist/commands/mcp-write-support.js +367 -0
- package/dist/commands/mcp.js +261 -5584
- package/dist/commands/update-handoff.js +28 -42
- package/dist/core/agent-capability.js +31 -14
- package/dist/core/agent-files.js +1 -1
- package/dist/core/coordination.js +5 -2
- package/dist/core/cross-project.js +35 -1
- package/dist/core/dispatcher.js +34 -20
- package/dist/core/entity-operations.js +335 -12
- package/dist/core/entity-registry.js +72 -9
- package/dist/core/execution.js +28 -4
- package/dist/core/facade-schema.js +18 -4
- package/dist/core/handoff-review.js +35 -0
- package/dist/core/protocol-tool-policy.js +113 -0
- package/dist/core/review-loop-close.js +115 -0
- package/dist/core/schema.js +14 -2
- package/dist/core/security-detectors.js +35 -6
- package/dist/core/security.js +32 -12
- package/dist/core/worktree.js +73 -5
- package/dist/facts.js +13 -11
- package/dist/facts.json +12 -10
- package/docs/PROTOCOL.md +7 -3
- package/docs/concepts/coordinator-runbook.md +3 -0
- package/docs/concepts/dispatch-lifecycle.md +4 -4
- package/docs/concepts/loop-engine.md +3 -1
- package/docs/concepts/troubleshooting.md +1 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/overview.md +1 -1
- package/docs/mcp-schema-changelog.md +137 -2
- package/docs/playbooks/orchestration.md +1 -1
- package/docs/product/entity-model-audit.md +3 -2
- package/docs/security.md +22 -1
- package/package.json +3 -1
package/dist/core/execution.js
CHANGED
|
@@ -143,17 +143,37 @@ export async function attemptExecution(invoke, options) {
|
|
|
143
143
|
const adapter = options.adapter ?? defaultExecutionAdapter;
|
|
144
144
|
// No invoke command available (IDE-only agents, etc.)
|
|
145
145
|
if (!invoke) {
|
|
146
|
-
return { execution_status: 'inbox_only' };
|
|
146
|
+
return { execution_status: 'inbox_only', execution_reason: 'no_invoke_command' };
|
|
147
147
|
}
|
|
148
148
|
const spawnCheck = adapter.canSpawn(options.agent);
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
149
|
+
// pln#626 Phase 1 — split the old `(!autoExecute || !spawnCheck.canSpawn)`
|
|
150
|
+
// branch, which collapsed two very different outcomes into one silent
|
|
151
|
+
// command_ready_manual (no reason, no failure_kind — it even discarded
|
|
152
|
+
// spawnCheck.reason). Callers could not tell "you didn't ask me to spawn"
|
|
153
|
+
// apart from "I tried and can't".
|
|
154
|
+
// (1) autoExecute explicitly disabled: a deliberate manual handoff, NOT a
|
|
155
|
+
// failure. Prepend BRAINCLAW_CLAIM_ID so manual copy-paste still routes.
|
|
156
|
+
if (!options.autoExecute) {
|
|
152
157
|
const manual = adapter.prepareManualCommand(invoke, options);
|
|
153
158
|
return {
|
|
154
159
|
execution_status: 'command_ready_manual',
|
|
155
160
|
command: manual.command,
|
|
156
161
|
shell: manual.shell,
|
|
162
|
+
execution_reason: 'auto_execute_disabled',
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
// (2) autoExecute requested but the agent cannot be spawned here: this IS a
|
|
166
|
+
// failure of the caller's intent. Surface spawnCheck.reason (previously
|
|
167
|
+
// dropped) instead of returning a bare manual command.
|
|
168
|
+
if (!spawnCheck.canSpawn) {
|
|
169
|
+
const manual = adapter.prepareManualCommand(invoke, options);
|
|
170
|
+
return {
|
|
171
|
+
execution_status: 'command_ready_manual',
|
|
172
|
+
command: manual.command,
|
|
173
|
+
shell: manual.shell,
|
|
174
|
+
error: spawnCheck.reason,
|
|
175
|
+
failure_kind: 'not_spawnable',
|
|
176
|
+
execution_reason: 'not_spawnable',
|
|
157
177
|
};
|
|
158
178
|
}
|
|
159
179
|
// pln#531 — isolation invariant: a spawned worker MUST run in its own
|
|
@@ -179,6 +199,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
179
199
|
shell: manual.shell,
|
|
180
200
|
error: 'Refusing to spawn without an isolated worktree: with no worktree the worker would run in the integration repo and edit the main tree. Fix worktree creation (see claim worktreeWarning) or run the command manually inside a worktree.',
|
|
181
201
|
failure_kind: 'spawn_no_worktree',
|
|
202
|
+
execution_reason: 'spawn_no_worktree',
|
|
182
203
|
};
|
|
183
204
|
}
|
|
184
205
|
// Capacity guard: skip if agent is at max concurrent tasks
|
|
@@ -201,6 +222,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
201
222
|
shell: manual.shell,
|
|
202
223
|
error: `Spawn skipped: ${instanceCheck.reason}. Use the command manually.`,
|
|
203
224
|
failure_kind: 'spawn_capacity',
|
|
225
|
+
execution_reason: 'spawn_capacity',
|
|
204
226
|
};
|
|
205
227
|
}
|
|
206
228
|
}
|
|
@@ -239,6 +261,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
239
261
|
shell: manual.shell,
|
|
240
262
|
error: `Spawn launched (pid ${result.pid}) but assignment ${options.assignmentId} did not acknowledge within ${handshakeTimeoutMs}ms`,
|
|
241
263
|
failure_kind: 'spawn_no_handshake',
|
|
264
|
+
execution_reason: 'spawn_no_handshake',
|
|
242
265
|
pid: result.pid,
|
|
243
266
|
};
|
|
244
267
|
}
|
|
@@ -280,6 +303,7 @@ export async function attemptExecution(invoke, options) {
|
|
|
280
303
|
shell: manual.shell,
|
|
281
304
|
error: `Spawn failed (${errorMsg}), falling back to manual execution`,
|
|
282
305
|
failure_kind: 'spawn_failed',
|
|
306
|
+
execution_reason: 'spawn_failed',
|
|
283
307
|
};
|
|
284
308
|
}
|
|
285
309
|
}
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
export const ExecutionStatusSchema = z.enum(['delivered_and_started', 'command_ready_manual', 'inbox_only']);
|
|
3
3
|
export const WorkIntentSchema = z.enum(['execute', 'consult', 'resume', 'review']);
|
|
4
|
-
// pln#
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// pln#626 — coordinate intents split into three honest contracts:
|
|
5
|
+
// • SPAWNING (assign / review / reroute, + multi-agent ideate): create a claim
|
|
6
|
+
// + worktree and, when autoExecute is on, spawn a worker on the brief.
|
|
7
|
+
// 'ideate' with targetAgents advances to critique and spawns one
|
|
8
|
+
// worktree-isolated critic worker per target (Phase 2, Option B); single-
|
|
9
|
+
// agent ideate just opens the loop for the champion to drive manually via
|
|
10
|
+
// bclaw_loop intent='turn'/'advance'.
|
|
11
|
+
// • INBOX-ONLY (consult): delivers the brief to the target inbox; autoExecute
|
|
12
|
+
// is a no-op here (never silently ignored).
|
|
13
|
+
// • READ-ONLY (summarize): reads a thread and returns a summary — no claim,
|
|
14
|
+
// no dispatch, no execution_status; autoExecute is irrelevant.
|
|
8
15
|
export const CoordinateIntentSchema = z.enum(['assign', 'consult', 'review', 'reroute', 'summarize', 'ideate']);
|
|
9
16
|
export const WorkRequestSchema = z.object({
|
|
10
17
|
intent: WorkIntentSchema,
|
|
@@ -172,6 +179,13 @@ export const FacadeResponseSchema = z.object({
|
|
|
172
179
|
session_id: z.string().optional(),
|
|
173
180
|
warnings: z.array(z.string()),
|
|
174
181
|
execution_status: ExecutionStatusSchema.optional(),
|
|
182
|
+
/**
|
|
183
|
+
* pln#626 Phase 1 — machine-readable reason accompanying execution_status
|
|
184
|
+
* when it is not `delivered_and_started`: auto_execute_disabled (manual
|
|
185
|
+
* handoff), not_spawnable, spawn_no_worktree/capacity/no_handshake/failed,
|
|
186
|
+
* or intent_inbox_only (consult/ideate). Absent when everything spawned.
|
|
187
|
+
*/
|
|
188
|
+
execution_reason: z.string().optional(),
|
|
175
189
|
/** pln#503 phase 3.3: present when execution_status === 'delivered_and_started'. */
|
|
176
190
|
verify_with: VerifyWithSchema.optional(),
|
|
177
191
|
/**
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { nowISO } from './ids.js';
|
|
2
|
+
/**
|
|
3
|
+
* The review sub-fields whose presence in a patch marks the review as
|
|
4
|
+
* "completed" and (re)stamps `reviewed_at`. Single source of truth for the
|
|
5
|
+
* completion rule — shared by the canonical grammar (`updateEntity(handoff)`)
|
|
6
|
+
* and the dispatcher/CLI path (`applyHandoffUpdates`) so the two write paths
|
|
7
|
+
* can never drift (pln#625 Phase 3, Codex review of #84).
|
|
8
|
+
*/
|
|
9
|
+
export const REVIEW_COMPLETION_FIELDS = [
|
|
10
|
+
'verdict',
|
|
11
|
+
'reviewed_by',
|
|
12
|
+
'summary',
|
|
13
|
+
'blocking_issues',
|
|
14
|
+
'suggestions',
|
|
15
|
+
];
|
|
16
|
+
/**
|
|
17
|
+
* Merge a partial review patch onto an existing review (shallow — PATCH
|
|
18
|
+
* semantics: provided fields overwrite, others survive) and stamp
|
|
19
|
+
* `reviewed_at` when the patch introduces a completion field.
|
|
20
|
+
*
|
|
21
|
+
* A caller that explicitly provides `reviewed_at` (e.g. a federation import
|
|
22
|
+
* replaying a prior review) keeps its value; otherwise a completing patch
|
|
23
|
+
* stamps `nowISO()`. The flat-option callers (applyHandoffUpdates) never carry
|
|
24
|
+
* `reviewed_at`, so for them this is an unconditional restamp-on-completion —
|
|
25
|
+
* identical to the pre-extraction behaviour.
|
|
26
|
+
*/
|
|
27
|
+
export function mergeHandoffReview(existing, patch) {
|
|
28
|
+
const merged = { ...(existing ?? {}), ...patch };
|
|
29
|
+
const completed = REVIEW_COMPLETION_FIELDS.some((field) => patch[field] !== undefined);
|
|
30
|
+
if (completed && patch.reviewed_at === undefined) {
|
|
31
|
+
merged.reviewed_at = nowISO();
|
|
32
|
+
}
|
|
33
|
+
return merged;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=handoff-review.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static protocol tool-policy name lists (core-owned, pln#622 PR1).
|
|
3
|
+
*
|
|
4
|
+
* Ces listes statiques REMPLACENT les dérivations depuis les annotations
|
|
5
|
+
* d'ALL_TOOLS ; la cohérence avec le catalogue est garantie par
|
|
6
|
+
* tests/unit/protocol-tool-policy.test.ts.
|
|
7
|
+
*
|
|
8
|
+
* (These STATIC lists REPLACE the derivations from ALL_TOOLS annotations for
|
|
9
|
+
* core consumers: core/ must not import the commands/ MCP layer, so the tool
|
|
10
|
+
* names are materialised here instead of being derived from the catalog at
|
|
11
|
+
* import time. The catalog — src/commands/mcp-catalog.ts — KEEPS deriving its
|
|
12
|
+
* own copies from tool annotations; bidirectional set equality between these
|
|
13
|
+
* static lists and the catalog derivations is enforced by
|
|
14
|
+
* tests/unit/protocol-tool-policy.test.ts.)
|
|
15
|
+
*
|
|
16
|
+
* Zero imports by design — this module must stay a pure leaf.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Tools safe for headless auto-approval (annotation `headlessApproval: 'auto'`
|
|
22
|
+
* in the catalog). Consumed by agent-files writers (Cline autoApprove, Roo
|
|
23
|
+
* alwaysAllow, Codex approval_mode). Order mirrors catalog declaration order
|
|
24
|
+
* so generated agent config files are byte-identical to the derived era.
|
|
25
|
+
*/
|
|
26
|
+
export const MCP_HEADLESS_AUTO_TOOL_NAMES = [
|
|
27
|
+
'bclaw_context',
|
|
28
|
+
'bclaw_search',
|
|
29
|
+
'bclaw_estimation_report',
|
|
30
|
+
'bclaw_list_sequences',
|
|
31
|
+
'bclaw_assignment_events',
|
|
32
|
+
'bclaw_list_agents',
|
|
33
|
+
'bclaw_list_instructions',
|
|
34
|
+
'bclaw_get_capabilities',
|
|
35
|
+
'bclaw_list_tools',
|
|
36
|
+
'bclaw_search_tools',
|
|
37
|
+
'bclaw_doctor',
|
|
38
|
+
'bclaw_history',
|
|
39
|
+
'bclaw_audit',
|
|
40
|
+
'bclaw_get_discovery',
|
|
41
|
+
'bclaw_conflict_check',
|
|
42
|
+
'bclaw_who',
|
|
43
|
+
'bclaw_check_policy',
|
|
44
|
+
'bclaw_check_security',
|
|
45
|
+
'bclaw_read_inbox',
|
|
46
|
+
'bclaw_get_thread',
|
|
47
|
+
'bclaw_dispatch_status',
|
|
48
|
+
'bclaw_code_status',
|
|
49
|
+
'bclaw_code_find',
|
|
50
|
+
'bclaw_code_brief',
|
|
51
|
+
'bclaw_send_message',
|
|
52
|
+
'bclaw_ack_message',
|
|
53
|
+
'bclaw_write_note',
|
|
54
|
+
'bclaw_quick_capture',
|
|
55
|
+
'bclaw_claim',
|
|
56
|
+
'bclaw_release_claim',
|
|
57
|
+
'bclaw_session_start',
|
|
58
|
+
'bclaw_session_end',
|
|
59
|
+
'bclaw_add_step',
|
|
60
|
+
'bclaw_complete_step',
|
|
61
|
+
'bclaw_update_step',
|
|
62
|
+
'bclaw_update_handoff',
|
|
63
|
+
'bclaw_work',
|
|
64
|
+
'bclaw_coordinate',
|
|
65
|
+
'bclaw_loop',
|
|
66
|
+
'bclaw_assignment_update',
|
|
67
|
+
'bclaw_assignment_action',
|
|
68
|
+
'bclaw_harvest_candidates',
|
|
69
|
+
'bclaw_find',
|
|
70
|
+
'bclaw_get',
|
|
71
|
+
];
|
|
72
|
+
/**
|
|
73
|
+
* Narrow "canonical grammar" tool set — the read-side facade entries
|
|
74
|
+
* (session + context) plus the five memory verbs. Consumed by writers
|
|
75
|
+
* (e.g. Hermes' tools.include) that want a minimal advertised surface.
|
|
76
|
+
*/
|
|
77
|
+
export const MCP_CANONICAL_GRAMMAR_TOOL_NAMES = [
|
|
78
|
+
'bclaw_context',
|
|
79
|
+
'bclaw_work',
|
|
80
|
+
'bclaw_find',
|
|
81
|
+
'bclaw_get',
|
|
82
|
+
'bclaw_create',
|
|
83
|
+
'bclaw_update',
|
|
84
|
+
'bclaw_transition',
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Tools removed from the MCP surface at the v1.0 cut (Phase 3 slice 3i).
|
|
88
|
+
* Hidden from every `tools/list` response; direct `tools/call` still works
|
|
89
|
+
* as a migration escape hatch.
|
|
90
|
+
*/
|
|
91
|
+
export const REMOVED_IN_V1_TOOLS = new Set([
|
|
92
|
+
'bclaw_list_plans',
|
|
93
|
+
'bclaw_list_candidates',
|
|
94
|
+
'bclaw_list_claims',
|
|
95
|
+
'bclaw_list_actions',
|
|
96
|
+
'bclaw_list_assignments',
|
|
97
|
+
'bclaw_list_runs',
|
|
98
|
+
'bclaw_list_agents', // pln#625 — retired for bclaw_find(entity='agent')
|
|
99
|
+
'bclaw_read_handoff',
|
|
100
|
+
'bclaw_create_plan',
|
|
101
|
+
'bclaw_update_plan',
|
|
102
|
+
'bclaw_create_candidate',
|
|
103
|
+
'bclaw_accept',
|
|
104
|
+
'bclaw_reject',
|
|
105
|
+
'bclaw_get_execution_context',
|
|
106
|
+
'bclaw_get_agent_board',
|
|
107
|
+
'bclaw_get_agent_board_summary',
|
|
108
|
+
'bclaw_dispatch_analysis',
|
|
109
|
+
'bclaw_dispatch_review',
|
|
110
|
+
'bclaw_update_handoff',
|
|
111
|
+
'bclaw_get_context',
|
|
112
|
+
]);
|
|
113
|
+
//# sourceMappingURL=protocol-tool-policy.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { getLoop } from './loops/store.js';
|
|
2
|
+
import { complete_turn, advance } from './loops/verbs.js';
|
|
3
|
+
import { withLoopLock } from './loops/lock.js';
|
|
4
|
+
/** review-loop:lop_xxx → the loop id (mirrors assignment-reconciler.ts). */
|
|
5
|
+
const REVIEW_LOOP_SCOPE_RE = /^review-loop:(lop_[0-9a-z]+)/;
|
|
6
|
+
const LOOP_TERMINAL = new Set(['completed', 'cancelled', 'blocked']);
|
|
7
|
+
/** Mirrors verbs.ts:isVerdictAccepted — reviewer_green fires only on a `verdict`
|
|
8
|
+
* artifact whose body starts with "accepted". */
|
|
9
|
+
function isAcceptedVerdict(artifact) {
|
|
10
|
+
if (artifact.type !== 'verdict')
|
|
11
|
+
return false;
|
|
12
|
+
return /^accepted(?:\b|[:\s])/.test((artifact.body ?? '').trim().toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the reviewer slot to complete. STRICT binding first: the active slot
|
|
16
|
+
* whose assignment_id matches this lane's assignment (the #87 coordinate fix
|
|
17
|
+
* stamps it), so symmetric loops target the right reviewer. If OTHER slots are
|
|
18
|
+
* bound but none matches ours, refuse (never complete someone else's slot).
|
|
19
|
+
* Falls back to a single active reviewer / agent match only for legacy unbound
|
|
20
|
+
* slots. Returns undefined when no active reviewer slot is ours — the caller
|
|
21
|
+
* then checks the resume path.
|
|
22
|
+
*/
|
|
23
|
+
function resolveReviewerSlot(loop, assignment) {
|
|
24
|
+
const active = loop.slots.filter((s) => s.role === 'reviewer' && s.status !== 'done' && s.status !== 'cancelled' && s.status !== 'failed');
|
|
25
|
+
if (active.length === 0)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (assignment.id) {
|
|
28
|
+
const bound = active.find((s) => s.assignment_id === assignment.id);
|
|
29
|
+
if (bound)
|
|
30
|
+
return bound;
|
|
31
|
+
// Some active slots are bound to OTHER assignments — do not guess/steal.
|
|
32
|
+
if (active.some((s) => s.assignment_id !== undefined))
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
// Legacy unbound slots: single reviewer, else disambiguate by agent.
|
|
36
|
+
if (active.length === 1)
|
|
37
|
+
return active[0];
|
|
38
|
+
const byAgent = assignment.agent ? active.find((s) => s.agent === assignment.agent) : undefined;
|
|
39
|
+
return byAgent ?? active[0];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Map a harvested review lane onto its loop and close/advance it.
|
|
43
|
+
*
|
|
44
|
+
* Fires ONLY when the assignment scope is a review-loop (`review-loop:lop_…`)
|
|
45
|
+
* AND the lane carries a `review_verdict` — otherwise returns undefined and the
|
|
46
|
+
* caller (harvest) proceeds unchanged. Idempotent, convergent, and defensive:
|
|
47
|
+
* a terminal loop is a no-op, a partial prior pass is resumed, and any
|
|
48
|
+
* loop-verb / lock error is swallowed into a `noop` result so a loop-close
|
|
49
|
+
* failure never breaks harvest (mirrors convergeSlotAssignmentsForClosedLoop).
|
|
50
|
+
*/
|
|
51
|
+
export function closeReviewLoopFromLaneResult(assignment, lane, actor, cwd) {
|
|
52
|
+
const scopeMatch = assignment.scope?.match(REVIEW_LOOP_SCOPE_RE);
|
|
53
|
+
if (!scopeMatch)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (!lane.review_verdict)
|
|
56
|
+
return undefined;
|
|
57
|
+
const loopId = scopeMatch[1];
|
|
58
|
+
const verdict = lane.review_verdict;
|
|
59
|
+
const noop = (reason, loop_status) => ({
|
|
60
|
+
loop_id: loopId, verdict, action: 'noop', reason, loop_status,
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
// Lock the loop so the compound complete_turn + advance can't interleave
|
|
64
|
+
// with a concurrent harvest (BLOCKING 3). All state is re-read inside.
|
|
65
|
+
return withLoopLock({
|
|
66
|
+
cwd,
|
|
67
|
+
intent: 'review-harvest-close',
|
|
68
|
+
agentId: actor,
|
|
69
|
+
scope: { kind: 'loop', loopId },
|
|
70
|
+
work: () => {
|
|
71
|
+
const loop = getLoop(loopId, cwd);
|
|
72
|
+
if (!loop)
|
|
73
|
+
return noop('loop not found');
|
|
74
|
+
if (LOOP_TERMINAL.has(loop.status))
|
|
75
|
+
return noop(`loop already ${loop.status}`, loop.status);
|
|
76
|
+
const slot = resolveReviewerSlot(loop, assignment);
|
|
77
|
+
const acceptedVerdictExists = loop.artifacts.some(isAcceptedVerdict);
|
|
78
|
+
if (slot) {
|
|
79
|
+
// Active reviewer slot → record the verdict on it. isVerdictAccepted
|
|
80
|
+
// fires reviewer_green ONLY on an "accepted…" body, so approve MUST
|
|
81
|
+
// start with "accepted" and request_changes must NOT.
|
|
82
|
+
const summary = (lane.review_summary ?? '').trim();
|
|
83
|
+
const body = verdict === 'approve'
|
|
84
|
+
? `accepted${summary ? `: ${summary}` : ''}`
|
|
85
|
+
: `changes-requested${summary ? `: ${summary}` : ''}`;
|
|
86
|
+
complete_turn({ id: loopId, slot_id: slot.slot_id, actor, artifact: { phase: loop.current_phase, type: 'verdict', body } }, cwd);
|
|
87
|
+
}
|
|
88
|
+
else if (!(verdict === 'approve' && acceptedVerdictExists)) {
|
|
89
|
+
// No reviewer slot is ours to complete. Resume ONLY the approve→close
|
|
90
|
+
// case: a prior pass recorded an accepted verdict but died before
|
|
91
|
+
// advancing. For request_changes (or no accepted verdict), the single
|
|
92
|
+
// advance already happened on the first pass — do not re-advance.
|
|
93
|
+
return noop('already processed (no active reviewer slot to (re)advance)', loop.status);
|
|
94
|
+
}
|
|
95
|
+
// Advance: closes on reviewer_green (approve), else moves one phase.
|
|
96
|
+
// Convergent — safe whether we just recorded the verdict or are resuming
|
|
97
|
+
// an interrupted approve.
|
|
98
|
+
const advanced = advance({ id: loopId, actor }, cwd);
|
|
99
|
+
return {
|
|
100
|
+
loop_id: loopId,
|
|
101
|
+
verdict,
|
|
102
|
+
action: advanced.auto_closed ? 'closed' : 'advanced',
|
|
103
|
+
reason: advanced.auto_closed
|
|
104
|
+
? `reviewer_green → loop ${advanced.loop.status}`
|
|
105
|
+
: `verdict recorded → advanced to phase "${advanced.loop.current_phase}" (awaiting fix cycle — PR2)`,
|
|
106
|
+
loop_status: advanced.loop.status,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return noop(`loop close error (harvest not blocked): ${err instanceof Error ? err.message : String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=review-loop-close.js.map
|
package/dist/core/schema.js
CHANGED
|
@@ -960,8 +960,9 @@ export const RuntimeEventTypeSchema = z.enum([
|
|
|
960
960
|
/**
|
|
961
961
|
* pln#526 — LANE-RESULT convention. A dispatched worker writes a single
|
|
962
962
|
* `LANE-RESULT.json` at its worktree root as its final step (a fallback that
|
|
963
|
-
* works even when bclaw_assignment_update / MCP is unavailable
|
|
964
|
-
*
|
|
963
|
+
* works even when bclaw_assignment_update / MCP is unavailable in the worker's
|
|
964
|
+
* environment, e.g. a genuinely MCP-less agent). The coordinator ingests it with
|
|
965
|
+
* `brainclaw harvest <assignment_id>`.
|
|
965
966
|
*/
|
|
966
967
|
export const LaneResultSchema = z.object({
|
|
967
968
|
assignment_id: z.string(),
|
|
@@ -973,6 +974,17 @@ export const LaneResultSchema = z.object({
|
|
|
973
974
|
files_changed: z.array(z.string()).optional(),
|
|
974
975
|
/** Free-form notes (blockers, follow-ups). */
|
|
975
976
|
notes: z.string().optional(),
|
|
977
|
+
/**
|
|
978
|
+
* pln#628 Focus 4B — review-loop verdict. A worker running a review-loop turn
|
|
979
|
+
* sets this to signal whether the change is good to merge (`approve`) or needs
|
|
980
|
+
* fixes (`request_changes`). The coordinator's harvest maps it onto a loop
|
|
981
|
+
* `verdict` artifact so `reviewer_green` can fire and the loop auto-closes
|
|
982
|
+
* without a human driving complete_turn/advance by hand. Absent on
|
|
983
|
+
* non-review lanes — harvest simply skips the loop-close callback then.
|
|
984
|
+
*/
|
|
985
|
+
review_verdict: z.enum(['approve', 'request_changes']).optional(),
|
|
986
|
+
/** One-line rationale accompanying review_verdict (shown in the verdict artifact). */
|
|
987
|
+
review_summary: z.string().optional(),
|
|
976
988
|
});
|
|
977
989
|
export const RuntimeEventSchema = z.object({
|
|
978
990
|
id: z.string(),
|
|
@@ -52,7 +52,7 @@ export function runStructuralDetectors(text, disabled) {
|
|
|
52
52
|
out.push({
|
|
53
53
|
detectorId: d.id,
|
|
54
54
|
label: d.label,
|
|
55
|
-
excerpt:
|
|
55
|
+
excerpt: maskSecret(m[0]),
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
}
|
|
@@ -113,13 +113,42 @@ export function runEntropyDetector(text, options = {}) {
|
|
|
113
113
|
const context = text.slice(start, end);
|
|
114
114
|
if (!SECRET_KEYWORD_CONTEXT.test(context))
|
|
115
115
|
continue;
|
|
116
|
-
out.push({ excerpt:
|
|
116
|
+
out.push({ excerpt: maskSecret(token), entropy: Math.round(entropy * 100) / 100 });
|
|
117
117
|
}
|
|
118
118
|
return out;
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Irreversibly mask a matched secret for display.
|
|
122
|
+
*
|
|
123
|
+
* The previous behavior truncated the match to ~48 chars, which returned
|
|
124
|
+
* short secrets (GitHub PATs are 40 chars, AWS key IDs are 20) verbatim in
|
|
125
|
+
* warning messages and logs. Masking keeps just enough to identify the
|
|
126
|
+
* token family without ever exposing recoverable material. Splitting is
|
|
127
|
+
* done per Unicode code point, so surrogate pairs are never cut in half.
|
|
128
|
+
*
|
|
129
|
+
* - matches of 2 code points or fewer: `***` alone (exposing even the
|
|
130
|
+
* first code point would reveal most or all of the value);
|
|
131
|
+
* - matches of 3–8 code points: first code point + `***`;
|
|
132
|
+
* - longer matches: at most ⌊length/3⌋ code points are exposed, capped
|
|
133
|
+
* at 6, split prefix-heavy (up to 4 leading — enough to identify
|
|
134
|
+
* `ghp_`, `AKIA`, `sk_l` — the remainder trailing) around a fixed
|
|
135
|
+
* `…***…` marker.
|
|
136
|
+
*
|
|
137
|
+
* The exposure budget grows smoothly with the match length (no cliff at
|
|
138
|
+
* the short/long boundary) and never reveals more than a third of a
|
|
139
|
+
* match longer than 8 code points.
|
|
140
|
+
*/
|
|
141
|
+
export function maskSecret(s) {
|
|
142
|
+
const cp = Array.from(s);
|
|
143
|
+
if (cp.length === 0)
|
|
144
|
+
return '';
|
|
145
|
+
if (cp.length <= 2)
|
|
146
|
+
return '***';
|
|
147
|
+
if (cp.length <= 8)
|
|
148
|
+
return cp[0] + '***';
|
|
149
|
+
const exposed = Math.min(6, Math.floor(cp.length / 3));
|
|
150
|
+
const lead = Math.min(4, exposed - 1);
|
|
151
|
+
const trail = exposed - lead;
|
|
152
|
+
return cp.slice(0, lead).join('') + '…***…' + cp.slice(cp.length - trail).join('');
|
|
124
153
|
}
|
|
125
154
|
//# sourceMappingURL=security-detectors.js.map
|
package/dist/core/security.js
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
|
-
import { runEntropyDetector, runStructuralDetectors } from './security-detectors.js';
|
|
1
|
+
import { maskSecret, runEntropyDetector, runStructuralDetectors } from './security-detectors.js';
|
|
2
2
|
/**
|
|
3
|
-
* Scan a text string for sensitive content.
|
|
4
|
-
*
|
|
5
|
-
* (the legacy MVP behavior).
|
|
6
|
-
* 2. Structural detectors — exact token shapes for GitHub PATs, AWS
|
|
7
|
-
* access keys, JWTs, etc. High precision; on by default.
|
|
8
|
-
* 3. Entropy detector — flags high-entropy token-like substrings near
|
|
9
|
-
* a sensitive keyword. Tunable, on by default.
|
|
3
|
+
* Scan a text string for sensitive content. Four independent signal layers
|
|
4
|
+
* run, each with its own enable-gate (S4 semantics, pln#623):
|
|
10
5
|
*
|
|
11
|
-
*
|
|
6
|
+
* 1. Redaction patterns — user-configured regexes from
|
|
7
|
+
* `config.redaction.patterns`. Gate: `config.redaction.enabled` (whole
|
|
8
|
+
* scan short-circuits off when false). The legacy MVP behavior.
|
|
9
|
+
* 2. Structural detectors — exact token shapes for GitHub PATs, AWS access
|
|
10
|
+
* keys, JWTs, etc. High precision. Gate: `security.token_detection.enabled`
|
|
11
|
+
* (default on); individual detectors via `token_detection.detectors[id]`.
|
|
12
|
+
* 3. Entropy detector — high-Shannon-entropy token-like substrings near a
|
|
13
|
+
* secret keyword. Gate: `security.token_detection.entropy.enabled` (nested
|
|
14
|
+
* under the token_detection gate; default on).
|
|
15
|
+
* 4. Sensitive paths — literal mentions of `config.sensitive_paths` entries
|
|
16
|
+
* (`.env`, `secrets/`, …). Gate: `security.block_sensitive_paths` (default
|
|
17
|
+
* on).
|
|
18
|
+
*
|
|
19
|
+
* LEVEL (uniform across ALL four layers): a match surfaces as `warn`, and
|
|
20
|
+
* escalates to `block` when `security.strict_redaction` is true (mode: strict).
|
|
21
|
+
* Strict mode blocks every signal uniformly — there is no per-layer level
|
|
22
|
+
* override. Detected/redacted excerpts in messages are always irreversibly
|
|
23
|
+
* masked (see maskSecret); the redaction pattern itself is referenced by index
|
|
24
|
+
* and masked, never echoed.
|
|
12
25
|
*/
|
|
13
26
|
export function scanText(text, config) {
|
|
14
27
|
const warnings = [];
|
|
@@ -16,7 +29,7 @@ export function scanText(text, config) {
|
|
|
16
29
|
return warnings;
|
|
17
30
|
const isStrict = config.security?.strict_redaction ?? false;
|
|
18
31
|
const level = isStrict ? 'block' : 'warn';
|
|
19
|
-
for (const pattern of config.redaction.patterns) {
|
|
32
|
+
for (const [i, pattern] of config.redaction.patterns.entries()) {
|
|
20
33
|
try {
|
|
21
34
|
// Strip Python-style inline flags (?i) etc. since we always use 'i' flag
|
|
22
35
|
const cleanPattern = pattern.replace(/^\(\?[gimsuy]+\)/g, '');
|
|
@@ -24,7 +37,9 @@ export function scanText(text, config) {
|
|
|
24
37
|
if (re.test(text)) {
|
|
25
38
|
warnings.push({
|
|
26
39
|
level,
|
|
27
|
-
|
|
40
|
+
// The configured pattern may itself be a literal secret value, so
|
|
41
|
+
// it is referenced by index and masked, never echoed verbatim.
|
|
42
|
+
message: `Possible sensitive content matching redaction pattern #${i} ('${maskSecret(pattern)}') found in text`,
|
|
28
43
|
});
|
|
29
44
|
}
|
|
30
45
|
}
|
|
@@ -61,7 +76,12 @@ export function scanText(text, config) {
|
|
|
61
76
|
for (const sp of config.sensitive_paths) {
|
|
62
77
|
if (text.includes(sp)) {
|
|
63
78
|
warnings.push({
|
|
64
|
-
|
|
79
|
+
// S3 (pln#623): the level is config-derived, not hardcoded. Like the
|
|
80
|
+
// three detector layers above, a sensitive-path match surfaces as a
|
|
81
|
+
// `warn` normally and escalates to `block` under strict_redaction —
|
|
82
|
+
// strict mode blocks EVERY signal, uniformly. `block_sensitive_paths`
|
|
83
|
+
// remains the enable-gate for this layer (default on).
|
|
84
|
+
level,
|
|
65
85
|
message: `Sensitive path '${sp}' mentioned in text`,
|
|
66
86
|
});
|
|
67
87
|
}
|
package/dist/core/worktree.js
CHANGED
|
@@ -369,14 +369,42 @@ export function commitWorktreeOnBehalf(worktreePath, message, options = {}) {
|
|
|
369
369
|
return { committed: false, files_changed: [], reason: 'worktree clean — nothing to commit' };
|
|
370
370
|
}
|
|
371
371
|
// Stage everything, then UNSTAGE the transient files that must never land on
|
|
372
|
-
// the lane branch: the worker's own `LANE-RESULT.json` report
|
|
373
|
-
// `.brainclaw/` coordination state
|
|
374
|
-
// (and master, on merge) with
|
|
372
|
+
// the lane branch: the worker's own `LANE-RESULT.json` report, any
|
|
373
|
+
// `.brainclaw/` coordination state, and the `.brainclaw-worktree.json` marker.
|
|
374
|
+
// Committing those would pollute the branch (and master, on merge) with
|
|
375
|
+
// non-deliverable artefacts — a field report (Codex on macOS) caught them
|
|
376
|
+
// landing in a lane commit (trp_01a2ba2a). `.brainclaw-worktree.json` sits at
|
|
377
|
+
// the worktree ROOT (NOT inside `.brainclaw/`), so the `.brainclaw` pathspec
|
|
378
|
+
// does not cover it — it needs its own entry. These are ALWAYS transient, so
|
|
379
|
+
// the unstage is unconditional.
|
|
375
380
|
const add = runGit(['add', '-A'], worktreePath);
|
|
376
381
|
if (!add.ok) {
|
|
377
382
|
return { committed: false, files_changed: [], reason: `git add failed: ${add.stderr.trim()}` };
|
|
378
383
|
}
|
|
379
|
-
runGit([
|
|
384
|
+
runGit([
|
|
385
|
+
'reset', '-q', '--',
|
|
386
|
+
'LANE-RESULT.json',
|
|
387
|
+
'.brainclaw',
|
|
388
|
+
'.brainclaw-worktree.json',
|
|
389
|
+
'.brainclaw-heartbeat-*',
|
|
390
|
+
], worktreePath);
|
|
391
|
+
// node_modules needs a TRACKED-AWARE exclusion (Codex review of #88, BLOCKING).
|
|
392
|
+
// Unstage the links/dirs brainclaw provisions — but a project that VENDORS
|
|
393
|
+
// node_modules tracks those files, and a worker's change to a TRACKED
|
|
394
|
+
// node_modules file is a REAL deliverable; dropping it would silently omit
|
|
395
|
+
// work. Strategy: unstage every node_modules path, then RE-ADD only the ones
|
|
396
|
+
// already tracked at HEAD and modified/deleted (never the fresh provisioned
|
|
397
|
+
// link/dir, which is `A` vs HEAD). The component-bounded pathspecs never match
|
|
398
|
+
// a similarly-named deliverable such as `src/node_modules_helper.ts` — the
|
|
399
|
+
// plain `node_modules` is root-leading-dir only, the `:(glob)` forms match the
|
|
400
|
+
// `node_modules` path component exactly (nested link entry + nested contents).
|
|
401
|
+
const NODE_MODULES_SPECS = ['node_modules', ':(glob)**/node_modules', ':(glob)**/node_modules/**'];
|
|
402
|
+
runGit(['reset', '-q', '--', ...NODE_MODULES_SPECS], worktreePath);
|
|
403
|
+
const trackedNm = runGit(['diff', '--name-only', '--diff-filter=MD', 'HEAD', '--', ...NODE_MODULES_SPECS], worktreePath);
|
|
404
|
+
const keepNm = trackedNm.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
405
|
+
if (keepNm.length > 0) {
|
|
406
|
+
runGit(['add', '--', ...keepNm], worktreePath);
|
|
407
|
+
}
|
|
380
408
|
// The files actually staged for this commit (post-exclusion) — also the
|
|
381
409
|
// truthful files_changed report.
|
|
382
410
|
const staged = runGit(['diff', '--cached', '--name-only'], worktreePath);
|
|
@@ -385,7 +413,7 @@ export function commitWorktreeOnBehalf(worktreePath, message, options = {}) {
|
|
|
385
413
|
// Only transient files changed — nothing deliverable to commit. Restore the
|
|
386
414
|
// index so the worktree is left exactly as the worker left it.
|
|
387
415
|
runGit(['reset', '-q'], worktreePath);
|
|
388
|
-
return { committed: false, files_changed: [], reason: 'no committable changes (only transient LANE-RESULT.json / .brainclaw)' };
|
|
416
|
+
return { committed: false, files_changed: [], reason: 'no committable changes (only transient LANE-RESULT.json / .brainclaw / node_modules links)' };
|
|
389
417
|
}
|
|
390
418
|
const authorName = options.authorName ?? 'brainclaw (on behalf)';
|
|
391
419
|
const authorEmail = options.authorEmail ?? 'brainclaw@on-behalf.local';
|
|
@@ -503,6 +531,30 @@ export function findWorktreePathForBranch(worktrees, branchName) {
|
|
|
503
531
|
*
|
|
504
532
|
* Returns the absolute path to the newly created worktree.
|
|
505
533
|
*/
|
|
534
|
+
/**
|
|
535
|
+
* Whether the project looks like a Next.js app — a `next` dependency in
|
|
536
|
+
* package.json or a `next.config.*` at the root. Used to warn that the
|
|
537
|
+
* out-of-root `node_modules` symlink brainclaw provisions is rejected by
|
|
538
|
+
* `next dev` / Turbopack (trp_37b05a15), even though tsc / vitest / build accept
|
|
539
|
+
* it. Best-effort + defensive: any read/parse error → false (never blocks
|
|
540
|
+
* worktree creation over a heuristic).
|
|
541
|
+
*/
|
|
542
|
+
export function projectUsesNextjs(projectRoot) {
|
|
543
|
+
try {
|
|
544
|
+
for (const cfg of ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs']) {
|
|
545
|
+
if (fs.existsSync(path.join(projectRoot, cfg)))
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
549
|
+
if (!fs.existsSync(pkgPath))
|
|
550
|
+
return false;
|
|
551
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
552
|
+
return Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
506
558
|
export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
507
559
|
// pln#614: resolve the true git toplevel first, so an in-tree project (project
|
|
508
560
|
// dir ≠ git root) creates its worktree from the real repo root — `git worktree
|
|
@@ -631,6 +683,22 @@ export function createWorktree(mainWorktreePath, branchName, options = {}) {
|
|
|
631
683
|
for (const entry of sharedPaths) {
|
|
632
684
|
trySymlinkSharedPath(entry);
|
|
633
685
|
}
|
|
686
|
+
// trp_37b05a15 (field report, Next.js 16 / Turbopack) — the node_modules link
|
|
687
|
+
// brainclaw provisions is an out-of-worktree-root symlink to the main repo.
|
|
688
|
+
// tsc / vitest / build follow it fine, but `next dev` (Turbopack) PANICS on a
|
|
689
|
+
// node_modules link that points outside the worktree root. Surface a warning
|
|
690
|
+
// (not a failure — the link is still correct for build/typecheck) so a worker
|
|
691
|
+
// or operator doing dev-server work knows the workaround up front. A full
|
|
692
|
+
// Turbopack-compatible per-worktree dependency mode is a planned follow-up.
|
|
693
|
+
const linkedNodeModules = sharedPaths.some((p) => p === 'node_modules' || p.endsWith('/node_modules'));
|
|
694
|
+
if (linkedNodeModules && projectUsesNextjs(mainWorktreePath)) {
|
|
695
|
+
const msg = 'Next.js detected: node_modules is linked as an out-of-worktree-root symlink, which '
|
|
696
|
+
+ '`next dev` / Turbopack rejects (it requires node_modules under the worktree root). '
|
|
697
|
+
+ 'tsc / vitest / build are unaffected. For dev-server work in this worktree, run '
|
|
698
|
+
+ '`npm install` here (optionally with BRAINCLAW_NO_LINK_DEPS=1), or smoke-test on the merged branch.';
|
|
699
|
+
symlinkWarnings.push(msg);
|
|
700
|
+
logger.warn(`[worktree] ${msg}`);
|
|
701
|
+
}
|
|
634
702
|
// NOTE: .brainclaw/ is intentionally NOT symlinked.
|
|
635
703
|
// Symlinking .brainclaw/ causes hooks and session_start to trigger on the
|
|
636
704
|
// shared store, creating session conflicts and potentially blocking agents
|