brainclaw 1.20.3 → 1.21.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/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/export.js +3 -3
- package/dist/commands/init.js +11 -0
- package/dist/commands/mcp-write-claims.js +191 -73
- package/dist/commands/mcp-write-entities.js +67 -0
- package/dist/commands/mcp.js +64 -1
- package/dist/commands/switch.js +41 -12
- package/dist/core/actions.js +25 -1
- package/dist/core/agent-files.js +19 -0
- package/dist/core/agentruns.js +68 -10
- package/dist/core/assignments.js +94 -19
- package/dist/core/claims.js +13 -6
- package/dist/core/config.js +58 -0
- package/dist/core/context-diff.js +28 -11
- package/dist/core/entity-locator.js +404 -0
- package/dist/core/execution-context.js +16 -9
- package/dist/core/identity.js +9 -1
- package/dist/core/io.js +39 -1
- package/dist/core/operations/relocate.js +40 -10
- package/dist/core/schema.js +24 -0
- package/dist/core/sequence.js +47 -6
- package/dist/core/store-resolution.js +99 -26
- package/dist/core/workspace-projects.js +23 -2
- package/dist/core/worktree.js +59 -1
- package/dist/facts.js +9 -9
- package/dist/facts.json +8 -8
- package/docs/cli.md +3 -1
- package/package.json +1 -1
package/dist/commands/switch.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { loadActiveProject, saveActiveProject, clearActiveProject } from '../core/active-project.js';
|
|
3
3
|
import { buildOperationalIdentity, loadCurrentSession, loadSessionById, resolveCurrentSessionId, saveCurrentSession } from '../core/identity.js';
|
|
4
4
|
import { memoryExists } from '../core/io.js';
|
|
5
|
-
import { resolveProjectRef } from '../core/store-resolution.js';
|
|
5
|
+
import { resolveEffectiveCwdInfo, resolveProjectRef } from '../core/store-resolution.js';
|
|
6
6
|
import { resolveCrossProjectLinks, resolveProjectCwd } from '../core/cross-project.js';
|
|
7
7
|
import { scanNestedBrainclawProjects } from '../core/workspace-projects.js';
|
|
8
8
|
import { loadConfig } from '../core/config.js';
|
|
@@ -103,10 +103,17 @@ export function listAvailableProjectsForSession(cwd, sessionId) {
|
|
|
103
103
|
if (!wsRoot) {
|
|
104
104
|
throw new Error('No brainclaw workspace found.');
|
|
105
105
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
// ONE RESOLVER, not a second ladder. This recomputed session-then-global locally, so it
|
|
107
|
+
// could report only two of the seven rungs: an agent physically inside a child project
|
|
108
|
+
// (cwd_child), or one anchored by BRAINCLAW_CWD / BRAINCLAW_PROJECT, was marked against
|
|
109
|
+
// the GLOBAL pointer or against nothing at all — while its writes went elsewhere. That
|
|
110
|
+
// divergence between the reader that DISPLAYS and the resolver that WRITES is what kept
|
|
111
|
+
// the pln#648 bug invisible for weeks: green status, data beside it.
|
|
112
|
+
const effective = resolveEffectiveCwdInfo({ baseCwd: cwd, sessionId });
|
|
113
|
+
const activeSource = effective.active_source;
|
|
114
|
+
const active = effective.resolved_project
|
|
115
|
+
? { path: effective.resolved_project.path, name: effective.resolved_project.name }
|
|
116
|
+
: undefined;
|
|
110
117
|
const projects = [];
|
|
111
118
|
const seen = new Set();
|
|
112
119
|
const addProject = (project) => {
|
|
@@ -262,13 +269,33 @@ export function runSwitch(projectRef, options = {}) {
|
|
|
262
269
|
console.log(`✔ Switched to ${switchedName ? `"${switchedName}" (${rel})` : rel}${scopeHint}`);
|
|
263
270
|
}
|
|
264
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* pln#649 step 5 — status derives from the SAME call that routes a write.
|
|
274
|
+
*
|
|
275
|
+
* This used to walk its own session-then-global ladder, so it could only ever name two of
|
|
276
|
+
* the resolver's seven rungs. An agent inside a child project read back the workspace's
|
|
277
|
+
* GLOBAL pointer — a different project from the one its writes reached. Reporting a source
|
|
278
|
+
* the writer does not use is the exact shape of the defect this plan exists to close, and
|
|
279
|
+
* it is why that defect stayed invisible: the status surface kept saying the reassuring
|
|
280
|
+
* thing.
|
|
281
|
+
*
|
|
282
|
+
* `active_source` is now the resolver's own vocabulary, matching what the MCP surfaces
|
|
283
|
+
* already echo. `cwd` — the resolver's "nothing pointed anywhere, use the directory" rung —
|
|
284
|
+
* keeps the previous no-active-project output, so the operator-facing text is unchanged for
|
|
285
|
+
* the case it described.
|
|
286
|
+
*/
|
|
265
287
|
function showCurrent(wsRoot, cwd, json) {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
271
|
-
|
|
288
|
+
const effective = resolveEffectiveCwdInfo({ baseCwd: cwd });
|
|
289
|
+
const source = effective.active_source;
|
|
290
|
+
// The pointer records carry switched_at / switched_by; the resolver carries authority.
|
|
291
|
+
// Read the metadata from whichever pointer actually won, and never from the other one.
|
|
292
|
+
const pointer = source === 'session'
|
|
293
|
+
? loadCurrentSession(cwd)?.active_project
|
|
294
|
+
: source === 'global' ? loadActiveProject(wsRoot) : undefined;
|
|
295
|
+
const active = pointer
|
|
296
|
+
?? (source === 'cwd' || !effective.resolved_project
|
|
297
|
+
? undefined
|
|
298
|
+
: { path: effective.resolved_project.path, name: effective.resolved_project.name, switched_at: undefined });
|
|
272
299
|
if (!active) {
|
|
273
300
|
if (json) {
|
|
274
301
|
console.log(JSON.stringify({ active: false, scope: 'none' }));
|
|
@@ -314,7 +341,9 @@ function listProjects(wsRoot, cwd, json) {
|
|
|
314
341
|
const name = p.name ? `${p.name} (${p.relative_path})` : p.relative_path;
|
|
315
342
|
console.log(`${marker}${name}`);
|
|
316
343
|
}
|
|
317
|
-
|
|
344
|
+
// `cwd` is the resolver's equivalent of the old locally-computed 'none': no pointer won,
|
|
345
|
+
// so commands operate on the current directory (pln#649 step 5).
|
|
346
|
+
if (result.active_source === 'cwd') {
|
|
318
347
|
console.log('\nNo active project. Use `brainclaw switch <project>` to set one.');
|
|
319
348
|
}
|
|
320
349
|
}
|
package/dist/core/actions.js
CHANGED
|
@@ -28,8 +28,32 @@ function actionStore(cwd) {
|
|
|
28
28
|
sort: (a, b) => a.created_at.localeCompare(b.created_at),
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Honours the declared `| undefined` instead of throwing, which it did.
|
|
33
|
+
*
|
|
34
|
+
* `JsonStore.load` THROWS on a missing id (json-store.ts), so this function could never
|
|
35
|
+
* return undefined and every `if (!action)` branch downstream was unreachable —
|
|
36
|
+
* including the `throw new Error('ActionRequired not found')` in `resolveActionRequired`
|
|
37
|
+
* just below. `assignments.ts` documents this exact JsonStore contract and wraps it; this
|
|
38
|
+
* module did not. (Fable audit.)
|
|
39
|
+
*
|
|
40
|
+
* WHAT I VERIFIED BEFORE FIXING, because the finding was reported as a security-shaped
|
|
41
|
+
* bug and is not one: the audit claimed the self-approval guard in mcp-write-claims.ts was
|
|
42
|
+
* "skipped by the throw". It is not. That guard reads `if (pendingAction && pendingAction
|
|
43
|
+
* .agent === caller)`, and the throw only happens when the action does NOT exist — in which
|
|
44
|
+
* case there is nothing to self-approve and `resolveActionRequired` refuses anyway. The
|
|
45
|
+
* dead code was the truthiness test, never the ownership test. So the real cost was a lying
|
|
46
|
+
* signature plus a worse error message for a missing id, which is what this fixes.
|
|
47
|
+
*/
|
|
31
48
|
export function loadActionRequired(id, cwd) {
|
|
32
|
-
|
|
49
|
+
const store = actionStore(cwd);
|
|
50
|
+
// `exists` rather than a try/catch: a bare catch would also swallow an UNPARSEABLE
|
|
51
|
+
// record and report it as "not found", which is a different fact and the kind of
|
|
52
|
+
// silent downgrade this codebase already got burned by. Absence answers undefined;
|
|
53
|
+
// a corrupt record still throws, as it should.
|
|
54
|
+
if (!store.exists(id))
|
|
55
|
+
return undefined;
|
|
56
|
+
return store.load(id);
|
|
33
57
|
}
|
|
34
58
|
/** Default TTL for pending actions (1 hour). */
|
|
35
59
|
const ACTION_DEFAULT_TTL_MS = 60 * 60 * 1000;
|
package/dist/core/agent-files.js
CHANGED
|
@@ -322,6 +322,25 @@ const UNIVERSAL_SKILL_RELATIVE_PATH = '.agents/skills/brainclaw/SKILL.md';
|
|
|
322
322
|
* Directories exclusively managed by brainclaw — safe to gitignore as a whole.
|
|
323
323
|
* Individual files in these directories don't need separate gitignore entries.
|
|
324
324
|
*/
|
|
325
|
+
/**
|
|
326
|
+
* pln#647 — coordination artifacts brainclaw writes at a WORKTREE ROOT, which must
|
|
327
|
+
* never be tracked and must not show up as untracked debris in a user's project.
|
|
328
|
+
*
|
|
329
|
+
* brainclaw's own repo lists these by hand (added after trp#546: a committed
|
|
330
|
+
* LANE-RESULT.json is inherited by every new worktree from HEAD and poisons
|
|
331
|
+
* dispatch-status into a false "completed" verdict). Nothing propagated them to a
|
|
332
|
+
* target project, so every OTHER repo got the debris — and `.brainclaw-heartbeat-*`
|
|
333
|
+
* was ignored nowhere at all, including here, which is what blocked worktree removal
|
|
334
|
+
* on 2026-08-04 (see removeWorktree).
|
|
335
|
+
*/
|
|
336
|
+
export const BRAINCLAW_PROTOCOL_ARTIFACT_IGNORES = [
|
|
337
|
+
'LANE-RESULT.json',
|
|
338
|
+
'REVIEW-FINDINGS.md',
|
|
339
|
+
'REVIEW_FINDINGS.md',
|
|
340
|
+
'TRIAGE-REPORT.json',
|
|
341
|
+
'.brainclaw-heartbeat-*',
|
|
342
|
+
'.brainclaw-worktree.json',
|
|
343
|
+
];
|
|
325
344
|
export const BRAINCLAW_EXCLUSIVE_DIRECTORIES = [
|
|
326
345
|
'.roo/',
|
|
327
346
|
'.kilo/',
|
package/dist/core/agentruns.js
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
10
11
|
import { AgentRunSchema } from './schema.js';
|
|
11
|
-
import {
|
|
12
|
+
import { resolveOwnerProjectId } from './config.js';
|
|
13
|
+
import { entityRecordDirs, resolveEntityDir } from './io.js';
|
|
12
14
|
import { mutate } from './mutation-pipeline.js';
|
|
13
15
|
import { nowISO, generateIdWithLabel } from './ids.js';
|
|
14
16
|
import { JsonStore } from './json-store.js';
|
|
@@ -25,9 +27,9 @@ function ensureAgentRunsDir(cwd) {
|
|
|
25
27
|
fs.mkdirSync(dir, { recursive: true });
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
|
-
function
|
|
30
|
+
function agentRunStoreForDir(dirPath) {
|
|
29
31
|
return new JsonStore({
|
|
30
|
-
dirPath
|
|
32
|
+
dirPath,
|
|
31
33
|
documentType: 'agent_run',
|
|
32
34
|
getId: (run) => run.id,
|
|
33
35
|
sort: (a, b) => {
|
|
@@ -38,6 +40,9 @@ function agentRunStore(cwd) {
|
|
|
38
40
|
},
|
|
39
41
|
});
|
|
40
42
|
}
|
|
43
|
+
// NOTE: no single-directory reader remains. The helper that resolved ONE dir via the
|
|
44
|
+
// hasContent heuristic is what let a legacy run stay invisible to the list while the by-id
|
|
45
|
+
// loader found it; removing it stops the next reader from reintroducing that asymmetry.
|
|
41
46
|
export function saveAgentRun(run, cwd) {
|
|
42
47
|
mutate({ cwd }, () => {
|
|
43
48
|
ensureAgentRunsDir(cwd);
|
|
@@ -53,18 +58,67 @@ export function saveAgentRun(run, cwd) {
|
|
|
53
58
|
emitRegistryPostImage('agent_run', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
|
|
54
59
|
registryFaultPoint('after_registry_journal');
|
|
55
60
|
store.save(parsed);
|
|
61
|
+
// Converge the other layout (mirrors saveClaim / saveAssignment): leaving a legacy copy
|
|
62
|
+
// holding the stale status is what let a deleted record be resurrected by its own zombie.
|
|
63
|
+
const writeDir = agentRunsDir(cwd, 'write');
|
|
64
|
+
for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
|
|
65
|
+
if (dirPath === writeDir)
|
|
66
|
+
continue;
|
|
67
|
+
const legacyPath = path.join(dirPath, `${parsed.id}.json`);
|
|
68
|
+
try {
|
|
69
|
+
if (fs.existsSync(legacyPath))
|
|
70
|
+
fs.unlinkSync(legacyPath);
|
|
71
|
+
}
|
|
72
|
+
catch { /* best effort — the dual-layout list keeps it visible */ }
|
|
73
|
+
}
|
|
56
74
|
});
|
|
57
75
|
}
|
|
58
76
|
export function loadAgentRun(id, cwd) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
// Record-specific across both layouts (pln#649, shared io.ts primitive). Resolving
|
|
78
|
+
// a single directory made a legacy run invisible as soon as the canonical one held
|
|
79
|
+
// any file — the same defect reproduced twice on assignments, found here by a Fable
|
|
80
|
+
// audit before it reached a field report.
|
|
81
|
+
for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
|
|
82
|
+
try {
|
|
83
|
+
return new JsonStore({
|
|
84
|
+
dirPath,
|
|
85
|
+
documentType: 'agent_run',
|
|
86
|
+
getId: (run) => run.id,
|
|
87
|
+
sort: (a, b) => {
|
|
88
|
+
const byAssignment = a.assignment_id.localeCompare(b.assignment_id);
|
|
89
|
+
if (byAssignment !== 0)
|
|
90
|
+
return byAssignment;
|
|
91
|
+
return a.created_at.localeCompare(b.created_at);
|
|
92
|
+
},
|
|
93
|
+
}).load(id);
|
|
94
|
+
}
|
|
95
|
+
catch { /* not in this layout — try the other */ }
|
|
64
96
|
}
|
|
97
|
+
return undefined;
|
|
65
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* BOTH LAYOUTS, canonical winning on a duplicate id (mirrors listClaims / listAssignments).
|
|
101
|
+
*
|
|
102
|
+
* This list is the one `nextAttemptIndex` and `findLatestAgentRunForAssignment` read, so a
|
|
103
|
+
* run visible by id but missing from the list is what lets an attempt index restart at 1
|
|
104
|
+
* and a run's FSM freeze. Reachability is LOW — agent runs postdate the partitioned layout,
|
|
105
|
+
* so brainclaw has never written them flat (measured: legacy=0 in the field) — which is why
|
|
106
|
+
* this is internal consistency, not a field fix.
|
|
107
|
+
*/
|
|
66
108
|
export function listAgentRuns(cwd, filter) {
|
|
67
|
-
|
|
109
|
+
const byId = new Map();
|
|
110
|
+
for (const dirPath of entityRecordDirs('runs', cwd ?? process.cwd())) {
|
|
111
|
+
for (const run of agentRunStoreForDir(dirPath).list()) {
|
|
112
|
+
if (!byId.has(run.id))
|
|
113
|
+
byId.set(run.id, run);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let runs = Array.from(byId.values()).sort((a, b) => {
|
|
117
|
+
const byAssignment = a.assignment_id.localeCompare(b.assignment_id);
|
|
118
|
+
if (byAssignment !== 0)
|
|
119
|
+
return byAssignment;
|
|
120
|
+
return a.created_at.localeCompare(b.created_at);
|
|
121
|
+
});
|
|
68
122
|
if (filter?.status)
|
|
69
123
|
runs = runs.filter((run) => run.status === filter.status);
|
|
70
124
|
if (filter?.agent)
|
|
@@ -123,7 +177,9 @@ export function createAgentRun(options, cwd) {
|
|
|
123
177
|
const run = AgentRunSchema.parse({
|
|
124
178
|
schema_version: 1,
|
|
125
179
|
id: options.id ?? generated.id,
|
|
126
|
-
|
|
180
|
+
// Same landmine as createAssignment: `generated` is undefined when the caller
|
|
181
|
+
// supplied an id, so `generated!` threw on "this id, derive the rest".
|
|
182
|
+
short_label: options.short_label ?? generated?.short_label ?? (options.id ?? generated.id),
|
|
127
183
|
assignment_id: options.assignment_id,
|
|
128
184
|
claim_id: options.claim_id,
|
|
129
185
|
message_id: options.message_id,
|
|
@@ -134,6 +190,8 @@ export function createAgentRun(options, cwd) {
|
|
|
134
190
|
agent: options.agent,
|
|
135
191
|
agent_id: options.agent_id,
|
|
136
192
|
session_id: options.session_id,
|
|
193
|
+
// OWNER project — same core-level capture as createAssignment (pln#649 step 1).
|
|
194
|
+
project_id: resolveOwnerProjectId(cwd),
|
|
137
195
|
transport: options.transport,
|
|
138
196
|
status: options.status ?? 'created',
|
|
139
197
|
status_reason: options.status_reason,
|
package/dist/core/assignments.js
CHANGED
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
* @module
|
|
12
12
|
*/
|
|
13
13
|
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
14
15
|
import { AssignmentSchema } from './schema.js';
|
|
15
|
-
import {
|
|
16
|
+
import { resolveOwnerProjectId } from './config.js';
|
|
17
|
+
import { entityRecordDirs, resolveEntityDir } from './io.js';
|
|
16
18
|
import { mutate } from './mutation-pipeline.js';
|
|
17
19
|
import { nowISO, generateIdWithLabel } from './ids.js';
|
|
18
20
|
import { JsonStore } from './json-store.js';
|
|
@@ -31,14 +33,18 @@ function ensureAssignmentsDir(cwd) {
|
|
|
31
33
|
fs.mkdirSync(dir, { recursive: true });
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
|
-
function
|
|
36
|
+
function assignmentStoreForDir(dirPath) {
|
|
35
37
|
return new JsonStore({
|
|
36
|
-
dirPath
|
|
38
|
+
dirPath,
|
|
37
39
|
documentType: 'assignment',
|
|
38
40
|
getId: (a) => a.id,
|
|
39
41
|
sort: (a, b) => a.created_at.localeCompare(b.created_at),
|
|
40
42
|
});
|
|
41
43
|
}
|
|
44
|
+
// NOTE: there is deliberately no single-directory reader left in this module. A helper that
|
|
45
|
+
// resolved ONE dir via the `hasContent` heuristic is what made a legacy record invisible to
|
|
46
|
+
// the list while the by-id loader could see it; removing it means the next reader cannot
|
|
47
|
+
// reintroduce the asymmetry by reaching for the convenient function.
|
|
42
48
|
// ── CRUD ─────────────────────────────────────────────────────
|
|
43
49
|
export function saveAssignment(assignment, cwd) {
|
|
44
50
|
mutate({ cwd }, () => {
|
|
@@ -55,21 +61,71 @@ export function saveAssignment(assignment, cwd) {
|
|
|
55
61
|
emitRegistryPostImage('assignment', parsed, { created, agent: parsed.agent, agent_id: parsed.agent_id, session_id: parsed.session_id, cwd });
|
|
56
62
|
registryFaultPoint('after_registry_journal');
|
|
57
63
|
store.save(parsed);
|
|
64
|
+
// CONVERGE THE OTHER LAYOUT, exactly as saveClaim does. Without this, a save wrote
|
|
65
|
+
// canonical and LEFT a legacy copy holding the stale status: `loadAssignment` reads
|
|
66
|
+
// canonical first so the record looked right, but `deleteAssignment` removed only the
|
|
67
|
+
// canonical one and the stale copy became the record again — a zombie resurrection.
|
|
68
|
+
// Best effort on purpose: `listAssignments` reads both dirs, so a missed cleanup stays
|
|
69
|
+
// visible rather than silently dropping data. (Fable audit; claims.ts already had it.)
|
|
70
|
+
const writeDir = assignmentsDir(cwd, 'write');
|
|
71
|
+
for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
|
|
72
|
+
if (dirPath === writeDir)
|
|
73
|
+
continue;
|
|
74
|
+
const legacyPath = path.join(dirPath, `${parsed.id}.json`);
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(legacyPath))
|
|
77
|
+
fs.unlinkSync(legacyPath);
|
|
78
|
+
}
|
|
79
|
+
catch { /* best effort — the dual-layout list keeps it visible */ }
|
|
80
|
+
}
|
|
58
81
|
});
|
|
59
82
|
}
|
|
60
83
|
export function loadAssignment(id, cwd) {
|
|
61
84
|
// JsonStore.load throws when the id is missing; honor the declared
|
|
62
85
|
// "| undefined" return type so callers (e.g. transitionAssignment)
|
|
63
86
|
// can emit their own 'Assignment not found' error with the right wording.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
//
|
|
88
|
+
// RECORD-SPECIFIC ACROSS BOTH LAYOUTS (pln#649 step 3 review P1-2, reproduced).
|
|
89
|
+
// `assignmentStore` resolves a DIRECTORY via resolveEntityDir(..., 'read'), which
|
|
90
|
+
// picks the canonical one as soon as it holds ANY file — so in a store
|
|
91
|
+
// mid-migration a legacy `assignments/asgn_x.json` was invisible even though the
|
|
92
|
+
// step-2 locator had just found it. Locator said `found`, this said `not found`:
|
|
93
|
+
// the same defect one layer down. Asking "where is THIS record" needs both
|
|
94
|
+
// layouts, exactly as recordPaths does in the locator.
|
|
95
|
+
for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
|
|
96
|
+
try {
|
|
97
|
+
return new JsonStore({
|
|
98
|
+
dirPath,
|
|
99
|
+
documentType: 'assignment',
|
|
100
|
+
getId: (a) => a.id,
|
|
101
|
+
sort: (a, b) => a.created_at.localeCompare(b.created_at),
|
|
102
|
+
}).load(id);
|
|
103
|
+
}
|
|
104
|
+
catch { /* not in this layout — try the other */ }
|
|
69
105
|
}
|
|
106
|
+
return undefined;
|
|
70
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* BOTH LAYOUTS, canonical winning on a duplicate id (mirrors listClaims).
|
|
110
|
+
*
|
|
111
|
+
* The by-id loader was fixed to read both layouts while this list still read ONE
|
|
112
|
+
* directory, chosen by the `hasContent` heuristic — so three layers gave three answers
|
|
113
|
+
* about the same store. The concrete consequence: a legacy run/assignment invisible to
|
|
114
|
+
* the list while visible by id, which lets `nextAttemptIndex` restart at 1 and collide,
|
|
115
|
+
* and makes `getActiveAssignmentForAgent` miss a live assignment so the worker's implicit
|
|
116
|
+
* heartbeat stops proving liveness. Reachability is LOW (assignments postdate the
|
|
117
|
+
* partitioned layout, so brainclaw has never written them flat — measured: legacy=0 in
|
|
118
|
+
* the field), which is why this is internal consistency rather than a field fix.
|
|
119
|
+
*/
|
|
71
120
|
export function listAssignments(cwd, filter) {
|
|
72
|
-
|
|
121
|
+
const byId = new Map();
|
|
122
|
+
for (const dirPath of entityRecordDirs('assignments', cwd ?? process.cwd())) {
|
|
123
|
+
for (const a of assignmentStoreForDir(dirPath).list()) {
|
|
124
|
+
if (!byId.has(a.id))
|
|
125
|
+
byId.set(a.id, a);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
let items = Array.from(byId.values()).sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
73
129
|
if (filter?.status)
|
|
74
130
|
items = items.filter((a) => a.status === filter.status);
|
|
75
131
|
if (filter?.agent)
|
|
@@ -82,21 +138,30 @@ export function listAssignments(cwd, filter) {
|
|
|
82
138
|
items = items.filter((a) => a.sequence_id === filter.sequence_id);
|
|
83
139
|
return items;
|
|
84
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Deletes the record in EVERY layout, not just the canonical one.
|
|
143
|
+
*
|
|
144
|
+
* Checking only the write dir meant a record `loadAssignment` could find returned `false`
|
|
145
|
+
* from delete — and worse, deleting the canonical copy of a dual-layout record promoted
|
|
146
|
+
* the stale legacy copy back to being the record. Deleting one layout is not deleting.
|
|
147
|
+
*/
|
|
85
148
|
export function deleteAssignment(id, cwd) {
|
|
86
149
|
return mutate({ cwd }, () => {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
getId: (a) => a.id,
|
|
91
|
-
sort: (a, b) => a.created_at.localeCompare(b.created_at),
|
|
92
|
-
});
|
|
93
|
-
if (!writableStore.exists(id)) {
|
|
150
|
+
const dirs = entityRecordDirs('assignments', cwd ?? process.cwd());
|
|
151
|
+
const holders = dirs.filter((dirPath) => assignmentStoreForDir(dirPath).exists(id));
|
|
152
|
+
if (holders.length === 0) {
|
|
94
153
|
return false;
|
|
95
154
|
}
|
|
96
|
-
|
|
155
|
+
// One tombstone for the record, from the copy that wins reads.
|
|
156
|
+
const assignment = assignmentStoreForDir(holders[0]).load(id);
|
|
97
157
|
emitRegistryTombstone('assignment', assignment.id, { agent: assignment.agent, agent_id: assignment.agent_id, session_id: assignment.session_id, cwd });
|
|
98
158
|
registryFaultPoint('after_registry_journal');
|
|
99
|
-
|
|
159
|
+
for (const dirPath of holders) {
|
|
160
|
+
try {
|
|
161
|
+
assignmentStoreForDir(dirPath).delete(id);
|
|
162
|
+
}
|
|
163
|
+
catch { /* another layout already gone — the remaining ones still must go */ }
|
|
164
|
+
}
|
|
100
165
|
return true;
|
|
101
166
|
});
|
|
102
167
|
}
|
|
@@ -295,7 +360,10 @@ export function recordProgress(id, options, cwd) {
|
|
|
295
360
|
export function createAssignment(options, cwd) {
|
|
296
361
|
const generated = options.id ? undefined : generateAssignmentId(cwd);
|
|
297
362
|
const id = options.id ?? generated.id;
|
|
298
|
-
|
|
363
|
+
// `generated` is undefined whenever the caller supplied an id, so the old
|
|
364
|
+
// `generated!.short_label` threw a TypeError on the perfectly reasonable call
|
|
365
|
+
// "give me this id, derive the rest". Found while pinning the layout primitive.
|
|
366
|
+
const short_label = options.short_label ?? generated?.short_label ?? id;
|
|
299
367
|
const assignment = AssignmentSchema.parse({
|
|
300
368
|
schema_version: 1,
|
|
301
369
|
id,
|
|
@@ -309,6 +377,13 @@ export function createAssignment(options, cwd) {
|
|
|
309
377
|
agent_id: options.agent_id,
|
|
310
378
|
dispatcher_agent: options.dispatcher_agent,
|
|
311
379
|
dispatcher_session_id: options.dispatcher_session_id,
|
|
380
|
+
// OWNER project, captured HERE in core (pln#649 step 1) rather than in the
|
|
381
|
+
// command layer: every caller — MCP, CLI, dispatcher, loop engine — writes
|
|
382
|
+
// into `cwd`, so deriving it here means no path can create an assignment
|
|
383
|
+
// without an owner. The claim surface does this in TWO command-layer sites
|
|
384
|
+
// that already disagree (mcp-write-claims.ts uses loadConfig(claimCwd),
|
|
385
|
+
// claim.ts uses actor.project_id); core is the one place that cannot drift.
|
|
386
|
+
project_id: resolveOwnerProjectId(cwd),
|
|
312
387
|
scope: options.scope,
|
|
313
388
|
description: options.description,
|
|
314
389
|
lane: options.lane,
|
package/dist/core/claims.js
CHANGED
|
@@ -3,7 +3,7 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { ClaimSchema } from './schema.js';
|
|
6
|
-
import { resolveEntityDir } from './io.js';
|
|
6
|
+
import { entityRecordDirs, resolveEntityDir } from './io.js';
|
|
7
7
|
import { mutate } from './mutation-pipeline.js';
|
|
8
8
|
import { nowISO } from './ids.js';
|
|
9
9
|
import { JsonStore } from './json-store.js';
|
|
@@ -33,12 +33,19 @@ function parseTtl(value) {
|
|
|
33
33
|
function claimsDir(cwd, mode = 'read') {
|
|
34
34
|
return resolveEntityDir('claims', cwd ?? process.cwd(), mode);
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Both layouts a claim record can occupy, canonical first (pln#649).
|
|
38
|
+
*
|
|
39
|
+
* The previous shape — `Set([claimsDir(write), claimsDir(read)])` — LOOKED like it
|
|
40
|
+
* covered both and did not: `write` is always canonical, and `read` also returns
|
|
41
|
+
* canonical as soon as that directory holds any file, so the Set collapsed to ONE
|
|
42
|
+
* entry and a legacy claim went invisible mid-migration. Same defect as the one
|
|
43
|
+
* reproduced twice on assignments; found here by a Fable audit before it reached a
|
|
44
|
+
* field report. Now derived from the shared io.ts primitive, so there is one
|
|
45
|
+
* definition instead of four look-alikes.
|
|
46
|
+
*/
|
|
36
47
|
function claimDirs(cwd) {
|
|
37
|
-
|
|
38
|
-
return Array.from(new Set([
|
|
39
|
-
claimsDir(effectiveCwd, 'write'),
|
|
40
|
-
claimsDir(effectiveCwd, 'read'),
|
|
41
|
-
]));
|
|
48
|
+
return entityRecordDirs('claims', cwd ?? process.cwd());
|
|
42
49
|
}
|
|
43
50
|
export function ensureClaimsDir(cwd) {
|
|
44
51
|
const dir = claimsDir(cwd, 'write');
|
package/dist/core/config.js
CHANGED
|
@@ -88,6 +88,64 @@ export function loadConfig(cwd, preferredDirName) {
|
|
|
88
88
|
const filepath = memoryPath(CONFIG_FILE, cwd, preferredDirName);
|
|
89
89
|
return loadVersionedYamlFile('config', filepath).document;
|
|
90
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* pln#649 step 1 (dec#153) — the OWNER project of an execution entity: the
|
|
93
|
+
* `project_id` of the store the entity is being written into, captured once at
|
|
94
|
+
* creation and never re-derived.
|
|
95
|
+
*
|
|
96
|
+
* This is the anchor the entity-authoritative routing of dec#153 compares
|
|
97
|
+
* against: for any work unit, `owner_project` is fixed at creation and every
|
|
98
|
+
* read/mutation of that unit must reach THAT store — regardless of pid, cwd,
|
|
99
|
+
* session or the shared global pointer. Without a persisted owner there is
|
|
100
|
+
* nothing for the hard-refusal check to compare, and nothing for a worker to
|
|
101
|
+
* derive its project from.
|
|
102
|
+
*
|
|
103
|
+
* DELIBERATELY `project_id`, not a new field. `ClaimSchema` already carries BOTH
|
|
104
|
+
* `project_id` (a stable prj_* id) and `project` (a free-text namespace LABEL
|
|
105
|
+
* consumed by filters — mcp-read-handlers.ts / coordination.ts). Adding a third
|
|
106
|
+
* project-ish field would have been the same defect one level up, so this reuses
|
|
107
|
+
* the id and leaves the label alone. Operator decision, 2026-08-04.
|
|
108
|
+
*
|
|
109
|
+
* SWALLOWS ONLY "there is no store here" (ENOENT / ENOTDIR). Everything else —
|
|
110
|
+
* malformed YAML, schema-invalid config, permission denied, I/O failure — is
|
|
111
|
+
* RETHROWN (review P1-2). A catch-all was the first version and it was wrong in a
|
|
112
|
+
* way that defeated the whole point: a store with a corrupt config but writable
|
|
113
|
+
* coordination dirs would create entities with NO owner, and step 4 would then
|
|
114
|
+
* read the absent owner as "legacy", skip its refusal, and silently hand back the
|
|
115
|
+
* guarantee this field exists to provide. Degrading quietly is exactly the failure
|
|
116
|
+
* mode dec#153 is against.
|
|
117
|
+
*
|
|
118
|
+
* The field is optional on every schema so a record written before it existed
|
|
119
|
+
* stays loadable. (An earlier version of this comment claimed a zod-invalid record
|
|
120
|
+
* is DELETED on the next syncDirectory — that is false for the current code:
|
|
121
|
+
* state.ts preserves unparseable files precisely so a parse failure cannot corrupt
|
|
122
|
+
* data (trp#126). Required would make old records unloadable, not deleted.)
|
|
123
|
+
*/
|
|
124
|
+
export function resolveOwnerProjectId(cwd, options = {}) {
|
|
125
|
+
try {
|
|
126
|
+
return loadConfig(cwd).project_id;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
const code = err?.code;
|
|
130
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
131
|
+
return undefined; // no store here
|
|
132
|
+
// A store that EXISTS but cannot be read (malformed YAML, schema-invalid,
|
|
133
|
+
// permission, I/O). Review P1-2 asked for a rethrow so a corrupt store could
|
|
134
|
+
// not quietly produce ownerless entities that a later refusal would wave
|
|
135
|
+
// through as "legacy". The intent is right and the placement was not: a
|
|
136
|
+
// rethrow HERE made `createAssignment` throw, the dispatcher swallowed it, and
|
|
137
|
+
// every dispatch silently lost its assignment_id — six CI jobs, caught only by
|
|
138
|
+
// the full suite. Breaking dispatch is worse than a missing owner.
|
|
139
|
+
//
|
|
140
|
+
// So the default is lenient — CREATION MUST NEVER FAIL because a config is
|
|
141
|
+
// corrupt — and the strictness moves to the consumer that actually needs it:
|
|
142
|
+
// pass `strict` from the refusal path (pln#649 step 4), where "I could not read
|
|
143
|
+
// the owner" must be a loud stop rather than an implicit "legacy".
|
|
144
|
+
if (options.strict)
|
|
145
|
+
throw err;
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
91
149
|
export function saveConfig(config, cwd, preferredDirName) {
|
|
92
150
|
const filepath = memoryPath(CONFIG_FILE, cwd, preferredDirName);
|
|
93
151
|
saveVersionedYamlFile('config', filepath, ConfigSchema.parse(config));
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import { readAuditLog } from './audit.js';
|
|
4
3
|
import { listCandidates } from './candidates.js';
|
|
5
|
-
import {
|
|
4
|
+
import { entityRecordPaths } from './io.js';
|
|
6
5
|
import { loadVersionedJsonFile } from './migration.js';
|
|
7
6
|
import { buildNotificationSummary, hasEventCursor, readUnseenEvents, seedCursorToEnd } from './event-log.js';
|
|
8
7
|
import { SessionSnapshotSchema } from './schema.js';
|
|
@@ -28,17 +27,35 @@ export function resolveContextDiffSince(options) {
|
|
|
28
27
|
// everyone's diff baseline.
|
|
29
28
|
return {};
|
|
30
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* THE LAST BY-ID SITE THAT COULD ACTUALLY FIRE, and the only one with live two-layout
|
|
32
|
+
* data in the field: this store holds 173 sessions in the legacy layout next to 1019
|
|
33
|
+
* canonical ones (dec#153-T2's dual write). `resolveEntityDir(..., 'read')` answers a
|
|
34
|
+
* DIRECTORY question with a `hasContent` heuristic, so one canonical file made every
|
|
35
|
+
* legacy record invisible — the same malformed abstraction pln#649 removed from the
|
|
36
|
+
* entity locator and the by-id loaders, still here because nothing routed sessions.
|
|
37
|
+
*
|
|
38
|
+
* The consequence was a SILENT WRONG ANSWER, which is why this one was worth fixing
|
|
39
|
+
* while the sibling sites were not: an invisible snapshot falls through to the audit-log
|
|
40
|
+
* scan, and if that misses too `resolveContextDiffSince` returns no `since`, so
|
|
41
|
+
* `buildContextDiff` returns undefined and the caller is told "no changes" over a window
|
|
42
|
+
* where there were changes. An agent cannot tell that apart from a quiet period.
|
|
43
|
+
*
|
|
44
|
+
* Uses the shared primitive rather than a fourth hand-rolled pair of paths (io.ts).
|
|
45
|
+
*/
|
|
31
46
|
function loadSessionSnapshot(sessionId, cwd) {
|
|
32
|
-
const snapshotPath
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
47
|
+
for (const snapshotPath of entityRecordPaths('sessions', sessionId, cwd ?? process.cwd())) {
|
|
48
|
+
if (!fs.existsSync(snapshotPath))
|
|
49
|
+
continue;
|
|
50
|
+
try {
|
|
51
|
+
return SessionSnapshotSchema.parse(loadVersionedJsonFile('session_snapshot', snapshotPath).document);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// An unparseable record in one layout must not mask a good one in the other.
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
41
57
|
}
|
|
58
|
+
return undefined;
|
|
42
59
|
}
|
|
43
60
|
export function buildContextDiff(options = {}) {
|
|
44
61
|
const resolved = resolveContextDiffSince(options);
|