brainclaw 0.29.2 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/README.md +193 -170
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli.js +673 -24
  4. package/dist/commands/accept.js +3 -0
  5. package/dist/commands/add-step.js +11 -26
  6. package/dist/commands/agent-board.js +70 -3
  7. package/dist/commands/audit.js +19 -0
  8. package/dist/commands/check-policy.js +54 -0
  9. package/dist/commands/check-security-mcp.js +145 -0
  10. package/dist/commands/check-security.js +106 -0
  11. package/dist/commands/claim-resource.js +1 -0
  12. package/dist/commands/codev.js +672 -0
  13. package/dist/commands/compact.js +74 -0
  14. package/dist/commands/complete-step.js +16 -26
  15. package/dist/commands/constraint.js +8 -20
  16. package/dist/commands/decision.js +9 -20
  17. package/dist/commands/delete-plan.js +10 -12
  18. package/dist/commands/delete-step.js +16 -0
  19. package/dist/commands/dispatch.js +163 -0
  20. package/dist/commands/doctor.js +1122 -49
  21. package/dist/commands/enable-agent.js +1 -0
  22. package/dist/commands/export.js +280 -22
  23. package/dist/commands/handoff.js +33 -0
  24. package/dist/commands/harvest.js +189 -0
  25. package/dist/commands/hooks.js +82 -25
  26. package/dist/commands/inbox.js +169 -0
  27. package/dist/commands/init.js +38 -31
  28. package/dist/commands/install-hooks.js +71 -44
  29. package/dist/commands/link.js +89 -0
  30. package/dist/commands/list-claims.js +48 -3
  31. package/dist/commands/list-plans.js +129 -25
  32. package/dist/commands/loops-handlers.js +409 -0
  33. package/dist/commands/mcp-read-handlers.js +1628 -0
  34. package/dist/commands/mcp-schemas.generated.js +74 -0
  35. package/dist/commands/mcp.js +4221 -1501
  36. package/dist/commands/plan-resource.js +64 -0
  37. package/dist/commands/plan.js +12 -26
  38. package/dist/commands/prune.js +37 -2
  39. package/dist/commands/reflect.js +20 -7
  40. package/dist/commands/release-claim.js +11 -6
  41. package/dist/commands/release-notes.js +170 -0
  42. package/dist/commands/repair.js +210 -0
  43. package/dist/commands/run-profile.js +57 -0
  44. package/dist/commands/sequence.js +113 -0
  45. package/dist/commands/session-end.js +423 -14
  46. package/dist/commands/session-start.js +214 -41
  47. package/dist/commands/setup-security.js +103 -0
  48. package/dist/commands/setup.js +42 -4
  49. package/dist/commands/stale.js +109 -0
  50. package/dist/commands/switch.js +100 -2
  51. package/dist/commands/trap.js +14 -31
  52. package/dist/commands/update-handoff.js +63 -4
  53. package/dist/commands/update-plan.js +21 -28
  54. package/dist/commands/update-step.js +37 -0
  55. package/dist/commands/upgrade.js +313 -6
  56. package/dist/commands/usage.js +102 -0
  57. package/dist/commands/version.js +20 -0
  58. package/dist/commands/who.js +33 -5
  59. package/dist/commands/worktree.js +105 -0
  60. package/dist/core/actions.js +315 -0
  61. package/dist/core/agent-capability.js +610 -17
  62. package/dist/core/agent-context.js +7 -1
  63. package/dist/core/agent-files.js +1169 -85
  64. package/dist/core/agent-integrations.js +160 -5
  65. package/dist/core/agent-inventory.js +2 -0
  66. package/dist/core/agent-profiles.js +93 -0
  67. package/dist/core/agent-registry.js +162 -30
  68. package/dist/core/agentrun-reconciler.js +345 -0
  69. package/dist/core/agentruns.js +424 -0
  70. package/dist/core/ai-agent-detection.js +31 -10
  71. package/dist/core/archival.js +77 -0
  72. package/dist/core/assignment-sweeper.js +82 -0
  73. package/dist/core/assignments.js +367 -0
  74. package/dist/core/audit.js +30 -0
  75. package/dist/core/brainclaw-version.js +94 -2
  76. package/dist/core/candidates.js +93 -2
  77. package/dist/core/claims.js +419 -0
  78. package/dist/core/codev-metrics.js +77 -0
  79. package/dist/core/codev-personas.js +31 -0
  80. package/dist/core/codev-plan-gen.js +35 -0
  81. package/dist/core/codev-prompts.js +74 -0
  82. package/dist/core/codev-responses.js +62 -0
  83. package/dist/core/codev-rounds.js +218 -0
  84. package/dist/core/config.js +4 -0
  85. package/dist/core/context.js +381 -34
  86. package/dist/core/coordination.js +201 -6
  87. package/dist/core/cross-project.js +230 -16
  88. package/dist/core/default-profiles/doctor.yaml +11 -0
  89. package/dist/core/default-profiles/janitor.yaml +11 -0
  90. package/dist/core/default-profiles/onboarder.yaml +11 -0
  91. package/dist/core/default-profiles/reviewer.yaml +13 -0
  92. package/dist/core/dispatcher.js +1189 -0
  93. package/dist/core/duplicates.js +2 -2
  94. package/dist/core/entity-operations.js +450 -0
  95. package/dist/core/entity-registry.js +344 -0
  96. package/dist/core/events.js +106 -2
  97. package/dist/core/execution-adapters.js +154 -0
  98. package/dist/core/execution-context.js +63 -0
  99. package/dist/core/execution-profile.js +270 -0
  100. package/dist/core/execution.js +255 -0
  101. package/dist/core/facade-schema.js +81 -0
  102. package/dist/core/federation-cloud.js +99 -0
  103. package/dist/core/federation-message.js +52 -0
  104. package/dist/core/federation-transport.js +65 -0
  105. package/dist/core/gc-semantic.js +482 -0
  106. package/dist/core/governance.js +247 -0
  107. package/dist/core/guards.js +19 -0
  108. package/dist/core/ideation.js +72 -0
  109. package/dist/core/identity.js +110 -25
  110. package/dist/core/ids.js +6 -0
  111. package/dist/core/input-validation.js +2 -2
  112. package/dist/core/instruction-templates.js +344 -136
  113. package/dist/core/io.js +90 -11
  114. package/dist/core/lock.js +6 -2
  115. package/dist/core/loops/brief-assembly.js +213 -0
  116. package/dist/core/loops/facade-schema.js +148 -0
  117. package/dist/core/loops/index.js +7 -0
  118. package/dist/core/loops/iteration-engine.js +139 -0
  119. package/dist/core/loops/lock.js +385 -0
  120. package/dist/core/loops/store.js +201 -0
  121. package/dist/core/loops/types.js +403 -0
  122. package/dist/core/loops/verbs.js +534 -0
  123. package/dist/core/markdown.js +15 -3
  124. package/dist/core/memory-compactor.js +432 -0
  125. package/dist/core/memory-git.js +152 -8
  126. package/dist/core/messaging.js +278 -0
  127. package/dist/core/migration.js +32 -1
  128. package/dist/core/mutation-pipeline.js +4 -2
  129. package/dist/core/operations/memory-mutation.js +129 -0
  130. package/dist/core/operations/memory-write.js +78 -0
  131. package/dist/core/operations/plan.js +190 -0
  132. package/dist/core/policy.js +169 -0
  133. package/dist/core/reputation.js +9 -3
  134. package/dist/core/schema.js +491 -6
  135. package/dist/core/search.js +21 -2
  136. package/dist/core/security-cache.js +71 -0
  137. package/dist/core/security-guard.js +152 -0
  138. package/dist/core/security-scoring.js +86 -0
  139. package/dist/core/sequence.js +130 -0
  140. package/dist/core/socket-client.js +113 -0
  141. package/dist/core/staleness.js +246 -0
  142. package/dist/core/state.js +98 -22
  143. package/dist/core/store-resolution.js +43 -11
  144. package/dist/core/toml-writer.js +76 -0
  145. package/dist/core/upgrades/backup.js +232 -0
  146. package/dist/core/upgrades/health-check.js +169 -0
  147. package/dist/core/upgrades/patches/candidate-archive.js +145 -0
  148. package/dist/core/upgrades/patches/handoff-review-strip.js +128 -0
  149. package/dist/core/upgrades/patches/provenance-rollout.js +136 -0
  150. package/dist/core/upgrades/schema-version.js +97 -0
  151. package/dist/core/worktree.js +606 -0
  152. package/dist/facts.js +114 -0
  153. package/dist/facts.json +111 -0
  154. package/docs/architecture/project-refs.md +5 -1
  155. package/docs/cli.md +690 -43
  156. package/docs/concepts/ideation-loop.md +317 -0
  157. package/docs/concepts/loop-engine.md +456 -0
  158. package/docs/concepts/mcp-governance.md +268 -0
  159. package/docs/concepts/memory-staleness.md +122 -0
  160. package/docs/concepts/multi-agent-workflows.md +166 -0
  161. package/docs/concepts/plans-and-claims.md +31 -6
  162. package/docs/concepts/project-md-convention.md +35 -0
  163. package/docs/concepts/troubleshooting.md +220 -0
  164. package/docs/concepts/upgrade-cli.md +202 -0
  165. package/docs/concepts/upgrade-dogfood-procedure.md +114 -0
  166. package/docs/context-format-changelog.md +2 -2
  167. package/docs/context-format.md +2 -2
  168. package/docs/index.md +68 -0
  169. package/docs/integrations/agents.md +15 -16
  170. package/docs/integrations/cline.md +88 -0
  171. package/docs/integrations/codex.md +75 -23
  172. package/docs/integrations/continue.md +60 -0
  173. package/docs/integrations/copilot.md +67 -9
  174. package/docs/integrations/kilocode.md +72 -0
  175. package/docs/integrations/mcp.md +304 -21
  176. package/docs/integrations/mistral-vibe.md +122 -0
  177. package/docs/integrations/opencode.md +84 -0
  178. package/docs/integrations/overview.md +23 -8
  179. package/docs/integrations/roo.md +74 -0
  180. package/docs/integrations/windsurf.md +83 -0
  181. package/docs/mcp-schema-changelog.md +191 -1
  182. package/docs/playbooks/integration/index.md +121 -0
  183. package/docs/playbooks/productivity/index.md +102 -0
  184. package/docs/playbooks/team/index.md +122 -0
  185. package/docs/product/agent-first-model.md +184 -0
  186. package/docs/product/entity-model-audit.md +462 -0
  187. package/docs/quickstart-existing-project.md +135 -0
  188. package/docs/quickstart.md +124 -37
  189. package/docs/release-maintenance.md +79 -0
  190. package/docs/review.md +2 -0
  191. package/docs/server-operations.md +118 -0
  192. package/package.json +20 -12
  193. package/dist/commands/claude-desktop-extension.js +0 -18
  194. package/dist/commands/diff.js +0 -99
  195. package/dist/core/claude-desktop-extension.js +0 -224
