brainclaw 1.28.1 → 1.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/brainclaw-vscode.vsix +0 -0
  2. package/dist/commands/code-map.js +2 -0
  3. package/dist/commands/doctor.js +1 -0
  4. package/dist/commands/harvest.js +32 -43
  5. package/dist/commands/loops-handlers.js +66 -3
  6. package/dist/commands/mcp-catalog.js +2 -2
  7. package/dist/commands/mcp-write-coordination.js +426 -141
  8. package/dist/commands/mcp-write-entities.js +5 -2
  9. package/dist/commands/mcp.js +57 -11
  10. package/dist/core/agentrun-reconciler.js +138 -4
  11. package/dist/core/claims.js +4 -1
  12. package/dist/core/code-map/aggregate.js +20 -7
  13. package/dist/core/code-map/backend.js +25 -7
  14. package/dist/core/code-map/cascade-jobs.js +174 -0
  15. package/dist/core/code-map/cascade-worker.js +15 -0
  16. package/dist/core/code-map/cascade.js +63 -26
  17. package/dist/core/code-map/query.js +6 -3
  18. package/dist/core/entity-operations.js +18 -4
  19. package/dist/core/execution-adapters.js +23 -8
  20. package/dist/core/hygiene-policy.js +2 -1
  21. package/dist/core/loop-turn-dispatch.js +18 -1
  22. package/dist/core/loops/attempt-authority.js +22 -4
  23. package/dist/core/loops/attempt-generations.js +17 -4
  24. package/dist/core/loops/attempt-reservation.js +14 -1
  25. package/dist/core/loops/attempt-takeover.js +173 -76
  26. package/dist/core/loops/reconcile-turn.js +224 -26
  27. package/dist/core/loops/result-reducers.js +8 -8
  28. package/dist/core/loops/turn-execution.js +38 -19
  29. package/dist/core/loops/types.js +3 -0
  30. package/dist/core/loops/verbs.js +1 -1
  31. package/dist/core/spawn-check.js +9 -1
  32. package/dist/facts.js +8 -8
  33. package/dist/facts.json +7 -7
  34. package/docs/cli.md +2 -2
  35. package/docs/code-map.md +30 -9
  36. package/docs/concepts/loop-engine.md +5 -0
  37. package/docs/integrations/mcp.md +2 -2
  38. package/docs/mcp-schema-changelog.md +30 -0
  39. package/package.json +1 -1
