brainclaw 1.7.5 → 1.8.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 +16 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +1 -0
- package/dist/commands/harvest.js +223 -0
- package/dist/commands/mcp.js +27 -0
- package/dist/core/dispatch-status.js +30 -0
- package/dist/core/dispatcher.js +1 -0
- package/dist/core/entity-registry.js +2 -2
- package/dist/core/execution.js +25 -0
- package/dist/core/facade-schema.js +10 -0
- package/dist/core/schema.js +11 -0
- package/dist/core/spawn-check.js +110 -4
- package/dist/core/staleness.js +37 -0
- package/dist/core/worktree.js +98 -0
- package/dist/facts.js +3 -3
- package/dist/facts.json +2 -2
- package/docs/concepts/dispatch-lifecycle.md +17 -0
- package/docs/mcp-schema-changelog.md +24 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -345,6 +345,22 @@ npm run test:coverage # with coverage report
|
|
|
345
345
|
|
|
346
346
|
For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
|
|
347
347
|
|
|
348
|
+
### v1.8.0
|
|
349
|
+
|
|
350
|
+
- **Multi-agent dispatch convergence — "worktree-as-contract"** (from a real
|
|
351
|
+
cross-project field session where a sandboxed worker could neither commit nor
|
|
352
|
+
reach MCP). The worker's contract shrinks to "edit files in this worktree +
|
|
353
|
+
drop `LANE-RESULT.json`": `brainclaw harvest --integrate` commits the worktree
|
|
354
|
+
diff on behalf of a worker that can't self-commit (hard-guarded to the linked
|
|
355
|
+
worktree, never the main repo), then completes the assignment and releases the
|
|
356
|
+
claim with plan cascade. A `LANE-RESULT.json` is now the #1 verdict signal in
|
|
357
|
+
`bclaw_dispatch_status` (worker FINISHED, even without self-update); the
|
|
358
|
+
dispatcher refuses to spawn without an isolated worktree; `open_loop` reviews
|
|
359
|
+
pre-flight each reviewer agent with a trivial validation spawn (clear
|
|
360
|
+
boot-failure reason instead of a generic loop timeout); and decisions/traps
|
|
361
|
+
gain `verified_at`/`verify_cmd` so perishable facts can be flagged stale.
|
|
362
|
+
Additive + opt-in throughout. (pln#530, pln#531, pln#532, pln#533, pln#534, trp#468)
|
|
363
|
+
|
|
348
364
|
### v1.7.5
|
|
349
365
|
|
|
350
366
|
- **Security patch (recommended upgrade)** — fixes a git command-injection / RCE
|
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -1014,6 +1014,7 @@ program
|
|
|
1014
1014
|
.command('harvest [assignment_id]')
|
|
1015
1015
|
.description('Harvest a worker LANE-RESULT.json from its worktree into the project (pass an assignment id, or --all)')
|
|
1016
1016
|
.option('--all', 'Harvest every lane result found across worktrees')
|
|
1017
|
+
.option('--integrate', 'Worktree-as-contract (pln#534): commit the worktree diff on behalf of a sandboxed worker, lifecycle the assignment, and release the claim')
|
|
1017
1018
|
.option('--dry-run', 'Preview without writing events/markers')
|
|
1018
1019
|
.option('--worktree <path>', 'Explicit worktree path to scan (repeatable)', collect, [])
|
|
1019
1020
|
.option('--json', 'Output as JSON')
|
package/dist/commands/harvest.js
CHANGED
|
@@ -18,6 +18,10 @@ import { CandidateSchema, LaneResultSchema } from '../core/schema.js';
|
|
|
18
18
|
import { listCandidates, listArchivedCandidates, saveCandidate } from '../core/candidates.js';
|
|
19
19
|
import { createRuntimeEvent } from '../core/events.js';
|
|
20
20
|
import { memoryExists } from '../core/io.js';
|
|
21
|
+
import { loadAssignment, transitionAssignment } from '../core/assignments.js';
|
|
22
|
+
import { releaseClaimWithCascade } from '../core/claims.js';
|
|
23
|
+
import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
|
|
24
|
+
import { commitWorktreeOnBehalf } from '../core/worktree.js';
|
|
21
25
|
/**
|
|
22
26
|
* Returns the base directory where brainclaw-managed worktrees are stored
|
|
23
27
|
* for the given project root: `~/.brainclaw/worktrees/<sha1-hash>/`.
|
|
@@ -264,6 +268,193 @@ export function harvestLaneResults(options = {}) {
|
|
|
264
268
|
}
|
|
265
269
|
return result;
|
|
266
270
|
}
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
// pln#534 — worktree-as-contract: integrate a worker's lane on its behalf.
|
|
273
|
+
//
|
|
274
|
+
// LEVER #1 from the LeaseUp frontier (can_100f1e8c). The worker's contract is
|
|
275
|
+
// reduced to "edit files in this worktree + drop LANE-RESULT.json". brainclaw
|
|
276
|
+
// carries the rest for a worker that cannot (a sandboxed agent whose root
|
|
277
|
+
// excludes `.git`, i.e. dispatchCanCommit=false): it COMMITS the worktree diff
|
|
278
|
+
// on the worker's behalf so the code lands on the lane branch, then lifecycles
|
|
279
|
+
// the assignment and releases the claim (with plan cascade). Self-commit / MCP /
|
|
280
|
+
// self-lifecycle become PROGRESSIVE enrichments, not prerequisites.
|
|
281
|
+
//
|
|
282
|
+
// Strictly ADDITIVE + opt-in: nothing here runs unless a caller invokes
|
|
283
|
+
// integrateLaneResults / `brainclaw harvest --integrate`. Existing harvest stays
|
|
284
|
+
// report-only.
|
|
285
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
286
|
+
/** Happy-path assignment FSM chain walked when force-completing on behalf. */
|
|
287
|
+
const ASSIGNMENT_COMPLETE_CHAIN = ['created', 'offered', 'accepted', 'started', 'completed'];
|
|
288
|
+
/**
|
|
289
|
+
* Walk a still-open assignment forward to `completed` through the valid FSM
|
|
290
|
+
* chain (offered→accepted→started→completed), attaching artifacts on the final
|
|
291
|
+
* step. Idempotent (already-completed → true; transitions no-op). Returns false
|
|
292
|
+
* for assignments parked off the happy path (failed/blocked/timed_out) — those
|
|
293
|
+
* are not silently force-completed.
|
|
294
|
+
*/
|
|
295
|
+
function forceCompleteAssignment(assignmentId, artifacts, statusReason, actor, cwd) {
|
|
296
|
+
const current = loadAssignment(assignmentId, cwd);
|
|
297
|
+
if (!current)
|
|
298
|
+
return false;
|
|
299
|
+
if (current.status === 'completed')
|
|
300
|
+
return true;
|
|
301
|
+
const startIdx = ASSIGNMENT_COMPLETE_CHAIN.indexOf(current.status);
|
|
302
|
+
if (startIdx === -1)
|
|
303
|
+
return false; // off the happy path (failed/blocked/…): leave it.
|
|
304
|
+
for (let i = startIdx + 1; i < ASSIGNMENT_COMPLETE_CHAIN.length; i++) {
|
|
305
|
+
const next = ASSIGNMENT_COMPLETE_CHAIN[i];
|
|
306
|
+
try {
|
|
307
|
+
transitionAssignment(assignmentId, next, next === 'completed' ? { actor, artifacts, status_reason: statusReason } : { actor }, cwd);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// A concurrent explicit transition may have moved it; stop walking.
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return loadAssignment(assignmentId, cwd)?.status === 'completed';
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Integrate completed lanes on behalf of workers that cannot self-commit.
|
|
318
|
+
*
|
|
319
|
+
* For each LANE-RESULT.json found (optionally filtered to one assignment):
|
|
320
|
+
* 1. resolve the assignment + the worker agent's commit capability;
|
|
321
|
+
* 2. when the worker CANNOT commit (sandboxed) and the worktree is dirty,
|
|
322
|
+
* commit the diff on its behalf onto the lane branch (guarded to the linked
|
|
323
|
+
* worktree only — never the main repo);
|
|
324
|
+
* 3. lifecycle the assignment (status=completed → walk to completed with the
|
|
325
|
+
* commit + files as artifacts; status=blocked/failed → best-effort
|
|
326
|
+
* transition) and release the claim with plan cascade.
|
|
327
|
+
*
|
|
328
|
+
* A worker that CAN commit is left to its self-commit handoff — brainclaw only
|
|
329
|
+
* lifecycles/releases, it does not author commits for it.
|
|
330
|
+
*/
|
|
331
|
+
export function integrateLaneResults(options = {}) {
|
|
332
|
+
const cwd = options.cwd ?? process.cwd();
|
|
333
|
+
const actor = options.agent ?? 'coordinator';
|
|
334
|
+
const result = { integrated: [], skipped: [], errors: [] };
|
|
335
|
+
const worktreePaths = (options.worktreePaths && options.worktreePaths.length > 0)
|
|
336
|
+
? options.worktreePaths
|
|
337
|
+
: autoDetectWorktreePaths(cwd);
|
|
338
|
+
for (const worktreePath of worktreePaths) {
|
|
339
|
+
const file = getLaneResultPath(worktreePath);
|
|
340
|
+
if (!fs.existsSync(file))
|
|
341
|
+
continue;
|
|
342
|
+
let lane;
|
|
343
|
+
try {
|
|
344
|
+
lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
result.errors.push(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (options.assignmentId && lane.assignment_id !== options.assignmentId)
|
|
351
|
+
continue;
|
|
352
|
+
const assignment = loadAssignment(lane.assignment_id, cwd);
|
|
353
|
+
if (!assignment) {
|
|
354
|
+
result.skipped.push(lane.assignment_id);
|
|
355
|
+
result.errors.push(`No assignment record for lane ${lane.assignment_id} — cannot integrate`);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const profile = getCapabilityProfile(assignment.agent);
|
|
359
|
+
// No profile ⇒ assume it can commit (conservative: don't author for an
|
|
360
|
+
// unknown agent), so brainclaw only lifecycles.
|
|
361
|
+
const workerCanCommit = profile ? dispatchCanCommit(profile) : true;
|
|
362
|
+
const entry = {
|
|
363
|
+
assignment_id: lane.assignment_id,
|
|
364
|
+
worker_agent: assignment.agent,
|
|
365
|
+
lane_status: lane.status,
|
|
366
|
+
worker_can_commit: workerCanCommit,
|
|
367
|
+
committed_on_behalf: false,
|
|
368
|
+
files_changed: lane.files_changed ?? [],
|
|
369
|
+
assignment_completed: false,
|
|
370
|
+
claim_released: false,
|
|
371
|
+
reason: '',
|
|
372
|
+
};
|
|
373
|
+
const reasons = [];
|
|
374
|
+
// 1. Commit on behalf (only when the worker cannot, and there is a diff).
|
|
375
|
+
if (!workerCanCommit) {
|
|
376
|
+
if (options.dryRun) {
|
|
377
|
+
reasons.push('(dry-run) would commit worktree diff on behalf');
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
const message = `chore(lane): integrate ${assignment.agent} work for ${lane.assignment_id}\n\n`
|
|
381
|
+
+ `${lane.summary}\n\n[brainclaw committed on behalf — worker sandbox cannot self-commit (pln#534)]`;
|
|
382
|
+
const commit = commitWorktreeOnBehalf(worktreePath, message, {
|
|
383
|
+
authorName: `${assignment.agent} (via brainclaw)`,
|
|
384
|
+
authorEmail: 'brainclaw@on-behalf.local',
|
|
385
|
+
});
|
|
386
|
+
entry.committed_on_behalf = commit.committed;
|
|
387
|
+
entry.commit_sha = commit.sha;
|
|
388
|
+
if (commit.committed)
|
|
389
|
+
entry.files_changed = commit.files_changed;
|
|
390
|
+
reasons.push(commit.reason);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
reasons.push('worker can self-commit — no on-behalf commit');
|
|
395
|
+
}
|
|
396
|
+
// 2. Lifecycle + release.
|
|
397
|
+
if (!options.dryRun) {
|
|
398
|
+
if (lane.status === 'completed') {
|
|
399
|
+
const artifacts = [
|
|
400
|
+
...(entry.commit_sha ? [{ type: 'commit', ref: entry.commit_sha, description: 'on-behalf integration commit' }] : []),
|
|
401
|
+
...entry.files_changed.slice(0, 50).map((f) => ({ type: 'file', ref: f })),
|
|
402
|
+
];
|
|
403
|
+
entry.assignment_completed = forceCompleteAssignment(lane.assignment_id, artifacts, `pln#534 on-behalf integration: ${lane.summary.slice(0, 120)}`, actor, cwd);
|
|
404
|
+
try {
|
|
405
|
+
const rel = releaseClaimWithCascade(assignment.claim_id, { planStatus: 'done', cwd });
|
|
406
|
+
entry.claim_released = rel.claim.status === 'released';
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
reasons.push(`claim release failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
// blocked / failed: best-effort lifecycle (FSM may reject from offered).
|
|
414
|
+
const target = lane.status === 'blocked' ? 'blocked' : 'failed';
|
|
415
|
+
try {
|
|
416
|
+
transitionAssignment(lane.assignment_id, target, { actor, status_reason: lane.summary.slice(0, 200) }, cwd);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
reasons.push(`assignment ${target} transition rejected: ${err instanceof Error ? err.message : String(err)}`);
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
const rel = releaseClaimWithCascade(assignment.claim_id, {
|
|
423
|
+
planStatus: lane.status === 'blocked' ? 'blocked' : undefined,
|
|
424
|
+
cwd,
|
|
425
|
+
});
|
|
426
|
+
entry.claim_released = rel.claim.status === 'released';
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
reasons.push(`claim release failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
// Durable trace of the integration.
|
|
433
|
+
try {
|
|
434
|
+
createRuntimeEvent({
|
|
435
|
+
agent: actor,
|
|
436
|
+
event_type: 'lane_integrated',
|
|
437
|
+
text: `Integrated lane ${lane.assignment_id} (${lane.status}) on behalf of ${assignment.agent}`,
|
|
438
|
+
tags: ['harvest', 'integrate', 'worktree-as-contract', lane.status],
|
|
439
|
+
assignment_id: lane.assignment_id,
|
|
440
|
+
metadata: {
|
|
441
|
+
assignment_id: lane.assignment_id,
|
|
442
|
+
worker_agent: assignment.agent,
|
|
443
|
+
committed_on_behalf: entry.committed_on_behalf,
|
|
444
|
+
commit_sha: entry.commit_sha ?? null,
|
|
445
|
+
files_changed: entry.files_changed,
|
|
446
|
+
assignment_completed: entry.assignment_completed,
|
|
447
|
+
claim_released: entry.claim_released,
|
|
448
|
+
},
|
|
449
|
+
}, cwd);
|
|
450
|
+
}
|
|
451
|
+
catch { /* event is best-effort */ }
|
|
452
|
+
}
|
|
453
|
+
entry.reason = reasons.join('; ');
|
|
454
|
+
result.integrated.push(entry);
|
|
455
|
+
}
|
|
456
|
+
return result;
|
|
457
|
+
}
|
|
267
458
|
export function runHarvestLane(assignmentId, options = {}) {
|
|
268
459
|
const cwd = options.cwd ?? process.cwd();
|
|
269
460
|
if (!memoryExists(cwd)) {
|
|
@@ -274,6 +465,38 @@ export function runHarvestLane(assignmentId, options = {}) {
|
|
|
274
465
|
console.error('Error: provide an <assignment_id>, or pass --all to harvest every lane result.');
|
|
275
466
|
process.exit(1);
|
|
276
467
|
}
|
|
468
|
+
// pln#534 — `--integrate` upgrades harvest from report-only to converge-the-
|
|
469
|
+
// lane: commit the worktree diff on behalf of a sandboxed worker, lifecycle
|
|
470
|
+
// the assignment, and release the claim. Runs alongside the normal ingest.
|
|
471
|
+
if (options.integrate) {
|
|
472
|
+
const integ = integrateLaneResults({
|
|
473
|
+
assignmentId: options.all ? undefined : assignmentId,
|
|
474
|
+
worktreePaths: options.worktree,
|
|
475
|
+
dryRun: options.dryRun,
|
|
476
|
+
cwd,
|
|
477
|
+
});
|
|
478
|
+
if (options.json) {
|
|
479
|
+
console.log(JSON.stringify(integ, null, 2));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const dry = options.dryRun ? ' (dry-run)' : '';
|
|
483
|
+
if (integ.integrated.length === 0 && integ.errors.length === 0) {
|
|
484
|
+
console.log(assignmentId ? `No LANE-RESULT.json to integrate for ${assignmentId}.` : 'No lane results to integrate.');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
for (const e of integ.integrated) {
|
|
488
|
+
console.log(` ✔ Integrated [${e.assignment_id}] ${e.lane_status} (worker=${e.worker_agent}, can_commit=${e.worker_can_commit})`);
|
|
489
|
+
if (e.committed_on_behalf)
|
|
490
|
+
console.log(` committed on behalf: ${e.commit_sha?.slice(0, 10)} (${e.files_changed.length} file(s))`);
|
|
491
|
+
console.log(` assignment_completed=${e.assignment_completed} claim_released=${e.claim_released}`);
|
|
492
|
+
if (e.reason)
|
|
493
|
+
console.log(` ${e.reason}`);
|
|
494
|
+
}
|
|
495
|
+
for (const err of integ.errors)
|
|
496
|
+
console.error(` ✗ ${err}`);
|
|
497
|
+
console.log(`\n✔ Lane integrate complete${dry}: ${integ.integrated.length} integrated, ${integ.errors.length} error(s).`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
277
500
|
const result = harvestLaneResults({
|
|
278
501
|
assignmentId: options.all ? undefined : assignmentId,
|
|
279
502
|
worktreePaths: options.worktree,
|
package/dist/commands/mcp.js
CHANGED
|
@@ -972,6 +972,7 @@ const MCP_WRITE_TOOLS = [
|
|
|
972
972
|
autoExecute: { type: 'boolean', description: 'Attempt to spawn target agents after delivery (default: true). When false, returns command_ready_manual with bash commands for the supervisor to run.' },
|
|
973
973
|
open_loop: { type: 'boolean', description: 'For intent=review only: also open a review Loop on top of the candidate (author + reviewer slots, advance to `findings`, dispatch turns). Default false — existing review callers are unaffected. See docs/concepts/loop-engine.md §Automation.' },
|
|
974
974
|
review_mode: { type: 'string', enum: ['asymmetric', 'symmetric'], description: 'Optional review Loop mode when open_loop=true. `asymmetric` (default) keeps the classical author→reviewer handoff; `symmetric` lets each reviewer turn also apply fixes directly, halving round-trips for spec/doc reviews. Ignored when open_loop is false.' },
|
|
975
|
+
preflight: { type: 'boolean', description: 'pln#533: when open_loop=true, run a trivial validation spawn per reviewer agent BEFORE opening the loop so an environment death (config rejected, auth fail, model mismatch) surfaces instantly with a clear reason instead of a generic loop timeout. Reviewers that fail pre-flight are dropped (with a targeted warning); if all fail, loop creation is skipped. Default true; set false to skip (e.g. you already ran `brainclaw doctor --spawn-check`). Ignored when open_loop is false or BRAINCLAW_NO_SPAWN is set.' },
|
|
975
976
|
agent: { type: 'string', description: 'Caller agent name.' },
|
|
976
977
|
agentId: { type: 'string', description: 'Caller registered agent id.' },
|
|
977
978
|
project: { type: 'string', description: 'Optional (pln#359 phase 1b): name of a linked project to dispatch into. When set, claim/assignment/message all land in the target project — the target agent picks the brief up async via its own bclaw_work. Auto-spawn is disabled in cross-project mode. Accepts cross_project_links and workspace store-chain children (see `brainclaw link list`).' },
|
|
@@ -4576,6 +4577,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4576
4577
|
dispatcherAgent: opts.senderAgent,
|
|
4577
4578
|
dispatcherAgentId: opts.senderAgentId,
|
|
4578
4579
|
cwd: opts.cwd,
|
|
4580
|
+
requireWorktree: true, // pln#531: never spawn a worker in the integration repo
|
|
4579
4581
|
});
|
|
4580
4582
|
entry.execution_status = execResult.execution_status;
|
|
4581
4583
|
if (execResult.pid)
|
|
@@ -4952,6 +4954,31 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4952
4954
|
loopReviewerAgents = resolvedAgents.slice(0, REVIEW_OPEN_LOOP_FANOUT_CAP);
|
|
4953
4955
|
preReviewWarnings.push(`open_loop: implicit reviewer fan-out capped at ${REVIEW_OPEN_LOOP_FANOUT_CAP} of ${resolvedAgents.length} spawnable agents; pass targetAgents to override`);
|
|
4954
4956
|
}
|
|
4957
|
+
// pln#533 — pre-flight the reviewer agents with a trivial validation
|
|
4958
|
+
// spawn BEFORE opening the loop, so an environment death (config
|
|
4959
|
+
// rejected, auth fail, model mismatch) surfaces instantly with a clear
|
|
4960
|
+
// reason instead of a generic "did not acknowledge" loop timeout. Drop
|
|
4961
|
+
// the agents that fail and surface their reasons; if none survive the
|
|
4962
|
+
// existing length===0 guard skips loop creation. Skipped when open_loop
|
|
4963
|
+
// is off, preflight=false, or BRAINCLAW_NO_SPAWN is set (handled inside
|
|
4964
|
+
// preflightAgents). Cross-project dispatch never auto-spawns, so skip.
|
|
4965
|
+
if (req.open_loop === true && req.preflight !== false && !req.project && loopReviewerAgents.length > 0) {
|
|
4966
|
+
try {
|
|
4967
|
+
const { preflightAgents } = await import('../core/spawn-check.js');
|
|
4968
|
+
const pf = await preflightAgents(loopReviewerAgents, { cwd: dispatchCwd });
|
|
4969
|
+
if (pf.blocked.length > 0) {
|
|
4970
|
+
const healthy = loopReviewerAgents.filter((a) => !pf.blocked.some((b) => b.agent === a));
|
|
4971
|
+
for (const b of pf.blocked) {
|
|
4972
|
+
preReviewWarnings.push(`pre-flight: dropped reviewer '${b.agent}' — ${b.reason}.${b.recommended_next_action ? ` ${b.recommended_next_action}` : ''}`);
|
|
4973
|
+
}
|
|
4974
|
+
loopReviewerAgents = healthy;
|
|
4975
|
+
}
|
|
4976
|
+
}
|
|
4977
|
+
catch (pfErr) {
|
|
4978
|
+
// Pre-flight is best-effort: a failure here must not block the review.
|
|
4979
|
+
preReviewWarnings.push(`pre-flight: skipped (check threw: ${pfErr instanceof Error ? pfErr.message : String(pfErr)})`);
|
|
4980
|
+
}
|
|
4981
|
+
}
|
|
4955
4982
|
// Lazy-import the loops module once before defining performReview so
|
|
4956
4983
|
// the synchronous withLoopLock work callback can use it without
|
|
4957
4984
|
// re-importing inside the callback.
|
|
@@ -26,6 +26,7 @@ import { loadClaim } from './claims.js';
|
|
|
26
26
|
import { getLoop, listLoops } from './loops/store.js';
|
|
27
27
|
import { isProcessAlive } from './agentrun-reconciler.js';
|
|
28
28
|
import { latestActivityMs } from './runtime-signals.js';
|
|
29
|
+
import { LaneResultSchema } from './schema.js';
|
|
29
30
|
const DEFAULT_TAIL = 20;
|
|
30
31
|
const DEFAULT_STALL_MS = 5 * 60_000;
|
|
31
32
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
@@ -136,6 +137,24 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
|
|
|
136
137
|
recommended_next_action: 'Verify the target_id is correct (asgn_/clm_/lop_/run_). Use bclaw_find(entity="assignment") to list available assignments.',
|
|
137
138
|
};
|
|
138
139
|
}
|
|
140
|
+
// pln#532 — RESULT is the #1 verdict signal. If the worker wrote LANE-RESULT.json
|
|
141
|
+
// it has FINISHED — regardless of pid / heartbeat / agent_run.status (a sandboxed
|
|
142
|
+
// worker frequently cannot self-update the run). This sits above every other
|
|
143
|
+
// signal, including the agent_run terminal/running checks below.
|
|
144
|
+
if (runtime.lane_result) {
|
|
145
|
+
const lr = runtime.lane_result;
|
|
146
|
+
const ok = lr.status === 'completed';
|
|
147
|
+
const stale = agentRun && agentRun.status !== 'completed'
|
|
148
|
+
? ` (agent_run still ${agentRun.status}; the worker could not self-update — harvest reconciles it)`
|
|
149
|
+
: '';
|
|
150
|
+
return {
|
|
151
|
+
health: 'terminal',
|
|
152
|
+
summary: `worker reported done via LANE-RESULT.json: status=${lr.status} — ${lr.summary.slice(0, 140)}${stale}`,
|
|
153
|
+
recommended_next_action: ok
|
|
154
|
+
? 'Worker finished. `brainclaw harvest <assignment_id>` to ingest the result, then commit/integrate its worktree diff and converge the lane.'
|
|
155
|
+
: `Worker reported "${lr.status}". Read the LANE-RESULT summary + stderr; address the blocker or reroute.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
139
158
|
if (!agentRun) {
|
|
140
159
|
return {
|
|
141
160
|
health: 'not_dispatched',
|
|
@@ -248,6 +267,16 @@ export function getDispatchStatus(options) {
|
|
|
248
267
|
if (lastFs !== undefined)
|
|
249
268
|
lastFsActivityMs = nowMs - lastFs;
|
|
250
269
|
}
|
|
270
|
+
// pln#532 — the #1 verdict signal: a LANE-RESULT.json at the worktree root means
|
|
271
|
+
// the worker FINISHED (even if it couldn't self-update the run). Read + validate it.
|
|
272
|
+
let laneResult;
|
|
273
|
+
if (worktreeForFs) {
|
|
274
|
+
try {
|
|
275
|
+
const parsed = LaneResultSchema.parse(JSON.parse(fs.readFileSync(path.join(worktreeForFs, 'LANE-RESULT.json'), 'utf-8')));
|
|
276
|
+
laneResult = { status: parsed.status, summary: parsed.summary };
|
|
277
|
+
}
|
|
278
|
+
catch { /* no / invalid LANE-RESULT.json */ }
|
|
279
|
+
}
|
|
251
280
|
const runtime = {
|
|
252
281
|
pid: agentRun?.pid,
|
|
253
282
|
pid_alive: isProcessAlive(agentRun?.pid),
|
|
@@ -260,6 +289,7 @@ export function getDispatchStatus(options) {
|
|
|
260
289
|
stderr: stderrPath ? readLogTail(stderrPath, tailLines) : undefined,
|
|
261
290
|
},
|
|
262
291
|
last_fs_activity_ms: lastFsActivityMs,
|
|
292
|
+
lane_result: laneResult,
|
|
263
293
|
};
|
|
264
294
|
const diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
|
|
265
295
|
return {
|
package/dist/core/dispatcher.js
CHANGED
|
@@ -850,6 +850,7 @@ export async function dispatch(options, cwd) {
|
|
|
850
850
|
dispatcherAgentId: options.dispatcherAgentId,
|
|
851
851
|
cwd,
|
|
852
852
|
handshakeTimeoutMs: options.handshakeTimeoutMs,
|
|
853
|
+
requireWorktree: true, // pln#531: never spawn a worker in the integration repo
|
|
853
854
|
});
|
|
854
855
|
entry.execution_status = execResult.execution_status;
|
|
855
856
|
if (execResult.pid)
|
|
@@ -107,7 +107,7 @@ const decision = {
|
|
|
107
107
|
name: 'decision',
|
|
108
108
|
shortLabelPrefix: 'dec',
|
|
109
109
|
schema: DecisionSchema,
|
|
110
|
-
updatable: ['text', 'tags', 'outcome', 'scope', 'related_paths'],
|
|
110
|
+
updatable: ['text', 'tags', 'outcome', 'scope', 'related_paths', 'verified_at', 'verify_cmd'],
|
|
111
111
|
statusField: 'outcome',
|
|
112
112
|
transitions: {
|
|
113
113
|
pending: ['approved', 'rejected', 'deferred'],
|
|
@@ -137,7 +137,7 @@ const trap = {
|
|
|
137
137
|
name: 'trap',
|
|
138
138
|
shortLabelPrefix: 'trp',
|
|
139
139
|
schema: TrapSchema,
|
|
140
|
-
updatable: ['text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope'],
|
|
140
|
+
updatable: ['text', 'tags', 'severity', 'scope', 'related_paths', 'expires_at', 'platform_scope', 'verified_at', 'verify_cmd'],
|
|
141
141
|
statusField: 'status',
|
|
142
142
|
transitions: {
|
|
143
143
|
active: ['resolved', 'expired'],
|
package/dist/core/execution.js
CHANGED
|
@@ -156,6 +156,31 @@ export async function attemptExecution(invoke, options) {
|
|
|
156
156
|
shell: manual.shell,
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
|
+
// pln#531 — isolation invariant: a spawned worker MUST run in its own
|
|
160
|
+
// worktree. If a worktree was required but none exists (creation failed, or a
|
|
161
|
+
// claim was reused/re-dispatched without one), REFUSE to spawn instead of
|
|
162
|
+
// falling back to options.cwd — which is the integration repo, where the
|
|
163
|
+
// worker would edit the main tree directly (dangerous for an autonomous fleet,
|
|
164
|
+
// debrief LeaseUp). Return the command for manual, isolated execution.
|
|
165
|
+
if (options.requireWorktree && !options.worktreePath) {
|
|
166
|
+
appendAuditEntry({
|
|
167
|
+
actor: options.dispatcherAgent,
|
|
168
|
+
actor_id: options.dispatcherAgentId,
|
|
169
|
+
action: 'spawn_failed',
|
|
170
|
+
item_id: options.claimId,
|
|
171
|
+
item_type: 'claim',
|
|
172
|
+
scope: options.agent,
|
|
173
|
+
after: { reason: 'no_worktree', refused: true },
|
|
174
|
+
}, options.cwd);
|
|
175
|
+
const manual = adapter.prepareManualCommand(invoke, options);
|
|
176
|
+
return {
|
|
177
|
+
execution_status: 'command_ready_manual',
|
|
178
|
+
command: manual.command,
|
|
179
|
+
shell: manual.shell,
|
|
180
|
+
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
|
+
failure_kind: 'spawn_no_worktree',
|
|
182
|
+
};
|
|
183
|
+
}
|
|
159
184
|
// Capacity guard: skip if agent is at max concurrent tasks
|
|
160
185
|
if (options.cwd) {
|
|
161
186
|
const instanceCheck = checkActiveInstance(options.agent, options.cwd);
|
|
@@ -39,6 +39,16 @@ export const CoordinateRequestSchema = z.object({
|
|
|
39
39
|
* Ignored when open_loop is false. Defaults to asymmetric.
|
|
40
40
|
*/
|
|
41
41
|
review_mode: z.enum(['asymmetric', 'symmetric']).optional(),
|
|
42
|
+
/**
|
|
43
|
+
* pln#533: when opening a review Loop (open_loop=true), run a trivial
|
|
44
|
+
* validation spawn against each reviewer agent first so an environment death
|
|
45
|
+
* (config rejected, auth fail, model mismatch) surfaces instantly with a
|
|
46
|
+
* clear reason instead of a generic loop timeout. Defaults to true for
|
|
47
|
+
* open_loop reviews; set false to skip (e.g. when you have just spawn-checked
|
|
48
|
+
* the agents yourself). Ignored when open_loop is false or BRAINCLAW_NO_SPAWN
|
|
49
|
+
* is set.
|
|
50
|
+
*/
|
|
51
|
+
preflight: z.boolean().optional(),
|
|
42
52
|
/**
|
|
43
53
|
* Caller-minted ULID/UUIDv7 for idempotent retries. Today this is observed
|
|
44
54
|
* on intent='review' + open_loop=true: a retry with the same
|
package/dist/core/schema.js
CHANGED
|
@@ -154,6 +154,11 @@ export const DecisionSchema = z.object({
|
|
|
154
154
|
related_paths: z.array(z.string()).optional(),
|
|
155
155
|
plan_id: z.string().optional(),
|
|
156
156
|
tags: TagsSchema,
|
|
157
|
+
// pln#530 — anti-staleness: ISO timestamp this fact was last empirically
|
|
158
|
+
// verified, and an optional command to re-confirm it. For fast-perishable
|
|
159
|
+
// facts (tool behaviour, config values), probe before trusting the memory.
|
|
160
|
+
verified_at: z.string().optional(),
|
|
161
|
+
verify_cmd: z.string().optional(),
|
|
157
162
|
provenance: ProvenancePassthroughSchema,
|
|
158
163
|
});
|
|
159
164
|
export const TrapSchema = z.object({
|
|
@@ -177,6 +182,11 @@ export const TrapSchema = z.object({
|
|
|
177
182
|
host_id: z.string().optional(),
|
|
178
183
|
expires_at: z.string().optional(),
|
|
179
184
|
platform_scope: z.string().optional(),
|
|
185
|
+
// pln#530 — anti-staleness (see DecisionSchema): when did we last verify this
|
|
186
|
+
// is still true, and how to re-confirm it. Critical for environment/tool-fix
|
|
187
|
+
// traps that go stale (e.g. a service_tier value that the API later rejects).
|
|
188
|
+
verified_at: z.string().optional(),
|
|
189
|
+
verify_cmd: z.string().optional(),
|
|
180
190
|
provenance: ProvenancePassthroughSchema,
|
|
181
191
|
});
|
|
182
192
|
export const HandoffContractSchema = z.object({
|
|
@@ -856,6 +866,7 @@ export const RuntimeEventTypeSchema = z.enum([
|
|
|
856
866
|
'plan_cascade_to_done',
|
|
857
867
|
'candidate_harvested',
|
|
858
868
|
'lane_result_harvested',
|
|
869
|
+
'lane_integrated',
|
|
859
870
|
]);
|
|
860
871
|
/**
|
|
861
872
|
* pln#526 — LANE-RESULT convention. A dispatched worker writes a single
|
package/dist/core/spawn-check.js
CHANGED
|
@@ -17,14 +17,33 @@
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import os from 'node:os';
|
|
19
19
|
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
20
21
|
import { buildInvokeCommand, getSpawnableAgents, getCapabilityProfile, } from './agent-capability.js';
|
|
21
22
|
import { defaultExecutionAdapter, resolveBinaryOnPath } from './execution-adapters.js';
|
|
22
23
|
import { signalExists, readLogTail } from './runtime-signals.js';
|
|
24
|
+
import { recognizeStderrSignature } from './dispatch-status.js';
|
|
23
25
|
const DEFAULT_PROBE_TIMEOUT_MS = 15_000;
|
|
24
26
|
const DEFAULT_PROBE_PROMPT = 'Reply with exactly: OK';
|
|
25
27
|
async function sleep(ms) {
|
|
26
28
|
return new Promise((r) => setTimeout(r, ms));
|
|
27
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Make the probe's temp dir a real (empty) git repo so the round-trip is
|
|
32
|
+
* representative of a real dispatch (workers always run inside a git worktree)
|
|
33
|
+
* and so CLIs with a boot-time git-repo / trusted-directory check don't refuse
|
|
34
|
+
* it (pln#533 fix). Best-effort: if git is unavailable the probe still runs.
|
|
35
|
+
*/
|
|
36
|
+
function initProbeGitRepo(root) {
|
|
37
|
+
try {
|
|
38
|
+
const run = (...args) => spawnSync('git', args, { cwd: root, encoding: 'utf-8', timeout: 5000 });
|
|
39
|
+
run('init', '-q');
|
|
40
|
+
run('config', 'user.email', 'spawn-check@brainclaw.local');
|
|
41
|
+
run('config', 'user.name', 'brainclaw spawn-check');
|
|
42
|
+
run('config', 'commit.gpgsign', 'false');
|
|
43
|
+
run('commit', '--allow-empty', '-q', '-m', 'spawn-check probe');
|
|
44
|
+
}
|
|
45
|
+
catch { /* git absent or failed — probe proceeds without it */ }
|
|
46
|
+
}
|
|
28
47
|
/** Check one agent's spawn round-trip. Exposed for focused testing. */
|
|
29
48
|
export async function checkAgentSpawn(agent, options = {}) {
|
|
30
49
|
const start = Date.now();
|
|
@@ -43,6 +62,11 @@ export async function checkAgentSpawn(agent, options = {}) {
|
|
|
43
62
|
}
|
|
44
63
|
// Isolated signals root so the probe never pollutes the project's runtime dir.
|
|
45
64
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), `bclaw-spawncheck-${agent}-`));
|
|
65
|
+
// pln#533 fix: make the probe dir a real git repo. Real workers run inside a
|
|
66
|
+
// git worktree, and some CLIs refuse a non-git / untrusted dir at boot (codex:
|
|
67
|
+
// "Not inside a trusted directory and --skip-git-repo-check was not specified")
|
|
68
|
+
// — a non-git temp dir would otherwise produce a false-negative spawn failure.
|
|
69
|
+
initProbeGitRepo(root);
|
|
46
70
|
const assignmentId = 'spawn_check';
|
|
47
71
|
try {
|
|
48
72
|
defaultExecutionAdapter.start(invoke, { agent, assignmentId, ackRoot: root, worktreePath: root });
|
|
@@ -59,17 +83,21 @@ export async function checkAgentSpawn(agent, options = {}) {
|
|
|
59
83
|
const completed = signalExists(root, assignmentId, 'completed');
|
|
60
84
|
const failed = signalExists(root, assignmentId, 'failed');
|
|
61
85
|
const duration_ms = Date.now() - start;
|
|
86
|
+
// Capture the stderr tail once (used both for the detail string and for
|
|
87
|
+
// pln#533 boot-signature recognition on the preflight path).
|
|
88
|
+
const stderrRaw = readLogTail(root, assignmentId, 'stderr', 800).trim();
|
|
89
|
+
const stderrTail = stderrRaw ? stderrRaw.split(/\r?\n/).filter(Boolean) : undefined;
|
|
62
90
|
if (completed) {
|
|
63
91
|
return { agent, binary, status: 'ok', delivered, completed: true, duration_ms, detail: 'ack + completed round-trip' };
|
|
64
92
|
}
|
|
65
93
|
if (failed) {
|
|
66
|
-
const tail =
|
|
67
|
-
return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}
|
|
94
|
+
const tail = stderrRaw || readLogTail(root, assignmentId, 'stdout', 400).trim();
|
|
95
|
+
return { agent, binary, status: 'failed', delivered, completed: false, duration_ms, detail: `wrapper reported failure${tail ? ` — ${tail.replace(/\s+/g, ' ').slice(0, 200)}` : ''}`, stderr_tail: stderrTail };
|
|
68
96
|
}
|
|
69
97
|
if (delivered) {
|
|
70
|
-
return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)
|
|
98
|
+
return { agent, binary, status: 'delivered_no_completion', delivered: true, completed: false, duration_ms, detail: `spawned + ack but no completion within ${timeout}ms (silent-death symptom)`, stderr_tail: stderrTail };
|
|
71
99
|
}
|
|
72
|
-
return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed
|
|
100
|
+
return { agent, binary, status: 'failed', delivered: false, completed: false, duration_ms, detail: `no ack within ${timeout}ms — delivery failed`, stderr_tail: stderrTail };
|
|
73
101
|
}
|
|
74
102
|
finally {
|
|
75
103
|
try {
|
|
@@ -122,4 +150,82 @@ export function renderSpawnCheckReport(report) {
|
|
|
122
150
|
}
|
|
123
151
|
return lines.join('\n');
|
|
124
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Pre-flight a single target agent. Pass criteria:
|
|
155
|
+
* - `ok` (ack + completed) → pass.
|
|
156
|
+
* - `delivered_no_completion` (ack but the trivial probe didn't finish in the
|
|
157
|
+
* short window) → PASS: the ack proves spawn + wrapper + delivery work; a
|
|
158
|
+
* boot death never acks. We don't want a slow-but-healthy agent to block.
|
|
159
|
+
* - `failed` / no-ack → BLOCK with a reason (enriched by a recognized boot
|
|
160
|
+
* signature when the stderr matches one).
|
|
161
|
+
* - `not_installed` / `no_template` → BLOCK: the agent cannot be spawned here,
|
|
162
|
+
* so opening a loop on it would only time out.
|
|
163
|
+
* When BRAINCLAW_NO_SPAWN is set (tests/CI), pre-flight is skipped (ok:true).
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* Pure mapper: SpawnCheckEntry → PreflightResult. No spawning — exposed so the
|
|
167
|
+
* pass/block policy (and the boot-signature enrichment) can be unit-tested with
|
|
168
|
+
* synthetic entries.
|
|
169
|
+
*/
|
|
170
|
+
export function preflightResultFromEntry(entry) {
|
|
171
|
+
const agent = entry.agent;
|
|
172
|
+
if (entry.status === 'ok' || entry.status === 'delivered_no_completion') {
|
|
173
|
+
return { agent, ok: true, status: entry.status, reason: entry.detail };
|
|
174
|
+
}
|
|
175
|
+
if (entry.status === 'not_installed') {
|
|
176
|
+
return {
|
|
177
|
+
agent, ok: false, status: entry.status,
|
|
178
|
+
reason: `${agent} binary not on PATH — cannot spawn it here`,
|
|
179
|
+
recommended_next_action: `Install the ${agent} CLI (or target a different agent), then retry.`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (entry.status === 'no_template') {
|
|
183
|
+
return {
|
|
184
|
+
agent, ok: false, status: entry.status,
|
|
185
|
+
reason: `${agent} has no CLI spawn template — it cannot be auto-dispatched (IDE-only?)`,
|
|
186
|
+
recommended_next_action: `Target a CLI-spawnable agent, or hand this work to ${agent} interactively.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// failed (or no-ack) — try to attach a recognized boot signature.
|
|
190
|
+
const sig = recognizeStderrSignature(entry.stderr_tail);
|
|
191
|
+
return {
|
|
192
|
+
agent, ok: false, status: entry.status,
|
|
193
|
+
reason: sig?.summary ?? `${agent} failed its pre-flight spawn — ${entry.detail}`,
|
|
194
|
+
recommended_next_action: sig?.recommended_next_action
|
|
195
|
+
?? `Inspect the ${agent} CLI config/auth (run \`brainclaw doctor --spawn-check\` for detail), fix it, then retry.`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
export async function preflightAgentSpawn(agent, options = {}) {
|
|
199
|
+
if (process.env.BRAINCLAW_NO_SPAWN === '1') {
|
|
200
|
+
return { agent, ok: true, status: 'skipped', reason: 'pre-flight skipped (BRAINCLAW_NO_SPAWN)' };
|
|
201
|
+
}
|
|
202
|
+
// Pre-flight uses a tighter window than the full doctor round-trip: a boot
|
|
203
|
+
// death fails fast, and an ack is enough to pass, so we don't need to wait
|
|
204
|
+
// out a healthy agent's full probe completion.
|
|
205
|
+
const entry = await checkAgentSpawn(agent, { timeoutMs: 8_000, ...options });
|
|
206
|
+
return preflightResultFromEntry(entry);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Pre-flight a set of target agents (deduped), one trivial probe each. Returns
|
|
210
|
+
* the per-agent results plus `blocked` (the agents that failed). Callers use
|
|
211
|
+
* `blocked` to skip those agents and surface their reasons instead of opening a
|
|
212
|
+
* loop / dispatching work that would only time out.
|
|
213
|
+
*/
|
|
214
|
+
export async function preflightAgents(agents, options = {}) {
|
|
215
|
+
const unique = [...new Set(agents)];
|
|
216
|
+
const results = [];
|
|
217
|
+
for (const agent of unique) {
|
|
218
|
+
try {
|
|
219
|
+
results.push(await preflightAgentSpawn(agent, options));
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
results.push({
|
|
223
|
+
agent, ok: false, status: 'failed',
|
|
224
|
+
reason: `pre-flight threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const blocked = results.filter((r) => !r.ok);
|
|
229
|
+
return { results, blocked, all_ok: blocked.length === 0 };
|
|
230
|
+
}
|
|
125
231
|
//# sourceMappingURL=spawn-check.js.map
|
package/dist/core/staleness.js
CHANGED
|
@@ -100,6 +100,41 @@ export function detectExpiredTraps(traps, nowIso = new Date().toISOString(), now
|
|
|
100
100
|
}
|
|
101
101
|
return warnings;
|
|
102
102
|
}
|
|
103
|
+
/** pln#530 — a perishable fact unverified for longer than this reads as stale. */
|
|
104
|
+
const VERIFIED_STALE_DAYS = 30;
|
|
105
|
+
/**
|
|
106
|
+
* pln#530 — flag perishable memories (traps that opted in by carrying a
|
|
107
|
+
* `verify_cmd` and/or `verified_at`) whose last empirical verification is stale
|
|
108
|
+
* or never happened, so an agent re-probes the live system instead of trusting a
|
|
109
|
+
* value that may have drifted (the LeaseUp `service_tier` trap that the API later
|
|
110
|
+
* rejected is the motivating case). Only traps with these fields are considered —
|
|
111
|
+
* durable facts are untouched.
|
|
112
|
+
*/
|
|
113
|
+
export function detectUnverifiedMemory(traps, nowMs = Date.now()) {
|
|
114
|
+
const warnings = [];
|
|
115
|
+
for (const trap of traps) {
|
|
116
|
+
if (trap.status !== 'active')
|
|
117
|
+
continue;
|
|
118
|
+
if (!trap.verify_cmd && !trap.verified_at)
|
|
119
|
+
continue; // opt-in: only perishable facts
|
|
120
|
+
const age = trap.verified_at ? ageDays(trap.verified_at, nowMs) : Infinity;
|
|
121
|
+
if (trap.verified_at && age < VERIFIED_STALE_DAYS)
|
|
122
|
+
continue; // freshly verified
|
|
123
|
+
warnings.push({
|
|
124
|
+
id: trap.id,
|
|
125
|
+
entity: 'trap',
|
|
126
|
+
text: truncate(trap.text),
|
|
127
|
+
age_days: Number.isFinite(age) ? age : 9999,
|
|
128
|
+
reason: trap.verified_at
|
|
129
|
+
? `Perishable fact last verified ${age} day${age === 1 ? '' : 's'} ago — re-confirm against the live system before trusting`
|
|
130
|
+
: `Perishable fact never empirically verified (verify_cmd set) — confirm before trusting`,
|
|
131
|
+
suggested_action: trap.verify_cmd
|
|
132
|
+
? `Run \`${trap.verify_cmd}\`, then bclaw_update(trap, ${trap.short_label ?? trap.id}, { verified_at: <now> })`
|
|
133
|
+
: `Re-verify against the live system, then set verified_at via bclaw_update`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return warnings;
|
|
137
|
+
}
|
|
103
138
|
/**
|
|
104
139
|
* Detect open handoffs that have not been acted on for a long time.
|
|
105
140
|
*/
|
|
@@ -207,12 +242,14 @@ export function detectStaleness(plans, traps, handoffs, candidates, nowMs = Date
|
|
|
207
242
|
const nowIso = new Date(nowMs).toISOString();
|
|
208
243
|
const planWarnings = detectStalePlans(plans, nowMs);
|
|
209
244
|
const trapWarnings = detectExpiredTraps(traps, nowIso, nowMs);
|
|
245
|
+
const unverifiedWarnings = detectUnverifiedMemory(traps, nowMs); // pln#530
|
|
210
246
|
const handoffWarnings = detectStaleHandoffs(handoffs, nowMs);
|
|
211
247
|
const candidateWarnings = detectStaleCandidates(candidates, nowMs);
|
|
212
248
|
const noteWarnings = detectStaleRuntimeNotes(runtimeNotes, nowMs);
|
|
213
249
|
const warnings = [
|
|
214
250
|
...planWarnings,
|
|
215
251
|
...trapWarnings,
|
|
252
|
+
...unverifiedWarnings,
|
|
216
253
|
...handoffWarnings,
|
|
217
254
|
...candidateWarnings,
|
|
218
255
|
...noteWarnings,
|
package/dist/core/worktree.js
CHANGED
|
@@ -176,6 +176,104 @@ export function hasGitLock(cwd) {
|
|
|
176
176
|
const lockPath = path.join(gitDir.stdout.trim(), 'index.lock');
|
|
177
177
|
return fs.existsSync(lockPath);
|
|
178
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* True when `worktreePath` is a LINKED git worktree (created by `git worktree
|
|
181
|
+
* add`), NOT the main repository. The key invariant for pln#534's commit-on-
|
|
182
|
+
* behalf: brainclaw must NEVER commit into the integration repo, only into the
|
|
183
|
+
* isolated worktree it dispatched.
|
|
184
|
+
*
|
|
185
|
+
* Detection uses the canonical, platform-stable signal: in a linked worktree
|
|
186
|
+
* the entry at `<worktree>/.git` is a FILE (a `gitdir: …` pointer into the main
|
|
187
|
+
* repo's `.git/worktrees/<name>`), whereas the main repository's `.git` is a
|
|
188
|
+
* DIRECTORY. (An earlier implementation compared `git rev-parse
|
|
189
|
+
* --absolute-git-dir` against `--git-common-dir`, but those returned
|
|
190
|
+
* differently-normalized paths on the Windows CI runner — short 8.3 names /
|
|
191
|
+
* drive-letter case — so the main repo was misread as linked. The file-vs-dir
|
|
192
|
+
* check needs no path normalization.)
|
|
193
|
+
*/
|
|
194
|
+
export function isLinkedWorktree(worktreePath) {
|
|
195
|
+
if (!fs.existsSync(worktreePath))
|
|
196
|
+
return false;
|
|
197
|
+
try {
|
|
198
|
+
const dotGit = path.join(worktreePath, '.git');
|
|
199
|
+
const st = fs.statSync(dotGit);
|
|
200
|
+
// main repo → .git is a directory; linked worktree → .git is a file pointer.
|
|
201
|
+
return st.isFile();
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false; // no .git entry → not a git worktree
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* pln#534 (worktree-as-contract) — commit the uncommitted diff of a dispatched
|
|
209
|
+
* worktree ON BEHALF of a worker that cannot commit itself (a sandboxed agent
|
|
210
|
+
* whose root excludes `.git`, i.e. `dispatchCanCommit=false`). The worker's only
|
|
211
|
+
* contract is "edit files in this worktree + drop LANE-RESULT.json"; brainclaw
|
|
212
|
+
* carries the commit so the code lands on the lane branch and propagates.
|
|
213
|
+
*
|
|
214
|
+
* GUARDS (defence-in-depth, since this writes git history):
|
|
215
|
+
* - the path must exist and be a LINKED worktree — NEVER the main repo;
|
|
216
|
+
* - no commit when an index.lock is present (concurrent git op);
|
|
217
|
+
* - no commit when the worktree is clean;
|
|
218
|
+
* - all git runs are `spawnSync('git', [...])` with `-C <worktree>` semantics
|
|
219
|
+
* (cwd-scoped, no shell) so nothing can escape the worktree.
|
|
220
|
+
* Returns a structured result instead of throwing so callers degrade gracefully.
|
|
221
|
+
*/
|
|
222
|
+
export function commitWorktreeOnBehalf(worktreePath, message, options = {}) {
|
|
223
|
+
if (!fs.existsSync(worktreePath)) {
|
|
224
|
+
return { committed: false, files_changed: [], reason: `worktree path does not exist: ${worktreePath}` };
|
|
225
|
+
}
|
|
226
|
+
if (!isLinkedWorktree(worktreePath)) {
|
|
227
|
+
return { committed: false, files_changed: [], reason: `refusing to commit: ${worktreePath} is not a linked git worktree (main-repo guard)` };
|
|
228
|
+
}
|
|
229
|
+
if (hasGitLock(worktreePath)) {
|
|
230
|
+
return { committed: false, files_changed: [], reason: 'git index.lock present — another git operation is in progress' };
|
|
231
|
+
}
|
|
232
|
+
const status = runGit(['status', '--porcelain'], worktreePath);
|
|
233
|
+
if (!status.ok) {
|
|
234
|
+
return { committed: false, files_changed: [], reason: `git status failed: ${status.stderr.trim()}` };
|
|
235
|
+
}
|
|
236
|
+
if (status.stdout.trim().length === 0) {
|
|
237
|
+
return { committed: false, files_changed: [], reason: 'worktree clean — nothing to commit' };
|
|
238
|
+
}
|
|
239
|
+
// Stage everything, then UNSTAGE the transient files that must never land on
|
|
240
|
+
// the lane branch: the worker's own `LANE-RESULT.json` report and any
|
|
241
|
+
// `.brainclaw/` coordination state. Committing those would pollute the branch
|
|
242
|
+
// (and master, on merge) with non-deliverable artefacts.
|
|
243
|
+
const add = runGit(['add', '-A'], worktreePath);
|
|
244
|
+
if (!add.ok) {
|
|
245
|
+
return { committed: false, files_changed: [], reason: `git add failed: ${add.stderr.trim()}` };
|
|
246
|
+
}
|
|
247
|
+
runGit(['reset', '-q', '--', 'LANE-RESULT.json', '.brainclaw'], worktreePath);
|
|
248
|
+
// The files actually staged for this commit (post-exclusion) — also the
|
|
249
|
+
// truthful files_changed report.
|
|
250
|
+
const staged = runGit(['diff', '--cached', '--name-only'], worktreePath);
|
|
251
|
+
const files = staged.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
252
|
+
if (files.length === 0) {
|
|
253
|
+
// Only transient files changed — nothing deliverable to commit. Restore the
|
|
254
|
+
// index so the worktree is left exactly as the worker left it.
|
|
255
|
+
runGit(['reset', '-q'], worktreePath);
|
|
256
|
+
return { committed: false, files_changed: [], reason: 'no committable changes (only transient LANE-RESULT.json / .brainclaw)' };
|
|
257
|
+
}
|
|
258
|
+
const authorName = options.authorName ?? 'brainclaw (on behalf)';
|
|
259
|
+
const authorEmail = options.authorEmail ?? 'brainclaw@on-behalf.local';
|
|
260
|
+
const commit = runGit([
|
|
261
|
+
'-c', `user.name=${authorName}`,
|
|
262
|
+
'-c', `user.email=${authorEmail}`,
|
|
263
|
+
'-c', 'commit.gpgsign=false',
|
|
264
|
+
'commit', '-m', message,
|
|
265
|
+
], worktreePath);
|
|
266
|
+
if (!commit.ok) {
|
|
267
|
+
return { committed: false, files_changed: files, reason: `git commit failed: ${commit.stderr.trim()}` };
|
|
268
|
+
}
|
|
269
|
+
const head = runGit(['rev-parse', 'HEAD'], worktreePath);
|
|
270
|
+
return {
|
|
271
|
+
committed: true,
|
|
272
|
+
sha: head.ok ? head.stdout.trim() : undefined,
|
|
273
|
+
files_changed: files,
|
|
274
|
+
reason: 'committed on behalf of worker',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
179
277
|
/**
|
|
180
278
|
* Re-points an EXISTING worktree to `ref` via a hard reset of its checked-out
|
|
181
279
|
* branch + working tree. Used when a dispatch reuses an existing claim/worktree
|
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.
|
|
2
|
+
// Source: brainclaw v1.8.0 on 2026-06-09T07:31:59.599Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-06-
|
|
4
|
+
"version": "1.8.0",
|
|
5
|
+
"generated_at": "2026-06-09T07:31:59.599Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 62,
|
|
8
8
|
"published_count": 61,
|
package/dist/facts.json
CHANGED
|
@@ -150,6 +150,23 @@ You called `bclaw_coordinate(intent="review", open_loop=true, …)` and got back
|
|
|
150
150
|
|
|
151
151
|
---
|
|
152
152
|
|
|
153
|
+
## Worktree-as-contract harvest
|
|
154
|
+
|
|
155
|
+
Some dispatched workers cannot self-commit or call MCP. For example, a sandboxed Codex run may have `dispatchCanCommit=false` because its writable root is the linked worktree, while `.git` lives outside that root. In that case the worker contract is intentionally small:
|
|
156
|
+
|
|
157
|
+
1. Edit files inside the dispatched worktree.
|
|
158
|
+
2. Write `LANE-RESULT.json` at the worktree root.
|
|
159
|
+
|
|
160
|
+
The worker does not need to commit, call `bclaw_assignment_update`, or release the claim itself. The worktree is the contract.
|
|
161
|
+
|
|
162
|
+
When the coordinator runs `brainclaw harvest <assignment_id> --integrate`, brainclaw reads the worker's `LANE-RESULT.json`, commits the linked worktree diff on the worker's behalf onto the lane branch, then completes the assignment and releases the claim, including the normal plan-status cascade.
|
|
163
|
+
|
|
164
|
+
The on-behalf commit is guarded by the linked-worktree check (`isLinkedWorktree`): integration only targets the worktree associated with the assignment, never the main repository. This keeps sandboxed-worker harvesting from turning into an accidental main-repo commit path.
|
|
165
|
+
|
|
166
|
+
Integration is strictly additive and opt-in. Plain `brainclaw harvest <assignment_id>` remains report-only; it reads and reports the lane result without committing or mutating assignment / claim state. The on-behalf commit and lifecycle completion happen only when the coordinator passes `--integrate`.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
153
170
|
## Diagnostic playbook
|
|
154
171
|
|
|
155
172
|
When a dispatch hangs, work top-down through these checks. For the symptom-driven variant see [troubleshooting.md#inbox-messages-stuck--brief-ack-never-arrived](troubleshooting.md#inbox-messages-stuck--brief-ack-never-arrived).
|
|
@@ -19,7 +19,7 @@ guarantees this changelog follows.
|
|
|
19
19
|
to `sha256:860fbaa30a486093` (zod 4). No tool was added, removed,
|
|
20
20
|
renamed, or had its required arguments change.
|
|
21
21
|
|
|
22
|
-
**Changed — `bclaw_loop(intent: 'open')` anti-pattern gate (pln#461)**
|
|
22
|
+
**Changed — `bclaw_loop(intent: 'open')` anti-pattern gate (pln#461)**
|
|
23
23
|
- New optional field `allow_orphan: boolean` on `BclawLoopOpenSchema`.
|
|
24
24
|
- Default (absent / `false`) — handler rejects with `validation_error`
|
|
25
25
|
and points to `bclaw_coordinate(intent: 'review', open_loop: true)`
|
|
@@ -28,20 +28,20 @@ guarantees this changelog follows.
|
|
|
28
28
|
out in `CLAUDE.md`.
|
|
29
29
|
- `allow_orphan: true` — explicit acknowledgement that the caller
|
|
30
30
|
will drive `turn()` + dispatch manually (advanced / test use only).
|
|
31
|
-
- Internal callers (`bclaw_coordinate`, `bclaw_dispatch`) are not
|
|
32
|
-
affected — they bypass `handleBclawLoop` and invoke the core
|
|
33
|
-
`openLoop()` directly.
|
|
34
|
-
|
|
35
|
-
**Changed — sequence tools promoted to default discovery (pln#522)**
|
|
36
|
-
- `bclaw_list_sequences`, `bclaw_create_sequence`,
|
|
37
|
-
`bclaw_update_sequence`, and `bclaw_delete_sequence` move from
|
|
38
|
-
`advanced` to `standard`, so fresh agents see them in the default
|
|
39
|
-
`tools/list` catalog. Sequences are a core agent-first coordination
|
|
40
|
-
primitive for parallel dispatch, not an advanced-only admin surface.
|
|
41
|
-
- `bclaw_create_sequence.items` and `bclaw_update_sequence.items` now
|
|
42
|
-
expose the full item shape in JSON Schema: `planId`, optional
|
|
43
|
-
`stepId`, `rank`, `hard_after`, `soft_after`, `lane`, `scope_hint`,
|
|
44
|
-
and `rationale`.
|
|
31
|
+
- Internal callers (`bclaw_coordinate`, `bclaw_dispatch`) are not
|
|
32
|
+
affected — they bypass `handleBclawLoop` and invoke the core
|
|
33
|
+
`openLoop()` directly.
|
|
34
|
+
|
|
35
|
+
**Changed — sequence tools promoted to default discovery (pln#522)**
|
|
36
|
+
- `bclaw_list_sequences`, `bclaw_create_sequence`,
|
|
37
|
+
`bclaw_update_sequence`, and `bclaw_delete_sequence` move from
|
|
38
|
+
`advanced` to `standard`, so fresh agents see them in the default
|
|
39
|
+
`tools/list` catalog. Sequences are a core agent-first coordination
|
|
40
|
+
primitive for parallel dispatch, not an advanced-only admin surface.
|
|
41
|
+
- `bclaw_create_sequence.items` and `bclaw_update_sequence.items` now
|
|
42
|
+
expose the full item shape in JSON Schema: `planId`, optional
|
|
43
|
+
`stepId`, `rank`, `hard_after`, `soft_after`, `lane`, `scope_hint`,
|
|
44
|
+
and `rationale`.
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
@@ -93,11 +93,15 @@ will still succeed. A follow-up PR will strip the dead handler code.
|
|
|
93
93
|
changelog records the published MCP surface fingerprint. When a tool
|
|
94
94
|
name, tier, category, or input schema changes, the test fails until
|
|
95
95
|
this section is updated.
|
|
96
|
-
- MCP public surface fingerprint: `sha256:
|
|
97
|
-
(updated 2026-
|
|
98
|
-
inputSchema — pln#
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
- MCP public surface fingerprint: `sha256:333be7c3cda7e166`
|
|
97
|
+
(updated 2026-06-09: added the `preflight` boolean to the bclaw_coordinate
|
|
98
|
+
inputSchema — pln#533, the pre-flight spawn validation for open_loop reviews
|
|
99
|
+
(run one trivial validation spawn per reviewer before opening the loop so an
|
|
100
|
+
environment death surfaces with a clear reason instead of a generic timeout).
|
|
101
|
+
Prior value `sha256:a1881ff57ddce377` added the `ref` property to the
|
|
102
|
+
bclaw_coordinate inputSchema (2026-05-27, pln#520 Tier 2 / trp#371, the
|
|
103
|
+
scope-aware dirty guard; `ref` lets a dispatch build its worktree from an
|
|
104
|
+
explicit git ref). `sha256:0a4ba280aeff142b` exposed `allow_dirty` in the
|
|
101
105
|
bclaw_coordinate inputSchema. `sha256:e88c1a97fc29cfd1` came from the
|
|
102
106
|
pln#520 LoopPhase/LoopSlotInput schema resync, which itself reconciled
|
|
103
107
|
earlier unrecorded drift from `sha256:724085642dc3e2d7`.)
|