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.
Files changed (143) hide show
  1. package/README.md +28 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +139 -13
  4. package/dist/commands/add-step.js +1 -1
  5. package/dist/commands/bootstrap.js +2 -26
  6. package/dist/commands/check-security-mcp.js +50 -33
  7. package/dist/commands/check-security.js +86 -43
  8. package/dist/commands/claim.js +22 -21
  9. package/dist/commands/confirm.js +26 -0
  10. package/dist/commands/context-diff.js +1 -1
  11. package/dist/commands/dispatch-watch.js +142 -0
  12. package/dist/commands/doctor.js +113 -2
  13. package/dist/commands/estimation-report.js +115 -16
  14. package/dist/commands/harvest.js +502 -16
  15. package/dist/commands/init.js +123 -21
  16. package/dist/commands/loops-handlers.js +4 -0
  17. package/dist/commands/mcp-read-handlers.js +198 -29
  18. package/dist/commands/mcp.js +615 -92
  19. package/dist/commands/memory.js +21 -17
  20. package/dist/commands/migrate.js +81 -17
  21. package/dist/commands/prune.js +78 -4
  22. package/dist/commands/reflect.js +26 -20
  23. package/dist/commands/register-agent.js +57 -1
  24. package/dist/commands/repair.js +20 -0
  25. package/dist/commands/session-end.js +15 -6
  26. package/dist/commands/session-start.js +18 -1
  27. package/dist/commands/setup-security.js +39 -18
  28. package/dist/commands/setup.js +26 -27
  29. package/dist/commands/stale.js +16 -2
  30. package/dist/commands/uninstall.js +126 -34
  31. package/dist/commands/update-step.js +6 -0
  32. package/dist/commands/worktree.js +60 -0
  33. package/dist/core/actions.js +12 -3
  34. package/dist/core/agent-capability.js +11 -13
  35. package/dist/core/agent-files.js +844 -547
  36. package/dist/core/agent-integrations.js +0 -3
  37. package/dist/core/agent-inventory.js +67 -0
  38. package/dist/core/agent-registry.js +163 -29
  39. package/dist/core/agentrun-reconciler.js +33 -2
  40. package/dist/core/agentruns.js +7 -1
  41. package/dist/core/ai-agent-detection.js +31 -44
  42. package/dist/core/archival.js +15 -9
  43. package/dist/core/assignment-reconciler.js +56 -0
  44. package/dist/core/assignment-sweeper.js +127 -4
  45. package/dist/core/assignments.js +69 -11
  46. package/dist/core/bootstrap.js +233 -67
  47. package/dist/core/brainclaw-version.js +22 -0
  48. package/dist/core/candidates.js +21 -1
  49. package/dist/core/claims.js +313 -150
  50. package/dist/core/config.js +6 -1
  51. package/dist/core/context-diff.js +148 -20
  52. package/dist/core/context.js +129 -8
  53. package/dist/core/coordination.js +22 -3
  54. package/dist/core/dispatch-status.js +109 -5
  55. package/dist/core/dispatcher.js +65 -11
  56. package/dist/core/entity-operations.js +45 -24
  57. package/dist/core/entity-registry.js +31 -5
  58. package/dist/core/event-log.js +138 -21
  59. package/dist/core/events/checkpoint.js +258 -0
  60. package/dist/core/events/genesis.js +220 -0
  61. package/dist/core/events/journal.js +507 -0
  62. package/dist/core/events/materialize.js +126 -0
  63. package/dist/core/events/registry-post-image.js +110 -0
  64. package/dist/core/events/verify.js +109 -0
  65. package/dist/core/execution-adapters.js +23 -0
  66. package/dist/core/execution.js +25 -0
  67. package/dist/core/facade-schema.js +48 -0
  68. package/dist/core/gc-semantic.js +130 -5
  69. package/dist/core/handoff-snapshot.js +68 -0
  70. package/dist/core/ids.js +19 -8
  71. package/dist/core/instruction-templates.js +34 -115
  72. package/dist/core/io.js +39 -3
  73. package/dist/core/json-store.js +10 -1
  74. package/dist/core/lock.js +153 -28
  75. package/dist/core/loops/bootstrap-acquire.js +25 -1
  76. package/dist/core/loops/facade-schema.js +2 -0
  77. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  78. package/dist/core/loops/index.js +1 -0
  79. package/dist/core/loops/presets/bootstrap.js +7 -0
  80. package/dist/core/loops/store.js +17 -0
  81. package/dist/core/loops/verbs.js +24 -1
  82. package/dist/core/markdown.js +8 -76
  83. package/dist/core/mcp-command-resolution.js +245 -0
  84. package/dist/core/memory-compactor.js +5 -3
  85. package/dist/core/memory-lifecycle.js +282 -0
  86. package/dist/core/merge-risk.js +150 -0
  87. package/dist/core/messaging.js +8 -1
  88. package/dist/core/migration.js +11 -1
  89. package/dist/core/observer-mode.js +26 -0
  90. package/dist/core/operations/memory-mutation.js +90 -65
  91. package/dist/core/operations/plan.js +27 -1
  92. package/dist/core/protocol-skills.js +210 -0
  93. package/dist/core/reflection-safety.js +6 -7
  94. package/dist/core/reputation.js +84 -2
  95. package/dist/core/runtime-signals.js +71 -9
  96. package/dist/core/runtime.js +84 -1
  97. package/dist/core/schema.js +125 -0
  98. package/dist/core/security-detectors.js +125 -0
  99. package/dist/core/security-extract.js +189 -0
  100. package/dist/core/security-guard.js +107 -29
  101. package/dist/core/security-packages.js +121 -0
  102. package/dist/core/security-scoring.js +76 -9
  103. package/dist/core/security.js +34 -2
  104. package/dist/core/sequence.js +11 -2
  105. package/dist/core/setup-flow.js +141 -13
  106. package/dist/core/spawn-check.js +110 -4
  107. package/dist/core/staleness.js +109 -1
  108. package/dist/core/state.js +250 -54
  109. package/dist/core/store-resolution.js +19 -5
  110. package/dist/core/worktree.js +169 -7
  111. package/dist/facts.js +8 -8
  112. package/dist/facts.json +7 -7
  113. package/docs/PROTOCOL.md +223 -0
  114. package/docs/cli.md +11 -10
  115. package/docs/concepts/coordinator-runbook.md +129 -0
  116. package/docs/concepts/dispatch-lifecycle.md +17 -0
  117. package/docs/concepts/event-log-store-critique-A.md +333 -0
  118. package/docs/concepts/event-log-store-critique-B.md +353 -0
  119. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  120. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  121. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  122. package/docs/concepts/event-log-store.md +928 -0
  123. package/docs/concepts/identity-model-proposal.md +371 -0
  124. package/docs/concepts/memory.md +5 -4
  125. package/docs/concepts/observer-protocol.md +361 -0
  126. package/docs/concepts/parallel-merge-protocol.md +71 -0
  127. package/docs/concepts/plans-and-claims.md +43 -0
  128. package/docs/concepts/skills.md +78 -0
  129. package/docs/concepts/workspace-bootstrapping.md +61 -0
  130. package/docs/integrations/agents.md +4 -4
  131. package/docs/integrations/cline.md +10 -11
  132. package/docs/integrations/codex.md +2 -2
  133. package/docs/integrations/continue.md +5 -5
  134. package/docs/integrations/copilot.md +14 -12
  135. package/docs/integrations/openclaw.md +7 -6
  136. package/docs/integrations/overview.md +7 -7
  137. package/docs/integrations/roo.md +3 -3
  138. package/docs/integrations/windsurf.md +6 -6
  139. package/docs/mcp-schema-changelog.md +51 -20
  140. package/docs/quickstart.md +48 -47
  141. package/docs/security.md +174 -15
  142. package/docs/storage.md +4 -2
  143. package/package.json +8 -6
@@ -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 crypto from 'node:crypto';
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
- * Returns the base directory where brainclaw-managed worktrees are stored
23
- * for the given project root: `~/.brainclaw/worktrees/<sha1-hash>/`.
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.worktreePaths && options.worktreePaths.length > 0)
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(0).toISOString(), 'utf-8');
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
- console.log(assignmentId ? `No LANE-RESULT.json found for ${assignmentId}.` : 'No lane results found in any worktree.');
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) {