@@ -0,0 +1,174 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { spawn } from 'node:child_process';
6
+ import { loadConfig } from '../config.js';
7
+ import { writeFileAtomic } from '../io.js';
8
+ import { codeMapDir } from './paths.js';
9
+ import { inspectNestedProjects, refreshWorkspaceCascade } from './cascade.js';
10
+ export function summarizeCascadeRefreshJob(job) {
11
+ const { result, ...summary } = job;
12
+ if (!result)
13
+ return summary;
14
+ const projects = [result.root_result, ...result.children];
15
+ const outcomeCounts = {};
16
+ for (const project of projects)
17
+ outcomeCounts[project.outcome] = (outcomeCounts[project.outcome] ?? 0) + 1;
18
+ const problemProjects = projects
19
+ .filter((project) => project.outcome !== 'indexed')
20
+ .map((project) => ({ path: project.path, outcome: project.outcome, ...(project.reason ? { reason: project.reason } : {}), ...(project.error ? { error: project.error } : {}) }));
21
+ return { ...summary, outcome_counts: outcomeCounts, ...(problemProjects.length ? { problem_projects: problemProjects } : {}) };
22
+ }
23
+ function jobsDir(root) {
24
+ return path.join(codeMapDir(root), 'cascade-jobs');
25
+ }
26
+ function jobPath(root, jobId) {
27
+ return path.join(jobsDir(root), `${jobId}.json`);
28
+ }
29
+ function writeJob(job) {
30
+ const dir = jobsDir(job.root);
31
+ fs.mkdirSync(dir, { recursive: true });
32
+ const target = jobPath(job.root, job.job_id);
33
+ writeFileAtomic(target, `${JSON.stringify(job, null, 2)}\n`);
34
+ }
35
+ export function readCascadeRefreshJob(root, jobId) {
36
+ try {
37
+ return JSON.parse(fs.readFileSync(jobPath(path.resolve(root), jobId), 'utf8'));
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ export function latestCascadeRefreshJob(root) {
44
+ const dir = jobsDir(path.resolve(root));
45
+ try {
46
+ const jobs = fs.readdirSync(dir)
47
+ .filter((name) => name.endsWith('.json'))
48
+ .map((name) => {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'));
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ })
56
+ .filter((job) => job !== null)
57
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at));
58
+ return jobs[0] ?? null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function processAlive(pid) {
65
+ if (!pid)
66
+ return false;
67
+ try {
68
+ process.kill(pid, 0);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ export function startCascadeRefreshJob(root, scope) {
76
+ const resolvedRoot = path.resolve(root);
77
+ try {
78
+ if (loadConfig(resolvedRoot).project_mode !== 'multi-project')
79
+ return null;
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ const previous = latestCascadeRefreshJob(resolvedRoot);
85
+ if (previous && (previous.status === 'queued' || previous.status === 'running') && processAlive(previous.pid)) {
86
+ return previous;
87
+ }
88
+ const now = new Date().toISOString();
89
+ const job = {
90
+ job_id: `cmj_${crypto.randomBytes(8).toString('hex')}`,
91
+ root: resolvedRoot,
92
+ scope,
93
+ status: 'queued',
94
+ created_at: now,
95
+ updated_at: now,
96
+ projects_total: inspectNestedProjects(resolvedRoot).projects.length + 1,
97
+ projects_completed: 0,
98
+ current_project: null,
99
+ };
100
+ writeJob(job);
101
+ const worker = fileURLToPath(new URL('./cascade-worker.js', import.meta.url));
102
+ const child = spawn(process.execPath, [worker, resolvedRoot, job.job_id, scope], {
103
+ cwd: resolvedRoot,
104
+ detached: true,
105
+ stdio: 'ignore',
106
+ windowsHide: true,
107
+ });
108
+ const markWorkerExit = (detail) => {
109
+ const current = readCascadeRefreshJob(resolvedRoot, job.job_id);
110
+ if (!current || current.status === 'completed' || current.status === 'failed')
111
+ return;
112
+ const completed = new Date().toISOString();
113
+ writeJob({
114
+ ...current,
115
+ status: 'failed',
116
+ error: detail,
117
+ current_project: null,
118
+ completed_at: completed,
119
+ updated_at: completed,
120
+ });
121
+ };
122
+ child.once('error', (error) => {
123
+ markWorkerExit(`cascade worker failed to start: ${error.message}`);
124
+ });
125
+ child.once('exit', (code, signal) => {
126
+ // The worker writes its terminal state synchronously before exiting. If no
127
+ // terminal state exists here, the launch/runtime failed outside its guard.
128
+ markWorkerExit(`cascade worker exited before completion (code=${code ?? 'null'}, signal=${signal ?? 'none'})`);
129
+ });
130
+ child.unref();
131
+ const current = readCascadeRefreshJob(resolvedRoot, job.job_id) ?? job;
132
+ if (current.status === 'queued') {
133
+ current.pid = child.pid;
134
+ current.updated_at = new Date().toISOString();
135
+ writeJob(current);
136
+ }
137
+ return current;
138
+ }
139
+ /** Worker entry seam, exported for deterministic tests and the tiny worker file. */
140
+ export async function runCascadeRefreshJob(root, jobId, scope) {
141
+ const job = readCascadeRefreshJob(root, jobId);
142
+ if (!job)
143
+ throw new Error(`cascade job not found: ${jobId}`);
144
+ const started = new Date().toISOString();
145
+ writeJob({ ...job, status: 'running', pid: process.pid, started_at: started, updated_at: started });
146
+ try {
147
+ const result = await refreshWorkspaceCascade({
148
+ rootCwd: root,
149
+ scope,
150
+ onProgress: (progress) => {
151
+ const current = readCascadeRefreshJob(root, jobId) ?? job;
152
+ writeJob({
153
+ ...current,
154
+ status: 'running',
155
+ pid: process.pid,
156
+ projects_total: progress.total,
157
+ projects_completed: progress.completed,
158
+ current_project: progress.current_project,
159
+ ...(progress.last_result ? { last_result: progress.last_result } : {}),
160
+ updated_at: new Date().toISOString(),
161
+ });
162
+ },
163
+ });
164
+ const current = readCascadeRefreshJob(root, jobId) ?? job;
165
+ const completed = new Date().toISOString();
166
+ writeJob({ ...current, status: 'completed', result, current_project: null, projects_completed: current.projects_total, completed_at: completed, updated_at: completed });
167
+ }
168
+ catch (error) {
169
+ const current = readCascadeRefreshJob(root, jobId) ?? job;
170
+ const completed = new Date().toISOString();
171
+ writeJob({ ...current, status: 'failed', error: error instanceof Error ? error.message : String(error), current_project: null, completed_at: completed, updated_at: completed });
172
+ }
173
+ }
174
+ //# sourceMappingURL=cascade-jobs.js.map
@@ -0,0 +1,15 @@
1
+ import { runCascadeRefreshJob } from './cascade-jobs.js';
2
+ const [root, jobId, rawScope] = process.argv.slice(2);
3
+ if (!root || !jobId)
4
+ process.exit(2);
5
+ const scope = rawScope === 'all' ? 'all' : 'changed';
6
+ try {
7
+ await runCascadeRefreshJob(root, jobId, scope);
8
+ // Tree-sitter/native handles can keep Node's event loop alive after the
9
+ // durable terminal record has been flushed. This process owns no other work.
10
+ process.exit(0);
11
+ }
12
+ catch {
13
+ process.exit(1);
14
+ }
15
+ //# sourceMappingURL=cascade-worker.js.map
@@ -20,7 +20,7 @@
20
20
  * refreshes existing brainclaw projects, it does not initialise new ones.
21
21
  */
22
22
  import path from 'node:path';
23
- import { scanNestedBrainclawProjects } from '../workspace-projects.js';
23
+ import { scanNestedBrainclawProjectsDetailed } from '../workspace-projects.js';
24
24
  import { loadConfig } from '../config.js';
25
25
  import { refresh as runRefresh } from './refresh.js';
26
26
  import { readManifest } from './store.js';
@@ -54,10 +54,14 @@ function projectIdFor(cwd, fallbackId) {
54
54
  * cascade and the `status --cascade` recap so both agree on the project set.
55
55
  */
56
56
  export function listNestedProjects(rootCwd) {
57
+ return inspectNestedProjects(rootCwd).projects;
58
+ }
59
+ export function inspectNestedProjects(rootCwd) {
57
60
  const root = path.resolve(rootCwd);
58
- return Array.from(new Set(scanNestedBrainclawProjects(root)
59
- .map((c) => path.resolve(c.path))
60
- .filter((abs) => abs !== root && isStrictlyUnder(abs, root)))).sort();
61
+ const discovered = scanNestedBrainclawProjectsDetailed(root);
62
+ return { projects: Array.from(new Set(discovered.projects
63
+ .map((c) => path.resolve(c.path))
64
+ .filter((abs) => abs !== root && isStrictlyUnder(abs, root)))).sort(), truncated: discovered.truncated };
61
65
  }
62
66
  /**
63
67
  * Refresh the whole multi-project workspace: every nested brainclaw project +
@@ -68,7 +72,8 @@ export async function refreshWorkspaceCascade(input) {
68
72
  const rootCwd = path.resolve(input.rootCwd);
69
73
  // Enumerate nested brainclaw projects strictly under the root (FS scan, so it
70
74
  // is strategy-agnostic). De-dup + sort by path for deterministic output.
71
- const childAbsPaths = listNestedProjects(rootCwd);
75
+ const discovery = inspectNestedProjects(rootCwd);
76
+ const childAbsPaths = discovery.projects;
72
77
  // Every project to refresh, root first.
73
78
  const allProjects = [rootCwd, ...childAbsPaths];
74
79
  const refreshOne = async (projectCwd, isRoot) => {
@@ -77,40 +82,72 @@ export async function refreshWorkspaceCascade(input) {
77
82
  const nestedUnder = allProjects.filter((p) => p !== projectCwd && isStrictlyUnder(p, projectCwd));
78
83
  const extraIgnorePatterns = nestedUnder.map((p) => `${toPosix(path.relative(projectCwd, p))}/**`);
79
84
  const projectId = projectIdFor(projectCwd);
80
- const result = await runRefresh({
81
- projectId,
82
- projectRoot: projectCwd,
83
- scope: input.scope,
84
- cwd: projectCwd,
85
- extraIgnorePatterns,
86
- ownerAgent: input.ownerAgent ?? null,
87
- ownerAgentId: input.ownerAgentId ?? null,
88
- });
89
- return {
90
- path: isRoot ? '.' : toPosix(path.relative(rootCwd, projectCwd)),
91
- project_id: projectId,
92
- is_root: isRoot,
93
- ran: result.ran,
94
- lock_acquired: result.lock_acquired,
95
- files_parsed: result.files_parsed,
96
- files_compacted: result.files_compacted,
97
- freshness: result.freshness.status,
98
- ...(result.lock_status ? { lock_status: result.lock_status } : {}),
99
- };
85
+ const projectPath = isRoot ? '.' : toPosix(path.relative(rootCwd, projectCwd));
86
+ try {
87
+ const result = await runRefresh({
88
+ projectId,
89
+ projectRoot: projectCwd,
90
+ scope: input.scope,
91
+ cwd: projectCwd,
92
+ extraIgnorePatterns,
93
+ ownerAgent: input.ownerAgent ?? null,
94
+ ownerAgentId: input.ownerAgentId ?? null,
95
+ });
96
+ const filesIndexed = readManifest(projectCwd)?.stats.files_indexed ?? 0;
97
+ const outcome = !result.lock_acquired
98
+ ? 'locked'
99
+ : filesIndexed === 0 ? 'no_eligible_files' : 'indexed';
100
+ return {
101
+ path: projectPath,
102
+ project_id: projectId,
103
+ is_root: isRoot,
104
+ ran: result.ran,
105
+ lock_acquired: result.lock_acquired,
106
+ files_parsed: result.files_parsed,
107
+ files_compacted: result.files_compacted,
108
+ files_indexed: filesIndexed,
109
+ freshness: result.freshness.status,
110
+ outcome,
111
+ ...(outcome === 'no_eligible_files' ? { reason: 'no eligible source files found' } : {}),
112
+ ...(result.lock_status ? { lock_status: result.lock_status, reason: result.lock_status } : {}),
113
+ };
114
+ }
115
+ catch (error) {
116
+ return {
117
+ path: projectPath,
118
+ project_id: projectId,
119
+ is_root: isRoot,
120
+ ran: false,
121
+ lock_acquired: false,
122
+ files_parsed: 0,
123
+ files_compacted: 0,
124
+ files_indexed: readManifest(projectCwd)?.stats.files_indexed ?? null,
125
+ freshness: 'partial',
126
+ outcome: 'failed',
127
+ reason: 'refresh_failed',
128
+ error: error instanceof Error ? error.message : String(error),
129
+ };
130
+ }
100
131
  };
101
132
  // Children first, then the root (sequential — each holds its own project lock
102
133
  // briefly; never blocks bclaw_work, rule 8).
103
134
  const children = [];
135
+ const total = childAbsPaths.length + 1;
136
+ input.onProgress?.({ completed: 0, total, current_project: childAbsPaths[0] ? toPosix(path.relative(rootCwd, childAbsPaths[0])) : '.' });
104
137
  for (const childCwd of childAbsPaths) {
105
- children.push(await refreshOne(childCwd, false));
138
+ const result = await refreshOne(childCwd, false);
139
+ children.push(result);
140
+ input.onProgress?.({ completed: children.length, total, current_project: children.length < childAbsPaths.length ? toPosix(path.relative(rootCwd, childAbsPaths[children.length])) : '.', last_result: result });
106
141
  }
107
142
  const rootResult = await refreshOne(rootCwd, true);
143
+ input.onProgress?.({ completed: total, total, current_project: null, last_result: rootResult });
108
144
  return {
109
145
  is_cascade: true,
110
146
  root: rootCwd,
111
147
  root_result: rootResult,
112
148
  children,
113
149
  children_refreshed: children.length,
150
+ discovery_truncated: discovery.truncated,
114
151
  };
115
152
  }
116
153
  //# sourceMappingURL=cascade.js.map
@@ -232,7 +232,7 @@ export function isTestPath(p) {
232
232
  /**
233
233
  * Score a symbol index entry against the query. Matching is separator/case
234
234
  * INSENSITIVE (pln#601): an exact NORMALIZED match scores highest, then prefix,
235
- * then substring, then the sub-token floor. Exported symbols + components/hooks
235
+ * then substring; sub-token-only candidates score zero. Exported symbols + components/hooks
236
236
  * get a small boost; test-file symbols are biased DOWN so a test helper never
237
237
  * outranks the real definition of the same name (the Fable-audit brief-noise
238
238
  * companion to the find defect). Exported for focused ranking tests.
@@ -250,7 +250,7 @@ export function scoreEntry(entry, query) {
250
250
  else if (name.includes(q))
251
251
  score += 3; // substring
252
252
  else
253
- score += 1; // matched only via a sub-token bucket
253
+ return 0; // shared-token noise is not a match
254
254
  score *= entry.score_hint; // exported (1.0) vs internal (0.8)
255
255
  if (entry.subtype === 'component' || entry.subtype === 'hook')
256
256
  score += 1;
@@ -367,6 +367,9 @@ export function findInStore(query, ctx, checker, acc) {
367
367
  const confident = validateEntry(entry, checker, acc, root, maxBytes, ctx.cwd, ctx.preferredDirName);
368
368
  if (!confident)
369
369
  continue;
370
+ const score = scoreEntry(entry, query);
371
+ if (score <= 0)
372
+ continue;
370
373
  ranked.push({
371
374
  match: {
372
375
  node_id: entry.node_id,
@@ -375,7 +378,7 @@ export function findInStore(query, ctx, checker, acc) {
375
378
  file_id: entry.file_id,
376
379
  kind: entry.kind,
377
380
  subtype: entry.subtype ?? null,
378
- score: scoreEntry(entry, query),
381
+ score,
379
382
  },
380
383
  centrality: importCentrality(entry, resolutionIndex),
381
384
  });
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import path from 'node:path';
17
17
  import { loadState, mutateState } from './state.js';
18
+ import { detectDuplicates } from './duplicates.js';
18
19
  import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from './candidates.js';
19
20
  import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
20
21
  import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
@@ -511,6 +512,19 @@ export function getEntity(name, idOrShortLabel, cwd) {
511
512
  // ─── CREATE ────────────────────────────────────────────────────────────
512
513
  export function createEntity(name, data, cwd) {
513
514
  assertKnownEntity(name, 'create');
515
+ const proximityKinds = new Set(['decision', 'constraint', 'trap']);
516
+ const nearby = proximityKinds.has(name) && typeof data.text === 'string'
517
+ ? detectDuplicates(data.text, name, loadState(cwd), listCandidates(undefined, cwd).filter((candidate) => candidate.status === 'pending')).slice(0, 3).map((match) => ({
518
+ id: match.id,
519
+ source: match.source,
520
+ reason: match.reason,
521
+ preview: match.text.length > 160 ? `${match.text.slice(0, 157)}…` : match.text,
522
+ }))
523
+ : [];
524
+ const result = (base) => ({
525
+ ...base,
526
+ ...(nearby.length ? { nearby_items: nearby } : {}),
527
+ });
514
528
  switch (name) {
515
529
  case 'plan': {
516
530
  // Explicit field whitelist + required-author check brings plan create in line
@@ -530,7 +544,7 @@ export function createEntity(name, data, cwd) {
530
544
  estimatedEffort: data.estimated_effort,
531
545
  }, cwd);
532
546
  stampProvenanceOnStateItem('plan', res.id, defaultProvenance(data), cwd);
533
- return { entity: name, id: res.id, short_label: res.shortLabel };
547
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
534
548
  }
535
549
  case 'decision': {
536
550
  const res = createDecision({
@@ -542,7 +556,7 @@ export function createEntity(name, data, cwd) {
542
556
  planId: data.plan_id,
543
557
  }, cwd);
544
558
  stampProvenanceOnStateItem('decision', res.id, defaultProvenance(data), cwd);
545
- return { entity: name, id: res.id, short_label: res.shortLabel };
559
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
546
560
  }
547
561
  case 'constraint': {
548
562
  const res = createConstraint({
@@ -553,7 +567,7 @@ export function createEntity(name, data, cwd) {
553
567
  relatedPaths: data.related_paths,
554
568
  }, cwd);
555
569
  stampProvenanceOnStateItem('constraint', res.id, defaultProvenance(data), cwd);
556
- return { entity: name, id: res.id, short_label: res.shortLabel };
570
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
557
571
  }
558
572
  case 'trap': {
559
573
  const res = createTrap({
@@ -564,7 +578,7 @@ export function createEntity(name, data, cwd) {
564
578
  relatedPaths: data.related_paths,
565
579
  }, cwd);
566
580
  stampProvenanceOnStateItem('trap', res.id, defaultProvenance(data), cwd);
567
- return { entity: name, id: res.id, short_label: res.shortLabel };
581
+ return result({ entity: name, id: res.id, short_label: res.shortLabel });
568
582
  }
569
583
  case 'runtime_note': {
570
584
  const id = generateId('runtime_note');
@@ -197,13 +197,27 @@ export function withCodexWorkspaceRoot(invoke, agent, worktreePath, isWin32 = pr
197
197
  args.splice(insertAt, 0, '--cd', worktreePath);
198
198
  const quote = (value) => isWin32
199
199
  ? `"${value.replace(/"/g, '""')}"`
200
- : `'${value.replace(/'/g, `'\\''`)}'`;
201
- const flags = `--cd ${quote(worktreePath)}`;
202
- const prefix = invoke.executable;
203
- const suffix = invoke.bashCommand.startsWith(`${prefix} `)
204
- ? invoke.bashCommand.slice(prefix.length + 1)
205
- : invoke.bashCommand;
206
- return { ...invoke, args, bashCommand: `${prefix} ${flags} ${suffix}` };
200
+ : `"${value.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/`/g, '\\`').replace(/"/g, '\\"')}"`;
201
+ // Re-render from structured argv. bashCommand also contains the model-authored
202
+ // prompt, so searching it for "| codex" can mistake prompt data for the real
203
+ // executable boundary and splice --cd inside quoted content.
204
+ const command = [invoke.executable, ...args.map(quote)].join(' ');
205
+ const prompt = invoke.promptText ?? '';
206
+ const escapedPrompt = prompt.replace(/'/g, "'\\''");
207
+ let bashCommand = command;
208
+ if (!isWin32 && invoke.promptDelivery === 'stdin_pipe') {
209
+ bashCommand = `printf '%s' '${escapedPrompt}' | ${command}`;
210
+ }
211
+ else if (!isWin32 && invoke.promptDelivery === 'temp_file' && invoke.tempFilePath) {
212
+ bashCommand = `printf '%s' '${escapedPrompt}' > ${quote(invoke.tempFilePath)} && ${command}`;
213
+ }
214
+ return { ...invoke, args, bashCommand };
215
+ }
216
+ /** Refuse a stdin-delivery invoke that would otherwise inherit `/dev/null`. */
217
+ export function assertPromptDelivery(invoke) {
218
+ if (invoke.promptDelivery === 'stdin_pipe' && !invoke.promptText?.trim()) {
219
+ throw new Error('Invalid stdin_pipe invocation: promptText is empty; refusing to spawn an agent with ignored stdin.');
220
+ }
207
221
  }
208
222
  export class CliExecutionAdapter {
209
223
  id = 'cli';
@@ -273,6 +287,7 @@ export class CliExecutionAdapter {
273
287
  };
274
288
  }
275
289
  start(invoke, options) {
290
+ assertPromptDelivery(invoke);
276
291
  const isWin32 = process.platform === 'win32';
277
292
  invoke = withCodexWorkspaceRoot(invoke, options.agent, options.worktreePath, isWin32);
278
293
  // F7 (trp_0e5150d3): route worker env through buildWorkerIdentityEnv so the
@@ -309,7 +324,7 @@ export class CliExecutionAdapter {
309
324
  }
310
325
  const spawnExecutable = resolvedExecutable ?? invoke.executable;
311
326
  const useShell = isWin32 && /\.(cmd|bat)$/i.test(spawnExecutable);
312
- const needsStdin = invoke.promptDelivery === 'stdin_pipe' && invoke.promptText;
327
+ const needsStdin = invoke.promptDelivery === 'stdin_pipe';
313
328
  // pln#520 step 4: when we ack-wrap, the SHELL redirects stdout/stderr to the
314
329
  // per-assignment log files (fds passed via stdio are NOT inherited through
315
330
  // the cmd.exe → .cmd → node shim — the empty-logs bug of can_f792cacd), and
@@ -25,7 +25,8 @@ import { logger } from './logger.js';
25
25
  const DAY_MS = 24 * 60 * 60 * 1000;
26
26
  export const DEFAULT_HYGIENE_POLICY = {
27
27
  disabled: false,
28
- assignment_offered_ttl_ms: 3 * DAY_MS,
28
+ // A never-accepted dispatch should not survive into the next day's dogfood.
29
+ assignment_offered_ttl_ms: 1 * DAY_MS,
29
30
  assignment_accepted_ttl_ms: 1 * DAY_MS,
30
31
  assignment_started_ttl_ms: 1 * DAY_MS,
31
32
  handoff_closed_ttl_ms: 30 * DAY_MS,
@@ -10,7 +10,7 @@ import { resolveModel } from './agent-capability.js';
10
10
  import { listAgentIdentities } from './agent-registry.js';
11
11
  import { transitionAgentRun } from './agentruns.js';
12
12
  import { loadAssignment, patchAssignmentMessageId, transitionAssignment } from './assignments.js';
13
- import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, } from './claims.js';
13
+ import { attachAssignmentMessageToClaim, createCoordinatorClaim, ensureClaimAssignmentBinding, releaseClaimIfActive, } from './claims.js';
14
14
  import { generateDispatchBrief } from './dispatcher.js';
15
15
  import { search } from './search.js';
16
16
  import { attemptExecution } from './execution.js';
@@ -21,6 +21,7 @@ import { buildIdeationBrief } from './loops/brief-assembly.js';
21
21
  import { getLoop } from './loops/store.js';
22
22
  import { prepareTurnExecution } from './loops/turn-execution.js';
23
23
  import { sendMessage } from './messaging.js';
24
+ import { removeWorktree } from './worktree.js';
24
25
  export async function dispatchLoopTurn(input) {
25
26
  const loop = getLoop(input.loop_id, input.cwd);
26
27
  if (!loop)
@@ -131,6 +132,22 @@ export async function dispatchLoopTurn(input) {
131
132
  if (prepared.kind !== 'won') {
132
133
  result.execution_status = 'inbox_only';
133
134
  result.error = prepared.reason;
135
+ if (prepared.claim_disposition === 'release' && !claim.reusedExisting) {
136
+ try {
137
+ const released = releaseClaimIfActive(claim.claimId, input.cwd);
138
+ if (released.released && claim.worktreePath) {
139
+ try {
140
+ removeWorktree(input.cwd, claim.worktreePath, { force: true });
141
+ }
142
+ catch (cleanupError) {
143
+ result.error += `; denied-claim worktree cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
144
+ }
145
+ }
146
+ }
147
+ catch (cleanupError) {
148
+ result.error += `; denied-claim cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
149
+ }
150
+ }
134
151
  return result;
135
152
  }
136
153
  result.assignment_id = prepared.assignment_id;
@@ -219,6 +219,7 @@ export function executionContractForGeneration(reservation, generation) {
219
219
  }
220
220
  return { contract, ref };
221
221
  }
222
+ const snapshot = generation.executor?.capability_snapshot ?? reservation.capability_snapshot;
222
223
  const contract = ExecutionContractSchema.parse({
223
224
  ...reservation.execution_contract,
224
225
  identity: {
@@ -234,7 +235,7 @@ export function executionContractForGeneration(reservation, generation) {
234
235
  isolation: 'worktree',
235
236
  },
236
237
  });
237
- const ref = executionContractRef(contract, reservation.capability_snapshot);
238
+ const ref = executionContractRef(contract, snapshot);
238
239
  if (ref.hash !== generation.contract_hash) {
239
240
  throw new AttemptGenerationError('fenced', `generation ${generation.attempt_epoch} contract hash ${generation.contract_hash} does not match derived ${ref.hash}`);
240
241
  }
@@ -269,6 +270,12 @@ export function bootstrapAttemptAuthorityV2(input) {
269
270
  workspace_path: workspacePath,
270
271
  workspace_digest: attemptWorkspaceDigest(workspacePath, reservation.turn_id, 0),
271
272
  launch_nonce: grant.token,
273
+ executor: {
274
+ agent: reservation.agent,
275
+ agent_id: reservation.agent_id,
276
+ claim_id: reservation.claim_id,
277
+ capability_snapshot: reservation.capability_snapshot,
278
+ },
272
279
  });
273
280
  if (initial.assignment_id !== reservation.child_ids.assignment_id
274
281
  || initial.run_id !== reservation.child_ids.run_id
@@ -331,7 +338,8 @@ export function prepareAttemptTakeoverV2(input) {
331
338
  && close.decision === expectedMode
332
339
  && close.cause === requestedCause
333
340
  && canonicalWorkspacePath(close.next_generation.workspace_path) === canonicalWorkspacePath(input.next_workspace_path)
334
- && JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)) {
341
+ && JSON.stringify(close.next_generation.authority_home) === JSON.stringify(input.authority_home)
342
+ && (!input.next_executor || JSON.stringify(close.next_generation.executor) === JSON.stringify(input.next_executor))) {
335
343
  const generationContract = executionContractForGeneration(reservation, close.next_generation);
336
344
  return {
337
345
  won: false,
@@ -376,9 +384,10 @@ export function prepareAttemptTakeoverV2(input) {
376
384
  contract_hash: '0'.repeat(64),
377
385
  workspace_path: nextWorkspacePath,
378
386
  workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
387
+ executor: input.next_executor,
379
388
  });
380
389
  const baseContract = reservation.execution_contract;
381
- const snapshot = reservation.capability_snapshot;
390
+ const snapshot = input.next_executor?.capability_snapshot ?? reservation.capability_snapshot;
382
391
  if (!baseContract || !snapshot) {
383
392
  throw new AttemptGenerationError('invalid_transition', `turn ${input.turn_id} lacks a contracted capability snapshot`);
384
393
  }
@@ -402,6 +411,7 @@ export function prepareAttemptTakeoverV2(input) {
402
411
  contract_hash: nextRef.hash,
403
412
  workspace_path: nextWorkspacePath,
404
413
  workspace_digest: attemptWorkspaceDigest(nextWorkspacePath, input.turn_id, nextEpoch),
414
+ executor: input.next_executor,
405
415
  launch_nonce: provisional.launch_nonce,
406
416
  created_at: provisional.created_at,
407
417
  });
@@ -419,7 +429,15 @@ export function prepareAttemptTakeoverV2(input) {
419
429
  if (generationDigest(incumbent.next_generation) !== generationDigest(next)) {
420
430
  throw new AttemptGenerationError('fenced', `a different successor already won generation ${current.attempt_epoch}`);
421
431
  }
422
- rebuildAttemptGenerationHead(input.cwd, current);
432
+ // The close cell above is the immutable authority boundary. `head.json` is
433
+ // only a rebuildable read cache, so a projection failure after a winning
434
+ // close must never escape as a pre-commit takeover failure: callers could
435
+ // otherwise roll back claims while the successor generation already owns
436
+ // the turn. Readers repair the cache from immutable cells on replay.
437
+ try {
438
+ rebuildAttemptGenerationHead(input.cwd, current);
439
+ }
440
+ catch { /* immutable close(epoch) remains authoritative */ }
423
441
  return {
424
442
  won: published.won,
425
443
  rollout,