@@ -38,6 +38,8 @@ export function buildExecutionContext(options = {}) {
38
38
  branch,
39
39
  git_status: gitStatus,
40
40
  has_remote: hasRemote,
41
+ git_worktree: detectGitWorktree(cwd, runner),
42
+ commits_behind_main: branch ? detectCommitsBehindMain(cwd, branch, runner) : undefined,
41
43
  toolchains: detectToolchains(cwd, runner),
42
44
  env_signals: captureEnvSignals(env),
43
45
  };
@@ -50,6 +52,7 @@ export function compactExecutionContext(snapshot) {
50
52
  branch: snapshot.branch,
51
53
  git_status: snapshot.git_status,
52
54
  has_remote: snapshot.has_remote,
55
+ commits_behind_main: snapshot.commits_behind_main,
53
56
  toolchains: snapshot.toolchains.filter((tool) => tool.available),
54
57
  };
55
58
  }
@@ -64,7 +67,17 @@ export function renderExecutionContextSummary(snapshot, includeEnvSignals = fals
64
67
  lines.push(`Git branch: ${snapshot.branch}`);
65
68
  }
66
69
  lines.push(`Git status: ${snapshot.git_status}`);
70
+ if ('git_worktree' in snapshot && snapshot.git_worktree) {
71
+ const wt = snapshot.git_worktree;
72
+ lines.push(`Git worktree: ${wt.worktree_path}${wt.is_linked_worktree ? ' (linked)' : ' (main)'}`);
73
+ if (wt.is_linked_worktree) {
74
+ lines.push(`Main worktree: ${wt.main_worktree_path}`);
75
+ }
76
+ }
67
77
  lines.push(`Git remote: ${snapshot.has_remote ? 'configured' : 'none'}`);
