brainclaw 1.8.0 → 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 (140) hide show
  1. package/README.md +12 -11
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +138 -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 +285 -22
  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 +588 -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 +79 -5
  55. package/dist/core/dispatcher.js +64 -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/facade-schema.js +38 -0
  67. package/dist/core/gc-semantic.js +130 -5
  68. package/dist/core/handoff-snapshot.js +68 -0
  69. package/dist/core/ids.js +19 -8
  70. package/dist/core/instruction-templates.js +34 -115
  71. package/dist/core/io.js +39 -3
  72. package/dist/core/json-store.js +10 -1
  73. package/dist/core/lock.js +153 -28
  74. package/dist/core/loops/bootstrap-acquire.js +25 -1
  75. package/dist/core/loops/facade-schema.js +2 -0
  76. package/dist/core/loops/hooks/survey-signals-baseline.js +36 -0
  77. package/dist/core/loops/index.js +1 -0
  78. package/dist/core/loops/presets/bootstrap.js +7 -0
  79. package/dist/core/loops/store.js +17 -0
  80. package/dist/core/loops/verbs.js +24 -1
  81. package/dist/core/markdown.js +8 -76
  82. package/dist/core/mcp-command-resolution.js +245 -0
  83. package/dist/core/memory-compactor.js +5 -3
  84. package/dist/core/memory-lifecycle.js +282 -0
  85. package/dist/core/merge-risk.js +150 -0
  86. package/dist/core/messaging.js +8 -1
  87. package/dist/core/migration.js +11 -1
  88. package/dist/core/observer-mode.js +26 -0
  89. package/dist/core/operations/memory-mutation.js +90 -65
  90. package/dist/core/operations/plan.js +27 -1
  91. package/dist/core/protocol-skills.js +210 -0
  92. package/dist/core/reflection-safety.js +6 -7
  93. package/dist/core/reputation.js +84 -2
  94. package/dist/core/runtime-signals.js +71 -9
  95. package/dist/core/runtime.js +84 -1
  96. package/dist/core/schema.js +114 -0
  97. package/dist/core/security-detectors.js +125 -0
  98. package/dist/core/security-extract.js +189 -0
  99. package/dist/core/security-guard.js +107 -29
  100. package/dist/core/security-packages.js +121 -0
  101. package/dist/core/security-scoring.js +76 -9
  102. package/dist/core/security.js +34 -2
  103. package/dist/core/sequence.js +11 -2
  104. package/dist/core/setup-flow.js +141 -13
  105. package/dist/core/staleness.js +72 -1
  106. package/dist/core/state.js +250 -54
  107. package/dist/core/store-resolution.js +19 -5
  108. package/dist/core/worktree.js +72 -8
  109. package/dist/facts.js +8 -8
  110. package/dist/facts.json +7 -7
  111. package/docs/PROTOCOL.md +223 -0
  112. package/docs/cli.md +11 -10
  113. package/docs/concepts/coordinator-runbook.md +129 -0
  114. package/docs/concepts/event-log-store-critique-A.md +333 -0
  115. package/docs/concepts/event-log-store-critique-B.md +353 -0
  116. package/docs/concepts/event-log-store-phase0-measurements.md +58 -0
  117. package/docs/concepts/event-log-store-proposal-A.md +365 -0
  118. package/docs/concepts/event-log-store-proposal-B.md +404 -0
  119. package/docs/concepts/event-log-store.md +928 -0
  120. package/docs/concepts/identity-model-proposal.md +371 -0
  121. package/docs/concepts/memory.md +5 -4
  122. package/docs/concepts/observer-protocol.md +361 -0
  123. package/docs/concepts/parallel-merge-protocol.md +71 -0
  124. package/docs/concepts/plans-and-claims.md +43 -0
  125. package/docs/concepts/skills.md +78 -0
  126. package/docs/concepts/workspace-bootstrapping.md +61 -0
  127. package/docs/integrations/agents.md +4 -4
  128. package/docs/integrations/cline.md +10 -11
  129. package/docs/integrations/codex.md +2 -2
  130. package/docs/integrations/continue.md +5 -5
  131. package/docs/integrations/copilot.md +14 -12
  132. package/docs/integrations/openclaw.md +7 -6
  133. package/docs/integrations/overview.md +7 -7
  134. package/docs/integrations/roo.md +3 -3
  135. package/docs/integrations/windsurf.md +6 -6
  136. package/docs/mcp-schema-changelog.md +29 -2
  137. package/docs/quickstart.md +48 -47
  138. package/docs/security.md +174 -15
  139. package/docs/storage.md +4 -2
  140. package/package.json +8 -6
