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
|
@@ -2,10 +2,9 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { readAuditLog } from './audit.js';
|
|
4
4
|
import { listCandidates } from './candidates.js';
|
|
5
|
-
import {
|
|
6
|
-
import { memoryDir, resolveEntityDir } from './io.js';
|
|
7
|
-
import { logger } from './logger.js';
|
|
5
|
+
import { resolveEntityDir } from './io.js';
|
|
8
6
|
import { loadVersionedJsonFile } from './migration.js';
|
|
7
|
+
import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
|
|
9
8
|
import { SessionSnapshotSchema } from './schema.js';
|
|
10
9
|
import { loadState } from './state.js';
|
|
11
10
|
export function resolveContextDiffSince(options) {
|
|
@@ -23,10 +22,10 @@ export function resolveContextDiffSince(options) {
|
|
|
23
22
|
}
|
|
24
23
|
return { since_session: options.session };
|
|
25
24
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
// No global marker fallback: the "what's new" default lives on the
|
|
26
|
+
// per-agent event-log cursors (buildContextDiffFromEvents). The store-global
|
|
27
|
+
// .last-context marker cross-contaminated agents — one agent's read reset
|
|
28
|
+
// everyone's diff baseline.
|
|
30
29
|
return {};
|
|
31
30
|
}
|
|
32
31
|
function loadSessionSnapshot(sessionId, cwd) {
|
|
@@ -66,33 +65,160 @@ export function buildContextDiff(options = {}) {
|
|
|
66
65
|
decisions: decisions.length,
|
|
67
66
|
traps: traps.length,
|
|
68
67
|
handoffs: handoffs.length,
|
|
68
|
+
plans: 0,
|
|
69
69
|
pending_candidates: pendingCandidates.length,
|
|
70
70
|
total: constraints.length + decisions.length + traps.length + handoffs.length + pendingCandidates.length,
|
|
71
71
|
};
|
|
72
72
|
return {
|
|
73
73
|
since: resolved.since,
|
|
74
74
|
since_session: resolved.since_session,
|
|
75
|
+
source: 'timestamp',
|
|
75
76
|
summary: buildContextDiffSummary(counts),
|
|
76
77
|
counts,
|
|
77
78
|
changed_items: changedItems,
|
|
78
79
|
};
|
|
79
80
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
const EVENT_SECTION_BY_ITEM_TYPE = {
|
|
82
|
+
constraint: 'constraint',
|
|
83
|
+
decision: 'decision',
|
|
84
|
+
trap: 'trap',
|
|
85
|
+
handoff: 'handoff',
|
|
86
|
+
candidate: 'candidate',
|
|
87
|
+
plan: 'plan',
|
|
88
|
+
};
|
|
89
|
+
const EVENT_ACTION_LABEL = {
|
|
90
|
+
create: 'created',
|
|
91
|
+
update: 'updated',
|
|
92
|
+
delete: 'deleted',
|
|
93
|
+
accept: 'accepted',
|
|
94
|
+
reject: 'rejected',
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Build a per-agent "what's new" diff from the event-log cursors
|
|
98
|
+
* (src/core/event-log.ts). This is the converged novelty mechanism: it
|
|
99
|
+
* replaces the store-global .last-context marker for the default diff path
|
|
100
|
+
* and natively covers status transitions (logged as `update` events by
|
|
101
|
+
* bclaw_transition / bclaw_update via the audit→event bridge).
|
|
102
|
+
*
|
|
103
|
+
* NOTE: reading ADVANCES the agent's cursor — events surfaced here are
|
|
104
|
+
* considered seen. Returns undefined when there is nothing new.
|
|
105
|
+
*
|
|
106
|
+
* First contact (no cursor for this agent yet): the diff would otherwise
|
|
107
|
+
* replay the ENTIRE event log from genesis — on a mature store that means
|
|
108
|
+
* thousands of stale claim/session events summarized into noise, and the
|
|
109
|
+
* agent's single chance to triage history is consumed by the cursor advance.
|
|
110
|
+
* Instead we emit a curated arrival digest (active constraints/traps,
|
|
111
|
+
* in-progress plans, latest open handoffs) and seed the cursor at log end so
|
|
112
|
+
* subsequent diffs are genuinely incremental.
|
|
113
|
+
*/
|
|
114
|
+
export function buildContextDiffFromEvents(agent, cwd, options = {}) {
|
|
115
|
+
if (!hasEventCursor(agent, cwd)) {
|
|
116
|
+
return buildArrivalDigest(agent, cwd, options);
|
|
84
117
|
}
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
118
|
+
const events = readUnseenEvents(agent, cwd);
|
|
119
|
+
if (events.length === 0) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
// Latest relevant event per item (an item created then updated counts once).
|
|
123
|
+
const latestByItem = new Map();
|
|
124
|
+
for (const event of events) {
|
|
125
|
+
if (!event.item_id)
|
|
126
|
+
continue;
|
|
127
|
+
if (!EVENT_SECTION_BY_ITEM_TYPE[event.item_type] || !EVENT_ACTION_LABEL[event.action])
|
|
128
|
+
continue;
|
|
129
|
+
latestByItem.set(event.item_id, event);
|
|
130
|
+
}
|
|
131
|
+
const state = loadState(cwd);
|
|
132
|
+
const pendingCandidates = listCandidates('pending', cwd);
|
|
133
|
+
const textById = new Map();
|
|
134
|
+
for (const collection of [state.active_constraints, state.recent_decisions, state.known_traps, state.open_handoffs, state.plan_items, pendingCandidates]) {
|
|
135
|
+
for (const item of collection) {
|
|
136
|
+
textById.set(item.id, { text: item.text, created_at: item.created_at });
|
|
93
137
|
}
|
|
94
138
|
}
|
|
95
|
-
|
|
139
|
+
const counts = { constraints: 0, decisions: 0, traps: 0, handoffs: 0, plans: 0, pending_candidates: 0, total: 0 };
|
|
140
|
+
const sectionToCountKey = {
|
|
141
|
+
constraint: 'constraints',
|
|
142
|
+
decision: 'decisions',
|
|
143
|
+
trap: 'traps',
|
|
144
|
+
handoff: 'handoffs',
|
|
145
|
+
plan: 'plans',
|
|
146
|
+
candidate: 'pending_candidates',
|
|
147
|
+
};
|
|
148
|
+
const changedItems = [];
|
|
149
|
+
for (const [itemId, event] of latestByItem) {
|
|
150
|
+
const section = EVENT_SECTION_BY_ITEM_TYPE[event.item_type];
|
|
151
|
+
const current = textById.get(itemId);
|
|
152
|
+
counts[sectionToCountKey[section]] += 1;
|
|
153
|
+
counts.total += 1;
|
|
154
|
+
changedItems.push({
|
|
155
|
+
section,
|
|
156
|
+
id: itemId,
|
|
157
|
+
text: current?.text ?? event.summary ?? `(${EVENT_ACTION_LABEL[event.action]} — no longer in state)`,
|
|
158
|
+
created_at: event.ts,
|
|
159
|
+
action: EVENT_ACTION_LABEL[event.action],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
changedItems.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
163
|
+
const oldest = events[0]?.ts;
|
|
164
|
+
return {
|
|
165
|
+
since: oldest,
|
|
166
|
+
source: 'event_cursor',
|
|
167
|
+
summary: buildContextDiffSummary(counts),
|
|
168
|
+
counts,
|
|
169
|
+
changed_items: options.includeItems === false ? undefined : changedItems,
|
|
170
|
+
event_summary: buildNotificationSummary(events),
|
|
171
|
+
unseen_event_count: events.length,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
/** Hard cap on arrival-digest items — the digest informs, it must not drown. */
|
|
175
|
+
const ARRIVAL_DIGEST_MAX_ITEMS = 12;
|
|
176
|
+
const ARRIVAL_DIGEST_MAX_HANDOFFS = 3;
|
|
177
|
+
/**
|
|
178
|
+
* First-contact digest for an agent arriving on a store it has never read.
|
|
179
|
+
* Curated active state (constraints, traps, in-progress plans, latest open
|
|
180
|
+
* handoffs) instead of an event-log replay; seeds the agent's cursor at the
|
|
181
|
+
* end of the log so the next diff is incremental. Returns undefined only when
|
|
182
|
+
* there is nothing to say (empty store with no event history) — that case is
|
|
183
|
+
* the bootstrap hint's job, not the diff's.
|
|
184
|
+
*/
|
|
185
|
+
export function buildArrivalDigest(agent, cwd, options = {}) {
|
|
186
|
+
const skippedBytes = seedCursorToEnd(agent, cwd);
|
|
187
|
+
const state = loadState(cwd);
|
|
188
|
+
const constraints = state.active_constraints.filter((item) => (item.status ?? 'active') === 'active');
|
|
189
|
+
const traps = state.known_traps.filter((item) => (item.status ?? 'active') === 'active');
|
|
190
|
+
const plans = state.plan_items.filter((item) => item.status === 'in_progress');
|
|
191
|
+
const handoffs = state.open_handoffs
|
|
192
|
+
.filter((item) => (item.status ?? 'open') === 'open')
|
|
193
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
194
|
+
.slice(0, ARRIVAL_DIGEST_MAX_HANDOFFS);
|
|
195
|
+
const counts = {
|
|
196
|
+
constraints: constraints.length,
|
|
197
|
+
decisions: 0,
|
|
198
|
+
traps: traps.length,
|
|
199
|
+
handoffs: handoffs.length,
|
|
200
|
+
plans: plans.length,
|
|
201
|
+
pending_candidates: 0,
|
|
202
|
+
total: constraints.length + traps.length + plans.length + handoffs.length,
|
|
203
|
+
};
|
|
204
|
+
if (counts.total === 0 && skippedBytes === 0) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
const changedItems = [
|
|
208
|
+
...constraints.map((item) => toChangedItem('constraint', item)),
|
|
209
|
+
...traps.map((item) => toChangedItem('trap', item)),
|
|
210
|
+
...plans.map((item) => toChangedItem('plan', item)),
|
|
211
|
+
...handoffs.map((item) => toChangedItem('handoff', item)),
|
|
212
|
+
].slice(0, ARRIVAL_DIGEST_MAX_ITEMS);
|
|
213
|
+
const skippedKb = Math.round(skippedBytes / 1024);
|
|
214
|
+
const stateSummary = counts.total > 0 ? buildContextDiffSummary(counts) : 'no active items';
|
|
215
|
+
const summary = `First contact — arrival digest: ${stateSummary}. Event history skipped (${skippedKb} KB); cursor initialized at log end, future diffs are incremental.`;
|
|
216
|
+
return {
|
|
217
|
+
source: 'arrival_digest',
|
|
218
|
+
summary,
|
|
219
|
+
counts,
|
|
220
|
+
changed_items: options.includeItems === false ? undefined : changedItems,
|
|
221
|
+
};
|
|
96
222
|
}
|
|
97
223
|
export function buildContextDiffSummary(counts) {
|
|
98
224
|
if (counts.total === 0) {
|
|
@@ -107,6 +233,8 @@ export function buildContextDiffSummary(counts) {
|
|
|
107
233
|
parts.push(`${counts.traps} trap${counts.traps > 1 ? 's' : ''}`);
|
|
108
234
|
if (counts.handoffs > 0)
|
|
109
235
|
parts.push(`${counts.handoffs} handoff${counts.handoffs > 1 ? 's' : ''}`);
|
|
236
|
+
if (counts.plans > 0)
|
|
237
|
+
parts.push(`${counts.plans} plan${counts.plans > 1 ? 's' : ''}`);
|
|
110
238
|
if (counts.pending_candidates > 0)
|
|
111
239
|
parts.push(`${counts.pending_candidates} pending candidate${counts.pending_candidates > 1 ? 's' : ''}`);
|
|
112
240
|
return parts.join(', ');
|
package/dist/core/context.js
CHANGED
|
@@ -7,7 +7,7 @@ import { checkBrainclawInstallableUpdate, renderBrainclawInstallableUpdateNotice
|
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import { loadCurrentSession, loadAllSessions } from './identity.js';
|
|
9
9
|
import { resolveCrossProjectLinks, loadCrossProjectState } from './cross-project.js';
|
|
10
|
-
import { buildContextDiff } from './context-diff.js';
|
|
10
|
+
import { buildContextDiff, buildContextDiffFromEvents } from './context-diff.js';
|
|
11
11
|
import { resolveContextStoreCwd, resolveStoreChain } from './store-resolution.js';
|
|
12
12
|
import { findAgentIdentityByName, resolveCurrentAgentIdentity } from './agent-registry.js';
|
|
13
13
|
import { hasReusableBootstrapProfile, runBootstrapProfile, selectDerivedSignals } from './bootstrap.js';
|
|
@@ -17,11 +17,13 @@ import { getVisibleMemoryVersion } from './freshness.js';
|
|
|
17
17
|
import { resolveCurrentHostId } from './host.js';
|
|
18
18
|
import { inferProjectFromTarget, loadInstructions, resolveInstructions } from './instructions.js';
|
|
19
19
|
import { buildCurrentAgentResumeSummary, buildReputationRankingLookup } from './reputation.js';
|
|
20
|
+
import { buildMemoryLifecycleMetricsForState, getLifecycleStats } from './memory-lifecycle.js';
|
|
20
21
|
import { loadState } from './state.js';
|
|
21
22
|
import { readAuditLog } from './audit.js';
|
|
22
23
|
import { listCandidates } from './candidates.js';
|
|
23
24
|
import { listClaims, isClaimExpired, assessClaimLiveness } from './claims.js';
|
|
24
25
|
import { listAssignments } from './assignments.js';
|
|
26
|
+
import { reconcileOrphanedLoopAssignmentsFromList } from './assignment-reconciler.js';
|
|
25
27
|
import { listRuntimeNotes } from './runtime.js';
|
|
26
28
|
import { isTrapActive, listOperationalTraps } from './traps.js';
|
|
27
29
|
import { buildEstimationReport } from '../commands/estimation-report.js';
|
|
@@ -102,6 +104,16 @@ export function buildContext(options = {}) {
|
|
|
102
104
|
host_id: c.host_id,
|
|
103
105
|
session_id: c.session_id,
|
|
104
106
|
},
|
|
107
|
+
lifecycle: {
|
|
108
|
+
entity: 'constraint',
|
|
109
|
+
created_at: c.created_at,
|
|
110
|
+
last_confirmed_at: c.last_confirmed_at,
|
|
111
|
+
last_infirmed_at: c.last_infirmed_at,
|
|
112
|
+
confirmation_count: c.confirmation_count,
|
|
113
|
+
infirmation_count: c.infirmation_count,
|
|
114
|
+
saved_me_count: c.saved_me_count,
|
|
115
|
+
misled_me_count: c.misled_me_count,
|
|
116
|
+
},
|
|
105
117
|
});
|
|
106
118
|
}
|
|
107
119
|
for (const d of state.recent_decisions) {
|
|
@@ -122,6 +134,16 @@ export function buildContext(options = {}) {
|
|
|
122
134
|
host_id: d.host_id,
|
|
123
135
|
session_id: d.session_id,
|
|
124
136
|
},
|
|
137
|
+
lifecycle: {
|
|
138
|
+
entity: 'decision',
|
|
139
|
+
created_at: d.created_at,
|
|
140
|
+
last_confirmed_at: d.last_confirmed_at,
|
|
141
|
+
last_infirmed_at: d.last_infirmed_at,
|
|
142
|
+
confirmation_count: d.confirmation_count,
|
|
143
|
+
infirmation_count: d.infirmation_count,
|
|
144
|
+
saved_me_count: d.saved_me_count,
|
|
145
|
+
misled_me_count: d.misled_me_count,
|
|
146
|
+
},
|
|
125
147
|
});
|
|
126
148
|
}
|
|
127
149
|
for (const t of state.known_traps.filter((trap) => isTrapActive(trap))) {
|
|
@@ -142,6 +164,16 @@ export function buildContext(options = {}) {
|
|
|
142
164
|
host_id: t.host_id,
|
|
143
165
|
session_id: t.session_id,
|
|
144
166
|
},
|
|
167
|
+
lifecycle: {
|
|
168
|
+
entity: 'trap',
|
|
169
|
+
created_at: t.created_at,
|
|
170
|
+
last_confirmed_at: t.last_confirmed_at,
|
|
171
|
+
last_infirmed_at: t.last_infirmed_at,
|
|
172
|
+
confirmation_count: t.confirmation_count,
|
|
173
|
+
infirmation_count: t.infirmation_count,
|
|
174
|
+
saved_me_count: t.saved_me_count,
|
|
175
|
+
misled_me_count: t.misled_me_count,
|
|
176
|
+
},
|
|
145
177
|
});
|
|
146
178
|
}
|
|
147
179
|
for (const trap of listOperationalTraps({ hostId: options.host, includeAllHosts: options.allHosts }, contextCwd).filter((entry) => isTrapActive(entry))) {
|
|
@@ -371,6 +403,21 @@ export function buildContext(options = {}) {
|
|
|
371
403
|
item.reasons = uniqueReasons([...item.reasons, `reputation signal:+${trustBonus.toFixed(2)}`]);
|
|
372
404
|
}
|
|
373
405
|
}
|
|
406
|
+
// pln#544 — memory lifecycle: items confirmed-recent + reinforced rise;
|
|
407
|
+
// stale-unconfirmed and explicitly-infirmed sink in the same ranking.
|
|
408
|
+
// Only items we tagged with a lifecycle payload (primary-store decision/
|
|
409
|
+
// constraint/trap) carry a delta — parent-store items rank by other signals.
|
|
410
|
+
if (item.score >= 0 && item.lifecycle) {
|
|
411
|
+
const stats = getLifecycleStats(item.lifecycle);
|
|
412
|
+
if (stats.ranking_delta !== 0) {
|
|
413
|
+
item.score += stats.ranking_delta;
|
|
414
|
+
const sign = stats.ranking_delta >= 0 ? '+' : '';
|
|
415
|
+
item.reasons = uniqueReasons([
|
|
416
|
+
...item.reasons,
|
|
417
|
+
`lifecycle ${stats.classification}:${sign}${stats.ranking_delta}`,
|
|
418
|
+
]);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
374
421
|
// Layer 3: boost machine-scoped items for broader visibility (+1)
|
|
375
422
|
if (item.score >= 0) {
|
|
376
423
|
const itemScope = item.scope;
|
|
@@ -393,7 +440,10 @@ export function buildContext(options = {}) {
|
|
|
393
440
|
runtimeNotes,
|
|
394
441
|
pendingCandidates: listCandidates('pending', contextCwd),
|
|
395
442
|
});
|
|
396
|
-
|
|
443
|
+
// Density reflects what the store HAS, not what the char budget keeps:
|
|
444
|
+
// classify pre-budget so a tight budget_tokens on a rich store never
|
|
445
|
+
// misreads as 'low' and triggers a bootstrap re-scan (pln#542 interaction).
|
|
446
|
+
const memoryDensity = classifyMemoryDensity(ranked.length);
|
|
397
447
|
const bootstrapEnabled = options.bootstrap !== false;
|
|
398
448
|
const testMode = process.env.BRAINCLAW_TEST_MODE === '1';
|
|
399
449
|
let bootstrapAvailable = false;
|
|
@@ -445,7 +495,19 @@ export function buildContext(options = {}) {
|
|
|
445
495
|
const currentSession = loadCurrentSession(contextCwd);
|
|
446
496
|
if (currentAgentIdentity || agent) {
|
|
447
497
|
const claimPlanIds = new Set(myClaims.map((c) => c.plan_id).filter(Boolean));
|
|
448
|
-
|
|
498
|
+
// pln#563 layer B: converge review-loop assignments orphaned in
|
|
499
|
+
// offered/accepted/started whose loop is already terminal, before listing
|
|
500
|
+
// active work. Reuse one assignment scan for reconcile and rendering;
|
|
501
|
+
// best-effort (swallow errors); steady state is zero writes.
|
|
502
|
+
const allAssignments = listAssignments(contextCwd);
|
|
503
|
+
let reconciledAssignmentIds = new Set();
|
|
504
|
+
try {
|
|
505
|
+
reconciledAssignmentIds = new Set(reconcileOrphanedLoopAssignmentsFromList(allAssignments, contextCwd));
|
|
506
|
+
}
|
|
507
|
+
catch { /* read path must not break on reconcile */ }
|
|
508
|
+
const activeAssignments = allAssignments.filter((assignment) => assignment.agent === agentName
|
|
509
|
+
&& !reconciledAssignmentIds.has(assignment.id)
|
|
510
|
+
&& !['completed', 'failed', 'cancelled', 'expired', 'rerouted', 'timed_out'].includes(assignment.status));
|
|
449
511
|
const inProgressPlans = state.plan_items.filter((p) => p.status === 'in_progress' &&
|
|
450
512
|
(p.assignee === agentName || claimPlanIds.has(p.id)));
|
|
451
513
|
if (myClaims.length > 0 || activeAssignments.length > 0 || inProgressPlans.length > 0) {
|
|
@@ -524,13 +586,46 @@ export function buildContext(options = {}) {
|
|
|
524
586
|
let staleWarnings;
|
|
525
587
|
try {
|
|
526
588
|
const pendingCandidatesForStaleness = listCandidates('pending', contextCwd);
|
|
527
|
-
|
|
528
|
-
|
|
589
|
+
// pln#564 step A — reuse the runtime notes already loaded above (line ~316)
|
|
590
|
+
// instead of a second unfiltered full scan of the runtime-note tree. On a
|
|
591
|
+
// store with thousands of notes that 2nd scan dominated buildContext cost
|
|
592
|
+
// (readAgentNotes ~11s / 6166 files, trp_439fec51). The earlier list is the
|
|
593
|
+
// broader one (honours options.host/allHosts), so staleness is at least as
|
|
594
|
+
// complete as before.
|
|
595
|
+
const runtimeNotesForStaleness = runtimeNotes;
|
|
596
|
+
const staleReport = detectStaleness(state.plan_items, state.known_traps, state.open_handoffs, pendingCandidatesForStaleness, Date.now(), runtimeNotesForStaleness,
|
|
597
|
+
// pln#557 step 2 — dead related_paths detection. Surfaces "confident
|
|
598
|
+
// but wrong" memory (paths deleted by a refactor) in stale_warnings,
|
|
599
|
+
// which both the steady-state context and the arrival digest carry.
|
|
600
|
+
{
|
|
601
|
+
decisions: state.recent_decisions,
|
|
602
|
+
constraints: state.active_constraints,
|
|
603
|
+
projectRoot: contextCwd,
|
|
604
|
+
});
|
|
529
605
|
if (staleReport.warnings.length > 0) {
|
|
530
606
|
staleWarnings = staleReport.warnings.slice(0, 5);
|
|
531
607
|
}
|
|
532
608
|
}
|
|
533
609
|
catch { /* non-fatal */ }
|
|
610
|
+
// pln#544 — memory lifecycle metrics. Best-effort: build once, surface in
|
|
611
|
+
// the result + thread to workflow_hints so the curation hint can quote the
|
|
612
|
+
// oldest unconfirmed id. Empty stores still pass through (total_items=0).
|
|
613
|
+
let memoryLifecycleMetrics;
|
|
614
|
+
try {
|
|
615
|
+
memoryLifecycleMetrics = buildMemoryLifecycleMetricsForState(state);
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
memoryLifecycleMetrics = {
|
|
619
|
+
total_items: 0,
|
|
620
|
+
confirmed_items: 0,
|
|
621
|
+
confirmed_ratio: 0,
|
|
622
|
+
average_age_days: 0,
|
|
623
|
+
total_saved_me: 0,
|
|
624
|
+
total_misled_me: 0,
|
|
625
|
+
total_infirmed_active: 0,
|
|
626
|
+
recall_precision_proxy: 0,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
534
629
|
const result = {
|
|
535
630
|
context_schema: CONTEXT_SCHEMA_VERSION,
|
|
536
631
|
profile,
|
|
@@ -552,13 +647,19 @@ export function buildContext(options = {}) {
|
|
|
552
647
|
execution_context: executionContext,
|
|
553
648
|
agent_tooling: agentTooling,
|
|
554
649
|
scoped_activity: scopedActivity,
|
|
650
|
+
// "What's new" — explicit session reference keeps the timestamp diff;
|
|
651
|
+
// otherwise the per-agent event-log cursor is the converged default
|
|
652
|
+
// (covers status transitions, no store-global marker). Note: the cursor
|
|
653
|
+
// read marks the surfaced events as seen for this agent.
|
|
555
654
|
context_diff: options.sinceSession
|
|
556
655
|
? buildContextDiff({
|
|
557
656
|
session: options.sinceSession,
|
|
558
657
|
cwd: contextCwd,
|
|
559
658
|
includeItems: true,
|
|
560
659
|
})
|
|
561
|
-
:
|
|
660
|
+
: agent
|
|
661
|
+
? buildContextDiffFromEvents(agent, contextCwd, { includeItems: true })
|
|
662
|
+
: undefined,
|
|
562
663
|
resolved_instructions: resolvedInstructions,
|
|
563
664
|
resume_summary: resumeSummary,
|
|
564
665
|
open_work: openWork,
|
|
@@ -578,8 +679,9 @@ export function buildContext(options = {}) {
|
|
|
578
679
|
active_project: findActiveProjectInChain(contextCwd, storeChain),
|
|
579
680
|
cross_project_items: crossProjectItems.length > 0 ? crossProjectItems : undefined,
|
|
580
681
|
claim_conflicts: detectClaimConflicts(myClaims, otherActiveClaims),
|
|
581
|
-
workflow_hints: buildWorkflowHints(myClaims, openWork, state.plan_items),
|
|
682
|
+
workflow_hints: buildWorkflowHints(myClaims, openWork, state.plan_items, memoryLifecycleMetrics),
|
|
582
683
|
stale_warnings: staleWarnings,
|
|
684
|
+
memory_lifecycle: memoryLifecycleMetrics.total_items > 0 ? memoryLifecycleMetrics : undefined,
|
|
583
685
|
selected,
|
|
584
686
|
};
|
|
585
687
|
if (options.digest) {
|
|
@@ -972,6 +1074,7 @@ export function renderContextPromptTemplate(result, compact = false) {
|
|
|
972
1074
|
lines.push(` decisions: ${result.context_diff.counts.decisions}`);
|
|
973
1075
|
lines.push(` traps: ${result.context_diff.counts.traps}`);
|
|
974
1076
|
lines.push(` handoffs: ${result.context_diff.counts.handoffs}`);
|
|
1077
|
+
lines.push(` plans: ${result.context_diff.counts.plans}`);
|
|
975
1078
|
lines.push(` pending_candidates: ${result.context_diff.counts.pending_candidates}`);
|
|
976
1079
|
lines.push(` total: ${result.context_diff.counts.total}`);
|
|
977
1080
|
}
|
|
@@ -1724,7 +1827,7 @@ function findActiveProjectInChain(contextCwd, _storeChain) {
|
|
|
1724
1827
|
return undefined;
|
|
1725
1828
|
}
|
|
1726
1829
|
// --- Workflow hints ---
|
|
1727
|
-
function buildWorkflowHints(myClaims, openWork, plans) {
|
|
1830
|
+
function buildWorkflowHints(myClaims, openWork, plans, memoryLifecycle) {
|
|
1728
1831
|
const hints = [];
|
|
1729
1832
|
// No claims — suggest claiming before editing
|
|
1730
1833
|
if (myClaims.length === 0) {
|
|
@@ -1744,6 +1847,24 @@ function buildWorkflowHints(myClaims, openWork, plans) {
|
|
|
1744
1847
|
hints.push(`${unclaimedInProgress.length} in-progress plan(s) without a claim — consider claiming the scope you're editing`);
|
|
1745
1848
|
}
|
|
1746
1849
|
}
|
|
1850
|
+
// pln#544 — memory curation surfacing. Pick the strongest signal:
|
|
1851
|
+
// - an oldest unconfirmed item past the half-life-ish horizon, OR
|
|
1852
|
+
// - an item that an agent flagged as misleading (still active).
|
|
1853
|
+
if (memoryLifecycle && memoryLifecycle.total_items > 0) {
|
|
1854
|
+
if (memoryLifecycle.total_infirmed_active > 0) {
|
|
1855
|
+
hints.push(`${memoryLifecycle.total_infirmed_active} active memory item(s) were infirmed after their last confirmation — review with bclaw_find(status:'active') and consider archiving via bclaw_transition`);
|
|
1856
|
+
}
|
|
1857
|
+
else if (memoryLifecycle.oldest_unconfirmed_id
|
|
1858
|
+
&& memoryLifecycle.oldest_unconfirmed_age_days !== undefined
|
|
1859
|
+
&& memoryLifecycle.oldest_unconfirmed_age_days >= 30) {
|
|
1860
|
+
const ent = memoryLifecycle.oldest_unconfirmed_entity ?? 'item';
|
|
1861
|
+
hints.push(`Oldest unconfirmed ${ent} ${memoryLifecycle.oldest_unconfirmed_id} is ${memoryLifecycle.oldest_unconfirmed_age_days}d old — confirm or retire when you next encounter it`);
|
|
1862
|
+
}
|
|
1863
|
+
else if (memoryLifecycle.confirmed_ratio < 0.2
|
|
1864
|
+
&& memoryLifecycle.total_items >= 10) {
|
|
1865
|
+
hints.push(`Memory health: only ${Math.round(memoryLifecycle.confirmed_ratio * 100)}% of ${memoryLifecycle.total_items} item(s) have been confirmed — start attesting in passing to age memory honestly`);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1747
1868
|
return hints.length > 0 ? hints : undefined;
|
|
1748
1869
|
}
|
|
1749
1870
|
//# sourceMappingURL=context.js.map
|
|
@@ -16,6 +16,7 @@ import { loadAllSessions } from './identity.js';
|
|
|
16
16
|
import { countActionable } from './messaging.js';
|
|
17
17
|
import { listCandidates } from './candidates.js';
|
|
18
18
|
import { pullSignalsFromLinkedProjects } from './federation-transport.js';
|
|
19
|
+
import { isObserverMode } from './observer-mode.js';
|
|
19
20
|
export function buildCoordinationSnapshot(options = {}) {
|
|
20
21
|
const config = loadConfig(options.cwd);
|
|
21
22
|
const state = loadState(options.cwd);
|
|
@@ -58,8 +59,13 @@ export function buildCoordinationSnapshot(options = {}) {
|
|
|
58
59
|
const filteredHandoffs = agent
|
|
59
60
|
? openHandoffs.filter((h) => (!project || !h.project || h.project === project) && (h.to === agent || h.from === agent))
|
|
60
61
|
: (project ? openHandoffs.filter((h) => !h.project || h.project === project) : openHandoffs);
|
|
61
|
-
// perf.2: auto-acknowledge shown handoffs
|
|
62
|
-
|
|
62
|
+
// perf.2: auto-acknowledge shown handoffs.
|
|
63
|
+
// Observer mode (BRAINCLAW_OBSERVER=1) suppresses this — a dashboard reading
|
|
64
|
+
// the board must never mutate the store it observes. The 2026-06-10 lock
|
|
65
|
+
// storm was caused by the VS Code extension polling kind='board' (which sets
|
|
66
|
+
// autoAcknowledge=true) and triggering persistState → full store rewrite +
|
|
67
|
+
// git commit on every refresh.
|
|
68
|
+
if (options.autoAcknowledge && filteredHandoffs.length > 0 && !isObserverMode()) {
|
|
63
69
|
const toAckIds = new Set(filteredHandoffs.map((h) => h.id));
|
|
64
70
|
let changed = false;
|
|
65
71
|
for (const h of state.open_handoffs) {
|
|
@@ -88,7 +94,7 @@ export function buildCoordinationSnapshot(options = {}) {
|
|
|
88
94
|
: filteredClaims,
|
|
89
95
|
active_assignments: (agent
|
|
90
96
|
? listAssignments(options.cwd, { agent })
|
|
91
|
-
: listAssignments(options.cwd)).filter((assignment) => !['completed', 'failed', 'cancelled', 'expired', 'rerouted'].includes(assignment.status) &&
|
|
97
|
+
: listAssignments(options.cwd)).filter((assignment) => !['completed', 'failed', 'cancelled', 'expired', 'rerouted', 'timed_out'].includes(assignment.status) &&
|
|
92
98
|
(!project || !assignment.plan_id || filteredPlans.some((plan) => plan.id === assignment.plan_id))),
|
|
93
99
|
active_runs: (agent
|
|
94
100
|
? listAgentRuns(options.cwd, { agent })
|
|
@@ -229,6 +235,19 @@ function buildOtherAgentsSummary(claims, notes, currentAgent, cwd) {
|
|
|
229
235
|
const result = [...agentMap.values()];
|
|
230
236
|
return result.length > 0 ? result : undefined;
|
|
231
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Lightweight cross-project snapshot — linked_projects + incoming_signals only.
|
|
240
|
+
* Used by the VS Code extension's SYSTEM section so it does not have to fetch
|
|
241
|
+
* the full coordination snapshot (pln#558 step 3). Loads two linked-project
|
|
242
|
+
* states plus the incoming-signals scan; never builds the agent/handoff/claim
|
|
243
|
+
* summaries.
|
|
244
|
+
*/
|
|
245
|
+
export function buildCrossProjectSnapshot(cwd) {
|
|
246
|
+
return {
|
|
247
|
+
linked_projects: buildLinkedProjectsSummary(cwd),
|
|
248
|
+
incoming_signals: buildIncomingSignalsSummary(cwd),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
232
251
|
function buildLinkedProjectsSummary(cwd) {
|
|
233
252
|
const links = resolveCrossProjectLinks(cwd);
|
|
234
253
|
if (links.length === 0)
|
|
@@ -20,15 +20,44 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import fs from 'node:fs';
|
|
22
22
|
import path from 'node:path';
|
|
23
|
+
import { execFileSync } from 'node:child_process';
|
|
23
24
|
import { loadAssignment, listAssignments } from './assignments.js';
|
|
25
|
+
import { logger } from './logger.js';
|
|
24
26
|
import { loadAgentRun, listAgentRuns } from './agentruns.js';
|
|
25
27
|
import { loadClaim } from './claims.js';
|
|
26
28
|
import { getLoop, listLoops } from './loops/store.js';
|
|
27
29
|
import { isProcessAlive } from './agentrun-reconciler.js';
|
|
28
|
-
import {
|
|
30
|
+
import { findRuntimeNoteById } from './runtime.js';
|
|
31
|
+
import { latestActivityMs, decodeOemAwareBuffer } from './runtime-signals.js';
|
|
29
32
|
import { LaneResultSchema } from './schema.js';
|
|
30
33
|
const DEFAULT_TAIL = 20;
|
|
31
34
|
const DEFAULT_STALL_MS = 5 * 60_000;
|
|
35
|
+
const DEFAULT_BASE_REF = 'master';
|
|
36
|
+
/**
|
|
37
|
+
* pln#554 — worktree git evidence, the signal that beats process/administrative
|
|
38
|
+
* status: a worker that committed everything to its lane branch has DELIVERED,
|
|
39
|
+
* whatever its pid/heartbeat/assignment.status say. Shared by dispatch-status
|
|
40
|
+
* and `brainclaw dispatch watch`. Returns undefined when there is no worktree
|
|
41
|
+
* or git could not be queried (never conclude "no commits" from a failed read).
|
|
42
|
+
*/
|
|
43
|
+
export function gitEvidence(worktreePath, baseRef) {
|
|
44
|
+
if (!worktreePath)
|
|
45
|
+
return undefined;
|
|
46
|
+
try {
|
|
47
|
+
const ahead = execFileSync('git', ['-C', worktreePath, 'rev-list', '--count', `${baseRef}..HEAD`], {
|
|
48
|
+
encoding: 'utf-8', timeout: 15000,
|
|
49
|
+
}).trim();
|
|
50
|
+
const status = execFileSync('git', ['-C', worktreePath, 'status', '--short'], {
|
|
51
|
+
encoding: 'utf-8', timeout: 15000,
|
|
52
|
+
});
|
|
53
|
+
const dirty = status.split('\n').filter((l) => l.trim() && !l.startsWith('??')).length;
|
|
54
|
+
return { commitsAhead: Number.parseInt(ahead, 10) || 0, dirtyTracked: dirty };
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
logger.debug('dispatch status: git evidence unavailable:', err);
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
32
61
|
// ── Internal helpers ──────────────────────────────────────────────────────
|
|
33
62
|
function readLogTail(filePath, lines) {
|
|
34
63
|
try {
|
|
@@ -36,7 +65,9 @@ function readLogTail(filePath, lines) {
|
|
|
36
65
|
if (lines <= 0) {
|
|
37
66
|
return { path: filePath, exists: true, size_bytes: stat.size };
|
|
38
67
|
}
|
|
39
|
-
|
|
68
|
+
// can_c39f0961: Windows-native tools write OEM cp850 — decode-aware read
|
|
69
|
+
// instead of blind utf-8 so the tail is human-readable.
|
|
70
|
+
const content = decodeOemAwareBuffer(fs.readFileSync(filePath));
|
|
40
71
|
const all = content.split(/\r?\n/);
|
|
41
72
|
// Strip trailing empty line from final \n
|
|
42
73
|
if (all.length > 0 && all[all.length - 1] === '')
|
|
@@ -155,6 +186,18 @@ function computeDiagnosis(assignment, agentRun, runtime, options) {
|
|
|
155
186
|
: `Worker reported "${lr.status}". Read the LANE-RESULT summary + stderr; address the blocker or reroute.`,
|
|
156
187
|
};
|
|
157
188
|
}
|
|
189
|
+
// pln#554 — git evidence is the #2 signal, ABOVE process sentinels and
|
|
190
|
+
// administrative status: commits ahead of base with a clean tracked tree
|
|
191
|
+
// means the worker delivered everything to the branch, even if its pid is
|
|
192
|
+
// dead, its heartbeat stale, or the run was relabeled interrupted by a TTL
|
|
193
|
+
// sweep (can_948acfd6). The verdict is "harvest it" — never "kill and reroute".
|
|
194
|
+
if ((runtime.commits_ahead ?? 0) > 0 && runtime.dirty_tracked === 0) {
|
|
195
|
+
return {
|
|
196
|
+
health: 'terminal',
|
|
197
|
+
summary: `worker delivered: ${runtime.commits_ahead} commit(s) ahead of base with a clean tracked tree — everything is on the lane branch${agentRun && !TERMINAL_RUN_STATUSES.has(agentRun.status) ? ` (agent_run still ${agentRun.status}; exit formalities missing — harvest reconciles it)` : ''}`,
|
|
198
|
+
recommended_next_action: 'Worker delivered; harvest it: `brainclaw harvest <assignment_id>` to ingest and merge the lane branch. Do NOT kill or reroute.',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
158
201
|
if (!agentRun) {
|
|
159
202
|
return {
|
|
160
203
|
health: 'not_dispatched',
|
|
@@ -234,7 +277,15 @@ export function getDispatchStatus(options) {
|
|
|
234
277
|
const resolved = resolveTarget(options.target_id, cwd);
|
|
235
278
|
const assignmentId = resolved.assignment_id;
|
|
236
279
|
const assignment = assignmentId ? loadAssignment(assignmentId, cwd) : undefined;
|
|
237
|
-
|
|
280
|
+
// loadClaim THROWS on a missing id — a GC'd/never-created claim must not
|
|
281
|
+
// crash the whole diagnostic (sprint 1.5).
|
|
282
|
+
let claim;
|
|
283
|
+
if (assignment?.claim_id) {
|
|
284
|
+
try {
|
|
285
|
+
claim = loadClaim(assignment.claim_id, cwd);
|
|
286
|
+
}
|
|
287
|
+
catch { /* claim gone — diagnose without it */ }
|
|
288
|
+
}
|
|
238
289
|
// Prefer the pre-resolved agent_run (when target_id was a run_…); otherwise
|
|
239
290
|
// look up by assignment_id and pick the most recent attempt.
|
|
240
291
|
let agentRun = resolved.agent_run;
|
|
@@ -260,7 +311,11 @@ export function getDispatchStatus(options) {
|
|
|
260
311
|
// pln#527 — filesystem-activity age: max mtime across the captured logs + the
|
|
261
312
|
// run's worktree files (skipping junctions). The truer liveness signal when
|
|
262
313
|
// the heartbeat / last_event_at is stale during a long single operation.
|
|
263
|
-
|
|
314
|
+
// can_948acfd6: also fall back to assignment.worktree_path — without it a
|
|
315
|
+
// LANE-RESULT.json sitting in the assignment's worktree was invisible when
|
|
316
|
+
// neither the run nor the claim carried the path, and the verdict degraded
|
|
317
|
+
// to 'read stderr for failure detail' despite a completed lane result.
|
|
318
|
+
const worktreeForFs = agentRun?.worktree_path ?? claim?.worktree_path ?? assignment?.worktree_path;
|
|
264
319
|
let lastFsActivityMs;
|
|
265
320
|
if (assignmentId) {
|
|
266
321
|
const lastFs = latestActivityMs(projectRoot, assignmentId, worktreeForFs);
|
|
@@ -277,6 +332,8 @@ export function getDispatchStatus(options) {
|
|
|
277
332
|
}
|
|
278
333
|
catch { /* no / invalid LANE-RESULT.json */ }
|
|
279
334
|
}
|
|
335
|
+
// pln#554 — worktree git evidence (commits ahead of base + dirty tracked files).
|
|
336
|
+
const evidence = gitEvidence(worktreeForFs, options.base_ref ?? DEFAULT_BASE_REF);
|
|
280
337
|
const runtime = {
|
|
281
338
|
pid: agentRun?.pid,
|
|
282
339
|
pid_alive: isProcessAlive(agentRun?.pid),
|
|
@@ -290,8 +347,25 @@ export function getDispatchStatus(options) {
|
|
|
290
347
|
},
|
|
291
348
|
last_fs_activity_ms: lastFsActivityMs,
|
|
292
349
|
lane_result: laneResult,
|
|
350
|
+
commits_ahead: evidence?.commitsAhead,
|
|
351
|
+
dirty_tracked: evidence?.dirtyTracked,
|
|
293
352
|
};
|
|
294
|
-
|
|
353
|
+
let diagnosis = computeDiagnosis(assignment, agentRun, runtime, { stallMs, nowMs });
|
|
354
|
+
// can_b8d53d18 — a `run_` target that resolves to nothing may be a LEGACY
|
|
355
|
+
// runtime_note id (pre-rtn_ prefix collision). Say so precisely instead of
|
|
356
|
+
// the generic "verify the target_id" message.
|
|
357
|
+
if (resolved.resolved_from === 'unresolved' && options.target_id.startsWith('run_')) {
|
|
358
|
+
try {
|
|
359
|
+
if (findRuntimeNoteById(options.target_id, {}, cwd)) {
|
|
360
|
+
diagnosis = {
|
|
361
|
+
health: 'unknown',
|
|
362
|
+
summary: `${options.target_id} is a runtime_note (legacy run_ id prefix), not an agent_run — nothing to dispatch-diagnose`,
|
|
363
|
+
recommended_next_action: 'Read it with bclaw_get(entity="runtime_note"). Run `brainclaw repair` to migrate legacy run_ note ids to rtn_.',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch { /* diagnosis stays generic */ }
|
|
368
|
+
}
|
|
295
369
|
return {
|
|
296
370
|
target_id: options.target_id,
|
|
297
371
|
resolved_from: resolved.resolved_from,
|