brainclaw 1.7.5 → 1.9.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 +28 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +139 -13
- package/dist/commands/add-step.js +1 -1
- package/dist/commands/bootstrap.js +2 -26
- package/dist/commands/check-security-mcp.js +50 -33
- package/dist/commands/check-security.js +86 -43
- package/dist/commands/claim.js +22 -21
- package/dist/commands/confirm.js +26 -0
- package/dist/commands/context-diff.js +1 -1
- package/dist/commands/dispatch-watch.js +142 -0
- package/dist/commands/doctor.js +113 -2
- package/dist/commands/estimation-report.js +115 -16
- package/dist/commands/harvest.js +502 -16
- package/dist/commands/init.js +123 -21
- package/dist/commands/loops-handlers.js +4 -0
- package/dist/commands/mcp-read-handlers.js +198 -29
- package/dist/commands/mcp.js +615 -92
- package/dist/commands/memory.js +21 -17
- package/dist/commands/migrate.js +81 -17
- package/dist/commands/prune.js +78 -4
- package/dist/commands/reflect.js +26 -20
- package/dist/commands/register-agent.js +57 -1
- package/dist/commands/repair.js +20 -0
- package/dist/commands/session-end.js +15 -6
- package/dist/commands/session-start.js +18 -1
- package/dist/commands/setup-security.js +39 -18
- package/dist/commands/setup.js +26 -27
- package/dist/commands/stale.js +16 -2
- package/dist/commands/uninstall.js +126 -34
- package/dist/commands/update-step.js +6 -0
- package/dist/commands/worktree.js +60 -0
- package/dist/core/actions.js +12 -3
- package/dist/core/agent-capability.js +11 -13
- package/dist/core/agent-files.js +844 -547
- package/dist/core/agent-integrations.js +0 -3
- package/dist/core/agent-inventory.js +67 -0
- package/dist/core/agent-registry.js +163 -29
- package/dist/core/agentrun-reconciler.js +33 -2
- package/dist/core/agentruns.js +7 -1
- package/dist/core/ai-agent-detection.js +31 -44
- package/dist/core/archival.js +15 -9
- package/dist/core/assignment-reconciler.js +56 -0
- package/dist/core/assignment-sweeper.js +127 -4
- package/dist/core/assignments.js +69 -11
- package/dist/core/bootstrap.js +233 -67
- package/dist/core/brainclaw-version.js +22 -0
- package/dist/core/candidates.js +21 -1
- package/dist/core/claims.js +313 -150
- package/dist/core/config.js +6 -1
- package/dist/core/context-diff.js +148 -20
- package/dist/core/context.js +129 -8
- package/dist/core/coordination.js +22 -3
- package/dist/core/dispatch-status.js +109 -5
- package/dist/core/dispatcher.js +65 -11
- package/dist/core/entity-operations.js +45 -24
- package/dist/core/entity-registry.js +31 -5
- package/dist/core/event-log.js +138 -21
- package/dist/core/events/checkpoint.js +258 -0
- package/dist/core/events/genesis.js +220 -0
- package/dist/core/events/journal.js +507 -0
- package/dist/core/events/materialize.js +126 -0
- package/dist/core/events/registry-post-image.js +110 -0
- package/dist/core/events/verify.js +109 -0
- package/dist/core/execution-adapters.js +23 -0
- package/dist/core/execution.js +25 -0
- package/dist/core/facade-schema.js +48 -0
- package/dist/core/gc-semantic.js +130 -5
- package/dist/core/handoff-snapshot.js +68 -0
- package/dist/core/ids.js +19 -8
- package/dist/core/instruction-templates.js +34 -115
- package/dist/core/io.js +39 -3
- package/dist/core/json-store.js +10 -1
- package/dist/core/lock.js +153 -28
- package/dist/core/loops/bootstrap-acquire.js +25 -1
- package/dist/core/loops/facade-schema.js +2 -0
- package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
- package/dist/core/loops/index.js +1 -0
- package/dist/core/loops/presets/bootstrap.js +7 -0
- package/dist/core/loops/store.js +17 -0
- package/dist/core/loops/verbs.js +24 -1
- package/dist/core/markdown.js +8 -76
- package/dist/core/mcp-command-resolution.js +245 -0
- package/dist/core/memory-compactor.js +5 -3
- package/dist/core/memory-lifecycle.js +282 -0
- package/dist/core/merge-risk.js +150 -0
- package/dist/core/messaging.js +8 -1
- package/dist/core/migration.js +11 -1
- package/dist/core/observer-mode.js +26 -0
- package/dist/core/operations/memory-mutation.js +90 -65
- package/dist/core/operations/plan.js +27 -1
- package/dist/core/protocol-skills.js +210 -0
- package/dist/core/reflection-safety.js +6 -7
- package/dist/core/reputation.js +84 -2
- package/dist/core/runtime-signals.js +71 -9
- package/dist/core/runtime.js +84 -1
- package/dist/core/schema.js +125 -0
- package/dist/core/security-detectors.js +125 -0
- package/dist/core/security-extract.js +189 -0
- package/dist/core/security-guard.js +107 -29
- package/dist/core/security-packages.js +121 -0
- package/dist/core/security-scoring.js +76 -9
- package/dist/core/security.js +34 -2
- package/dist/core/sequence.js +11 -2
- package/dist/core/setup-flow.js +141 -13
- package/dist/core/spawn-check.js +110 -4
- package/dist/core/staleness.js +109 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +169 -7
- package/dist/facts.js +8 -8
- package/dist/facts.json +7 -7
- package/docs/PROTOCOL.md +223 -0
- package/docs/cli.md +11 -10
- package/docs/concepts/coordinator-runbook.md +129 -0
- package/docs/concepts/dispatch-lifecycle.md +17 -0
- package/docs/concepts/event-log-store-critique-A.md +333 -0
- package/docs/concepts/event-log-store-critique-B.md +353 -0
- package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
- package/docs/concepts/event-log-store-proposal-A.md +365 -0
- package/docs/concepts/event-log-store-proposal-B.md +404 -0
- package/docs/concepts/event-log-store.md +928 -0
- package/docs/concepts/identity-model-proposal.md +371 -0
- package/docs/concepts/memory.md +5 -4
- package/docs/concepts/observer-protocol.md +361 -0
- package/docs/concepts/parallel-merge-protocol.md +71 -0
- package/docs/concepts/plans-and-claims.md +43 -0
- package/docs/concepts/skills.md +78 -0
- package/docs/concepts/workspace-bootstrapping.md +61 -0
- package/docs/integrations/agents.md +4 -4
- package/docs/integrations/cline.md +10 -11
- package/docs/integrations/codex.md +2 -2
- package/docs/integrations/continue.md +5 -5
- package/docs/integrations/copilot.md +14 -12
- package/docs/integrations/openclaw.md +7 -6
- package/docs/integrations/overview.md +7 -7
- package/docs/integrations/roo.md +3 -3
- package/docs/integrations/windsurf.md +6 -6
- package/docs/mcp-schema-changelog.md +51 -20
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- package/package.json +8 -6
package/dist/commands/harvest.js
CHANGED
|
@@ -11,21 +11,17 @@
|
|
|
11
11
|
* @module
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
-
import os from 'node:os';
|
|
15
14
|
import path from 'node:path';
|
|
16
|
-
import
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
17
16
|
import { CandidateSchema, LaneResultSchema } from '../core/schema.js';
|
|
17
|
+
import { gitEvidence } from '../core/dispatch-status.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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
function worktreesBaseDir(projectRoot) {
|
|
26
|
-
const hash = crypto.createHash('sha1').update(projectRoot).digest('hex').slice(0, 12);
|
|
27
|
-
return path.join(os.homedir(), '.brainclaw', 'worktrees', hash);
|
|
28
|
-
}
|
|
21
|
+
import { loadAssignment, transitionAssignment } from '../core/assignments.js';
|
|
22
|
+
import { releaseClaimWithCascade, loadClaim } from '../core/claims.js';
|
|
23
|
+
import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
|
|
24
|
+
import { commitWorktreeOnBehalf, worktreesBaseDir } from '../core/worktree.js';
|
|
29
25
|
/**
|
|
30
26
|
* Auto-detect all worktree directories under the brainclaw-managed base dir.
|
|
31
27
|
* Returns subdirectories that exist on disk (may or may not have an inbox).
|
|
@@ -38,6 +34,44 @@ function autoDetectWorktreePaths(cwd) {
|
|
|
38
34
|
.filter((entry) => entry.isDirectory())
|
|
39
35
|
.map((entry) => path.join(base, entry.name));
|
|
40
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* sprint 1.5 — deterministic worktree resolution for one assignment. The
|
|
39
|
+
* auto-detect scan depends on the project-hash directory layout and missed a
|
|
40
|
+
* LANE-RESULT.json that demonstrably existed (asgn_ab11b801): the assignment's
|
|
41
|
+
* own worktree_path (and its claim's) are authoritative — scan them FIRST.
|
|
42
|
+
* Works regardless of assignment status (incl. expired — evidence arriving
|
|
43
|
+
* late must still be harvestable).
|
|
44
|
+
*/
|
|
45
|
+
function resolveAssignmentWorktreePaths(assignmentId, cwd) {
|
|
46
|
+
const paths = [];
|
|
47
|
+
const assignment = loadAssignment(assignmentId, cwd);
|
|
48
|
+
if (assignment?.worktree_path)
|
|
49
|
+
paths.push(assignment.worktree_path);
|
|
50
|
+
if (assignment?.claim_id) {
|
|
51
|
+
try {
|
|
52
|
+
const claim = loadClaim(assignment.claim_id, cwd);
|
|
53
|
+
if (claim.worktree_path)
|
|
54
|
+
paths.push(claim.worktree_path);
|
|
55
|
+
}
|
|
56
|
+
catch { /* claim gone — assignment path may still resolve */ }
|
|
57
|
+
}
|
|
58
|
+
return [...new Set(paths)].filter((p) => {
|
|
59
|
+
try {
|
|
60
|
+
return fs.existsSync(p);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** Scan list for a lane harvest: explicit paths win; otherwise the assignment's
|
|
68
|
+
* own worktrees first, then the auto-detected pool (deduped). */
|
|
69
|
+
function resolveLaneScanPaths(options, cwd) {
|
|
70
|
+
if (options.worktreePaths && options.worktreePaths.length > 0)
|
|
71
|
+
return options.worktreePaths;
|
|
72
|
+
const assignmentPaths = options.assignmentId ? resolveAssignmentWorktreePaths(options.assignmentId, cwd) : [];
|
|
73
|
+
return [...new Set([...assignmentPaths, ...autoDetectWorktreePaths(cwd)])];
|
|
74
|
+
}
|
|
41
75
|
/**
|
|
42
76
|
* Collect all `cnd_*.json` files from a worktree's candidate inbox.
|
|
43
77
|
*
|
|
@@ -213,9 +247,7 @@ export function harvestLaneResults(options = {}) {
|
|
|
213
247
|
const cwd = options.cwd ?? process.cwd();
|
|
214
248
|
const agent = options.agent ?? 'coordinator';
|
|
215
249
|
const result = { harvested: [], skipped: [], errors: [] };
|
|
216
|
-
const worktreePaths = (options
|
|
217
|
-
? options.worktreePaths
|
|
218
|
-
: autoDetectWorktreePaths(cwd);
|
|
250
|
+
const worktreePaths = resolveLaneScanPaths(options, cwd);
|
|
219
251
|
for (const worktreePath of worktreePaths) {
|
|
220
252
|
const file = getLaneResultPath(worktreePath);
|
|
221
253
|
if (!fs.existsSync(file))
|
|
@@ -253,7 +285,7 @@ export function harvestLaneResults(options = {}) {
|
|
|
253
285
|
},
|
|
254
286
|
}, cwd);
|
|
255
287
|
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
256
|
-
fs.writeFileSync(marker, new Date(
|
|
288
|
+
fs.writeFileSync(marker, new Date().toISOString(), 'utf-8');
|
|
257
289
|
}
|
|
258
290
|
catch (err) {
|
|
259
291
|
result.errors.push(`Failed to ingest lane result for ${lane.assignment_id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -264,16 +296,461 @@ export function harvestLaneResults(options = {}) {
|
|
|
264
296
|
}
|
|
265
297
|
return result;
|
|
266
298
|
}
|
|
299
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
300
|
+
// pln#534 — worktree-as-contract: integrate a worker's lane on its behalf.
|
|
301
|
+
//
|
|
302
|
+
// LEVER #1 from the LeaseUp frontier (can_100f1e8c). The worker's contract is
|
|
303
|
+
// reduced to "edit files in this worktree + drop LANE-RESULT.json". brainclaw
|
|
304
|
+
// carries the rest for a worker that cannot (a sandboxed agent whose root
|
|
305
|
+
// excludes `.git`, i.e. dispatchCanCommit=false): it COMMITS the worktree diff
|
|
306
|
+
// on the worker's behalf so the code lands on the lane branch, then lifecycles
|
|
307
|
+
// the assignment and releases the claim (with plan cascade). Self-commit / MCP /
|
|
308
|
+
// self-lifecycle become PROGRESSIVE enrichments, not prerequisites.
|
|
309
|
+
//
|
|
310
|
+
// Strictly ADDITIVE + opt-in: nothing here runs unless a caller invokes
|
|
311
|
+
// integrateLaneResults / `brainclaw harvest --integrate`. Existing harvest stays
|
|
312
|
+
// report-only.
|
|
313
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
314
|
+
/** Happy-path assignment FSM chain walked when force-completing on behalf. */
|
|
315
|
+
const ASSIGNMENT_COMPLETE_CHAIN = ['created', 'offered', 'accepted', 'started', 'completed'];
|
|
316
|
+
/**
|
|
317
|
+
* Walk a still-open assignment forward to `completed` through the valid FSM
|
|
318
|
+
* chain (offered→accepted→started→completed), attaching artifacts on the final
|
|
319
|
+
* step. Idempotent (already-completed → true; transitions no-op). Returns false
|
|
320
|
+
* for assignments parked off the happy path (failed/blocked/timed_out) — those
|
|
321
|
+
* are not silently force-completed.
|
|
322
|
+
*/
|
|
323
|
+
function forceCompleteAssignment(assignmentId, artifacts, statusReason, actor, cwd) {
|
|
324
|
+
const current = loadAssignment(assignmentId, cwd);
|
|
325
|
+
if (!current)
|
|
326
|
+
return false;
|
|
327
|
+
if (current.status === 'completed')
|
|
328
|
+
return true;
|
|
329
|
+
// can_948acfd6 — expired→completed: a LANE-RESULT arriving after an
|
|
330
|
+
// administrative expiry is the truth; converge instead of FSM-blocking.
|
|
331
|
+
if (current.status === 'expired') {
|
|
332
|
+
try {
|
|
333
|
+
transitionAssignment(assignmentId, 'completed', {
|
|
334
|
+
actor, artifacts, status_reason: `${statusReason} (late evidence after administrative expiry)`,
|
|
335
|
+
}, cwd);
|
|
336
|
+
}
|
|
337
|
+
catch { /* concurrent transition */ }
|
|
338
|
+
return loadAssignment(assignmentId, cwd)?.status === 'completed';
|
|
339
|
+
}
|
|
340
|
+
const startIdx = ASSIGNMENT_COMPLETE_CHAIN.indexOf(current.status);
|
|
341
|
+
if (startIdx === -1)
|
|
342
|
+
return false; // off the happy path (failed/blocked/…): leave it.
|
|
343
|
+
for (let i = startIdx + 1; i < ASSIGNMENT_COMPLETE_CHAIN.length; i++) {
|
|
344
|
+
const next = ASSIGNMENT_COMPLETE_CHAIN[i];
|
|
345
|
+
try {
|
|
346
|
+
transitionAssignment(assignmentId, next, next === 'completed' ? { actor, artifacts, status_reason: statusReason } : { actor }, cwd);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// A concurrent explicit transition may have moved it; stop walking.
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return loadAssignment(assignmentId, cwd)?.status === 'completed';
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Integrate completed lanes on behalf of workers that cannot self-commit.
|
|
357
|
+
*
|
|
358
|
+
* For each LANE-RESULT.json found (optionally filtered to one assignment):
|
|
359
|
+
* 1. resolve the assignment + the worker agent's commit capability;
|
|
360
|
+
* 2. when the worker CANNOT commit (sandboxed) and the worktree is dirty,
|
|
361
|
+
* commit the diff on its behalf onto the lane branch (guarded to the linked
|
|
362
|
+
* worktree only — never the main repo);
|
|
363
|
+
* 3. lifecycle the assignment (status=completed → walk to completed with the
|
|
364
|
+
* commit + files as artifacts; status=blocked/failed → best-effort
|
|
365
|
+
* transition) and release the claim with plan cascade.
|
|
366
|
+
*
|
|
367
|
+
* A worker that CAN commit is left to its self-commit handoff — brainclaw only
|
|
368
|
+
* lifecycles/releases, it does not author commits for it.
|
|
369
|
+
*/
|
|
370
|
+
export function integrateLaneResults(options = {}) {
|
|
371
|
+
const cwd = options.cwd ?? process.cwd();
|
|
372
|
+
const actor = options.agent ?? 'coordinator';
|
|
373
|
+
const result = { integrated: [], skipped: [], errors: [] };
|
|
374
|
+
const worktreePaths = resolveLaneScanPaths(options, cwd);
|
|
375
|
+
for (const worktreePath of worktreePaths) {
|
|
376
|
+
const file = getLaneResultPath(worktreePath);
|
|
377
|
+
if (!fs.existsSync(file))
|
|
378
|
+
continue;
|
|
379
|
+
let lane;
|
|
380
|
+
try {
|
|
381
|
+
lane = LaneResultSchema.parse(JSON.parse(fs.readFileSync(file, 'utf-8')));
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
result.errors.push(`Failed to parse ${file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (options.assignmentId && lane.assignment_id !== options.assignmentId)
|
|
388
|
+
continue;
|
|
389
|
+
const assignment = loadAssignment(lane.assignment_id, cwd);
|
|
390
|
+
if (!assignment) {
|
|
391
|
+
result.skipped.push(lane.assignment_id);
|
|
392
|
+
result.errors.push(`No assignment record for lane ${lane.assignment_id} — cannot integrate`);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const profile = getCapabilityProfile(assignment.agent);
|
|
396
|
+
// No profile ⇒ assume it can commit (conservative: don't author for an
|
|
397
|
+
// unknown agent), so brainclaw only lifecycles.
|
|
398
|
+
const workerCanCommit = profile ? dispatchCanCommit(profile) : true;
|
|
399
|
+
const entry = {
|
|
400
|
+
assignment_id: lane.assignment_id,
|
|
401
|
+
worker_agent: assignment.agent,
|
|
402
|
+
lane_status: lane.status,
|
|
403
|
+
worker_can_commit: workerCanCommit,
|
|
404
|
+
committed_on_behalf: false,
|
|
405
|
+
files_changed: lane.files_changed ?? [],
|
|
406
|
+
assignment_completed: false,
|
|
407
|
+
claim_released: false,
|
|
408
|
+
reason: '',
|
|
409
|
+
};
|
|
410
|
+
const reasons = [];
|
|
411
|
+
// 1. Commit on behalf (only when the worker cannot, and there is a diff).
|
|
412
|
+
if (!workerCanCommit) {
|
|
413
|
+
if (options.dryRun) {
|
|
414
|
+
reasons.push('(dry-run) would commit worktree diff on behalf');
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
const message = `chore(lane): integrate ${assignment.agent} work for ${lane.assignment_id}\n\n`
|
|
418
|
+
+ `${lane.summary}\n\n[brainclaw committed on behalf — worker sandbox cannot self-commit (pln#534)]`;
|
|
419
|
+
const commit = commitWorktreeOnBehalf(worktreePath, message, {
|
|
420
|
+
authorName: `${assignment.agent} (via brainclaw)`,
|
|
421
|
+
authorEmail: 'brainclaw@on-behalf.local',
|
|
422
|
+
});
|
|
423
|
+
entry.committed_on_behalf = commit.committed;
|
|
424
|
+
entry.commit_sha = commit.sha;
|
|
425
|
+
if (commit.committed)
|
|
426
|
+
entry.files_changed = commit.files_changed;
|
|
427
|
+
reasons.push(commit.reason);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
reasons.push('worker can self-commit — no on-behalf commit');
|
|
432
|
+
}
|
|
433
|
+
// 2. Lifecycle + release.
|
|
434
|
+
if (!options.dryRun) {
|
|
435
|
+
if (lane.status === 'completed') {
|
|
436
|
+
const artifacts = [
|
|
437
|
+
...(entry.commit_sha ? [{ type: 'commit', ref: entry.commit_sha, description: 'on-behalf integration commit' }] : []),
|
|
438
|
+
...entry.files_changed.slice(0, 50).map((f) => ({ type: 'file', ref: f })),
|
|
439
|
+
];
|
|
440
|
+
entry.assignment_completed = forceCompleteAssignment(lane.assignment_id, artifacts, `pln#534 on-behalf integration: ${lane.summary.slice(0, 120)}`, actor, cwd);
|
|
441
|
+
try {
|
|
442
|
+
const rel = releaseClaimWithCascade(assignment.claim_id, { planStatus: 'done', cwd });
|
|
443
|
+
entry.claim_released = rel.claim.status === 'released';
|
|
444
|
+
}
|
|
445
|
+
catch (err) {
|
|
446
|
+
reasons.push(`claim release failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
// blocked / failed: best-effort lifecycle (FSM may reject from offered).
|
|
451
|
+
const target = lane.status === 'blocked' ? 'blocked' : 'failed';
|
|
452
|
+
try {
|
|
453
|
+
transitionAssignment(lane.assignment_id, target, { actor, status_reason: lane.summary.slice(0, 200) }, cwd);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
reasons.push(`assignment ${target} transition rejected: ${err instanceof Error ? err.message : String(err)}`);
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const rel = releaseClaimWithCascade(assignment.claim_id, {
|
|
460
|
+
planStatus: lane.status === 'blocked' ? 'blocked' : undefined,
|
|
461
|
+
cwd,
|
|
462
|
+
});
|
|
463
|
+
entry.claim_released = rel.claim.status === 'released';
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
reasons.push(`claim release failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Durable trace of the integration.
|
|
470
|
+
try {
|
|
471
|
+
createRuntimeEvent({
|
|
472
|
+
agent: actor,
|
|
473
|
+
event_type: 'lane_integrated',
|
|
474
|
+
text: `Integrated lane ${lane.assignment_id} (${lane.status}) on behalf of ${assignment.agent}`,
|
|
475
|
+
tags: ['harvest', 'integrate', 'worktree-as-contract', lane.status],
|
|
476
|
+
assignment_id: lane.assignment_id,
|
|
477
|
+
metadata: {
|
|
478
|
+
assignment_id: lane.assignment_id,
|
|
479
|
+
worker_agent: assignment.agent,
|
|
480
|
+
committed_on_behalf: entry.committed_on_behalf,
|
|
481
|
+
commit_sha: entry.commit_sha ?? null,
|
|
482
|
+
files_changed: entry.files_changed,
|
|
483
|
+
assignment_completed: entry.assignment_completed,
|
|
484
|
+
claim_released: entry.claim_released,
|
|
485
|
+
},
|
|
486
|
+
}, cwd);
|
|
487
|
+
}
|
|
488
|
+
catch { /* event is best-effort */ }
|
|
489
|
+
}
|
|
490
|
+
entry.reason = reasons.join('; ');
|
|
491
|
+
result.integrated.push(entry);
|
|
492
|
+
}
|
|
493
|
+
return result;
|
|
494
|
+
}
|
|
495
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
496
|
+
// pln#554 step 3 — `harvest --orphaned`: recover a dead worker that left NO
|
|
497
|
+
// LANE-RESULT. Codifies the manual recovery executed twice on 2026-06-10
|
|
498
|
+
// (42 and 41 files, zero loss): inspect the worktree, typecheck if possible,
|
|
499
|
+
// commit on-behalf with the standard marker, lifecycle when the FSM allows,
|
|
500
|
+
// release the claim. NEVER deletes or resets anything.
|
|
501
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
502
|
+
export const ORPHANED_COMMIT_MARKER = '[brainclaw committed on behalf — worker died before delivering; coordinator harvest --orphaned]';
|
|
503
|
+
function countUntracked(worktreePath) {
|
|
504
|
+
const r = spawnSync('git', ['-C', worktreePath, 'status', '--short'], { encoding: 'utf-8', timeout: 15000 });
|
|
505
|
+
if (r.status !== 0)
|
|
506
|
+
return 0;
|
|
507
|
+
return (r.stdout ?? '').split('\n').filter((l) => l.startsWith('??')).length;
|
|
508
|
+
}
|
|
509
|
+
/** `npx tsc --noEmit` in the worktree; skips gracefully when node_modules is absent. */
|
|
510
|
+
function typecheckWorktree(worktreePath) {
|
|
511
|
+
if (!fs.existsSync(path.join(worktreePath, 'node_modules'))) {
|
|
512
|
+
return {
|
|
513
|
+
status: 'skipped_no_node_modules',
|
|
514
|
+
output: 'node_modules absent in worktree — link it from the main repo (Windows junction / symlink) to typecheck locally; the coordinator validates centrally after harvest.',
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
// Fixed command string (no user input) — shell needed for npx on Windows.
|
|
518
|
+
const r = spawnSync('npx tsc --noEmit', { cwd: worktreePath, shell: true, encoding: 'utf-8', timeout: 300_000 });
|
|
519
|
+
if (r.status === 0)
|
|
520
|
+
return { status: 'passed' };
|
|
521
|
+
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`.trim();
|
|
522
|
+
return { status: 'failed', output: out.slice(0, 2000) };
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Recover an orphaned lane: a worker died without writing LANE-RESULT.json.
|
|
526
|
+
*
|
|
527
|
+
* Evidence-first and strictly non-destructive:
|
|
528
|
+
* - LANE-RESULT present → not orphaned; refuse and point at the normal harvest;
|
|
529
|
+
* - tracked changes → typecheck (best effort), then commit on-behalf with
|
|
530
|
+
* ORPHANED_COMMIT_MARKER;
|
|
531
|
+
* - clean tree + no commits ahead → 'nothing to recover', state untouched;
|
|
532
|
+
* - then lifecycle the assignment when the FSM allows and release the claim.
|
|
533
|
+
*/
|
|
534
|
+
export function harvestOrphaned(options) {
|
|
535
|
+
const cwd = options.cwd ?? process.cwd();
|
|
536
|
+
const actor = options.agent ?? 'coordinator';
|
|
537
|
+
const baseRef = options.baseRef ?? 'master';
|
|
538
|
+
const report = {
|
|
539
|
+
assignment_id: options.assignmentId,
|
|
540
|
+
worktree_path: undefined,
|
|
541
|
+
commits_ahead: 0,
|
|
542
|
+
dirty_tracked: 0,
|
|
543
|
+
untracked: 0,
|
|
544
|
+
nothing_to_recover: false,
|
|
545
|
+
typecheck: 'not_run',
|
|
546
|
+
committed_on_behalf: false,
|
|
547
|
+
files_changed: [],
|
|
548
|
+
assignment_completed: false,
|
|
549
|
+
claim_released: false,
|
|
550
|
+
errors: [],
|
|
551
|
+
recommended_next_action: '',
|
|
552
|
+
};
|
|
553
|
+
let worktree = options.worktreePath;
|
|
554
|
+
if (!worktree && options.assignmentId) {
|
|
555
|
+
worktree = resolveAssignmentWorktreePaths(options.assignmentId, cwd)[0];
|
|
556
|
+
}
|
|
557
|
+
if (!worktree || !fs.existsSync(worktree)) {
|
|
558
|
+
report.errors.push('No worktree resolved — pass --worktree <path> explicitly, or patch claim.worktree_path.');
|
|
559
|
+
report.recommended_next_action = 'Resolve the worktree path first; nothing was touched.';
|
|
560
|
+
return report;
|
|
561
|
+
}
|
|
562
|
+
report.worktree_path = worktree;
|
|
563
|
+
if (fs.existsSync(getLaneResultPath(worktree))) {
|
|
564
|
+
report.errors.push('LANE-RESULT.json present — this lane is NOT orphaned. Use `brainclaw harvest <assignment_id> [--integrate]` instead.');
|
|
565
|
+
report.recommended_next_action = 'Run the normal lane harvest; nothing was touched.';
|
|
566
|
+
return report;
|
|
567
|
+
}
|
|
568
|
+
const evidence = gitEvidence(worktree, baseRef);
|
|
569
|
+
if (!evidence) {
|
|
570
|
+
report.errors.push(`Could not read git evidence from ${worktree} (base ref '${baseRef}') — is it a git worktree and does the base ref exist?`);
|
|
571
|
+
report.recommended_next_action = 'Fix the base ref (--base) or inspect the worktree manually; nothing was touched.';
|
|
572
|
+
return report;
|
|
573
|
+
}
|
|
574
|
+
report.commits_ahead = evidence.commitsAhead;
|
|
575
|
+
report.dirty_tracked = evidence.dirtyTracked;
|
|
576
|
+
report.untracked = countUntracked(worktree);
|
|
577
|
+
if (evidence.dirtyTracked === 0 && evidence.commitsAhead === 0) {
|
|
578
|
+
report.nothing_to_recover = true;
|
|
579
|
+
report.recommended_next_action = report.untracked > 0
|
|
580
|
+
? `Nothing to recover (no tracked changes, no commits ahead). ${report.untracked} untracked file(s) present — inspect them manually before any cleanup. State left untouched.`
|
|
581
|
+
: 'Nothing to recover — worktree clean with no commits ahead. State left untouched.';
|
|
582
|
+
return report;
|
|
583
|
+
}
|
|
584
|
+
// Tracked changes → typecheck (best effort), then commit on-behalf.
|
|
585
|
+
if (evidence.dirtyTracked > 0) {
|
|
586
|
+
if (options.dryRun) {
|
|
587
|
+
report.recommended_next_action = `(dry-run) would typecheck + commit ${evidence.dirtyTracked} tracked change(s) on behalf.`;
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
const tc = typecheckWorktree(worktree);
|
|
591
|
+
report.typecheck = tc.status;
|
|
592
|
+
report.typecheck_output = tc.output;
|
|
593
|
+
const message = `chore(lane): recover orphaned worker output${options.assignmentId ? ` for ${options.assignmentId}` : ''}\n\n${ORPHANED_COMMIT_MARKER}`;
|
|
594
|
+
const commit = commitWorktreeOnBehalf(worktree, message, {
|
|
595
|
+
authorName: 'brainclaw (orphaned recovery)',
|
|
596
|
+
authorEmail: 'brainclaw@on-behalf.local',
|
|
597
|
+
});
|
|
598
|
+
report.committed_on_behalf = commit.committed;
|
|
599
|
+
report.commit_sha = commit.sha;
|
|
600
|
+
report.files_changed = commit.files_changed;
|
|
601
|
+
if (!commit.committed)
|
|
602
|
+
report.errors.push(`commit on behalf failed: ${commit.reason}`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else if (options.dryRun) {
|
|
606
|
+
// commits ahead with a clean tree — the worker delivered before dying.
|
|
607
|
+
report.recommended_next_action = `(dry-run) nothing to commit (${evidence.commitsAhead} commit(s) already on the branch); would lifecycle the assignment + release the claim.`;
|
|
608
|
+
}
|
|
609
|
+
// Lifecycle + claim release (only with an assignment to converge, never dry-run).
|
|
610
|
+
if (!options.dryRun && options.assignmentId) {
|
|
611
|
+
const assignment = loadAssignment(options.assignmentId, cwd);
|
|
612
|
+
if (assignment) {
|
|
613
|
+
const artifacts = [
|
|
614
|
+
...(report.commit_sha ? [{ type: 'commit', ref: report.commit_sha, description: 'orphaned-recovery commit (on behalf)' }] : []),
|
|
615
|
+
...report.files_changed.slice(0, 50).map((f) => ({ type: 'file', ref: f })),
|
|
616
|
+
];
|
|
617
|
+
report.assignment_completed = forceCompleteAssignment(options.assignmentId, artifacts, 'pln#554 harvest --orphaned: worker died before delivering; work recovered from worktree', actor, cwd);
|
|
618
|
+
try {
|
|
619
|
+
const rel = releaseClaimWithCascade(assignment.claim_id, { planStatus: 'done', cwd });
|
|
620
|
+
report.claim_released = rel.claim.status === 'released';
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
report.errors.push(`claim release failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
report.errors.push(`No assignment record for ${options.assignmentId} — recovered the worktree but skipped lifecycle/claim release.`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
if (!options.dryRun) {
|
|
631
|
+
try {
|
|
632
|
+
createRuntimeEvent({
|
|
633
|
+
agent: actor,
|
|
634
|
+
event_type: 'lane_integrated',
|
|
635
|
+
text: `Orphaned lane recovered${options.assignmentId ? ` for ${options.assignmentId}` : ''}: ${report.files_changed.length} file(s) committed on behalf (typecheck=${report.typecheck})`,
|
|
636
|
+
tags: ['harvest', 'orphaned', 'recovery'],
|
|
637
|
+
assignment_id: options.assignmentId,
|
|
638
|
+
metadata: {
|
|
639
|
+
assignment_id: options.assignmentId ?? null,
|
|
640
|
+
worktree_path: worktree,
|
|
641
|
+
commit_sha: report.commit_sha ?? null,
|
|
642
|
+
files_changed: report.files_changed,
|
|
643
|
+
typecheck: report.typecheck,
|
|
644
|
+
commits_ahead: report.commits_ahead,
|
|
645
|
+
assignment_completed: report.assignment_completed,
|
|
646
|
+
claim_released: report.claim_released,
|
|
647
|
+
},
|
|
648
|
+
}, cwd);
|
|
649
|
+
}
|
|
650
|
+
catch { /* event is best-effort */ }
|
|
651
|
+
const tcWarn = report.typecheck === 'failed'
|
|
652
|
+
? ' WARNING: typecheck FAILED — fix the branch before merging (output captured in the report).'
|
|
653
|
+
: report.typecheck === 'skipped_no_node_modules'
|
|
654
|
+
? ' Typecheck was skipped (no node_modules) — validate centrally.'
|
|
655
|
+
: '';
|
|
656
|
+
report.recommended_next_action =
|
|
657
|
+
`Run targeted tests for the recovered files, then merge the lane branch.${tcWarn}`;
|
|
658
|
+
}
|
|
659
|
+
return report;
|
|
660
|
+
}
|
|
267
661
|
export function runHarvestLane(assignmentId, options = {}) {
|
|
268
662
|
const cwd = options.cwd ?? process.cwd();
|
|
269
663
|
if (!memoryExists(cwd)) {
|
|
270
664
|
console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
|
|
271
665
|
process.exit(1);
|
|
272
666
|
}
|
|
273
|
-
if (!assignmentId && !options.all) {
|
|
667
|
+
if (!assignmentId && !options.all && !(options.orphaned && options.worktree?.length)) {
|
|
274
668
|
console.error('Error: provide an <assignment_id>, or pass --all to harvest every lane result.');
|
|
275
669
|
process.exit(1);
|
|
276
670
|
}
|
|
671
|
+
// pln#554 — `--orphaned`: the worker died WITHOUT a lane-result. Recover its
|
|
672
|
+
// worktree (typecheck + commit on behalf), lifecycle, and release. Never
|
|
673
|
+
// deletes or resets anything.
|
|
674
|
+
if (options.orphaned) {
|
|
675
|
+
const report = harvestOrphaned({
|
|
676
|
+
assignmentId,
|
|
677
|
+
worktreePath: options.worktree?.[0],
|
|
678
|
+
baseRef: options.base,
|
|
679
|
+
dryRun: options.dryRun,
|
|
680
|
+
cwd,
|
|
681
|
+
});
|
|
682
|
+
if (options.json) {
|
|
683
|
+
console.log(JSON.stringify(report, null, 2));
|
|
684
|
+
process.exitCode = report.errors.length > 0 ? 1 : 0;
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const dry = options.dryRun ? ' (dry-run)' : '';
|
|
688
|
+
console.log(`Orphaned-lane recovery${dry} for ${assignmentId ?? report.worktree_path ?? '(unresolved)'}:`);
|
|
689
|
+
if (report.worktree_path)
|
|
690
|
+
console.log(` worktree: ${report.worktree_path}`);
|
|
691
|
+
console.log(` evidence: commits_ahead=${report.commits_ahead} dirty_tracked=${report.dirty_tracked} untracked=${report.untracked}`);
|
|
692
|
+
if (report.nothing_to_recover) {
|
|
693
|
+
console.log(' → nothing to recover; state left untouched.');
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
if (report.typecheck !== 'not_run') {
|
|
697
|
+
console.log(` typecheck: ${report.typecheck}`);
|
|
698
|
+
if (report.typecheck_output)
|
|
699
|
+
console.log(` ${report.typecheck_output.split('\n').slice(0, 12).join('\n ')}`);
|
|
700
|
+
}
|
|
701
|
+
if (report.committed_on_behalf) {
|
|
702
|
+
console.log(` ✔ committed on behalf: ${report.commit_sha?.slice(0, 10)} (${report.files_changed.length} file(s))`);
|
|
703
|
+
}
|
|
704
|
+
console.log(` assignment_completed=${report.assignment_completed} claim_released=${report.claim_released}`);
|
|
705
|
+
}
|
|
706
|
+
for (const err of report.errors)
|
|
707
|
+
console.error(` ✗ ${err}`);
|
|
708
|
+
if (report.recommended_next_action)
|
|
709
|
+
console.log(` → ${report.recommended_next_action}`);
|
|
710
|
+
process.exitCode = report.errors.length > 0 ? 1 : 0;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
// pln#534 — `--integrate` upgrades harvest from report-only to converge-the-
|
|
714
|
+
// lane: commit the worktree diff on behalf of a sandboxed worker, lifecycle
|
|
715
|
+
// the assignment, and release the claim. Runs alongside the normal ingest.
|
|
716
|
+
if (options.integrate) {
|
|
717
|
+
const integ = integrateLaneResults({
|
|
718
|
+
assignmentId: options.all ? undefined : assignmentId,
|
|
719
|
+
worktreePaths: options.worktree,
|
|
720
|
+
dryRun: options.dryRun,
|
|
721
|
+
cwd,
|
|
722
|
+
});
|
|
723
|
+
if (options.json) {
|
|
724
|
+
console.log(JSON.stringify(integ, null, 2));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const dry = options.dryRun ? ' (dry-run)' : '';
|
|
728
|
+
if (integ.integrated.length === 0 && integ.errors.length === 0) {
|
|
729
|
+
if (assignmentId) {
|
|
730
|
+
const checked = resolveLaneScanPaths({ assignmentId, worktreePaths: options.worktree }, cwd);
|
|
731
|
+
console.log(`No LANE-RESULT.json to integrate for ${assignmentId}.`);
|
|
732
|
+
console.log(checked.length > 0
|
|
733
|
+
? ` Checked worktree(s): ${checked.slice(0, 5).join(', ')}${checked.length > 5 ? ` (+${checked.length - 5} more)` : ''}`
|
|
734
|
+
: ' No worktree resolved for this assignment — pass --worktree <path> explicitly, or patch claim.worktree_path.');
|
|
735
|
+
}
|
|
736
|
+
else {
|
|
737
|
+
console.log('No lane results to integrate.');
|
|
738
|
+
}
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
for (const e of integ.integrated) {
|
|
742
|
+
console.log(` ✔ Integrated [${e.assignment_id}] ${e.lane_status} (worker=${e.worker_agent}, can_commit=${e.worker_can_commit})`);
|
|
743
|
+
if (e.committed_on_behalf)
|
|
744
|
+
console.log(` committed on behalf: ${e.commit_sha?.slice(0, 10)} (${e.files_changed.length} file(s))`);
|
|
745
|
+
console.log(` assignment_completed=${e.assignment_completed} claim_released=${e.claim_released}`);
|
|
746
|
+
if (e.reason)
|
|
747
|
+
console.log(` ${e.reason}`);
|
|
748
|
+
}
|
|
749
|
+
for (const err of integ.errors)
|
|
750
|
+
console.error(` ✗ ${err}`);
|
|
751
|
+
console.log(`\n✔ Lane integrate complete${dry}: ${integ.integrated.length} integrated, ${integ.errors.length} error(s).`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
277
754
|
const result = harvestLaneResults({
|
|
278
755
|
assignmentId: options.all ? undefined : assignmentId,
|
|
279
756
|
worktreePaths: options.worktree,
|
|
@@ -290,7 +767,16 @@ export function runHarvestLane(assignmentId, options = {}) {
|
|
|
290
767
|
}
|
|
291
768
|
const dryTag = options.dryRun ? ' (dry-run)' : '';
|
|
292
769
|
if (result.harvested.length === 0 && result.skipped.length === 0 && result.errors.length === 0) {
|
|
293
|
-
|
|
770
|
+
if (assignmentId) {
|
|
771
|
+
const checked = resolveLaneScanPaths({ assignmentId, worktreePaths: options.worktree }, cwd);
|
|
772
|
+
console.log(`No LANE-RESULT.json found for ${assignmentId}.`);
|
|
773
|
+
console.log(checked.length > 0
|
|
774
|
+
? ` Checked worktree(s): ${checked.slice(0, 5).join(', ')}${checked.length > 5 ? ` (+${checked.length - 5} more)` : ''}`
|
|
775
|
+
: ' No worktree resolved for this assignment — pass --worktree <path> explicitly, or patch claim.worktree_path.');
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
console.log('No lane results found in any worktree.');
|
|
779
|
+
}
|
|
294
780
|
return;
|
|
295
781
|
}
|
|
296
782
|
for (const lane of result.harvested) {
|