brainclaw 1.11.0 → 1.12.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 +8 -0
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli.js +6 -1
- package/dist/commands/context-diff.js +31 -0
- package/dist/commands/doctor.js +23 -1
- package/dist/commands/mcp.js +170 -57
- package/dist/commands/session-end.js +8 -1
- package/dist/commands/session-start.js +8 -1
- package/dist/commands/setup.js +80 -22
- package/dist/core/agent-files.js +93 -3
- package/dist/core/agent-registry.js +16 -1
- package/dist/core/hook-log.js +43 -0
- package/dist/facts.js +3 -3
- package/dist/facts.json +2 -2
- package/docs/cli.md +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -417,6 +417,14 @@ npm run test:coverage # with coverage report
|
|
|
417
417
|
|
|
418
418
|
For older releases (v0.x and the early v1.0 launch series), `git log` on `master` is the source of truth — every release commit follows the `chore(release): bump version to <semver>` convention, and the matching feature/fix commits reference their plan id (e.g. `feat(mcp): self-heal ... (pln#478)`).
|
|
419
419
|
|
|
420
|
+
### v1.11.1
|
|
421
|
+
|
|
422
|
+
Agent-identity & session-hook resilience, from a fresh-CLI dogfood on a monorepo:
|
|
423
|
+
|
|
424
|
+
- **Session hooks no longer spam `UserPromptSubmit hook error` on every prompt.** Root cause was an agent-identity error whose own remediation (`register-agent --set-current`) the resolver ignored, swallowed by `2>/dev/null`. Now: the error hint points at what actually works (`--agent` / `$BRAINCLAW_AGENT_NAME`), a **single registered agent auto-resolves** with no env signal, and `session-start`/`context-diff`/`session-end` run with `--hook` so they degrade to exit 0 + `~/.brainclaw/hook.log` instead of erroring the prompt loop. Multi-agent safety (pln#562) is unchanged.
|
|
425
|
+
- **`brainclaw doctor --fix-hooks`** purges stale/broken/duplicate brainclaw hooks across all Claude Code settings scopes (user + cwd) and rewrites the canonical ones — for the broken hooks that accumulate where `setup`'s git-repo discovery never reached.
|
|
426
|
+
- **`setup` repo discovery recurses** (bounded, skipping `node_modules`/build dirs) so repos nested deep in a workspace are found instead of silently missed, and it registers the detected agent so the hooks it installs can resolve an identity.
|
|
427
|
+
|
|
420
428
|
### v1.11.0
|
|
421
429
|
|
|
422
430
|
Monorepo project-resolution + Code Map, cross-project relocation, and dispatch-worktree hygiene — from a multi-project (monorepo) dogfood:
|
|
Binary file
|
package/dist/cli.js
CHANGED
|
@@ -774,6 +774,7 @@ program
|
|
|
774
774
|
.option('--json', 'Output as JSON dashboard')
|
|
775
775
|
.option('--migration-check', 'Report versioned documents that need schema migration')
|
|
776
776
|
.option('--fix-agent-ignore', 'Add missing .gitignore entries for generated local Brainclaw agent files')
|
|
777
|
+
.option('--fix-hooks', 'Purge stale/broken/duplicate brainclaw session hooks across all Claude Code settings scopes (user + cwd) and rewrite the canonical ones')
|
|
777
778
|
.option('--fix', 'Fix auto-resolvable issues (e.g. drifting MCP configs)')
|
|
778
779
|
.option('--repair', 'Rebuild dist/ when the MCP runtime is missing or stale')
|
|
779
780
|
.option('--after-migration', 'Run the v1.0 post-migration health check only (exits non-zero on any failure)')
|
|
@@ -1435,6 +1436,7 @@ program
|
|
|
1435
1436
|
.option('--model <id>', 'Model identifier (e.g. claude-sonnet-4-6)')
|
|
1436
1437
|
.option('--maintenance-mode <mode>', 'Maintenance mode: full (default) or fast')
|
|
1437
1438
|
.option('--include-context', 'Output full project context after starting session (replaces separate context call)')
|
|
1439
|
+
.option('--hook', 'Hook mode: degrade to exit 0 + ~/.brainclaw/hook.log on failure (advisory session hooks)')
|
|
1438
1440
|
.option('--json', 'Output as JSON')
|
|
1439
1441
|
.action(async (options) => {
|
|
1440
1442
|
await runSessionStart(options);
|
|
@@ -1452,6 +1454,7 @@ program
|
|
|
1452
1454
|
.option('--dispatch-review', 'When used with --reflect-handoff, auto-dispatch a code review if the handoff is reviewable')
|
|
1453
1455
|
.option('--reviewer <name>', 'Explicit reviewer to route the reflected handoff review to')
|
|
1454
1456
|
.option('--no-reflect', 'Suppress the dogfooding reflection prompt (project + your surfaces/skills/tools), shown by default')
|
|
1457
|
+
.option('--hook', 'Hook mode: degrade to exit 0 + ~/.brainclaw/hook.log on failure (advisory Stop hook)')
|
|
1455
1458
|
.option('--json', 'Output as JSON')
|
|
1456
1459
|
.action(async (options) => {
|
|
1457
1460
|
await runSessionEnd({
|
|
@@ -1462,6 +1465,7 @@ program
|
|
|
1462
1465
|
dispatchReview: options.dispatchReview,
|
|
1463
1466
|
reviewer: options.reviewer,
|
|
1464
1467
|
reflect: options.reflect,
|
|
1468
|
+
hook: options.hook,
|
|
1465
1469
|
});
|
|
1466
1470
|
});
|
|
1467
1471
|
// --- whoami ---
|
|
@@ -1753,9 +1757,10 @@ program
|
|
|
1753
1757
|
.description('Show what changed in memory since last context read, a session start, or a given timestamp')
|
|
1754
1758
|
.option('--since <date>', 'Show changes since this ISO date')
|
|
1755
1759
|
.option('--session <id>', 'Show changes since the start of this session')
|
|
1760
|
+
.option('--hook', 'Hook mode: exit 0 silently when there is no diff baseline (advisory session hooks)')
|
|
1756
1761
|
.option('--json', 'Output as JSON')
|
|
1757
1762
|
.action((options) => {
|
|
1758
|
-
runContextDiff({ since: options.since, session: options.session, json: options.json });
|
|
1763
|
+
runContextDiff({ since: options.since, session: options.session, json: options.json, hook: options.hook });
|
|
1759
1764
|
});
|
|
1760
1765
|
program
|
|
1761
1766
|
.command('capability <subcommand> [args...]')
|
|
@@ -4,18 +4,45 @@ import { loadInstructions, resolveInstructions } from '../core/instructions.js';
|
|
|
4
4
|
import { memoryExists } from '../core/io.js';
|
|
5
5
|
import { loadState } from '../core/state.js';
|
|
6
6
|
import { isTrapActive } from '../core/traps.js';
|
|
7
|
+
import { logHookDiagnostic } from '../core/hook-log.js';
|
|
7
8
|
/**
|
|
8
9
|
* Hybrid context-diff: always includes critical anchors (active claims,
|
|
9
10
|
* top traps, instructions) so the agent stays grounded, plus the memory
|
|
10
11
|
* delta since last context read.
|
|
11
12
|
*/
|
|
12
13
|
export function runContextDiff(options = {}) {
|
|
14
|
+
try {
|
|
15
|
+
contextDiffBody(options);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
19
|
+
if (options.hook) {
|
|
20
|
+
// Advisory hook (trp#917): any failure past the early guards — e.g. a
|
|
21
|
+
// corrupt session snapshot / claim / instruction file feeding buildContextDiff
|
|
22
|
+
// or buildCriticalAnchors — must NOT error the prompt loop. Log + exit 0.
|
|
23
|
+
logHookDiagnostic(`context-diff skipped: ${message}`);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
console.error(`Error: ${message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function contextDiffBody(options) {
|
|
13
31
|
if (!memoryExists(options.cwd)) {
|
|
32
|
+
if (options.hook) {
|
|
33
|
+
logHookDiagnostic('context-diff skipped: .brainclaw/ not found');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
14
36
|
console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
|
|
15
37
|
process.exit(1);
|
|
16
38
|
}
|
|
17
39
|
const resolved = resolveContextDiffSince(options);
|
|
18
40
|
if (!resolved.since) {
|
|
41
|
+
if (options.hook) {
|
|
42
|
+
// No diff baseline yet (e.g. first prompt before a session marker exists).
|
|
43
|
+
// Nothing to surface — exit 0 silently so the hook never errors (trp#917).
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
19
46
|
if (options.session) {
|
|
20
47
|
console.error(`Error: session '${options.session}' not found in session snapshots or audit log.`);
|
|
21
48
|
process.exit(1);
|
|
@@ -25,6 +52,10 @@ export function runContextDiff(options = {}) {
|
|
|
25
52
|
}
|
|
26
53
|
const diff = buildContextDiff({ ...options, includeItems: true });
|
|
27
54
|
if (!diff) {
|
|
55
|
+
if (options.hook) {
|
|
56
|
+
logHookDiagnostic('context-diff skipped: unable to build diff');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
28
59
|
console.error('Error: unable to build context diff.');
|
|
29
60
|
process.exit(1);
|
|
30
61
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import * as childProcess from 'node:child_process';
|
|
5
6
|
import { reconcileAllOpenRuns } from '../core/agentrun-reconciler.js';
|
|
@@ -35,7 +36,7 @@ import { assessBrainclawVersion, detectConcurrentInstallations } from '../core/b
|
|
|
35
36
|
import { resolveStoreChain } from '../core/store-resolution.js';
|
|
36
37
|
import { listWorktrees, detectSharedCheckoutRisk } from '../core/worktree.js';
|
|
37
38
|
import { resolveCrossProjectLinks, detectCrossProjectCycles } from '../core/cross-project.js';
|
|
38
|
-
import { auditLocalAgentWorkspaceFiles, ensureGitignoreEntries, patchAllMcpConfigs } from '../core/agent-files.js';
|
|
39
|
+
import { auditLocalAgentWorkspaceFiles, ensureGitignoreEntries, patchAllMcpConfigs, fixClaudeCodeHooksAllScopes } from '../core/agent-files.js';
|
|
39
40
|
import { summarizeWorkspaceProjects } from '../core/workspace-projects.js';
|
|
40
41
|
import { detectStaleness, staleSummary } from '../core/staleness.js';
|
|
41
42
|
import { InboxMessageSchema } from '../core/schema.js';
|
|
@@ -654,6 +655,27 @@ export function runDoctor(options = {}) {
|
|
|
654
655
|
process.exit(report.exit_code);
|
|
655
656
|
return;
|
|
656
657
|
}
|
|
658
|
+
if (options.fixHooks) {
|
|
659
|
+
const cwd = options.cwd ?? process.cwd();
|
|
660
|
+
const results = fixClaudeCodeHooksAllScopes(cwd, os.homedir());
|
|
661
|
+
const fixed = results.filter((r) => r.changed);
|
|
662
|
+
if (options.json) {
|
|
663
|
+
console.log(JSON.stringify({ ok: true, action: 'fix-hooks', results }, null, 2));
|
|
664
|
+
}
|
|
665
|
+
else if (fixed.length === 0) {
|
|
666
|
+
console.log('✔ No stale/broken brainclaw session hooks found across Claude Code settings scopes.');
|
|
667
|
+
}
|
|
668
|
+
else {
|
|
669
|
+
for (const r of fixed) {
|
|
670
|
+
const suffix = r.collapsed > 0
|
|
671
|
+
? ` (collapsed ${r.collapsed} stale/duplicate entr${r.collapsed === 1 ? 'y' : 'ies'})`
|
|
672
|
+
: '';
|
|
673
|
+
console.log(`✔ Sanitized brainclaw session hooks in ${r.filePath}${suffix}`);
|
|
674
|
+
}
|
|
675
|
+
console.log(' → Restart your Claude Code session to pick up the canonical hooks.');
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
657
679
|
if (options.repair) {
|
|
658
680
|
try {
|
|
659
681
|
const result = repairDistRuntime(options);
|
package/dist/commands/mcp.js
CHANGED
|
@@ -7,14 +7,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { Worker } from 'node:worker_threads';
|
|
8
8
|
import { generatedSchemas } from './mcp-schemas.generated.js';
|
|
9
9
|
import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
|
|
10
|
-
import { resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
|
|
10
|
+
import { resolveCrossProjectLinks, resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
|
|
11
11
|
import { buildContext, renderContextMarkdown, renderContextPromptTemplate, renderContextBriefing } from '../core/context.js';
|
|
12
12
|
import { buildCoordinationSnapshot } from '../core/coordination.js';
|
|
13
13
|
import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, readDiskBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
|
|
14
14
|
import { loadConfig } from '../core/config.js';
|
|
15
15
|
import { collectLoadValidationWarnings, findLoadValidationWarning, loadState, persistState, saveState } from '../core/state.js';
|
|
16
16
|
import { generateIdWithLabel } from '../core/ids.js';
|
|
17
|
-
import { memoryExists } from '../core/io.js';
|
|
17
|
+
import { memoryExists, MEMORY_DIR } from '../core/io.js';
|
|
18
18
|
import { generateCandidateIdWithLabel, loadCandidate, saveCandidate } from '../core/candidates.js';
|
|
19
19
|
import { createEntity, getEntity, listEntities, boundListResult, DEFAULT_FIND_CHAR_BUDGET, removeEntity, transitionEntity, updateEntity, } from '../core/entity-operations.js';
|
|
20
20
|
import { relocateEntity } from '../core/operations/relocate.js';
|
|
@@ -40,7 +40,8 @@ import { createCapability, createTool as createRegistryTool } from '../core/regi
|
|
|
40
40
|
import { detectAiAgent } from '../core/ai-agent-detection.js';
|
|
41
41
|
import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, getDetectedSetupAgentNames, getInstalledAgentNames, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
|
|
42
42
|
import { buildAgentInventory } from '../core/agent-inventory.js';
|
|
43
|
-
import { resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
|
|
43
|
+
import { findOutermostBrainclawRoot, resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
|
|
44
|
+
import { switchProject } from './switch.js';
|
|
44
45
|
import { assessBootstrapNeed, probeForQuickSetup, buildQuickSetupProbeResponse, buildOnboardingPreview, resolveEmptyMemoryRecommendation } from '../core/setup-flow.js';
|
|
45
46
|
import { ensureUserStore, resolveHomeDir } from '../core/setup-state.js';
|
|
46
47
|
import { createPlan, addStep as addStepOp, completeStep as completeStepOp, updateStep as updateStepOp, deleteStep as deleteStepOp, deletePlan as deletePlanOp } from '../core/operations/plan.js';
|
|
@@ -2647,6 +2648,100 @@ function blockCrossProjectExecution(entity, args) {
|
|
|
2647
2648
|
return createToolErrorResponse('validation_error', error instanceof Error ? error.message : String(error));
|
|
2648
2649
|
}
|
|
2649
2650
|
}
|
|
2651
|
+
function matchesCrossProjectLink(ref, cwd) {
|
|
2652
|
+
const trimmed = ref.trim();
|
|
2653
|
+
if (!trimmed)
|
|
2654
|
+
return false;
|
|
2655
|
+
const linkRoots = new Set([path.resolve(cwd), path.resolve(resolveWorkspaceAnchor(cwd))]);
|
|
2656
|
+
for (const root of linkRoots) {
|
|
2657
|
+
for (const link of resolveCrossProjectLinks(root)) {
|
|
2658
|
+
if (link.projectName === trimmed
|
|
2659
|
+
|| link.name === trimmed
|
|
2660
|
+
|| link.path === trimmed
|
|
2661
|
+
|| link.absolutePath === trimmed
|
|
2662
|
+
|| path.basename(link.absolutePath) === trimmed) {
|
|
2663
|
+
return true;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
return false;
|
|
2668
|
+
}
|
|
2669
|
+
/**
|
|
2670
|
+
* Resolve the destination store for an execution-entity write (plan / claim and
|
|
2671
|
+
* their sub-objects: steps).
|
|
2672
|
+
*
|
|
2673
|
+
* The signaling-only boundary (cnd cross_project_signaling_vs_execution) forbids
|
|
2674
|
+
* driving execution entities into ANOTHER project — but that rule is about
|
|
2675
|
+
* FEDERATION (cross_project_links / other machines), not about workspace siblings
|
|
2676
|
+
* in the same monorepo. Switching into a sibling and creating a plan there is a
|
|
2677
|
+
* purely local operation, and the one the agent actually wants.
|
|
2678
|
+
*
|
|
2679
|
+
* So when `project=X` resolves to a workspace store-chain child (resolveProjectRef
|
|
2680
|
+
* hits — it only matches projects reachable WITHIN this workspace, never a
|
|
2681
|
+
* federated link), we AUTO-LOCALIZE: open a session + session-scoped switch into X
|
|
2682
|
+
* (sticky, per-agent — switchProject auto-creates the session if missing), then
|
|
2683
|
+
* write locally in X. Federated links and unknown names stay blocked.
|
|
2684
|
+
*
|
|
2685
|
+
* DGX dogfood 2026-06-27: without this an agent on the /srv monorepo cannot
|
|
2686
|
+
* `bclaw_create(entity=plan, project=<child>)` — it was rejected as cross-project —
|
|
2687
|
+
* so plans silently fell back to the default project instead.
|
|
2688
|
+
*/
|
|
2689
|
+
function resolveExecutionWriteTarget(entity, args, cwd, connectionSessionId) {
|
|
2690
|
+
const targetProject = getCrossProjectArg(args, 'targetProject', 'target_project', 'crossProject', 'cross_project', 'project');
|
|
2691
|
+
if (!targetProject) {
|
|
2692
|
+
return { targetCwd: cwd, autoSwitched: false };
|
|
2693
|
+
}
|
|
2694
|
+
if (matchesCrossProjectLink(targetProject, cwd)) {
|
|
2695
|
+
const block = blockCrossProjectExecution(entity, args);
|
|
2696
|
+
return {
|
|
2697
|
+
block: block ?? createToolErrorResponse('validation_error', `Cross-project execution write blocked: ${targetProject}`),
|
|
2698
|
+
targetCwd: cwd,
|
|
2699
|
+
autoSwitched: false,
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
// Workspace store-chain child (or the workspace root / the current project)?
|
|
2703
|
+
const wsHit = resolveProjectRef(targetProject, cwd);
|
|
2704
|
+
if (wsHit) {
|
|
2705
|
+
// Same-workspace → auto-localize. Switch the session into X (sticky,
|
|
2706
|
+
// session-scoped) so subsequent un-qualified writes follow, and persist the
|
|
2707
|
+
// session under the workspace anchor where resolveEffectiveCwd probes for it
|
|
2708
|
+
// — NOT the effective child cwd, or stickiness would be invisible on the next
|
|
2709
|
+
// call. The switch is best-effort: the write still localizes to wsHit below.
|
|
2710
|
+
let autoSwitched = false;
|
|
2711
|
+
try {
|
|
2712
|
+
const anchor = resolveWorkspaceAnchor(cwd);
|
|
2713
|
+
const sessionId = connectionSessionId ?? explicitSessionIdFromEnv();
|
|
2714
|
+
switchProject(targetProject, { cwd: anchor, sessionOnly: true, sessionId });
|
|
2715
|
+
autoSwitched = true;
|
|
2716
|
+
}
|
|
2717
|
+
catch {
|
|
2718
|
+
/* sticky switch is best-effort */
|
|
2719
|
+
}
|
|
2720
|
+
return { targetCwd: wsHit, autoSwitched, resolvedProject: projectInfoForCwd(wsHit) };
|
|
2721
|
+
}
|
|
2722
|
+
// Not a workspace child → federated link or unknown name. The signaling-only
|
|
2723
|
+
// boundary stands: execution entities never cross a federation boundary.
|
|
2724
|
+
const block = blockCrossProjectExecution(entity, args);
|
|
2725
|
+
return {
|
|
2726
|
+
block: block ?? createToolErrorResponse('validation_error', `Unknown project: ${targetProject}`),
|
|
2727
|
+
targetCwd: cwd,
|
|
2728
|
+
autoSwitched: false,
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
/**
|
|
2732
|
+
* Workspace anchor for persisting a session switch — mirrors resolveEffectiveCwd's
|
|
2733
|
+
* anchor selection (BRAINCLAW_CWD when it is a real store, else the outermost
|
|
2734
|
+
* store walking up from cwd) so an auto-switch is found on the next resolution.
|
|
2735
|
+
*/
|
|
2736
|
+
function resolveWorkspaceAnchor(cwd) {
|
|
2737
|
+
const env = process.env.BRAINCLAW_CWD?.trim();
|
|
2738
|
+
if (env) {
|
|
2739
|
+
const resolved = path.resolve(env);
|
|
2740
|
+
if (fs.existsSync(path.join(resolved, MEMORY_DIR, 'config.yaml')))
|
|
2741
|
+
return resolved;
|
|
2742
|
+
}
|
|
2743
|
+
return findOutermostBrainclawRoot(cwd) ?? cwd;
|
|
2744
|
+
}
|
|
2650
2745
|
// Read handlers moved to mcp-read-handlers.ts
|
|
2651
2746
|
import { handleMcpReadToolCall } from './mcp-read-handlers.js';
|
|
2652
2747
|
export { handleMcpReadToolCall };
|
|
@@ -3300,21 +3395,17 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
3300
3395
|
}
|
|
3301
3396
|
}
|
|
3302
3397
|
if (name === 'bclaw_claim') {
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
const
|
|
3310
|
-
|
|
3311
|
-
const resolvedProject = resolveProjectRef(claimProjectArg, cwd);
|
|
3312
|
-
if (resolvedProject)
|
|
3313
|
-
effectiveClaimCwd = resolvedProject;
|
|
3314
|
-
}
|
|
3398
|
+
// project=X naming a workspace sibling auto-localizes (session+switch then
|
|
3399
|
+
// claim locally); federated links / unknown names stay blocked.
|
|
3400
|
+
const claimLoc = resolveExecutionWriteTarget('claim', args, cwd, connectionSessionId);
|
|
3401
|
+
if (claimLoc.block) {
|
|
3402
|
+
return { response: claimLoc.block };
|
|
3403
|
+
}
|
|
3404
|
+
const effectiveClaimCwd = claimLoc.targetCwd;
|
|
3405
|
+
const claimAutoSwitched = claimLoc.autoSwitched;
|
|
3315
3406
|
const storeTarget = args.store ?? 'local';
|
|
3316
3407
|
const claimCwd = resolveTargetStore(effectiveClaimCwd, storeTarget);
|
|
3317
|
-
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor',
|
|
3408
|
+
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
3318
3409
|
if (resolved.error) {
|
|
3319
3410
|
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
3320
3411
|
}
|
|
@@ -3329,10 +3420,13 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
3329
3420
|
return { response: createToolErrorResponse('validation_error', descCheck.message) };
|
|
3330
3421
|
}
|
|
3331
3422
|
const resolvedIdentity = resolved.identity;
|
|
3332
|
-
const identity =
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3423
|
+
const identity = {
|
|
3424
|
+
...buildOperationalIdentity(resolvedIdentity.agent_name, cwd, {
|
|
3425
|
+
agentId: resolvedIdentity.agent_id,
|
|
3426
|
+
sessionId: connectionSessionId,
|
|
3427
|
+
}),
|
|
3428
|
+
project_id: loadConfig(claimCwd).project_id,
|
|
3429
|
+
};
|
|
3336
3430
|
const claimId = generateClaimId();
|
|
3337
3431
|
let worktreePath;
|
|
3338
3432
|
let worktreeWarn = '';
|
|
@@ -3438,7 +3532,8 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
3438
3532
|
const worktreeNote = worktreePath ? `\n Worktree: ${worktreePath}` : '';
|
|
3439
3533
|
const expiryNote = claimExpiresAt ? `\n Expires: ${claimExpiresAt.slice(0, 16).replace('T', ' ')} UTC` : '';
|
|
3440
3534
|
const handoffNote = handoffMode ? `\n Handoff: ${handoffMode} (another agent will review and merge)` : '';
|
|
3441
|
-
const
|
|
3535
|
+
const autoSwitchNote = claimAutoSwitched ? `\n Auto-switched → ${projectInfoForCwd(effectiveClaimCwd).name ?? effectiveClaimCwd}` : '';
|
|
3536
|
+
const claimText = `✔ Claimed scope [${claimId}]${worktreeNote}${expiryNote}${handoffNote}${autoSwitchNote}${noPlanWarn}${worktreeWarn}${branchWarn}${staleBranchWarn}${policyWarn}${postClaimText ? `\n${postClaimText}` : ''}`;
|
|
3442
3537
|
return {
|
|
3443
3538
|
response: appendLegacyMcpToolWarning(toolResponse({
|
|
3444
3539
|
content: [{ type: 'text', text: claimText }],
|
|
@@ -3446,6 +3541,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
3446
3541
|
session_id: identity.session_id,
|
|
3447
3542
|
worktree_path: worktreePath,
|
|
3448
3543
|
triggered_items: postClaimItems,
|
|
3544
|
+
...(claimAutoSwitched ? { auto_switched: true, resolved_project: projectInfoForCwd(effectiveClaimCwd) } : {}),
|
|
3449
3545
|
}), name),
|
|
3450
3546
|
nextConnectionSessionId: explicitSessionIdFromEnv() ? undefined : identity.session_id,
|
|
3451
3547
|
};
|
|
@@ -4232,9 +4328,9 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4232
4328
|
}
|
|
4233
4329
|
}
|
|
4234
4330
|
if (name === 'bclaw_add_step') {
|
|
4235
|
-
const
|
|
4236
|
-
if (
|
|
4237
|
-
return { response:
|
|
4331
|
+
const stepLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
4332
|
+
if (stepLoc.block) {
|
|
4333
|
+
return { response: stepLoc.block };
|
|
4238
4334
|
}
|
|
4239
4335
|
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
4240
4336
|
if (resolved.error) {
|
|
@@ -4256,7 +4352,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4256
4352
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
|
|
4257
4353
|
if (!stepText)
|
|
4258
4354
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: data.text') };
|
|
4259
|
-
const stepTargetCwd =
|
|
4355
|
+
const stepTargetCwd = stepLoc.targetCwd;
|
|
4260
4356
|
try {
|
|
4261
4357
|
const result = addStepOp({ planId: stepPlanId, text: stepText, assignee: stepAssignee, estimatedEffort: stepEstimated, actualEffort: stepActual }, stepTargetCwd);
|
|
4262
4358
|
return {
|
|
@@ -4277,9 +4373,9 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4277
4373
|
}
|
|
4278
4374
|
}
|
|
4279
4375
|
if (name === 'bclaw_complete_step') {
|
|
4280
|
-
const
|
|
4281
|
-
if (
|
|
4282
|
-
return { response:
|
|
4376
|
+
const csLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
4377
|
+
if (csLoc.block) {
|
|
4378
|
+
return { response: csLoc.block };
|
|
4283
4379
|
}
|
|
4284
4380
|
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
4285
4381
|
if (resolved.error) {
|
|
@@ -4291,7 +4387,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4291
4387
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
|
|
4292
4388
|
if (!csStepId)
|
|
4293
4389
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
|
|
4294
|
-
const csTargetCwd =
|
|
4390
|
+
const csTargetCwd = csLoc.targetCwd;
|
|
4295
4391
|
try {
|
|
4296
4392
|
const result = completeStepOp({ planId: csPlanId, stepId: csStepId }, csTargetCwd);
|
|
4297
4393
|
return {
|
|
@@ -4313,9 +4409,9 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4313
4409
|
}
|
|
4314
4410
|
}
|
|
4315
4411
|
if (name === 'bclaw_update_step') {
|
|
4316
|
-
const
|
|
4317
|
-
if (
|
|
4318
|
-
return { response:
|
|
4412
|
+
const usLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
4413
|
+
if (usLoc.block) {
|
|
4414
|
+
return { response: usLoc.block };
|
|
4319
4415
|
}
|
|
4320
4416
|
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
4321
4417
|
if (resolved.error) {
|
|
@@ -4331,7 +4427,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4331
4427
|
if (args.status && !validStatuses.includes(String(args.status))) {
|
|
4332
4428
|
return { response: createToolErrorResponse('validation_error', `Invalid status: ${args.status}. Valid: ${validStatuses.join(', ')}`) };
|
|
4333
4429
|
}
|
|
4334
|
-
const usTargetCwd =
|
|
4430
|
+
const usTargetCwd = usLoc.targetCwd;
|
|
4335
4431
|
try {
|
|
4336
4432
|
const result = updateStepOp({
|
|
4337
4433
|
planId: usPlanId,
|
|
@@ -4372,9 +4468,9 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4372
4468
|
}
|
|
4373
4469
|
}
|
|
4374
4470
|
if (name === 'bclaw_delete_step') {
|
|
4375
|
-
const
|
|
4376
|
-
if (
|
|
4377
|
-
return { response:
|
|
4471
|
+
const dsLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
4472
|
+
if (dsLoc.block) {
|
|
4473
|
+
return { response: dsLoc.block };
|
|
4378
4474
|
}
|
|
4379
4475
|
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'contributor', cwd, connectionSessionId);
|
|
4380
4476
|
if (resolved.error) {
|
|
@@ -4386,7 +4482,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4386
4482
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: planId') };
|
|
4387
4483
|
if (!dsStepId)
|
|
4388
4484
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: stepId') };
|
|
4389
|
-
const dsTargetCwd =
|
|
4485
|
+
const dsTargetCwd = dsLoc.targetCwd;
|
|
4390
4486
|
try {
|
|
4391
4487
|
const result = deleteStepOp({ planId: dsPlanId, stepId: dsStepId }, dsTargetCwd);
|
|
4392
4488
|
return {
|
|
@@ -4407,10 +4503,11 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4407
4503
|
}
|
|
4408
4504
|
}
|
|
4409
4505
|
if (name === 'bclaw_delete_plan') {
|
|
4410
|
-
const
|
|
4411
|
-
if (
|
|
4412
|
-
return { response:
|
|
4506
|
+
const dpLoc = resolveExecutionWriteTarget('plan', args, cwd, connectionSessionId);
|
|
4507
|
+
if (dpLoc.block) {
|
|
4508
|
+
return { response: dpLoc.block };
|
|
4413
4509
|
}
|
|
4510
|
+
const dpTargetCwd = dpLoc.targetCwd;
|
|
4414
4511
|
const resolved = ensureTrust(args, { nameField: 'agent', idField: 'agentId' }, 'trusted', cwd, connectionSessionId);
|
|
4415
4512
|
if (resolved.error) {
|
|
4416
4513
|
return { response: createToolErrorResponse(resolved.error.kind, resolved.error.message, resolved.error.details) };
|
|
@@ -4419,7 +4516,7 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
4419
4516
|
if (!dpId)
|
|
4420
4517
|
return { response: createToolErrorResponse('validation_error', 'Missing required argument: id') };
|
|
4421
4518
|
try {
|
|
4422
|
-
const result = deletePlanOp(dpId,
|
|
4519
|
+
const result = deletePlanOp(dpId, dpTargetCwd);
|
|
4423
4520
|
return {
|
|
4424
4521
|
response: toolResponse({
|
|
4425
4522
|
content: [{ type: 'text', text: `✔ Plan deleted: [${result.id}] ${result.text.slice(0, 80)}` }],
|
|
@@ -6537,15 +6634,22 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
6537
6634
|
if (name === 'bclaw_create') {
|
|
6538
6635
|
try {
|
|
6539
6636
|
const entity = String(args.entity ?? '');
|
|
6540
|
-
// Execution entities
|
|
6541
|
-
//
|
|
6637
|
+
// Execution entities (plan/claim) auto-localize into a workspace sibling
|
|
6638
|
+
// when project=X names one: session+switch then write locally. Only
|
|
6639
|
+
// federated links / unknown names are blocked (signaling-only boundary).
|
|
6640
|
+
let targetCwd;
|
|
6641
|
+
let autoSwitched = false;
|
|
6542
6642
|
if (entity === 'claim' || entity === 'plan') {
|
|
6543
|
-
const
|
|
6544
|
-
if (
|
|
6545
|
-
return { response:
|
|
6643
|
+
const loc = resolveExecutionWriteTarget(entity, args, cwd, connectionSessionId);
|
|
6644
|
+
if (loc.block)
|
|
6645
|
+
return { response: loc.block };
|
|
6646
|
+
targetCwd = loc.targetCwd;
|
|
6647
|
+
autoSwitched = loc.autoSwitched;
|
|
6648
|
+
}
|
|
6649
|
+
else {
|
|
6650
|
+
targetCwd = resolveProjectCwd(args.project, cwd);
|
|
6546
6651
|
}
|
|
6547
6652
|
const rawData = (args.data ?? {});
|
|
6548
|
-
const targetCwd = resolveProjectCwd(args.project, cwd);
|
|
6549
6653
|
const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
|
|
6550
6654
|
// Auto-fill identity fields. Without this, a caller who omits author/agent
|
|
6551
6655
|
// creates a schema-invalid record that is silently dropped on read and
|
|
@@ -6577,11 +6681,12 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
6577
6681
|
appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
|
|
6578
6682
|
return {
|
|
6579
6683
|
response: toolResponse({
|
|
6580
|
-
content: [{ type: 'text', text: `✔ created ${entity} ${result.id}` }],
|
|
6684
|
+
content: [{ type: 'text', text: `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}` }],
|
|
6581
6685
|
structuredContent: {
|
|
6582
6686
|
...result,
|
|
6583
6687
|
resolved_project: targetScope.resolved_project,
|
|
6584
|
-
active_source: targetScope.active_source,
|
|
6688
|
+
active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
|
|
6689
|
+
...(autoSwitched ? { auto_switched: true } : {}),
|
|
6585
6690
|
},
|
|
6586
6691
|
}),
|
|
6587
6692
|
};
|
|
@@ -6664,28 +6769,36 @@ async function _executeMcpToolCallInner(payload) {
|
|
|
6664
6769
|
if (name === 'bclaw_transition') {
|
|
6665
6770
|
try {
|
|
6666
6771
|
const entity = String(args.entity ?? '');
|
|
6667
|
-
// Same
|
|
6668
|
-
//
|
|
6772
|
+
// Same auto-localize as bclaw_create: a workspace sibling named by
|
|
6773
|
+
// project=X is switched into and transitioned locally; only federated
|
|
6774
|
+
// links / unknown names are blocked (signaling-only boundary).
|
|
6775
|
+
let targetCwd;
|
|
6776
|
+
let autoSwitched = false;
|
|
6669
6777
|
if (entity === 'claim' || entity === 'plan') {
|
|
6670
|
-
const
|
|
6671
|
-
if (
|
|
6672
|
-
return { response:
|
|
6778
|
+
const loc = resolveExecutionWriteTarget(entity, args, cwd, connectionSessionId);
|
|
6779
|
+
if (loc.block)
|
|
6780
|
+
return { response: loc.block };
|
|
6781
|
+
targetCwd = loc.targetCwd;
|
|
6782
|
+
autoSwitched = loc.autoSwitched;
|
|
6783
|
+
}
|
|
6784
|
+
else {
|
|
6785
|
+
targetCwd = resolveProjectCwd(args.project, cwd);
|
|
6673
6786
|
}
|
|
6674
6787
|
const id = String(args.id ?? '');
|
|
6675
6788
|
const to = String(args.to ?? '');
|
|
6676
6789
|
const reason = args.reason;
|
|
6677
|
-
const targetCwd = resolveProjectCwd(args.project, cwd);
|
|
6678
6790
|
const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
|
|
6679
6791
|
const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
|
|
6680
6792
|
const result = transitionEntity(entity, id, to, targetCwd, reason);
|
|
6681
6793
|
appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'update', item_id: id, item_type: entity, reason: `transition ${result.from} → ${to}${reason ? ` (${reason})` : ''}` }, targetCwd);
|
|
6682
6794
|
return {
|
|
6683
6795
|
response: toolResponse({
|
|
6684
|
-
content: [{ type: 'text', text: `✔ ${entity} ${id}: ${result.from} → ${to}` }],
|
|
6796
|
+
content: [{ type: 'text', text: `✔ ${entity} ${id}: ${result.from} → ${to}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}` }],
|
|
6685
6797
|
structuredContent: {
|
|
6686
6798
|
...result,
|
|
6687
6799
|
resolved_project: targetScope.resolved_project,
|
|
6688
|
-
active_source: targetScope.active_source,
|
|
6800
|
+
active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
|
|
6801
|
+
...(autoSwitched ? { auto_switched: true } : {}),
|
|
6689
6802
|
},
|
|
6690
6803
|
}),
|
|
6691
6804
|
};
|
|
@@ -39,6 +39,7 @@ import { extractFilesFromDiff } from '../commands/handoff.js';
|
|
|
39
39
|
import { capHandoffDiff } from '../core/handoff-snapshot.js';
|
|
40
40
|
import { suggestCompaction } from '../core/memory-compactor.js';
|
|
41
41
|
import { dispatchReview } from '../core/dispatcher.js';
|
|
42
|
+
import { logHookDiagnostic } from '../core/hook-log.js';
|
|
42
43
|
export const REFLECTION_QUESTIONS = [
|
|
43
44
|
'Dogfooding the project — using brainclaw to do real work this session, what friction did you hit (slow reads, confusing surfaces, missing affordances, awkward workflows)? What concrete change to the project would have removed it?',
|
|
44
45
|
'Your surfaces, skills & tools — did your generated surface files (CLAUDE.md / agent surface), skills (SKILL.md), or tools (MCP / CLI) help or get in the way? Name at least one concrete edit that would make them serve you better next time.',
|
|
@@ -113,7 +114,13 @@ export async function runSessionEnd(options = {}) {
|
|
|
113
114
|
}
|
|
114
115
|
}
|
|
115
116
|
catch (e) {
|
|
116
|
-
|
|
117
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
118
|
+
if (options.hook) {
|
|
119
|
+
// Advisory Stop hook (trp#917): never fail the prompt loop. Log + exit 0.
|
|
120
|
+
logHookDiagnostic(`session-end skipped: ${message}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
console.error(`Error: ${message}`);
|
|
117
124
|
process.exit(1);
|
|
118
125
|
}
|
|
119
126
|
}
|
|
@@ -11,6 +11,7 @@ import { writeContextMarker } from '../core/freshness.js';
|
|
|
11
11
|
import { saveRuntimeNote, generateRuntimeNoteId } from '../core/runtime.js';
|
|
12
12
|
import { nowISO, generateId } from '../core/ids.js';
|
|
13
13
|
import { appendAuditEntry } from '../core/audit.js';
|
|
14
|
+
import { logHookDiagnostic } from '../core/hook-log.js';
|
|
14
15
|
import { releaseStaleClaimsFromOtherAgents } from '../core/claims.js';
|
|
15
16
|
import { SessionSnapshotSchema } from '../core/schema.js';
|
|
16
17
|
import { auditLocalAgentWorkspaceFiles } from '../core/agent-files.js';
|
|
@@ -93,7 +94,13 @@ export async function runSessionStart(options = {}) {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
catch (e) {
|
|
96
|
-
|
|
97
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
98
|
+
if (options.hook) {
|
|
99
|
+
// Advisory hook (trp#917): never fail the prompt loop. Log + exit 0.
|
|
100
|
+
logHookDiagnostic(`session-start skipped: ${message}`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
console.error(`Error: ${message}`);
|
|
97
104
|
process.exit(1);
|
|
98
105
|
}
|
|
99
106
|
}
|
package/dist/commands/setup.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ensureClaudeCodeUserSettings, ensureClaudeCodeUserCommand, ensureCursor
|
|
|
12
12
|
import { MEMORY_DIR, memoryExists } from '../core/io.js';
|
|
13
13
|
import { ensureUserStore, readSetupState, resolveHomeDir, writeSetupState } from '../core/setup-state.js';
|
|
14
14
|
import { writeDetectedAgentHooks } from './hooks.js';
|
|
15
|
+
import { normalizeAgentName, findAgentIdentityByName, registerAgentIdentity } from '../core/agent-registry.js';
|
|
15
16
|
export { readSetupState } from '../core/setup-state.js';
|
|
16
17
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
17
18
|
export const ALL_KNOWN_AGENTS = [
|
|
@@ -83,37 +84,59 @@ export function parseRoots(input, env = process.env) {
|
|
|
83
84
|
return results;
|
|
84
85
|
}
|
|
85
86
|
// ─── Step 2: Scan repos ───────────────────────────────────────────────────────
|
|
86
|
-
|
|
87
|
+
/** Heavy / generated directories never worth descending into when hunting repos. */
|
|
88
|
+
const SCAN_SKIP_DIRS = new Set([
|
|
89
|
+
'node_modules', '.git', 'dist', 'dist-test', 'build', 'out', '.next',
|
|
90
|
+
'coverage', 'vendor', '.venv', 'venv', '__pycache__', '.cache', 'target', '.gradle',
|
|
91
|
+
]);
|
|
92
|
+
/**
|
|
93
|
+
* Discover git repositories under each root, recursing up to `maxDepth` levels.
|
|
94
|
+
*
|
|
95
|
+
* trp#918: the previous scan was depth-1 only (root + immediate children), so in
|
|
96
|
+
* a workspace like /srv with repos nested at /srv/dev/repos/global/<svc> it found
|
|
97
|
+
* just the shallow ones and silently missed the rest. This walk descends (skipping
|
|
98
|
+
* heavy/build/hidden dirs, bounded by `maxDepth`) and surfaces every directory
|
|
99
|
+
* that contains a `.git`. It keeps descending past a found repo because a
|
|
100
|
+
* workspace repo can legitimately contain independent child repos (the prior
|
|
101
|
+
* depth-1 scan already surfaced depth-1 children); the user selects which to init.
|
|
102
|
+
*/
|
|
103
|
+
export function scanGitRepos(roots, maxDepth = 6) {
|
|
87
104
|
const repos = [];
|
|
88
105
|
const seen = new Set();
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
candidates.push(path.join(root, entry.name));
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
// skip unreadable dirs
|
|
101
|
-
}
|
|
102
|
-
for (const candidate of candidates) {
|
|
103
|
-
const repoPath = path.resolve(candidate);
|
|
104
|
-
if (seen.has(repoPath))
|
|
105
|
-
continue;
|
|
106
|
-
if (isBrainclawInternalPath(repoPath))
|
|
107
|
-
continue;
|
|
108
|
-
if (!fs.existsSync(path.join(repoPath, '.git')))
|
|
109
|
-
continue;
|
|
106
|
+
const walk = (dir, depth) => {
|
|
107
|
+
const repoPath = path.resolve(dir);
|
|
108
|
+
if (seen.has(repoPath))
|
|
109
|
+
return;
|
|
110
|
+
if (isBrainclawInternalPath(repoPath))
|
|
111
|
+
return;
|
|
112
|
+
if (fs.existsSync(path.join(repoPath, '.git'))) {
|
|
110
113
|
seen.add(repoPath);
|
|
111
114
|
repos.push({
|
|
112
115
|
path: repoPath,
|
|
113
116
|
name: path.basename(repoPath) || repoPath,
|
|
114
117
|
alreadyInitialised: memoryExists(repoPath),
|
|
115
118
|
});
|
|
119
|
+
// Keep descending — a workspace repo may contain independent child repos.
|
|
120
|
+
}
|
|
121
|
+
if (depth >= maxDepth)
|
|
122
|
+
return;
|
|
123
|
+
let entries;
|
|
124
|
+
try {
|
|
125
|
+
entries = fs.readdirSync(repoPath, { withFileTypes: true });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return; // unreadable dir — skip
|
|
116
129
|
}
|
|
130
|
+
for (const entry of entries) {
|
|
131
|
+
if (!entry.isDirectory())
|
|
132
|
+
continue;
|
|
133
|
+
if (SCAN_SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
|
|
134
|
+
continue;
|
|
135
|
+
walk(path.join(repoPath, entry.name), depth + 1);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
for (const root of roots) {
|
|
139
|
+
walk(root, 0);
|
|
117
140
|
}
|
|
118
141
|
return repos;
|
|
119
142
|
}
|
|
@@ -309,6 +332,39 @@ export async function initReposAndConfigureAgents(selectedRepos, selectedAgents,
|
|
|
309
332
|
}
|
|
310
333
|
return { initialisedRepos, configActions };
|
|
311
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Ensure a resolvable session identity exists (pln#596 / trp#917). setup installs
|
|
337
|
+
* session hooks that need an agent identity; a hook with no identity now degrades
|
|
338
|
+
* silently (exit 0, logs to ~/.brainclaw/hook.log) but won't inject context. If we
|
|
339
|
+
* detected the running agent, register it in each selected repo so the hook resolves
|
|
340
|
+
* immediately. Otherwise emit a clear, actionable note — we don't guess an agent (a
|
|
341
|
+
* wrong identity is worse than none, and the single-registered-agent fallback wants
|
|
342
|
+
* exactly one).
|
|
343
|
+
*/
|
|
344
|
+
export function ensureSessionIdentityForRepos(repoPaths, detectedName) {
|
|
345
|
+
if (detectedName) {
|
|
346
|
+
const normalized = normalizeAgentName(detectedName);
|
|
347
|
+
let registered = 0;
|
|
348
|
+
for (const repoPath of repoPaths) {
|
|
349
|
+
try {
|
|
350
|
+
if (!findAgentIdentityByName(normalized, repoPath)) {
|
|
351
|
+
registerAgentIdentity({ agentName: normalized, kind: 'agent', trustLevel: 'contributor', cwd: repoPath });
|
|
352
|
+
registered++;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
/* best-effort — identity registration must not abort setup */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (registered > 0) {
|
|
360
|
+
console.log(` ✔ Registered session identity '${normalized}' in ${registered} repo(s) so session hooks resolve immediately.`);
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
console.log('\n⚠ No agent identity registered yet — session hooks will no-op (exit 0) until one resolves.');
|
|
365
|
+
console.log(' Set `export BRAINCLAW_AGENT_NAME=<agent>` in your shell, or run `brainclaw register-agent <name>`.');
|
|
366
|
+
console.log(' (A single registered agent then resolves automatically; running inside a detected agent auto-registers on first session.)');
|
|
367
|
+
}
|
|
312
368
|
// ─── Step 7: Reload reminder ──────────────────────────────────────────────────
|
|
313
369
|
export function printReloadReminder(detectedAgent) {
|
|
314
370
|
console.log('');
|
|
@@ -540,6 +596,8 @@ export async function runSetup(options = {}) {
|
|
|
540
596
|
// Step 6: Init repos + configure agents
|
|
541
597
|
console.log('\n→ Initialising repositories and configuring agents...');
|
|
542
598
|
const { initialisedRepos, configActions } = await initReposAndConfigureAgents(selectedRepos, selectedAgents, env);
|
|
599
|
+
// Step 6.5: ensure a resolvable session identity for the hooks we just installed.
|
|
600
|
+
ensureSessionIdentityForRepos(selectedRepos.map((r) => r.path), detectedName);
|
|
543
601
|
// Step 7: VS Code extension
|
|
544
602
|
installVscodeExtension();
|
|
545
603
|
// Save state
|
package/dist/core/agent-files.js
CHANGED
|
@@ -1095,6 +1095,98 @@ function replaceBrainclawHooks(entries, canonical) {
|
|
|
1095
1095
|
kept.push(canonical);
|
|
1096
1096
|
return kept;
|
|
1097
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Canonical Claude Code session-hook commands.
|
|
1100
|
+
*
|
|
1101
|
+
* `--hook` (pln#596) makes session-start / context-diff / session-end degrade to
|
|
1102
|
+
* exit 0 + ~/.brainclaw/hook.log on failure instead of hard-erroring the prompt
|
|
1103
|
+
* loop (trp#917). `2>/dev/null` is kept to suppress incidental stderr noise —
|
|
1104
|
+
* the actionable diagnostic now goes to hook.log, not stderr, so it survives the
|
|
1105
|
+
* redirect (the bug was the non-zero EXIT, not the stream).
|
|
1106
|
+
*/
|
|
1107
|
+
function buildClaudeCodeHookCommands(bclawBin) {
|
|
1108
|
+
return {
|
|
1109
|
+
session: `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context --hook 2>/dev/null; else ${bclawBin} context-diff --hook 2>/dev/null; fi`,
|
|
1110
|
+
stop: `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review --hook 2>/dev/null`,
|
|
1111
|
+
checkEvents: `${bclawBin} check-events 2>/dev/null`,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Sanitize the brainclaw session hooks in ONE Claude Code settings file: collapse
|
|
1116
|
+
* every recognized brainclaw hook (across UserPromptSubmit / Stop / PostToolUse)
|
|
1117
|
+
* to a single canonical entry, repairing stale/broken forms (e.g. the legacy
|
|
1118
|
+
* `node session-start` with the cli.js arg dropped, or dead install paths).
|
|
1119
|
+
*
|
|
1120
|
+
* Only touches events that ALREADY contain a brainclaw hook — it never injects
|
|
1121
|
+
* hooks into a file (or event) that lacked them, so user-scope settings without
|
|
1122
|
+
* brainclaw hooks stay untouched. This is the cross-scope counterpart to
|
|
1123
|
+
* `ensureClaudeCodeSettings`, which only rewrites the cwd project file (pln#596 /
|
|
1124
|
+
* trp#918: setup's git-repo discovery never reaches the launch dir or user scope,
|
|
1125
|
+
* so broken hooks accumulate exactly where the agent executes them).
|
|
1126
|
+
*/
|
|
1127
|
+
export function fixClaudeCodeHooksInFile(filePath) {
|
|
1128
|
+
if (!fs.existsSync(filePath))
|
|
1129
|
+
return { filePath, existed: false, changed: false, collapsed: 0 };
|
|
1130
|
+
const existing = readJsonObject(filePath); // returns {} for missing, undefined for unparseable
|
|
1131
|
+
if (existing === undefined)
|
|
1132
|
+
return { filePath, existed: true, changed: false, collapsed: 0 };
|
|
1133
|
+
const hooksObj = isJsonObject(existing.hooks) ? existing.hooks : undefined;
|
|
1134
|
+
if (!hooksObj)
|
|
1135
|
+
return { filePath, existed: true, changed: false, collapsed: 0 };
|
|
1136
|
+
const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
|
|
1137
|
+
const cmds = buildClaudeCodeHookCommands(bclawBin);
|
|
1138
|
+
const countBrainclawHooks = (arr) => Array.isArray(arr)
|
|
1139
|
+
? arr.reduce((n, e) => n + (isJsonObject(e) && Array.isArray(e.hooks)
|
|
1140
|
+
? e.hooks.filter((h) => isJsonObject(h) && typeof h.command === 'string' && isBrainclawHookCommand(h.command)).length
|
|
1141
|
+
: 0), 0)
|
|
1142
|
+
: 0;
|
|
1143
|
+
const events = [
|
|
1144
|
+
['UserPromptSubmit', buildCommandHookEntry(cmds.session)],
|
|
1145
|
+
['Stop', buildCommandHookEntry(cmds.stop)],
|
|
1146
|
+
['PostToolUse', buildMatchedCommandHookEntry('mcp__brainclaw__', cmds.checkEvents)],
|
|
1147
|
+
];
|
|
1148
|
+
const nextHooks = { ...hooksObj };
|
|
1149
|
+
let collapsed = 0;
|
|
1150
|
+
let touched = false;
|
|
1151
|
+
for (const [event, canonical] of events) {
|
|
1152
|
+
const arr = Array.isArray(hooksObj[event]) ? hooksObj[event] : undefined;
|
|
1153
|
+
if (!arr)
|
|
1154
|
+
continue;
|
|
1155
|
+
const before = countBrainclawHooks(arr);
|
|
1156
|
+
if (before === 0)
|
|
1157
|
+
continue; // never add hooks to an event that had none
|
|
1158
|
+
nextHooks[event] = replaceBrainclawHooks(arr, canonical);
|
|
1159
|
+
collapsed += Math.max(0, before - 1); // N brainclaw entries → 1 canonical
|
|
1160
|
+
touched = true;
|
|
1161
|
+
}
|
|
1162
|
+
if (!touched)
|
|
1163
|
+
return { filePath, existed: true, changed: false, collapsed: 0 };
|
|
1164
|
+
const { updated } = writeJsonFileIfChanged(filePath, { ...existing, hooks: nextHooks });
|
|
1165
|
+
return { filePath, existed: true, changed: updated, collapsed };
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Run `fixClaudeCodeHooksInFile` across every Claude Code settings scope that can
|
|
1169
|
+
* carry brainclaw hooks: user-scope (`~/.claude/settings*.json`) and the cwd
|
|
1170
|
+
* project (`<cwd>/.claude/settings*.json`). Independent of git-repo discovery.
|
|
1171
|
+
*/
|
|
1172
|
+
export function fixClaudeCodeHooksAllScopes(cwd, homeDir) {
|
|
1173
|
+
const candidates = [
|
|
1174
|
+
path.join(homeDir, '.claude', 'settings.json'),
|
|
1175
|
+
path.join(homeDir, '.claude', 'settings.local.json'),
|
|
1176
|
+
path.join(cwd, '.claude', 'settings.json'),
|
|
1177
|
+
path.join(cwd, '.claude', 'settings.local.json'),
|
|
1178
|
+
];
|
|
1179
|
+
const seen = new Set();
|
|
1180
|
+
const results = [];
|
|
1181
|
+
for (const candidate of candidates) {
|
|
1182
|
+
const resolved = path.resolve(candidate);
|
|
1183
|
+
if (seen.has(resolved))
|
|
1184
|
+
continue;
|
|
1185
|
+
seen.add(resolved);
|
|
1186
|
+
results.push(fixClaudeCodeHooksInFile(resolved));
|
|
1187
|
+
}
|
|
1188
|
+
return results;
|
|
1189
|
+
}
|
|
1098
1190
|
export function ensureProjectDevDependency(cwd) {
|
|
1099
1191
|
const filePath = path.join(cwd, 'package.json');
|
|
1100
1192
|
if (!fs.existsSync(filePath))
|
|
@@ -1242,10 +1334,8 @@ export function ensureClaudeCodeSettings(cwd) {
|
|
|
1242
1334
|
// binary resolution succeeded (hidden by 2>/dev/null).
|
|
1243
1335
|
const hooks = isJsonObject(existing.hooks) ? { ...existing.hooks } : {};
|
|
1244
1336
|
const bclawBin = getBclawCliParts().map(quoteShellArg).join(' ');
|
|
1245
|
-
const sessionCommand = `f=.claude/.bclaw-session; if [ ! -f "$f" ]; then touch "$f"; ${bclawBin} session-start --include-context 2>/dev/null; else ${bclawBin} context-diff 2>/dev/null; fi`;
|
|
1246
|
-
const stopCommand = `rm -f .claude/.bclaw-session; ${bclawBin} session-end --auto-release --reflect --reflect-handoff --dispatch-review 2>/dev/null`;
|
|
1247
1337
|
// PostToolUse — check for unseen events after any brainclaw MCP tool call
|
|
1248
|
-
const checkEventsCommand =
|
|
1338
|
+
const { session: sessionCommand, stop: stopCommand, checkEvents: checkEventsCommand } = buildClaudeCodeHookCommands(bclawBin);
|
|
1249
1339
|
hooks.UserPromptSubmit = replaceBrainclawHooks(Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [], buildCommandHookEntry(sessionCommand));
|
|
1250
1340
|
hooks.Stop = replaceBrainclawHooks(Array.isArray(hooks.Stop) ? hooks.Stop : [], buildCommandHookEntry(stopCommand));
|
|
1251
1341
|
hooks.PostToolUse = replaceBrainclawHooks(Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [], buildMatchedCommandHookEntry('mcp__brainclaw__', checkEventsCommand));
|
|
@@ -314,6 +314,18 @@ export function resolveCurrentAgentIdentity(cwd, preferredDirName, _homeDir) {
|
|
|
314
314
|
// In multi-agent setups this always resolves to the wrong agent.
|
|
315
315
|
// The field remains in config for display (status, doctor) and for resolveExistingCurrentAgent
|
|
316
316
|
// which is used during setup/init only.
|
|
317
|
+
// Single-registered-agent fallback (pln#596). When there is NO identity signal
|
|
318
|
+
// at all — no env id/name AND no detected native agent — but exactly one agent
|
|
319
|
+
// is registered in this scope, resolving it is unambiguous. This is the
|
|
320
|
+
// solo-dev / single-CLI case (a fresh hook with no BRAINCLAW_AGENT_NAME). The
|
|
321
|
+
// pln#562 guard against config.current_agent only matters at ≥2 agents, so this
|
|
322
|
+
// preserves multi-agent safety: ≥2 registered → still falls through to undefined.
|
|
323
|
+
if (!envAgentId && !envAgentName && !detected) {
|
|
324
|
+
const registered = listAgentIdentities(cwd, preferredDirName);
|
|
325
|
+
if (registered.length === 1) {
|
|
326
|
+
return registered[0];
|
|
327
|
+
}
|
|
328
|
+
}
|
|
317
329
|
return undefined;
|
|
318
330
|
}
|
|
319
331
|
export function resolveRegisteredAgentIdentity(options = {}) {
|
|
@@ -416,7 +428,10 @@ export function requireRegisteredAgentIdentity(options = {}) {
|
|
|
416
428
|
throw new AgentIdentityResolutionError(`Environment agent '${normalizedEnv}' is not registered.`, { agent_name: normalizedEnv });
|
|
417
429
|
}
|
|
418
430
|
}
|
|
419
|
-
throw new AgentIdentityResolutionError('No registered agent identity resolved.
|
|
431
|
+
throw new AgentIdentityResolutionError('No registered agent identity resolved. Pass `--agent <name>` or `--agent-id <id>`, '
|
|
432
|
+
+ 'set $BRAINCLAW_AGENT_NAME, or register an agent with `brainclaw register-agent <name>` '
|
|
433
|
+
+ '— a single registered agent is then resolved automatically. '
|
|
434
|
+
+ '(`--set-current` alone does NOT affect resolution.)');
|
|
420
435
|
}
|
|
421
436
|
/**
|
|
422
437
|
* Resolve agent identity for session start, returning both the resolved identity and whether
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Append a one-line, timestamped diagnostic to ~/.brainclaw/hook.log.
|
|
6
|
+
*
|
|
7
|
+
* brainclaw session hooks (UserPromptSubmit / Stop) historically wrapped every
|
|
8
|
+
* CLI call in `2>/dev/null`, which turned an actionable failure (e.g. "no
|
|
9
|
+
* registered agent identity resolved") into a contentless "hook error: No
|
|
10
|
+
* stderr output" on every prompt (trp#917). Hook-mode commands now degrade to
|
|
11
|
+
* exit 0 and drop a line here instead, so the failure is silent to the agent's
|
|
12
|
+
* prompt loop but still debuggable.
|
|
13
|
+
*
|
|
14
|
+
* Best-effort and never throws — a logging failure must not break a hook.
|
|
15
|
+
*/
|
|
16
|
+
const MAX_LOG_BYTES = 256 * 1024;
|
|
17
|
+
export function hookLogPath(homeDir = os.homedir()) {
|
|
18
|
+
return path.join(homeDir, '.brainclaw', 'hook.log');
|
|
19
|
+
}
|
|
20
|
+
export function logHookDiagnostic(message, homeDir = os.homedir()) {
|
|
21
|
+
try {
|
|
22
|
+
const file = hookLogPath(homeDir);
|
|
23
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
24
|
+
// Size-cap: when the log grows past the cap, keep only the tail so it never
|
|
25
|
+
// balloons unbounded across thousands of prompts.
|
|
26
|
+
try {
|
|
27
|
+
const stat = fs.statSync(file);
|
|
28
|
+
if (stat.size > MAX_LOG_BYTES) {
|
|
29
|
+
const tail = fs.readFileSync(file, 'utf-8').slice(-Math.floor(MAX_LOG_BYTES / 2));
|
|
30
|
+
fs.writeFileSync(file, tail, 'utf-8');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* no existing file — nothing to truncate */
|
|
35
|
+
}
|
|
36
|
+
const line = `${new Date().toISOString()} ${message.replace(/\s+/g, ' ').trim()}\n`;
|
|
37
|
+
fs.appendFileSync(file, line, 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* best-effort — never break a hook over logging */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=hook-log.js.map
|
package/dist/facts.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Generated by scripts/emit-site-facts.mjs at build time. Do not edit manually.
|
|
2
|
-
// Source: brainclaw v1.
|
|
2
|
+
// Source: brainclaw v1.12.0 on 2026-06-27T13:47:04.254Z
|
|
3
3
|
export const FACTS = {
|
|
4
|
-
"version": "1.
|
|
5
|
-
"generated_at": "2026-06-
|
|
4
|
+
"version": "1.12.0",
|
|
5
|
+
"generated_at": "2026-06-27T13:47:04.254Z",
|
|
6
6
|
"tools": {
|
|
7
7
|
"count": 67,
|
|
8
8
|
"published_count": 66,
|
package/dist/facts.json
CHANGED
package/docs/cli.md
CHANGED
|
@@ -264,14 +264,18 @@ Run health checks on config, state, and generated views.
|
|
|
264
264
|
| `--json` | Output as JSON |
|
|
265
265
|
| `--migration-check` | Check for pending migrations |
|
|
266
266
|
| `--fix-agent-ignore` | Add missing `.gitignore` entries for generated local Brainclaw agent files |
|
|
267
|
+
| `--fix-hooks` | Purge stale/broken/duplicate brainclaw session hooks across all Claude Code settings scopes (user + cwd) and rewrite the canonical ones |
|
|
267
268
|
|
|
268
269
|
```bash
|
|
269
270
|
brainclaw doctor
|
|
270
271
|
brainclaw doctor --json
|
|
271
272
|
brainclaw doctor --migration-check
|
|
272
273
|
brainclaw doctor --fix-agent-ignore
|
|
274
|
+
brainclaw doctor --fix-hooks
|
|
273
275
|
```
|
|
274
276
|
|
|
277
|
+
`--fix-hooks` collapses every recognized brainclaw session hook (across `UserPromptSubmit` / `Stop` / `PostToolUse`) in `~/.claude/settings.json`, `~/.claude/settings.local.json`, `<cwd>/.claude/settings.json`, and `<cwd>/.claude/settings.local.json` down to a single canonical entry, repairing legacy/broken forms. It only touches files (and events) that already contain a brainclaw hook, so user-authored hooks and hook-less scopes are left untouched. Use it when you see repeated `UserPromptSubmit hook error` warnings.
|
|
278
|
+
|
|
275
279
|
When Brainclaw detects generated local agent files such as `.mcp.json` or `.claude/settings.local.json` inside a Git repo, `doctor` warns if they are not ignored or are still tracked. `--fix-agent-ignore` only updates `.gitignore`; if a file is already tracked you still need to untrack it with `git rm --cached <path>`.
|
|
276
280
|
|
|
277
281
|
In `multi-project` mode with `projects.strategy: folder`, `doctor` now checks the effective workspace project set, not just `config.projects.known`. That avoids false positives on workspaces that resolve child stores from the filesystem or global project registry.
|