@@ -1,5 +1,47 @@
1
1
  import { loadState } from '../core/state.js';
2
2
  import { memoryExists } from '../core/io.js';
3
+ /** Default wall-clock outlier cutoff: a single plan's "actual" over 24h is almost certainly idle, not work. */
4
+ export const DEFAULT_OUTLIER_THRESHOLD_MINUTES = 1440;
5
+ /**
6
+ * Sum of per-step estimates — only when EVERY step carries one. A mixed plan
7
+ * (some steps estimated, some not) returns undefined so the caller falls back
8
+ * to the plan-level estimate rather than mixing half-measured data (pln#495).
9
+ */
10
+ function sumStepEstimates(plan) {
11
+ const steps = plan.steps ?? [];
12
+ if (steps.length === 0)
13
+ return undefined;
14
+ if (!steps.every((s) => typeof s.estimated_effort === 'number'))
15
+ return undefined;
16
+ return steps.reduce((acc, s) => acc + s.estimated_effort, 0);
17
+ }
18
+ /**
19
+ * Sum of per-step actual durations — only when EVERY step is measurable, via an
20
+ * explicit `actual_effort` string OR both `started_at`+`completed_at`. Returns
21
+ * undefined otherwise (→ fall back to plan-level). This is the key win: summing
22
+ * per-step durations excludes the idle time BETWEEN steps that plan-level
23
+ * wall-clock wrongly counts as work (pln#495, the pln#494 step-6 bias).
24
+ */
25
+ function sumStepActuals(plan) {
26
+ const steps = plan.steps ?? [];
27
+ if (steps.length === 0)
28
+ return undefined;
29
+ let total = 0;
30
+ for (const s of steps) {
31
+ let dur;
32
+ if (s.actual_effort) {
33
+ dur = parseEffortMinutes(s.actual_effort);
34
+ }
35
+ else if (s.started_at && s.completed_at) {
36
+ const d = (new Date(s.completed_at).getTime() - new Date(s.started_at).getTime()) / 60000;
37
+ dur = d > 0 ? d : undefined;
38
+ }
39
+ if (dur === undefined)
40
+ return undefined; // any unmeasurable step → no step-level actual
41
+ total += dur;
42
+ }
43
+ return total > 0 ? total : undefined;
44
+ }
3
45
  /** Parse legacy actual_effort strings ("30min", "2h", "1h30m", "1d", "45m") → minutes.
4
46
  * Still needed for actual_effort which remains a free string. */