78
+ if ('commits_behind_main' in snapshot && snapshot.commits_behind_main && snapshot.commits_behind_main > 0) {
79
+ lines.push(`⚠ Branch is ${snapshot.commits_behind_main} commit(s) behind master. Consider rebasing before editing.`);
80
+ }
68
81
  const availableToolchains = snapshot.toolchains.filter((tool) => tool.available);
69
82
  if (availableToolchains.length > 0) {
70
83
  lines.push(`Toolchains: ${availableToolchains.map((tool) => `${tool.name}${tool.version ? ` ${tool.version}` : ''}`).join(', ')}`);
@@ -95,6 +108,33 @@ function detectGitBranch(cwd, runner) {
95
108
  const branch = result.stdout.trim();
96
109
  return branch && branch !== 'HEAD' ? branch : undefined;
97
110
  }
111
+ function detectGitWorktree(cwd, runner) {
112
+ const gitDir = runner('git', ['rev-parse', '--git-dir'], cwd);
113
+ const toplevel = runner('git', ['rev-parse', '--show-toplevel'], cwd);
114
+ if (gitDir.status !== 0 || toplevel.status !== 0)
115
+ return undefined;
116
+ const gitDirPath = path.resolve(cwd, gitDir.stdout.trim());
117
+ const worktreePath = toplevel.stdout.trim();
118
+ // Main worktree: resolve via git-common-dir (points to the shared .git for linked worktrees)
119
+ const commonDir = runner('git', ['rev-parse', '--git-common-dir'], cwd);
120
+ let mainWorktreePath = worktreePath;
121
+ let isLinked = false;
122
+ if (commonDir.status === 0) {
123
+ const commonDirPath = path.resolve(cwd, commonDir.stdout.trim());
124
+ // For linked worktrees, git-common-dir !== git-dir
125
+ if (path.normalize(commonDirPath) !== path.normalize(gitDirPath)) {
126
+ isLinked = true;
127
+ // The main worktree is the parent of the common .git directory
128
+ mainWorktreePath = path.dirname(commonDirPath);
129
+ }
130
+ }
131
+ return {
132
+ git_dir: gitDirPath,
133
+ worktree_path: worktreePath,
134
+ main_worktree_path: mainWorktreePath,
135
+ is_linked_worktree: isLinked,
136
+ };
137
+ }
98
138
  function detectGitStatus(cwd, runner) {
99
139
  const result = runner('git', ['status', '--porcelain'], cwd);
100
140
  if (result.status !== 0) {
@@ -109,6 +149,29 @@ function detectGitRemote(cwd, runner) {
109
149
  }
110
150
  return result.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).length > 0;
111
151
  }
152
+ /**
153
+ * Detect how many commits the current branch is behind the main branch.
154
+ * Tries master then main as the reference branch.
155
+ * Returns undefined if not in a git repo or on the main branch itself.
156
+ */
157
+ function detectCommitsBehindMain(cwd, currentBranch, runner) {
158
+ // Don't check if already on main branch
159
+ if (currentBranch === 'master' || currentBranch === 'main')
160
+ return undefined;
161
+ // Try both master and main, return the highest count found.
162
+ // This handles repos where both branches exist but only one is the real reference.
163
+ let maxBehind;
164
+ for (const mainBranch of ['master', 'main']) {
165
+ const result = runner('git', ['rev-list', '--count', `${currentBranch}..${mainBranch}`], cwd);
166
+ if (result.status === 0) {
167
+ const count = parseInt(result.stdout.trim(), 10);
168
+ if (!isNaN(count) && (maxBehind === undefined || count > maxBehind)) {
169
+ maxBehind = count;
170
+ }
171
+ }
172
+ }
173
+ return maxBehind;
174
+ }
112
175
  function detectToolchains(cwd, runner) {
113
176
  if (runner === defaultRunner && cachedToolchains) {
114
177
  return cachedToolchains;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Agent execution profile (pln#496 step stp_0339a439).
3
+ *
4
+ * The dispatch system needs to know how to spawn each agent on each host:
5
+ * - which shell to invoke (`set X=...` for cmd, `$env:X=...` for pwsh,
6
+ * `X=...` for bash/zsh)
7
+ * - which OS we are on (path quoting, sandbox semantics)
8
+ * - where Node lives if we have to invoke the CLI binary directly
9
+ * - which spawn mechanism (fresh CLI, long-running app-server, IDE
10
+ * extension IPC) is right for this agent
11
+ * - how to set the working directory (just spawn from cwd, cd into a
12
+ * worktree first, or rely solely on env vars)
13
+ * - which sandbox profile to ask the agent for (codex needs
14
+ * `workspace-write` on local-only commits per
15
+ * trap_review_sandbox_blocks_source_access)
16
+ *
17
+ * Today the dispatcher hardcodes most of this in OS-specific branches
18
+ * (`set X=...` strings inside a Windows-only branch, etc.). The agent
19
+ * registry has the right level of granularity but no place to record
20
+ * execution-relevant facts. This module fills that gap.
21
+ *
22
+ * Design choices:
23
+ *
24
+ * 1. **Host vs per-agent fields.** The host detection is one value
25
+ * (this machine is Windows + pwsh + nodejs). Each agent inherits the
26
+ * host context and may override individual fields (e.g. an agent that
27
+ * needs bash even on Windows would override `shell`). Lookup uses
28
+ * `getExecutionProfile(agentName, inventory)` which merges host +
29
+ * agent override and supplies safe defaults so the dispatcher never
30
+ * receives undefined for required fields.
31
+ *
32
+ * 2. **Backward-compatible.** Both the host record and the per-agent
33
+ * override are optional. Inventories written before this module
34
+ * deserialize cleanly; the dispatcher applies defaults via
35
+ * `getExecutionProfile`. The OS-aware spawn step (stp_a9afe59d)
36
+ * consumes these fields in a follow-up commit.
37
+ *
38
+ * 3. **No assumption about sandbox semantics.** `sandbox_profile` is a
39
+ * hint to the agent invocation template; not every agent honours it,
40
+ * and brainclaw does not enforce sandbox at the OS level. The default
41
+ * is 'none' so legacy invocations stay literally identical.
42
+ *
43
+ * @module
44
+ */
45
+ import { spawnSync } from 'node:child_process';
46
+ // ── Types ──────────────────────────────────────────────────────────────────
47
+ /** Shells brainclaw knows how to emit env-set syntax for. */
48
+ export const SHELLS = ['bash', 'pwsh', 'cmd', 'sh', 'zsh'];
49
+ /** Coarse OS family. Matches process.platform's coverage of brainclaw's tier-A platforms. */
50
+ export const OPERATING_SYSTEMS = ['win', 'mac', 'linux'];
51
+ /** Spawn mechanism the dispatcher should use for this agent. */
52
+ export const SPAWN_METHODS = ['cli', 'app-server', 'extension-ipc', 'cli_spawn_legacy'];
53
+ /** Working-directory strategy at spawn time. */
54
+ export const WORKING_DIR_STRATEGIES = ['cwd', 'worktree-cd', 'env-only'];
55
+ /** Sandbox hint passed to the agent invocation template. */
56
+ export const SANDBOX_PROFILES = ['workspace-write', 'read-only', 'none'];
57
+ // ── Detection ──────────────────────────────────────────────────────────────
58
+ /**
59
+ * Map process.platform onto our coarse OS enum. Anything outside the
60
+ * tier-A trio resolves to 'linux' as the most permissive default — it
61
+ * does not lie about what the host is, but it lets the dispatcher pick
62
+ * a working set of shell-syntax decisions instead of refusing to spawn.
63
+ */
64
+ function detectOs(platform = process.platform) {
65
+ if (platform === 'win32')
66
+ return 'win';
67
+ if (platform === 'darwin')
68
+ return 'mac';
69
+ return 'linux';
70
+ }
71
+ /**
72
+ * Sniff the host shell from environment variables. POSIX uses $SHELL;
73
+ * Windows has neither $SHELL nor a single canonical answer — we look at
74
+ * COMSPEC (cmd) and PSModulePath (pwsh) and prefer pwsh when both are
75
+ * set since modern Windows agent integrations rely on it (codex CLI
76
+ * resolves through pwsh.exe on Windows per pln#475).
77
+ */
78
+ function detectShell(env = process.env, platform = process.platform) {
79
+ // Windows first because it lacks $SHELL and uses different env vars.
80
+ if (platform === 'win32') {
81
+ if (env.PSModulePath)
82
+ return 'pwsh';
83
+ if (env.COMSPEC)
84
+ return 'cmd';
85
+ // Fallback: assume pwsh on modern Windows. Empirically more agents
86
+ // tolerate pwsh than cmd, and this matches the brainclaw dispatch
87
+ // template that ships today.
88
+ return 'pwsh';
89
+ }
90
+ const shellEnv = env.SHELL;
91
+ if (shellEnv) {
92
+ const lower = shellEnv.toLowerCase();
93
+ if (lower.endsWith('/zsh') || lower.endsWith('\\zsh'))
94
+ return 'zsh';
95
+ if (lower.endsWith('/bash') || lower.endsWith('\\bash'))
96
+ return 'bash';
97
+ if (lower.endsWith('/sh') || lower.endsWith('\\sh'))
98
+ return 'sh';
99
+ // Other shells (fish, csh, …) — bash is the safest fallback for
100
+ // brainclaw's `X=value cmd` env-set pattern.
101
+ return 'bash';
102
+ }
103
+ // POSIX without $SHELL — extremely rare but bash is the canonical default.
104
+ return 'bash';
105
+ }
106
+ /**
107
+ * Best-effort path to the running Node binary.
108
+ *
109
+ * `process.execPath` is the canonical answer for the current process —
110
+ * it always points to the Node that's running brainclaw, which is the
111
+ * Node any spawned agent should also see if PATH is misconfigured.
112
+ *
113
+ * On Windows this is a `.exe`; on POSIX it's a binary. Either way, the
114
+ * dispatcher can fall back to it when `node` is not on PATH.
115
+ */
116
+ function detectNodePath() {
117
+ try {
118
+ return process.execPath || undefined;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ export function detectHostExecutionProfile(options = {}) {
125
+ const env = options.env ?? process.env;
126
+ const platform = options.platform ?? process.platform;
127
+ return {
128
+ shell: detectShell(env, platform),
129
+ os: detectOs(platform),
130
+ node_path: detectNodePath(),
131
+ spawn_method: 'cli',
132
+ working_dir_strategy: 'cwd',
133
+ sandbox_profile: 'none',
134
+ };
135
+ }
136
+ // ── Resolution ─────────────────────────────────────────────────────────────
137
+ /**
138
+ * Hard-coded defaults that apply when neither the host record nor the
139
+ * per-agent override specify a field. Kept centralised here so the
140
+ * dispatcher never has to encode them.
141
+ */
142
+ const DEFAULT_PROFILE = {
143
+ shell: 'bash',
144
+ os: 'linux',
145
+ node_path: undefined,
146
+ spawn_method: 'cli',
147
+ working_dir_strategy: 'cwd',
148
+ sandbox_profile: 'none',
149
+ };
150
+ /**
151
+ * Merge host + per-agent override + module defaults. The agent override
152
+ * takes priority over the host, the host takes priority over module
153
+ * defaults. Missing fields cascade silently — no exception is ever
154
+ * thrown for a partially populated input, which matters because
155
+ * inventories saved before this module gained the new fields must still
156
+ * resolve cleanly.
157
+ */
158
+ export function resolveExecutionProfile(host, agentOverride) {
159
+ return {
160
+ shell: agentOverride?.shell ?? host?.shell ?? DEFAULT_PROFILE.shell,
161
+ os: agentOverride?.os ?? host?.os ?? DEFAULT_PROFILE.os,
162
+ node_path: agentOverride?.node_path ?? host?.node_path ?? DEFAULT_PROFILE.node_path,
163
+ spawn_method: agentOverride?.spawn_method ?? host?.spawn_method ?? DEFAULT_PROFILE.spawn_method,
164
+ working_dir_strategy: agentOverride?.working_dir_strategy ?? host?.working_dir_strategy ?? DEFAULT_PROFILE.working_dir_strategy,
165
+ sandbox_profile: agentOverride?.sandbox_profile ?? host?.sandbox_profile ?? DEFAULT_PROFILE.sandbox_profile,
166
+ };
167
+ }
168
+ // ── Env-set rendering ──────────────────────────────────────────────────────
169
+ /**
170
+ * Render a single `KEY=VALUE` environment assignment in the syntax of
171
+ * the named shell. Used by the OS-aware spawn step (stp_a9afe59d) to
172
+ * generate spawn commands the host shell will actually parse.
173
+ *
174
+ * bash/zsh/sh → KEY="VALUE"
175
+ * pwsh → $env:KEY="VALUE"
176
+ * cmd → set KEY=VALUE
177
+ *
178
+ * Values are double-quoted in shells that support it; cmd uses the bare
179
+ * `set NAME=VALUE` form because cmd's quoting rules are pathological for
180
+ * embedded equals/quotes. Callers who need cmd-safe values must escape
181
+ * upstream — this helper is intentionally thin.
182
+ */
183
+ export function renderEnvSet(shell, key, value) {
184
+ switch (shell) {
185
+ case 'pwsh': return `$env:${key}="${value}"`;
186
+ case 'cmd': return `set ${key}=${value}`;
187
+ case 'bash':
188
+ case 'zsh':
189
+ case 'sh':
190
+ default: return `${key}="${value}"`;
191
+ }
192
+ }
193
+ // ── Spawn prefix (used by dispatcher + execution adapters) ─────────────────
194
+ /**
195
+ * Build a shell-correct prefix for an inline env-var set followed by a
196
+ * command. Centralises what dispatcher.ts:buildEnvPrefix,
197
+ * execution-adapters.ts:buildManualEnvPrefix, and the inline branch in
198
+ * mcp.ts spawn dispatch were duplicating before pln#496 step
199
+ * stp_a9afe59d.
200
+ *
201
+ * Output by shell (legacy bytes preserved for bash and cmd — see codex
202
+ * review findings on commit 87a9f73, asgn_02c3c742):
203
+ * bash / zsh / sh : `BRAINCLAW_CLAIM_ID=clm_xxx ` (unquoted, legacy)
204
+ * pwsh : `$env:BRAINCLAW_CLAIM_ID="clm_xxx"; `
205
+ * cmd : `set BRAINCLAW_CLAIM_ID=clm_xxx && `
206
+ *
207
+ * The unquoted bash form matches the pre-pln#496 dispatcher.ts:buildEnvPrefix
208
+ * output exactly, so any caller string-matching the legacy bytes keeps
209
+ * working. `renderEnvSet` still emits the quoted form for general use.
210
+ *
211
+ * Returns an empty string when claimId is empty or the dry-run sentinel —
212
+ * callers use the prefix as `${prefix}<command>` so concatenation stays
213
+ * safe.
214
+ *
215
+ * Defaults: when `shell` is omitted:
216
+ * - On Windows hosts → `cmd`. The historical brainclaw behaviour
217
+ * unconditionally used `set X=Y && ` on Windows regardless of whether
218
+ * pwsh was available. Picking pwsh based on PSModulePath sniffing
219
+ * would be a regression for hosts whose dispatch pipeline runs
220
+ * through child_process.spawn(shell:true) — Windows resolves that
221
+ * to cmd, not pwsh, so a pwsh prefix would not parse. Pwsh becomes
222
+ * opt-in via explicit `{ shell: 'pwsh' }` (used by Phase 3 app-server
223
+ * integrations that already speak pwsh natively).
224
+ * - On POSIX hosts → host detection (bash / zsh / sh / default bash).
225
+ */
226
+ export function buildClaimEnvPrefix(claimId, options) {
227
+ if (!claimId || claimId === '(dry-run)')
228
+ return '';
229
+ let shell;
230
+ if (options?.shell) {
231
+ shell = options.shell;
232
+ }
233
+ else {
234
+ // Pre-pln#496 default: Windows always emits cmd syntax. The smart
235
+ // detector exists for explicit callers (Phase 3 app-server) that
236
+ // know they want pwsh. See codex review on commit 87a9f73 — the
237
+ // PSModulePath sniff was a regression when used as the host
238
+ // default for the legacy spawn pipeline.
239
+ shell = process.platform === 'win32' ? 'cmd' : detectHostExecutionProfile().shell;
240
+ }
241
+ switch (shell) {
242
+ case 'cmd': return `set BRAINCLAW_CLAIM_ID=${claimId} && `;
243
+ case 'pwsh': return `$env:BRAINCLAW_CLAIM_ID="${claimId}"; `;
244
+ case 'bash':
245
+ case 'zsh':
246
+ case 'sh':
247
+ default: return `BRAINCLAW_CLAIM_ID=${claimId} `;
248
+ }
249
+ }
250
+ // ── Verification helper (used by setup / doctor) ───────────────────────────
251
+ /**
252
+ * Try `node --version` from the resolved profile's `node_path` to confirm
253
+ * the binary actually executes. Returns the version string when it does,
254
+ * undefined otherwise. Cheap enough to call from setup; not called from
255
+ * the dispatch hot path.
256
+ */
257
+ export function verifyNodeBinary(nodePath, timeoutMs = 5000) {
258
+ if (!nodePath)
259
+ return undefined;
260
+ try {
261
+ const result = spawnSync(nodePath, ['--version'], { encoding: 'utf-8', timeout: timeoutMs, windowsHide: true });
262
+ if (result.status !== 0)
263
+ return undefined;
264
+ return (result.stdout ?? '').trim() || undefined;
265
+ }
266
+ catch {
267
+ return undefined;
268
+ }
269
+ }
270
+ //# sourceMappingURL=execution-profile.js.map
@@ -0,0 +1,255 @@
1
+ /**
2
+ * E2E dispatch execution — spawn agent processes after claim+inbox delivery.
3
+ *
4
+ * This module bridges the gap between dispatch (claim + inbox + brief) and
5
+ * actual agent execution. It detects whether the coordinator can spawn CLI
6
+ * processes and, if so, launches the agent in a detached subprocess.
7
+ *
8
+ * @module
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { getCapabilityProfile } from './agent-capability.js';
13
+ import { appendAuditEntry } from './audit.js';
14
+ import { loadAllSessions } from './identity.js';
15
+ import { loadConfig } from './config.js';
16
+ import { loadAssignment } from './assignments.js';
17
+ import { defaultExecutionAdapter, } from './execution-adapters.js';
18
+ function sleep(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+ /**
22
+ * Compute the brief-ack sentinel file path for an assignment.
23
+ *
24
+ * pln#476 — half-2 of trp#59. The dispatcher prefixes the spawn command
25
+ * with a shell `touch`/`type nul >` step that creates this file BEFORE
26
+ * the agent binary executes. Its existence proves the spawn actually
27
+ * started doing work — independent of whether the agent has the
28
+ * brainclaw MCP wired (codex spawned without MCP cannot call
29
+ * bclaw_assignment_update; the ack file lets us recognize a healthy
30
+ * spawn anyway).
31
+ */
32
+ export function getAssignmentAckPath(cwd, assignmentId) {
33
+ return path.join(cwd, '.brainclaw', 'coordination', 'runtime', 'ack', `${assignmentId}.ack`);
34
+ }
35
+ function isAssignmentAcked(assignmentId, cwd) {
36
+ // Fast path: the brief-ack sentinel was written by the worker shell.
37
+ if (fs.existsSync(getAssignmentAckPath(cwd, assignmentId)))
38
+ return true;
39
+ // Standard path: the worker called bclaw_assignment_update via MCP and
40
+ // moved the assignment past the offered/created state.
41
+ const assignment = loadAssignment(assignmentId, cwd);
42
+ return !!assignment && assignment.status !== 'created' && assignment.status !== 'offered';
43
+ }
44
+ async function waitForAssignmentHandshake(assignmentId, cwd, timeoutMs) {
45
+ const deadline = Date.now() + timeoutMs;
46
+ while (Date.now() < deadline) {
47
+ if (isAssignmentAcked(assignmentId, cwd))
48
+ return true;
49
+ await sleep(100);
50
+ }
51
+ return isAssignmentAcked(assignmentId, cwd);
52
+ }
53
+ // ── Helpers ────────────────────────────────────────────────
54
+ /** Parse a duration string like '4h', '30m', '1d' to milliseconds. */
55
+ function parseDurationMs(value) {
56
+ const match = /^(\d+)([mhd])$/i.exec(value.trim());
57
+ if (!match)
58
+ return 4 * 3_600_000; // default 4h
59
+ const amount = parseInt(match[1], 10);
60
+ const unit = match[2].toLowerCase();
61
+ if (unit === 'm')
62
+ return amount * 60_000;
63
+ if (unit === 'h')
64
+ return amount * 3_600_000;
65
+ return amount * 86_400_000;
66
+ }
67
+ export function checkActiveInstance(agentName, cwd) {
68
+ const sessions = loadAllSessions(cwd);
69
+ let ttlStr = '4h';
70
+ try {
71
+ ttlStr = loadConfig(cwd).implicit_session_ttl ?? '4h';
72
+ }
73
+ catch { /* use default */ }
74
+ const SESSION_STALE_MS = parseDurationMs(ttlStr);
75
+ const now = Date.now();
76
+ const activeSessions = [];
77
+ for (const session of sessions) {
78
+ if (session.agent !== agentName)
79
+ continue;
80
+ const lastSeen = new Date(session.last_seen_at).getTime();
81
+ if (isNaN(lastSeen))
82
+ continue;
83
+ if (now - lastSeen < SESSION_STALE_MS) {
84
+ activeSessions.push(session.session_id);
85
+ }
86
+ }
87
+ const profile = getCapabilityProfile(agentName);
88
+ const maxAllowed = profile?.max_concurrent_tasks ?? 1;
89
+ const activeCount = activeSessions.length;
90
+ const canSpawnMore = activeCount < maxAllowed;
91
+ return {
92
+ active: !canSpawnMore, // backward compat: active=true means "cannot spawn more"
93
+ canSpawnMore,
94
+ activeCount,
95
+ maxAllowed,
96
+ reason: canSpawnMore
97
+ ? `Agent ${agentName} has capacity (${activeCount}/${maxAllowed} slots used)`
98
+ : `Agent ${agentName} at capacity (${activeCount}/${maxAllowed} slots used)`,
99
+ activeSessions,
100
+ };
101
+ }
102
+ // ── Spawn detection ─────────────────────────────────────────
103
+ /**
104
+ * Check if the coordinator can spawn an agent as a CLI subprocess.
105
+ *
106
+ * Returns canSpawn=true when:
107
+ * 1. The target agent has runtime.canBeSpawnedCli = true in its profile
108
+ * 2. The current process is NOT running inside an MCP stdio sandbox
109
+ * (heuristic: stdin is a TTY, or BRAINCLAW_CAN_SPAWN env is set)
110
+ */
111
+ export function canSpawnAgent(agentName) {
112
+ return defaultExecutionAdapter.canSpawn(agentName);
113
+ }
114
+ // ── Process spawning ────────────────────────────────────────
115
+ /**
116
+ * Spawn an agent CLI process in a detached, fire-and-forget mode.
117
+ *
118
+ * The spawned process is fully detached from the parent — it won't block
119
+ * the coordinator and survives parent exit. The PID is returned for
120
+ * tracking purposes.
121
+ */
122
+ export function executeDispatchedCommand(invoke, options) {
123
+ return defaultExecutionAdapter.start(invoke, options);
124
+ }
125
+ // ── Execution orchestrator ──────────────────────────────────
126
+ /**
127
+ * Attempt E2E execution after dispatch delivery.
128
+ *
129
+ * This is the main entry point called by bclaw_coordinate and bclaw_dispatch
130
+ * after claim+inbox+brief delivery is complete.
131
+ *
132
+ * - If autoExecute=true and agent is spawnable: spawn and return delivered_and_started
133
+ * - If autoExecute=false or not spawnable: return command_ready_manual with command string
134
+ * - If spawn fails: log warning, fallback to command_ready_manual
135
+ */
136
+ export async function attemptExecution(invoke, options) {
137
+ const adapter = options.adapter ?? defaultExecutionAdapter;
138
+ // No invoke command available (IDE-only agents, etc.)
139
+ if (!invoke) {
140
+ return { execution_status: 'inbox_only' };
141
+ }
142
+ const spawnCheck = adapter.canSpawn(options.agent);
143
+ // Opt-out or can't spawn: return command for manual execution
144
+ // Prepend BRAINCLAW_CLAIM_ID so manual copy-paste still routes correctly
145
+ if (!options.autoExecute || !spawnCheck.canSpawn) {
146
+ const manual = adapter.prepareManualCommand(invoke, options);
147
+ return {
148
+ execution_status: 'command_ready_manual',
149
+ command: manual.command,
150
+ shell: manual.shell,
151
+ };
152
+ }
153
+ // Capacity guard: skip if agent is at max concurrent tasks
154
+ if (options.cwd) {
155
+ const instanceCheck = checkActiveInstance(options.agent, options.cwd);
156
+ if (!instanceCheck.canSpawnMore) {
157
+ appendAuditEntry({
158
+ actor: options.dispatcherAgent,
159
+ actor_id: options.dispatcherAgentId,
160
+ action: 'spawn_failed',
161
+ item_id: options.claimId,
162
+ item_type: 'claim',
163
+ scope: options.agent,
164
+ after: { reason: instanceCheck.reason, active_sessions: instanceCheck.activeSessions, skipped: true },
165
+ }, options.cwd);
166
+ const manual = adapter.prepareManualCommand(invoke, options);
167
+ return {
168
+ execution_status: 'command_ready_manual',
169
+ command: manual.command,
170
+ shell: manual.shell,
171
+ error: `Spawn skipped: ${instanceCheck.reason}. Use the command manually.`,
172
+ failure_kind: 'spawn_capacity',
173
+ };
174
+ }
175
+ }
176
+ // Attempt spawn (await handles both sync and async adapters)
177
+ try {
178
+ // pln#476: pass ackRoot=options.cwd so the spawn wrap writes the
179
+ // brief-ack sentinel under the project's coordination dir (not the
180
+ // worktree's local store), where waitForAssignmentHandshake reads.
181
+ const result = await adapter.start(invoke, { ...options, ackRoot: options.cwd });
182
+ if (options.assignmentId && options.cwd) {
183
+ // pln#475: TTL bumped from 5000 → 30000ms. Real workers (claude-code,
184
+ // codex) take 8–15s to load the runtime + open the inbox + call
185
+ // bclaw_assignment_update(accepted). 5s caused legitimate spawns to be
186
+ // marked failed (trp#59 — observed during P1 dispatch on 2026-04-25).
187
+ // Override with BRAINCLAW_HANDSHAKE_TIMEOUT_MS for very fast / very slow
188
+ // environments. options.handshakeTimeoutMs (programmatic) wins over env.
189
+ const envTimeout = process.env.BRAINCLAW_HANDSHAKE_TIMEOUT_MS;
190
+ const parsedEnvTimeout = envTimeout ? Number.parseInt(envTimeout, 10) : NaN;
191
+ const handshakeTimeoutMs = options.handshakeTimeoutMs ??
192
+ (Number.isFinite(parsedEnvTimeout) && parsedEnvTimeout > 0 ? parsedEnvTimeout : 30_000);
193
+ const handshakeOk = await waitForAssignmentHandshake(options.assignmentId, options.cwd, handshakeTimeoutMs);
194
+ if (!handshakeOk) {
195
+ appendAuditEntry({
196
+ actor: options.dispatcherAgent,
197
+ actor_id: options.dispatcherAgentId,
198
+ action: 'spawn_failed',
199
+ item_id: options.claimId,
200
+ item_type: 'claim',
201
+ scope: options.agent,
202
+ after: { reason: `No assignment handshake within ${handshakeTimeoutMs}ms`, pid: result.pid, command: invoke.bashCommand },
203
+ }, options.cwd);
204
+ const manual = adapter.prepareManualCommand(invoke, options);
205
+ return {
206
+ execution_status: 'command_ready_manual',
207
+ command: manual.command,
208
+ shell: manual.shell,
209
+ error: `Spawn launched (pid ${result.pid}) but assignment ${options.assignmentId} did not acknowledge within ${handshakeTimeoutMs}ms`,
210
+ failure_kind: 'spawn_no_handshake',
211
+ pid: result.pid,
212
+ };
213
+ }
214
+ }
215
+ // Audit success
216
+ appendAuditEntry({
217
+ actor: options.dispatcherAgent,
218
+ actor_id: options.dispatcherAgentId,
219
+ action: 'agent_spawned',
220
+ item_id: options.claimId,
221
+ item_type: 'claim',
222
+ scope: options.agent,
223
+ after: { pid: result.pid, command: invoke.bashCommand, worktree_path: options.worktreePath },
224
+ }, options.cwd);
225
+ return {
226
+ execution_status: 'delivered_and_started',
227
+ pid: result.pid,
228
+ command: invoke.bashCommand,
229
+ started_at: result.started_at,
230
+ };
231
+ }
232
+ catch (err) {
233
+ const errorMsg = err instanceof Error ? err.message : String(err);
234
+ // Audit failure
235
+ appendAuditEntry({
236
+ actor: options.dispatcherAgent,
237
+ actor_id: options.dispatcherAgentId,
238
+ action: 'spawn_failed',
239
+ item_id: options.claimId,
240
+ item_type: 'claim',
241
+ scope: options.agent,
242
+ after: { error: errorMsg, command: invoke.bashCommand },
243
+ }, options.cwd);
244
+ // Graceful fallback — include BRAINCLAW_CLAIM_ID for manual routing
245
+ const manual = adapter.prepareManualCommand(invoke, options);
246
+ return {
247
+ execution_status: 'command_ready_manual',
248
+ command: manual.command,
249
+ shell: manual.shell,
250
+ error: `Spawn failed (${errorMsg}), falling back to manual execution`,
251
+ failure_kind: 'spawn_failed',
252
+ };
253
+ }
254
+ }
255
+ //# sourceMappingURL=execution.js.map