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.
- package/README.md +12 -11
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +138 -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 +285 -22
- 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 +588 -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 +79 -5
- package/dist/core/dispatcher.js +64 -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/facade-schema.js +38 -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 +114 -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/staleness.js +72 -1
- package/dist/core/state.js +250 -54
- package/dist/core/store-resolution.js +19 -5
- package/dist/core/worktree.js +72 -8
- 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/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 +29 -2
- package/docs/quickstart.md +48 -47
- package/docs/security.md +174 -15
- package/docs/storage.md +4 -2
- 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,
|
|
117
|
+
estimated_minutes: stepEstimate ?? p.estimated_effort,
|
|
73
118
|
actual_effort: p.actual_effort,
|
|
74
119
|
completed_at: p.completed_at,
|
|
75
120
|
};
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/commands/harvest.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|