brainclaw 1.6.0 → 1.7.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 +5 -4
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/commands/mcp-schemas.generated.js +33 -0
- package/dist/commands/mcp.js +66 -31
- package/dist/commands/setup.js +64 -13
- package/dist/core/agent-capability.js +19 -0
- package/dist/core/agent-files.js +86 -0
- package/dist/core/agent-integrations.js +34 -0
- package/dist/core/agent-inventory.js +27 -0
- package/dist/core/agentrun-reconciler.js +80 -15
- package/dist/core/ai-agent-detection.js +17 -1
- package/dist/core/claims.js +25 -3
- package/dist/core/dirty-scope.js +242 -0
- package/dist/core/facade-schema.js +17 -1
- package/dist/core/schema.js +2 -0
- package/dist/core/worktree.js +58 -0
- package/dist/facts.js +356 -3
- package/dist/facts.json +355 -2
- package/docs/cli.md +10 -7
- package/docs/concepts/troubleshooting.md +26 -0
- package/docs/index.md +4 -3
- package/docs/integrations/hermes.md +78 -0
- package/docs/integrations/overview.md +9 -7
- package/docs/mcp-schema-changelog.md +8 -1
- package/docs/quickstart-existing-project.md +1 -1
- package/package.json +5 -4
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import yaml from 'yaml';
|
|
5
6
|
import { getInstalledBrainclawVersion } from './brainclaw-version.js';
|
|
6
7
|
import { getAgentCapabilityProfile } from './agent-capability.js';
|
|
7
8
|
const SUPPORTED_AGENT_INTEGRATION_NAMES = new Set([
|
|
@@ -16,12 +17,17 @@ const SUPPORTED_AGENT_INTEGRATION_NAMES = new Set([
|
|
|
16
17
|
'continue',
|
|
17
18
|
'roo',
|
|
18
19
|
'kilocode',
|
|
20
|
+
'mistral-vibe',
|
|
21
|
+
'hermes',
|
|
19
22
|
'openclaw',
|
|
20
23
|
'nanoclaw',
|
|
21
24
|
'nemoclaw',
|
|
22
25
|
'picoclaw',
|
|
23
26
|
'zeroclaw',
|
|
24
27
|
]);
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
25
31
|
const DEFAULT_SURFACES = {
|
|
26
32
|
'github-copilot': [
|
|
27
33
|
{ kind: 'instructions', location: 'workspace', path: '.github/copilot-instructions.md' },
|
|
@@ -85,6 +91,16 @@ const DEFAULT_SURFACES = {
|
|
|
85
91
|
{ kind: 'mcp', location: 'workspace', path: '.kilo/mcp.json' },
|
|
86
92
|
{ kind: 'skill', location: 'workspace', path: '.agents/skills/brainclaw/SKILL.md' },
|
|
87
93
|
],
|
|
94
|
+
'mistral-vibe': [
|
|
95
|
+
{ kind: 'instructions', location: 'workspace', path: 'AGENTS.md' },
|
|
96
|
+
{ kind: 'mcp', location: 'workspace', path: '.vibe/config.toml' },
|
|
97
|
+
{ kind: 'skill', location: 'workspace', path: '.agents/skills/brainclaw/SKILL.md' },
|
|
98
|
+
],
|
|
99
|
+
'hermes': [
|
|
100
|
+
{ kind: 'instructions', location: 'workspace', path: 'AGENTS.md' },
|
|
101
|
+
{ kind: 'mcp', location: 'machine', path: '.hermes/config.yaml' },
|
|
102
|
+
{ kind: 'skill', location: 'workspace', path: '.agents/skills/brainclaw/SKILL.md' },
|
|
103
|
+
],
|
|
88
104
|
'openclaw': [
|
|
89
105
|
{ kind: 'skill', location: 'machine', path: '.openclaw/workspace/skills/brainclaw/SKILL.md' },
|
|
90
106
|
{ kind: 'mcp', location: 'machine', path: '.openclaw/mcp.json' },
|
|
@@ -161,6 +177,24 @@ export function extractMcpCommandVal(agentName, expectedPath) {
|
|
|
161
177
|
is_valid: true,
|
|
162
178
|
};
|
|
163
179
|
}
|
|
180
|
+
if (expectedPath.endsWith('.yaml') || expectedPath.endsWith('.yml')) {
|
|
181
|
+
try {
|
|
182
|
+
const parsed = yaml.parse(content);
|
|
183
|
+
const root = isRecord(parsed) ? parsed : {};
|
|
184
|
+
const servers = isRecord(root.mcp_servers) ? root.mcp_servers : {};
|
|
185
|
+
const bc = isRecord(servers.brainclaw) ? servers.brainclaw : {};
|
|
186
|
+
const cmd = bc.command;
|
|
187
|
+
const args = bc.args;
|
|
188
|
+
return {
|
|
189
|
+
command: typeof cmd === 'string' ? cmd : undefined,
|
|
190
|
+
args: Array.isArray(args) ? args.filter((a) => typeof a === 'string') : undefined,
|
|
191
|
+
is_valid: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return { is_valid: false };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
164
198
|
try {
|
|
165
199
|
const j = JSON.parse(content);
|
|
166
200
|
let cmd;
|
|
@@ -300,6 +300,33 @@ const AGENT_DEFINITIONS = [
|
|
|
300
300
|
hooks_support: false,
|
|
301
301
|
instruction_file: '.continue/rules/',
|
|
302
302
|
},
|
|
303
|
+
{
|
|
304
|
+
name: 'hermes',
|
|
305
|
+
detect: (home, env) => {
|
|
306
|
+
if (env.HERMES_SESSION_ID || env.HERMES_AGENT || env.HERMES_HOME) {
|
|
307
|
+
return { installed: true, method: 'HERMES_* env' };
|
|
308
|
+
}
|
|
309
|
+
if (fs.existsSync(path.join(home, '.hermes'))) {
|
|
310
|
+
return { installed: true, method: '~/.hermes directory' };
|
|
311
|
+
}
|
|
312
|
+
const cli = tryCommand('hermes', ['--version'], 3000);
|
|
313
|
+
if (cli.ok) {
|
|
314
|
+
return { installed: true, method: 'hermes CLI', version: cli.stdout.trim() };
|
|
315
|
+
}
|
|
316
|
+
return { installed: false, method: '' };
|
|
317
|
+
},
|
|
318
|
+
models: [
|
|
319
|
+
{ name: 'model-agnostic' },
|
|
320
|
+
],
|
|
321
|
+
native_tools: ['execute_code', 'shell', 'file_read', 'file_write', 'web_search', 'delegate_task'],
|
|
322
|
+
mcp_support: true,
|
|
323
|
+
mcp_config_format: '~/.hermes/config.yaml',
|
|
324
|
+
skills_support: true,
|
|
325
|
+
skills_path_pattern: '~/.hermes/skills/ and .agents/skills/',
|
|
326
|
+
rules_support: false,
|
|
327
|
+
hooks_support: false,
|
|
328
|
+
instruction_file: 'AGENTS.md',
|
|
329
|
+
},
|
|
303
330
|
];
|
|
304
331
|
// ── Public API ─────────────────────────────────────────────────────────────────
|
|
305
332
|
/**
|
|
@@ -311,6 +311,28 @@ export function reconcileAgentRun(runId, cwd, options = {}) {
|
|
|
311
311
|
evidence, previous_status, current_status: run.status,
|
|
312
312
|
};
|
|
313
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Read-path reconciliation for a `running` run whose tracked PID reads dead.
|
|
316
|
+
*
|
|
317
|
+
* IMPORTANT (pln#520): the tracked PID is NOT trustworthy. On Windows the
|
|
318
|
+
* ack-wrap spawn (shell:true) records the cmd.exe wrapper PID, not the real
|
|
319
|
+
* worker (cmd.exe -> claude.cmd -> node.exe), so a dead PID does NOT prove the
|
|
320
|
+
* worker died — empirically, 6 workers were cancelled here yet committed their
|
|
321
|
+
* work 4-7 min later. This function therefore NEVER cancels prematurely:
|
|
322
|
+
* - work evidence (commit / claim released / assignment completed)
|
|
323
|
+
* -> inferred `completed`;
|
|
324
|
+
* - past the stale threshold with a dead pid and still no evidence
|
|
325
|
+
* -> inferred `failed` (silent_termination_no_evidence). This MUST converge
|
|
326
|
+
* HERE: the canonical read path (entity-operations.ts) and the MCP pre-read
|
|
327
|
+
* sweep route `running` runs through THIS function, never through
|
|
328
|
+
* reconcileAgentRun, so deferring would leave a crashed run `running`
|
|
329
|
+
* forever (the trp#292 pattern);
|
|
330
|
+
* - otherwise (young, dead pid, no evidence yet) -> non-mutating health-check,
|
|
331
|
+
* leaving the run `running` so a worker behind an untrusted pid keeps its
|
|
332
|
+
* fair chance.
|
|
333
|
+
* Net vs pre-pln#520: a genuine silent death converges to `failed` after the
|
|
334
|
+
* stale window instead of an immediate (often false) `cancelled`.
|
|
335
|
+
*/
|
|
314
336
|
export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {}) {
|
|
315
337
|
const run = loadAgentRun(runId, cwd);
|
|
316
338
|
if (!run) {
|
|
@@ -337,23 +359,66 @@ export function reconcileDeadPidRunningAgentRunAtRead(runId, cwd, options = {})
|
|
|
337
359
|
evidence, previous_status: run.status, current_status: run.status,
|
|
338
360
|
};
|
|
339
361
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
362
|
+
// pid reads dead — but the tracked pid is NOT trustworthy (see doc above),
|
|
363
|
+
// so a bare dead pid NEVER cancels. Evidence of real work wins; otherwise
|
|
364
|
+
// surface the uncertainty non-destructively and leave the run `running` for
|
|
365
|
+
// reconcileAgentRun's stale-threshold path to fail it only after a fair,
|
|
366
|
+
// evidence-based delay.
|
|
367
|
+
const actor = options.actor ?? 'reconciler';
|
|
368
|
+
if (anyCompletionEvidence(evidence)) {
|
|
369
|
+
try {
|
|
370
|
+
transitionAgentRun(run.id, 'completed', {
|
|
371
|
+
actor,
|
|
372
|
+
status_reason: `inferred=true; evidence: ${describeEvidence(evidence)}`,
|
|
373
|
+
}, cwd);
|
|
374
|
+
return {
|
|
375
|
+
run_id: run.id, action: 'inferred_completed',
|
|
376
|
+
reason: `inferred=true; ${describeEvidence(evidence)}`,
|
|
377
|
+
evidence, previous_status: run.status, current_status: 'completed',
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
return {
|
|
382
|
+
run_id: run.id, action: 'no_op',
|
|
383
|
+
reason: `completion transition rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
384
|
+
evidence, previous_status: run.status, current_status: run.status,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
349
387
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
388
|
+
// Stale + provably dead + still no evidence -> genuine silent failure. This
|
|
389
|
+
// MUST converge HERE: the canonical read path (entity-operations.ts) and the
|
|
390
|
+
// MCP pre-read sweep route `running` runs through this function, never
|
|
391
|
+
// through reconcileAgentRun, so deferring would leave a crashed run `running`
|
|
392
|
+
// forever (trp#292). The 30-min stale window — vs the immediate cancel before
|
|
393
|
+
// pln#520 — gives a worker behind an untrusted pid ample time to leave
|
|
394
|
+
// evidence first. Reported as `failed` (it died), not `cancelled`.
|
|
395
|
+
const stale = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
396
|
+
if (evidence.age_ms >= stale) {
|
|
397
|
+
try {
|
|
398
|
+
transitionAgentRun(run.id, 'failed', {
|
|
399
|
+
actor,
|
|
400
|
+
status_reason: 'silent_termination_no_evidence',
|
|
401
|
+
}, cwd);
|
|
402
|
+
return {
|
|
403
|
+
run_id: run.id, action: 'inferred_failed',
|
|
404
|
+
reason: 'silent_termination_no_evidence',
|
|
405
|
+
evidence, previous_status: run.status, current_status: 'failed',
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
return {
|
|
410
|
+
run_id: run.id, action: 'no_op',
|
|
411
|
+
reason: `failure transition rejected: ${err instanceof Error ? err.message : String(err)}`,
|
|
412
|
+
evidence, previous_status: run.status, current_status: run.status,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
356
415
|
}
|
|
416
|
+
emitUnverifiedEvent(run, evidence, actor, cwd);
|
|
417
|
+
return {
|
|
418
|
+
run_id: run.id, action: 'health_check_unverified',
|
|
419
|
+
reason: `pid_dead_untrusted_no_evidence (age=${Math.round(evidence.age_ms / 1000)}s) — awaiting evidence or stale window`,
|
|
420
|
+
evidence, previous_status: run.status, current_status: run.status,
|
|
421
|
+
};
|
|
357
422
|
}
|
|
358
423
|
export function sweepDeadPidRunningAgentRunsAtRead(cwd, options = {}) {
|
|
359
424
|
const now = options.nowMs ?? Date.now();
|
|
@@ -19,7 +19,8 @@ import os from 'node:os';
|
|
|
19
19
|
* 9. Antigravity / Gemini CLI (ANTIGRAVITY_* env or ~/.gemini/antigravity/)
|
|
20
20
|
* 10. Continue (CONTINUE_*)
|
|
21
21
|
* 11. Roo Code (ROO_*)
|
|
22
|
-
* 12.
|
|
22
|
+
* 12. Hermes (HERMES_* or ~/.hermes/)
|
|
23
|
+
* 13. OpenClaw (~/.openclaw/ or OPENCLAW_*)
|
|
23
24
|
*/
|
|
24
25
|
export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
|
|
25
26
|
// Explicit override
|
|
@@ -161,6 +162,21 @@ export function detectAiAgent(env = process.env, homeDir = os.homedir()) {
|
|
|
161
162
|
detection_source: env.VIBE_HOME ? 'VIBE_HOME env var' : '~/.vibe directory',
|
|
162
163
|
};
|
|
163
164
|
}
|
|
165
|
+
// Hermes Agent — supports MCP and skills from ~/.hermes/. Detect after
|
|
166
|
+
// editor/CLI agents with stronger session env vars to avoid stealing mixed
|
|
167
|
+
// shells where Hermes is merely installed.
|
|
168
|
+
if (env.HERMES_SESSION_ID || env.HERMES_AGENT || env.HERMES_HOME || fs.existsSync(path.join(homeDir, '.hermes'))) {
|
|
169
|
+
return {
|
|
170
|
+
name: 'hermes',
|
|
171
|
+
kind: 'autonomous',
|
|
172
|
+
trust_level: 'trusted',
|
|
173
|
+
detection_source: env.HERMES_SESSION_ID || env.HERMES_AGENT
|
|
174
|
+
? 'HERMES_* env var'
|
|
175
|
+
: env.HERMES_HOME
|
|
176
|
+
? 'HERMES_HOME env var'
|
|
177
|
+
: '~/.hermes directory',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
164
180
|
return undefined;
|
|
165
181
|
}
|
|
166
182
|
/**
|
package/dist/core/claims.js
CHANGED
|
@@ -6,7 +6,7 @@ import { mutate } from './mutation-pipeline.js';
|
|
|
6
6
|
import { nowISO } from './ids.js';
|
|
7
7
|
import { JsonStore } from './json-store.js';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
|
-
import { createWorktree } from './worktree.js';
|
|
9
|
+
import { createWorktree, resetWorktreeToRef } from './worktree.js';
|
|
10
10
|
import { appendAuditEntry } from './audit.js';
|
|
11
11
|
import { refreshLiveCompanions } from '../commands/export.js';
|
|
12
12
|
import { loadSessionById } from './identity.js';
|
|
@@ -407,10 +407,28 @@ export function createCoordinatorClaim(options) {
|
|
|
407
407
|
const existingScopeClaim = listClaims(options.cwd).find((claim) => claim.status === 'active' && claim.scope === options.scope);
|
|
408
408
|
if (existingScopeClaim) {
|
|
409
409
|
if (existingScopeClaim.agent === options.agent) {
|
|
410
|
-
// Same agent already has this scope — reuse the claim (backward compat,
|
|
410
|
+
// Same agent already has this scope — reuse the claim (backward compat,
|
|
411
|
+
// same-agent multi-call). If the caller pinned a base ref, the reused
|
|
412
|
+
// worktree MUST be re-pointed to it; otherwise a dispatch that relied on
|
|
413
|
+
// the ref (e.g. the dirty-guard bypass) would run the worker on a stale
|
|
414
|
+
// worktree — the same silent false-negative the guard exists to prevent
|
|
415
|
+
// (pln#520 Tier 2 / codex r2).
|
|
416
|
+
let reuseWarning;
|
|
417
|
+
if (options.worktreeBaseRef) {
|
|
418
|
+
if (existingScopeClaim.worktree_path) {
|
|
419
|
+
const reset = resetWorktreeToRef(existingScopeClaim.worktree_path, options.worktreeBaseRef);
|
|
420
|
+
if (!reset.ok) {
|
|
421
|
+
reuseWarning = `Reused claim ${existingScopeClaim.id} pinned to ref "${options.worktreeBaseRef}": ${reset.stderr.trim()}`;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
reuseWarning = `Reused claim ${existingScopeClaim.id} has no worktree to pin to ref "${options.worktreeBaseRef}".`;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
411
428
|
return {
|
|
412
429
|
claimId: existingScopeClaim.id,
|
|
413
430
|
worktreePath: existingScopeClaim.worktree_path,
|
|
431
|
+
worktreeWarning: reuseWarning,
|
|
414
432
|
reusedExisting: true,
|
|
415
433
|
};
|
|
416
434
|
}
|
|
@@ -435,7 +453,11 @@ export function createCoordinatorClaim(options) {
|
|
|
435
453
|
sessionId: options.sessionId,
|
|
436
454
|
agent: options.agent,
|
|
437
455
|
baseRef: options.worktreeBaseRef,
|
|
438
|
-
|
|
456
|
+
// A pinned base ref implies the branch must be reset to it: createWorktree
|
|
457
|
+
// otherwise reuses a pre-existing feat/<scope> branch and ignores baseRef.
|
|
458
|
+
// Deriving it here (not only at the call site) keeps the invariant — "a
|
|
459
|
+
// pinned ref ⇒ the worktree reflects that ref" — owned by this chokepoint.
|
|
460
|
+
resetExistingBranch: options.resetExistingWorktreeBranch || Boolean(options.worktreeBaseRef),
|
|
439
461
|
});
|
|
440
462
|
}
|
|
441
463
|
catch (err) {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope-aware dirty-working-tree guard for dispatch (pln#520 Tier 2, trp#371).
|
|
3
|
+
*
|
|
4
|
+
* The original guard (can_30c295b4) refused review/assign/consult/ideate the
|
|
5
|
+
* moment ANY uncommitted file existed in the source repo — repo-global, no
|
|
6
|
+
* comparison to the dispatch scope. In multi-agent use (codex/claude leave
|
|
7
|
+
* uncommitted files around) that hard-blocks legitimate dispatches, and the
|
|
8
|
+
* coordination store itself (`.brainclaw/`) is rewritten on every dispatch so
|
|
9
|
+
* a second `bclaw_coordinate` in the same session ALWAYS saw a dirty tree.
|
|
10
|
+
*
|
|
11
|
+
* This helper makes the decision scope-aware. The cardinal rule (converged
|
|
12
|
+
* across the Tier 2 ideation loop lop_5fc24cc8707992ea): never turn a noisy,
|
|
13
|
+
* visible false-positive (block a legitimate dispatch) into a SILENT
|
|
14
|
+
* false-negative (let a worker review/edit stale code with no signal). So
|
|
15
|
+
* when the scope cannot be proven disjoint from the dirty files, we still
|
|
16
|
+
* block — `allow_dirty=true` remains the explicit, auditable override.
|
|
17
|
+
*
|
|
18
|
+
* Empirical grounding: of ~450 real claim scopes in the store, only 4 contain
|
|
19
|
+
* a glob and ~60% are not resolvable to paths at all (plan-ids, loop-refs,
|
|
20
|
+
* prose). So the resolver optimises for the path-prefix case and treats
|
|
21
|
+
* anything ambiguous as `unknown` (→ conservative block when dirty), rather
|
|
22
|
+
* than building a glob engine. The rare real globs are delegated to git.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
/** Top-level directories that read as code paths even when not yet on disk. */
|
|
28
|
+
const KNOWN_TOP_LEVEL = new Set([
|
|
29
|
+
'src', 'lib', 'docs', 'doc', 'test', 'tests', 'packages', 'app', 'apps',
|
|
30
|
+
'scripts', 'bin', 'public', 'assets', 'examples', 'config',
|
|
31
|
+
]);
|
|
32
|
+
/** Entity-id / loop-ref prefixes that are never file paths. */
|
|
33
|
+
const ENTITY_ID_RE = /^(pln|clm|asg|asgn|run|lop|dec|cst|con|trp|han|can|rtn|rn|seq|sess|agt|art|lsl)[_#]/i;
|
|
34
|
+
function defaultRunGit(cwd, args) {
|
|
35
|
+
try {
|
|
36
|
+
const probe = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 5000 });
|
|
37
|
+
if (probe.status !== 0 || typeof probe.stdout !== 'string') {
|
|
38
|
+
return { ok: false, stdout: '' };
|
|
39
|
+
}
|
|
40
|
+
return { ok: true, stdout: probe.stdout };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Never block a dispatch because git hiccuped — surface as "not probeable".
|
|
44
|
+
return { ok: false, stdout: '' };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** True for coordination/store paths that are dirty as a side effect of dispatching. */
|
|
48
|
+
export function isSystemDirtyPath(p) {
|
|
49
|
+
const norm = p.replace(/\\/g, '/');
|
|
50
|
+
return norm === '.brainclaw'
|
|
51
|
+
|| norm.startsWith('.brainclaw/')
|
|
52
|
+
|| norm === '.git'
|
|
53
|
+
|| norm.startsWith('.git/');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse `git status --porcelain=v1 -z` output into a flat list of paths.
|
|
57
|
+
*
|
|
58
|
+
* NUL-separated entries avoid the quoting/newline pitfalls of line parsing.
|
|
59
|
+
* Rename/copy entries (`R`/`C`) carry the NEW path in the status field and the
|
|
60
|
+
* ORIGINAL path in the following NUL field — we keep BOTH, because a move out
|
|
61
|
+
* of scope that creates a file in scope (or vice versa) is a relevant change.
|
|
62
|
+
*/
|
|
63
|
+
export function parsePorcelainZ(stdout) {
|
|
64
|
+
const parts = stdout.split('\0').filter((p) => p.length > 0);
|
|
65
|
+
const paths = [];
|
|
66
|
+
for (let i = 0; i < parts.length; i++) {
|
|
67
|
+
const entry = parts[i];
|
|
68
|
+
if (entry.length < 4)
|
|
69
|
+
continue; // malformed; "XY p" is the minimum
|
|
70
|
+
const status = entry.slice(0, 2);
|
|
71
|
+
const newPath = entry.slice(3); // skip "XY " (2 status chars + 1 separator)
|
|
72
|
+
paths.push(newPath);
|
|
73
|
+
if (status[0] === 'R' || status[0] === 'C') {
|
|
74
|
+
const origPath = parts[i + 1];
|
|
75
|
+
if (origPath !== undefined) {
|
|
76
|
+
paths.push(origPath);
|
|
77
|
+
i++; // consume the original-path field
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return paths;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a free-string scope into git pathspecs, or `unknown` when it cannot
|
|
85
|
+
* be proven to denote file paths. All-or-nothing across comma-separated
|
|
86
|
+
* tokens: a single unresolvable token marks the whole scope unknown, so the
|
|
87
|
+
* guard never blocks on a partial (and therefore misleading) view.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveScopeToPathspecs(scope, cwd) {
|
|
90
|
+
if (!scope || !scope.trim()) {
|
|
91
|
+
return { kind: 'unknown', reason: 'no scope provided' };
|
|
92
|
+
}
|
|
93
|
+
const tokens = scope.split(',').map((t) => t.trim()).filter(Boolean);
|
|
94
|
+
if (tokens.length === 0) {
|
|
95
|
+
return { kind: 'unknown', reason: 'empty scope' };
|
|
96
|
+
}
|
|
97
|
+
const pathspecs = [];
|
|
98
|
+
for (const token of tokens) {
|
|
99
|
+
if (ENTITY_ID_RE.test(token) || /^review-loop:/i.test(token)) {
|
|
100
|
+
return { kind: 'unknown', reason: `token "${token}" is an entity id, not a path` };
|
|
101
|
+
}
|
|
102
|
+
if (/\s/.test(token)) {
|
|
103
|
+
return { kind: 'unknown', reason: `token "${token}" contains whitespace (prose, not a path)` };
|
|
104
|
+
}
|
|
105
|
+
const normalized = token.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
106
|
+
if (/[*?[\]]/.test(normalized)) {
|
|
107
|
+
// Delegate the rare real glob to git's native pathspec matcher.
|
|
108
|
+
pathspecs.push(`:(glob)${normalized}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const exists = fs.existsSync(path.resolve(cwd, normalized));
|
|
112
|
+
const topLevel = normalized.split('/')[0];
|
|
113
|
+
if (exists || KNOWN_TOP_LEVEL.has(topLevel)) {
|
|
114
|
+
pathspecs.push(normalized);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
kind: 'unknown',
|
|
119
|
+
reason: `token "${token}" is not a resolvable path (no such file/dir and not a known top-level)`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { kind: 'pathspecs', pathspecs };
|
|
123
|
+
}
|
|
124
|
+
/** Cap a file list for human-readable messages. */
|
|
125
|
+
function summariseFiles(files, max = 10) {
|
|
126
|
+
if (files.length <= max)
|
|
127
|
+
return files.join(', ');
|
|
128
|
+
return `${files.slice(0, max).join(', ')} (and ${files.length - max} more)`;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Decide whether a dispatch may proceed given the source repo's dirty state and
|
|
132
|
+
* the dispatch scope. See the module header for the cardinal rule.
|
|
133
|
+
*/
|
|
134
|
+
export function assessDirtyDispatchGuard(input) {
|
|
135
|
+
const runGit = input.runGit ?? defaultRunGit;
|
|
136
|
+
const resolution = resolveScopeToPathspecs(input.scope, input.cwd);
|
|
137
|
+
// 1. Global probe — is the tree dirty at all (ignoring the coordination store)?
|
|
138
|
+
const global = runGit(input.cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']);
|
|
139
|
+
if (!global.ok) {
|
|
140
|
+
return {
|
|
141
|
+
decision: 'allow',
|
|
142
|
+
reason: 'source cwd is not a git repo (or git is unavailable) — dirty guard skipped',
|
|
143
|
+
dirtyCount: 0,
|
|
144
|
+
overlapping: [],
|
|
145
|
+
ignoredSystemDirty: [],
|
|
146
|
+
scopeResolution: resolution.kind,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
const allPaths = parsePorcelainZ(global.stdout);
|
|
150
|
+
const ignoredSystemDirty = allPaths.filter(isSystemDirtyPath);
|
|
151
|
+
const realDirty = allPaths.filter((p) => !isSystemDirtyPath(p));
|
|
152
|
+
if (realDirty.length === 0) {
|
|
153
|
+
return {
|
|
154
|
+
decision: 'allow',
|
|
155
|
+
reason: ignoredSystemDirty.length > 0
|
|
156
|
+
? `working tree clean apart from ${ignoredSystemDirty.length} coordination-store file(s) (.brainclaw/, .git/) which the worker rebuilds itself`
|
|
157
|
+
: 'working tree clean',
|
|
158
|
+
dirtyCount: 0,
|
|
159
|
+
overlapping: [],
|
|
160
|
+
ignoredSystemDirty,
|
|
161
|
+
scopeResolution: resolution.kind,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// 2. Explicit ref → the worktree is built from that ref (the dispatch path
|
|
165
|
+
// forces resetExistingWorktreeBranch so a pre-existing branch is reset to
|
|
166
|
+
// the ref, not silently reused), so uncommitted working-tree changes are
|
|
167
|
+
// intentionally out of scope. Only bypass when the ref actually resolves —
|
|
168
|
+
// a bogus/unresolvable ref must NOT widen the allow; fall through to the
|
|
169
|
+
// scope-aware check below so a dirty in-scope file still blocks.
|
|
170
|
+
if (input.checkoutRef) {
|
|
171
|
+
const refResolves = runGit(input.cwd, ['rev-parse', '--verify', '--quiet', `${input.checkoutRef}^{commit}`]).ok;
|
|
172
|
+
if (refResolves) {
|
|
173
|
+
return {
|
|
174
|
+
decision: 'allow',
|
|
175
|
+
reason: `dispatch builds its worktree from explicit ref "${input.checkoutRef}"; ${realDirty.length} uncommitted working-tree file(s) are intentionally out of scope`,
|
|
176
|
+
dirtyCount: realDirty.length,
|
|
177
|
+
overlapping: [],
|
|
178
|
+
ignoredSystemDirty,
|
|
179
|
+
scopeResolution: resolution.kind,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
// else: ref does not resolve → continue to the scope-aware evaluation.
|
|
183
|
+
}
|
|
184
|
+
// 3. Scope-aware intersection (delegated to git for glob + segment-boundary correctness).
|
|
185
|
+
if (resolution.kind === 'pathspecs') {
|
|
186
|
+
const scoped = runGit(input.cwd, [
|
|
187
|
+
'status', '--porcelain=v1', '-z', '--untracked-files=normal',
|
|
188
|
+
'--', ...resolution.pathspecs, ':(exclude).brainclaw/', ':(exclude).git/',
|
|
189
|
+
]);
|
|
190
|
+
// If the scoped probe fails for any reason, fall back conservatively:
|
|
191
|
+
// treat the whole real-dirty set as potentially overlapping.
|
|
192
|
+
const overlapping = scoped.ok ? parsePorcelainZ(scoped.stdout).filter((p) => !isSystemDirtyPath(p)) : realDirty;
|
|
193
|
+
if (overlapping.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
decision: 'allow',
|
|
196
|
+
reason: `${realDirty.length} uncommitted file(s) but none overlap the dispatch scope`,
|
|
197
|
+
dirtyCount: realDirty.length,
|
|
198
|
+
overlapping: [],
|
|
199
|
+
ignoredSystemDirty,
|
|
200
|
+
scopeResolution: resolution.kind,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (input.allowDirty) {
|
|
204
|
+
return {
|
|
205
|
+
decision: 'warn',
|
|
206
|
+
reason: `allow_dirty=true: proceeding despite ${overlapping.length} dirty file(s) overlapping the dispatch scope: ${summariseFiles(overlapping)}`,
|
|
207
|
+
dirtyCount: realDirty.length,
|
|
208
|
+
overlapping,
|
|
209
|
+
ignoredSystemDirty,
|
|
210
|
+
scopeResolution: resolution.kind,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
decision: 'block',
|
|
215
|
+
reason: `${overlapping.length} uncommitted file(s) overlap the dispatch scope and the worker spawns from HEAD, so it will not see them: ${summariseFiles(overlapping)}. Commit or stash these, or pass allow_dirty=true to override.`,
|
|
216
|
+
dirtyCount: realDirty.length,
|
|
217
|
+
overlapping,
|
|
218
|
+
ignoredSystemDirty,
|
|
219
|
+
scopeResolution: resolution.kind,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
// 4. Scope not resolvable to paths → cannot prove disjointness → conservative.
|
|
223
|
+
if (input.allowDirty) {
|
|
224
|
+
return {
|
|
225
|
+
decision: 'warn',
|
|
226
|
+
reason: `allow_dirty=true: proceeding despite ${realDirty.length} uncommitted file(s); dispatch scope is not resolvable to paths (${resolution.reason}) so overlap could not be ruled out`,
|
|
227
|
+
dirtyCount: realDirty.length,
|
|
228
|
+
overlapping: [],
|
|
229
|
+
ignoredSystemDirty,
|
|
230
|
+
scopeResolution: resolution.kind,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
decision: 'block',
|
|
235
|
+
reason: `${realDirty.length} uncommitted file(s) and the dispatch scope is not resolvable to file paths (${resolution.reason}), so overlap cannot be ruled out; the worker spawns from HEAD and will not see them: ${summariseFiles(realDirty)}. Commit or stash, pass a resolvable file scope, or pass allow_dirty=true to override.`,
|
|
236
|
+
dirtyCount: realDirty.length,
|
|
237
|
+
overlapping: [],
|
|
238
|
+
ignoredSystemDirty,
|
|
239
|
+
scopeResolution: resolution.kind,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
//# sourceMappingURL=dirty-scope.js.map
|
|
@@ -64,7 +64,23 @@ export const CoordinateRequestSchema = z.object({
|
|
|
64
64
|
* dispatched work doesn't depend on the dirty files, or when running
|
|
65
65
|
* tests). Has no effect when the source cwd is not a git repo.
|
|
66
66
|
*/
|
|
67
|
-
allow_dirty: z.
|
|
67
|
+
allow_dirty: z.preprocess(
|
|
68
|
+
// MCP clients that don't know allow_dirty is a boolean (it was previously
|
|
69
|
+
// absent from the published inputSchema) send the string "true"/"false".
|
|
70
|
+
// Coerce those so the documented escape hatch actually works; leave real
|
|
71
|
+
// booleans and undefined untouched.
|
|
72
|
+
(value) => (typeof value === 'string' ? value.trim().toLowerCase() === 'true' : value), z.boolean().optional()),
|
|
73
|
+
/**
|
|
74
|
+
* pln#520 Tier 2 (P2c) — explicit git ref (commit / branch / tag) the
|
|
75
|
+
* dispatched worker should build its worktree from, instead of the default
|
|
76
|
+
* HEAD. When set on a worktree-creating intent (assign / review / reroute),
|
|
77
|
+
* the worktree is checked out at this ref, so uncommitted working-tree
|
|
78
|
+
* changes in the source are intentionally out of scope and the scope-aware
|
|
79
|
+
* dirty guard allows the dispatch. Passed through to
|
|
80
|
+
* createCoordinatorClaim's worktreeBaseRef. Ignored by intents that don't
|
|
81
|
+
* create a worktree (consult / ideate / summarize).
|
|
82
|
+
*/
|
|
83
|
+
ref: z.string().min(1).optional(),
|
|
68
84
|
/**
|
|
69
85
|
* pln#511 step 2 — loop preset selector. When set on intent='ideate',
|
|
70
86
|
* the handler bypasses the kind-default ideation phases and opens the
|
package/dist/core/schema.js
CHANGED
package/dist/core/worktree.js
CHANGED
|
@@ -94,6 +94,64 @@ export function hasGitLock(cwd) {
|
|
|
94
94
|
const lockPath = path.join(gitDir.stdout.trim(), 'index.lock');
|
|
95
95
|
return fs.existsSync(lockPath);
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Re-points an EXISTING worktree to `ref` via a hard reset of its checked-out
|
|
99
|
+
* branch + working tree. Used when a dispatch reuses an existing claim/worktree
|
|
100
|
+
* but pins a base ref: the worktree must reflect that ref, not stale state,
|
|
101
|
+
* otherwise the dirty-guard ref bypass would let the worker run on stale code
|
|
102
|
+
* (pln#520 Tier 2 / codex r2). Returns ok=false (with stderr) rather than
|
|
103
|
+
* throwing, so callers surface a visible warning instead of a hard failure.
|
|
104
|
+
*/
|
|
105
|
+
export function resetWorktreeToRef(worktreePath, ref) {
|
|
106
|
+
if (!fs.existsSync(worktreePath)) {
|
|
107
|
+
return { ok: false, stderr: `worktree path does not exist: ${worktreePath}` };
|
|
108
|
+
}
|
|
109
|
+
if (hasGitLock(worktreePath)) {
|
|
110
|
+
return { ok: false, stderr: 'git index.lock present — another git operation is in progress' };
|
|
111
|
+
}
|
|
112
|
+
const res = runGit(['reset', '--hard', ref], worktreePath);
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
return { ok: false, stderr: res.stderr };
|
|
115
|
+
}
|
|
116
|
+
// `reset --hard` realigns HEAD + tracked files, but leaves UNTRACKED residue
|
|
117
|
+
// from a prior use of the worktree — files the worker could still compile or
|
|
118
|
+
// test against even though they don't exist at the pinned ref (codex r3).
|
|
119
|
+
// We do NOT auto-delete (a blind `git clean` would also remove the brainclaw
|
|
120
|
+
// sidecar and gitignored shared symlinks); instead we detect non-system
|
|
121
|
+
// untracked files and report them so the caller surfaces a visible warning
|
|
122
|
+
// rather than letting the stale state pass silently. Ignored files (e.g.
|
|
123
|
+
// node_modules) are not listed by --untracked-files=normal, so the symlinked
|
|
124
|
+
// shared paths are unaffected.
|
|
125
|
+
const status = runGit(['status', '--porcelain=v1', '-z', '--untracked-files=normal'], worktreePath);
|
|
126
|
+
if (!status.ok) {
|
|
127
|
+
// The reset succeeded but we cannot confirm the worktree is residue-free -
|
|
128
|
+
// surface it rather than silently reporting a clean reset (cardinal rule).
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
stderr: `reset to ${ref} succeeded but the untracked-residue check (git status) failed: ${status.stderr.trim()}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (status.stdout.length > 0) {
|
|
135
|
+
const residue = status.stdout
|
|
136
|
+
.split('\0')
|
|
137
|
+
.filter((entry) => entry.startsWith('?? '))
|
|
138
|
+
.map((entry) => entry.slice(3))
|
|
139
|
+
.filter((p) => {
|
|
140
|
+
const norm = p.replace(/\\/g, '/');
|
|
141
|
+
return norm !== '.brainclaw-worktree.json'
|
|
142
|
+
&& !norm.startsWith('.brainclaw/')
|
|
143
|
+
&& !norm.startsWith('.git/');
|
|
144
|
+
});
|
|
145
|
+
if (residue.length > 0) {
|
|
146
|
+
const sample = residue.slice(0, 5).join(', ');
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
stderr: `reset to ${ref} succeeded but ${residue.length} untracked file(s) remain from prior worktree use (e.g. ${sample}) — the worker may see state absent at the ref. Remove them or dispatch with a fresh scope.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, stderr: '' };
|
|
154
|
+
}
|
|
97
155
|
/**
|
|
98
156
|
* Detects whether multiple distinct brainclaw sessions are using the same
|
|
99
157
|
* physical worktree directory (shared-checkout risk).
|