5
47
  export function parseEffortMinutes(effort) {
@@ -23,7 +65,7 @@ export function parseEffortMinutes(effort) {
23
65
  }
24
66
  if (!matched) {
25
67
  const bare = parseFloat(s);
26
- if (!isNaN(bare))
68
+ if (!isNaN(bare) && /^\d+(?:\.\d+)?$/.test(s))
27
69
  return bare;
28
70
  return undefined;
29
71
  }
@@ -65,37 +107,78 @@ export function buildEstimationReport(options = {}) {
65
107
  const state = loadState(options.cwd);
66
108
  const done = state.plan_items.filter((p) => p.status === 'done' && (!options.agent || p.author === options.agent));
67
109
  const entries = done.map((p) => {
110
+ // Estimate: prefer the sum of per-step estimates when ALL steps carry one,
111
+ // else the plan-level estimate (pln#495).
112
+ const stepEstimate = sumStepEstimates(p);
68
113
  const entry = {
69
114
  id: p.id,
70
115
  text: p.text,
71
116
  author: p.author,
72
- estimated_minutes: p.estimated_effort, // already a number after schema coercion
117
+ estimated_minutes: stepEstimate ?? p.estimated_effort,
73
118
  actual_effort: p.actual_effort,
74
119
  completed_at: p.completed_at,
75
120
  };
76
- // Compute elapsed from wall-clock timestamps
77
- const endTime = p.completed_at ?? p.updated_at;
78
- const startTime = p.started_at ?? p.created_at;
79
- if (endTime && startTime) {
80
- const elapsed = (new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000;
81
- if (elapsed > 0)
82
- entry.elapsed_minutes = Math.round(elapsed);
121
+ // Actual + source, in fallback order (pln#495):
122
+ // step durations (idle-gap-free) > plan.actual_effort string > plan wall-clock.
123
+ let actualMinutes;
124
+ let source;
125
+ const stepActual = sumStepActuals(p);
126
+ if (stepActual !== undefined) {
127
+ actualMinutes = stepActual;
128
+ source = 'step';
83
129
  }
84
- // Resolve actual minutes: explicit actual_effort string > elapsed wall-clock
85
- const actualMinutes = p.actual_effort
86
- ? parseEffortMinutes(p.actual_effort)
87
- : entry.elapsed_minutes;
88
- if (actualMinutes !== undefined)
130
+ else if (p.actual_effort) {
131
+ actualMinutes = parseEffortMinutes(p.actual_effort);
132
+ source = 'plan_string';
133
+ }
134
+ else {
135
+ const endTime = p.completed_at ?? p.updated_at;
136
+ const startTime = p.started_at ?? p.created_at;
137
+ if (endTime && startTime) {
138
+ const elapsed = (new Date(endTime).getTime() - new Date(startTime).getTime()) / 60000;
139
+ if (elapsed > 0)
140
+ actualMinutes = elapsed;
141
+ }
142
+ source = 'plan_wallclock';
143
+ }
144
+ if (actualMinutes !== undefined) {
89
145
  entry.elapsed_minutes = Math.round(actualMinutes);
146
+ entry.source = source;
147
+ }
90
148
  if (entry.estimated_minutes !== undefined && actualMinutes !== undefined && actualMinutes > 0) {
91
149
  entry.ratio = parseFloat((entry.estimated_minutes / actualMinutes).toFixed(2));
92
150
  }
93
151
  return entry;
94
152
  });
153
+ // Wall-clock outlier filter (pln#495 step 7): a plan whose actual is plan-level
154
+ // wall-clock AND exceeds the threshold is flagged and dropped from the summary
155
+ // stats — it would otherwise drag the median/mean (the post-restoration +9900%
156
+ // accident). Step- and string-derived actuals are trusted at any size. A
157
+ // threshold of 0 disables the filter. Outliers keep their ratio and stay in
158
+ // `entries` so the chart still shows them, just marked.
159
+ const threshold = options.outlierThresholdMinutes ?? DEFAULT_OUTLIER_THRESHOLD_MINUTES;
160
+ if (threshold > 0) {
161
+ for (const e of entries) {
162
+ if (e.source === 'plan_wallclock' && e.elapsed_minutes !== undefined && e.elapsed_minutes > threshold) {
163
+ e.excluded_from_stats = true;
164
+ }
165
+ }
166
+ }
95
167
  const withBoth = entries.filter((e) => e.ratio !== undefined);
96
- const ratios = withBoth.map((e) => e.ratio);
168
+ const included = withBoth.filter((e) => !e.excluded_from_stats);
169
+ const ratios = included.map((e) => e.ratio);
97
170
  const medianRatio = ratios.length > 0 ? parseFloat(median(ratios).toFixed(2)) : undefined;
98
171
  const meanRatio = ratios.length > 0 ? parseFloat(mean(ratios).toFixed(2)) : undefined;
172
+ // Per-source median (pln#495): exposes how much calibration noise is wall-clock
173
+ // contamination vs idle-gap-free step measurement. Excludes flagged outliers.
174
+ const bySource = {};
175
+ for (const src of ['step', 'plan_string', 'plan_wallclock']) {
176
+ const srcRatios = included.filter((e) => e.source === src).map((e) => e.ratio);
177
+ if (srcRatios.length > 0) {
178
+ bySource[src] = { count: srcRatios.length, median_ratio: parseFloat(median(srcRatios).toFixed(2)) };
179
+ }
180
+ }
181
+ const outliersExcluded = withBoth.filter((e) => e.excluded_from_stats).length;
99
182
  return {
100
183
  entries,
101
184
  summary: {
@@ -105,6 +188,8 @@ export function buildEstimationReport(options = {}) {
105
188
  median_ratio: medianRatio,
106
189
  mean_ratio: meanRatio,
107
190
  calibration_hint: medianRatio !== undefined ? buildCalibrationHint(medianRatio) : undefined,
191
+ by_source: Object.keys(bySource).length > 0 ? bySource : undefined,
192
+ outliers_excluded: outliersExcluded > 0 ? outliersExcluded : undefined,
108
193
  },
109
194
  };
110
195
  }
@@ -129,6 +214,19 @@ export function runEstimationReport(options = {}) {
129
214
  console.log(`\nMedian ratio (estimated÷actual): ${summary.median_ratio}x · Mean: ${summary.mean_ratio}x`);
130
215
  console.log(`→ ${summary.calibration_hint}`);
131
216
  }
217
+ if (summary.outliers_excluded) {
218
+ console.log(`(${summary.outliers_excluded} wall-clock outlier(s) >24h excluded from the stats above — shown ⚠ in the chart)`);
219
+ }
220
+ if (summary.by_source) {
221
+ const labels = {
222
+ step: 'step-derived', plan_string: 'plan-string', plan_wallclock: 'plan-wallclock',
223
+ };
224
+ const parts = ['step', 'plan_string', 'plan_wallclock']
225
+ .filter((s) => summary.by_source[s])
226
+ .map((s) => `${labels[s]}: ${summary.by_source[s].median_ratio}x (n=${summary.by_source[s].count})`);
227
+ console.log(`By measurement quality — ${parts.join(' · ')}`);
228
+ console.log(' (step-derived excludes inter-step idle; plan-wallclock is the noisiest)');
229
+ }
132
230
  // Chart — only plans with ratio data
133
231
  const chartable = entries.filter((e) => e.ratio !== undefined);
134
232
  if (chartable.length > 0) {
@@ -149,7 +247,8 @@ export function runEstimationReport(options = {}) {
149
247
  : ` OVER +${Math.round((1 / e.ratio - 1) * 100)}%`;
150
248
  const est = e.estimated_minutes !== undefined ? `${e.estimated_minutes}min` : '?';
151
249
  const act = e.elapsed_minutes !== undefined ? `${e.elapsed_minutes}min` : '?';
152
- console.log(` ${label} ${bar} ${e.ratio}x ${est}→${act}${pct}`);
250
+ const srcTag = e.excluded_from_stats ? ' ⚠outlier' : e.source === 'step' ? ' ✓step' : e.source === 'plan_wallclock' ? ' ~wall' : '';
251
+ console.log(` ${label} ${bar} ${e.ratio}x ${est}→${act}${pct}${srcTag}`);
153
252
  }
154
253
  console.log('');
155
254
  }
@@ -11,25 +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
21
  import { loadAssignment, transitionAssignment } from '../core/assignments.js';
22
- import { releaseClaimWithCascade } from '../core/claims.js';
22
+ import { releaseClaimWithCascade, loadClaim } from '../core/claims.js';
23
23
  import { getCapabilityProfile, dispatchCanCommit } from '../core/agent-capability.js';
24
- import { commitWorktreeOnBehalf } from '../core/worktree.js';
25
- /**
26
- * Returns the base directory where brainclaw-managed worktrees are stored
27
- * for the given project root: `~/.brainclaw/worktrees/<sha1-hash>/`.
28
- */
29
- function worktreesBaseDir(projectRoot) {
30
- const hash = crypto.createHash('sha1').update(projectRoot).digest('hex').slice(0, 12);
31
- return path.join(os.homedir(), '.brainclaw', 'worktrees', hash);
32
- }
24
+ import { commitWorktreeOnBehalf, worktreesBaseDir } from '../core/worktree.js';
33
25
  /**
34
26
  * Auto-detect all worktree directories under the brainclaw-managed base dir.
35
27
  * Returns subdirectories that exist on disk (may or may not have an inbox).
@@ -42,6 +34,44 @@ function autoDetectWorktreePaths(cwd) {
42
34
  .filter((entry) => entry.isDirectory())
43
35
  .map((entry) => path.join(base, entry.name));
44
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
+ }
45
75
  /**
46
76
  * Collect all `cnd_*.json` files from a worktree's candidate inbox.
47
77
  *
@@ -217,9 +247,7 @@ export function harvestLaneResults(options = {}) {
217
247
  const cwd = options.cwd ?? process.cwd();
218
248
  const agent = options.agent ?? 'coordinator';
219
249
  const result = { harvested: [], skipped: [], errors: [] };
220
- const worktreePaths = (options.worktreePaths && options.worktreePaths.length > 0)
221
- ? options.worktreePaths
222
- : autoDetectWorktreePaths(cwd);
250
+ const worktreePaths = resolveLaneScanPaths(options, cwd);
223
251
  for (const worktreePath of worktreePaths) {
224
252
  const file = getLaneResultPath(worktreePath);
225
253
  if (!fs.existsSync(file))
@@ -257,7 +285,7 @@ export function harvestLaneResults(options = {}) {
257
285
  },
258
286
  }, cwd);
259
287
  fs.mkdirSync(path.dirname(marker), { recursive: true });
260
- fs.writeFileSync(marker, new Date(0).toISOString(), 'utf-8');
288
+ fs.writeFileSync(marker, new Date().toISOString(), 'utf-8');
261
289
  }
262
290
  catch (err) {
263
291
  result.errors.push(`Failed to ingest lane result for ${lane.assignment_id}: ${err instanceof Error ? err.message : String(err)}`);
@@ -298,6 +326,17 @@ function forceCompleteAssignment(assignmentId, artifacts, statusReason, actor, c
298
326
  return false;
299
327
  if (current.status === 'completed')
300
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
+ }
301
340
  const startIdx = ASSIGNMENT_COMPLETE_CHAIN.indexOf(current.status);
302
341
  if (startIdx === -1)
303
342
  return false; // off the happy path (failed/blocked/…): leave it.
@@ -332,9 +371,7 @@ export function integrateLaneResults(options = {}) {
332
371
  const cwd = options.cwd ?? process.cwd();
333
372
  const actor = options.agent ?? 'coordinator';
334
373
  const result = { integrated: [], skipped: [], errors: [] };
335
- const worktreePaths = (options.worktreePaths && options.worktreePaths.length > 0)
336
- ? options.worktreePaths
337
- : autoDetectWorktreePaths(cwd);
374
+ const worktreePaths = resolveLaneScanPaths(options, cwd);
338
375
  for (const worktreePath of worktreePaths) {
339
376
  const file = getLaneResultPath(worktreePath);
340
377
  if (!fs.existsSync(file))
@@ -455,16 +492,224 @@ export function integrateLaneResults(options = {}) {
455
492
  }
456
493
  return result;
457
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
+ }
458
661
  export function runHarvestLane(assignmentId, options = {}) {
459
662
  const cwd = options.cwd ?? process.cwd();
460
663
  if (!memoryExists(cwd)) {
461
664
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
462
665
  process.exit(1);
463
666
  }
464
- if (!assignmentId && !options.all) {
667
+ if (!assignmentId && !options.all && !(options.orphaned && options.worktree?.length)) {
465
668
  console.error('Error: provide an <assignment_id>, or pass --all to harvest every lane result.');
466
669
  process.exit(1);
467
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
+ }
468
713
  // pln#534 — `--integrate` upgrades harvest from report-only to converge-the-
469
714
  // lane: commit the worktree diff on behalf of a sandboxed worker, lifecycle
470
715
  // the assignment, and release the claim. Runs alongside the normal ingest.
@@ -481,7 +726,16 @@ export function runHarvestLane(assignmentId, options = {}) {
481
726
  }
482
727
  const dry = options.dryRun ? ' (dry-run)' : '';
483
728
  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.');
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
+ }
485
739
  return;
486
740
  }
487
741
  for (const e of integ.integrated) {
@@ -513,7 +767,16 @@ export function runHarvestLane(assignmentId, options = {}) {
513
767
  }
514
768
  const dryTag = options.dryRun ? ' (dry-run)' : '';
515
769
  if (result.harvested.length === 0 && result.skipped.length === 0 && result.errors.length === 0) {
516
- 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
+ }
517
780
  return;
518
781
  }
519
782
  for (const lane of result.harvested) {