brainclaw 1.12.0 → 1.13.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.
@@ -31,13 +31,14 @@ import { rejectCandidate } from './reject.js';
31
31
  import { startSession } from './session-start.js';
32
32
  import { endSession } from './session-end.js';
33
33
  import { applyHandoffUpdates } from './update-handoff.js';
34
- import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, hasMinimumTrustLevel, normalizeAgentName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, } from '../core/agent-registry.js';
34
+ import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, hasMinimumTrustLevel, normalizeAgentName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, resolveOrAutoRegisterAgentIdentity, } from '../core/agent-registry.js';
35
35
  import { appendAuditEntry } from '../core/audit.js';
36
36
  import { nowISO, generateId } from '../core/ids.js';
37
- import { buildOperationalIdentity, loadAllSessions, loadSessionById } from '../core/identity.js';
37
+ import { buildOperationalIdentity, loadAllSessions, loadCurrentSession, loadSessionById, saveCurrentSession } from '../core/identity.js';
38
38
  import { validateMcpInput, validateMcpField } from '../core/input-validation.js';
39
39
  import { createCapability, createTool as createRegistryTool } from '../core/registries.js';
40
40
  import { detectAiAgent } from '../core/ai-agent-detection.js';
41
+ import { isObserverMode } from '../core/observer-mode.js';
41
42
  import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, getDetectedSetupAgentNames, getInstalledAgentNames, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
42
43
  import { buildAgentInventory } from '../core/agent-inventory.js';
43
44
  import { findOutermostBrainclawRoot, resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
@@ -656,13 +657,17 @@ const MCP_WRITE_TOOLS = [
656
657
  },
657
658
  {
658
659
  name: 'bclaw_release_claim',
659
- description: 'Release a work claim.',
660
+ description: 'Release a work claim. Callers own their own claims; a trusted+ coordinator releasing another agent\'s claim MUST pass coordinator_override:true (audited).',
660
661
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
661
662
  inputSchema: {
662
663
  type: 'object',
663
664
  properties: {
664
665
  id: { type: 'string', description: 'Claim ID to release.' },
665
666
  planStatus: { type: 'string', description: 'Optional: update linked plan status.' },
667
+ coordinator_override: {
668
+ type: 'boolean',
669
+ description: 'Opt-in override for a trusted+ caller releasing a claim they do NOT own (cross-agent teardown, ghost-claim cleanup). Rejected for contributor-level callers; audited when used. trp#928.',
670
+ },
666
671
  },
667
672
  required: ['id'],
668
673
  },
@@ -1188,7 +1193,7 @@ const MCP_WRITE_TOOLS = [
1188
1193
  type: 'object',
1189
1194
  properties: {
1190
1195
  entity: { type: 'string', description: 'Entity name: plan | decision | constraint | trap | handoff | runtime_note | candidate | sequence | claim | action | assignment | agent_run | cross_project_link. Others not yet wired.' },
1191
- filter: { type: 'object', description: 'Filter keys: status, tag (single tag), tags (array, any-match), author, plan_id, source, auto_generated, limit, offset, includeLegacy (bool, default false), minAutoReflectConfidence (0-1, default 0.6). entity=agent_run also accepts assignment_id, claim_id, message_id.' },
1196
+ filter: { type: 'object', description: 'Filter keys (ANY entity): status, tag (single tag), tags (array, any-match), author, plan_id, source, auto_generated, limit, offset, includeLegacy (bool, default false), minAutoReflectConfidence (0-1, default 0.6). ENTITY-SCOPED keys (rejected with a validation_error if used with any other entity): assignment_id, claim_id, message_id — ONLY for entity="agent_run". Unknown/mis-scoped keys are rejected loudly.' },
1192
1197
  project: { type: 'string', description: 'Optional: name (or path/basename) of a linked project to query. Defaults to the current project. Only cross_project_links (config.yaml) and workspace store-chain children are accepted — list with `brainclaw link list`.' },
1193
1198
  budget_tokens: { type: 'number', description: 'Optional token budget for the page payload (~4 chars/token). Tightens the default size cap; pagination metadata (has_more/next_offset) still applies.' },
1194
1199
  },
@@ -1256,7 +1261,7 @@ const MCP_WRITE_TOOLS = [
1256
1261
  },
1257
1262
  {
1258
1263
  name: 'bclaw_transition',
1259
- description: 'Transition an entity to a new status. Validated against EntityRegistry.transitions. Returns the triggered side-effect tags. Pass `project` to transition an entity in a linked project instead of the current one.',
1264
+ description: 'Transition an entity to a new status. Validated against EntityRegistry.transitions. Returns the triggered side-effect tags. Pass `project` to transition an entity in a linked project instead of the current one. For entity="claim": released/stale transitions are ownership-checked — non-owners must pass coordinator_override:true (trusted+ trust level required).',
1260
1265
  annotations: { tier: 'standard', category: 'memory', headlessApproval: 'prompt' },
1261
1266
  inputSchema: {
1262
1267
  type: 'object',
@@ -1266,6 +1271,7 @@ const MCP_WRITE_TOOLS = [
1266
1271
  to: { type: 'string', description: 'Target status.' },
1267
1272
  reason: { type: 'string', description: 'Optional free-text reason, audited alongside the transition.' },
1268
1273
  project: { type: 'string', description: 'Optional: name of a linked project to transition the entity in. Defaults to the current project.' },
1274
+ coordinator_override: { type: 'boolean', description: 'entity="claim" only: opt-in override for a trusted+ caller releasing/staling a claim they do NOT own. Audited when used. trp#928.' },
1269
1275
  },
1270
1276
  required: ['entity', 'id', 'to'],
1271
1277
  },
@@ -1829,10 +1835,22 @@ function ensureTrust(args, fields, level, cwd, sessionId) {
1829
1835
  * missing field — which would then be silently GC'd by the state sync loop
1830
1836
  * (see fix plan pln_5f44426c).
1831
1837
  *
1832
- * pln#562 step 3 — resolution failure is a HARD error. The old fallback to
1833
- * author:'unknown' produced records that passed creation but were schema-
1834
- * invalid on read and silently GC'd: a write that lies about succeeding.
1835
- * Callers map the throw to a validation_error tool response.
1838
+ * pln#562 step 3 — a write that would create a record with a missing/'unknown'
1839
+ * author must never be silent (that produced records that passed creation but
1840
+ * were schema-invalid on read and silently GC'd from disk).
1841
+ *
1842
+ * pln#608 — extended with auto-repair: when the caller has no session but a
1843
+ * derivable agent name (arg / $BRAINCLAW_AGENT_NAME / detected AI agent),
1844
+ * fall through to `resolveOrAutoRegisterAgentIdentity` and materialize the
1845
+ * session via `buildOperationalIdentity({ persistImplicitSession: true })`
1846
+ * (same mechanic as switchProject:86-106 and session-start). The freshly-
1847
+ * created session is tagged `auto_created` so aggressive harvesting can
1848
+ * distinguish it from operator sessions (pln#602). The caller receives
1849
+ * `auto_repair` and surfaces it as a warning — never silent.
1850
+ *
1851
+ * KEEP (still a hard error, doctrine boundary): the identity is ambiguous
1852
+ * (no name in args, no env signal, no detectable agent). We do not invent
1853
+ * an identity — invoke intent is unclear and the write would misattribute.
1836
1854
  */
1837
1855
  function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1838
1856
  const resolved = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
@@ -1842,9 +1860,92 @@ function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1842
1860
  agent_id: resolved.identity.agent_id,
1843
1861
  };
1844
1862
  }
1845
- const detail = 'error' in resolved && resolved.error ? resolved.error.message : 'no registered agent identity resolved';
1846
- throw new Error(`cannot resolve mutation author: ${detail} `
1847
- + 'Start a session (bclaw_session_start) or pass a registered agent before writing.');
1863
+ const strictError = 'error' in resolved && resolved.error ? resolved.error : undefined;
1864
+ // KEEP (doctrine boundary): a pinned principal that rejected the caller args
1865
+ // is a SPOOF/MISMATCH, not an ambiguous first-write. Never auto-repair over
1866
+ // it — silently re-attributing would defeat pln#562 step 3. The strict error
1867
+ // already carries the pointer to a curator override.
1868
+ if (resolveConnectionPrincipal(cwd, connectionSessionId)) {
1869
+ throw new Error(`cannot resolve mutation author: ${strictError?.message ?? 'principal mismatch'}`);
1870
+ }
1871
+ // Observer processes are read-only dashboards/inspectors. Even when an env
1872
+ // variable leaks an agent name into the observer process, canonical writes
1873
+ // must not use the auto-repair path because it can mint identity/session
1874
+ // state as a side effect.
1875
+ if (isObserverMode()) {
1876
+ throw new Error(`cannot resolve mutation author: ${strictError?.message ?? 'observer mode cannot auto-repair identity/session state'}`);
1877
+ }
1878
+ const explicitName = typeof args.agent === 'string' ? args.agent : undefined;
1879
+ const explicitId = typeof args.agentId === 'string' ? args.agentId : undefined;
1880
+ // resolveOrAutoRegisterAgentIdentity's fall-through helper only reads
1881
+ // BRAINCLAW_AGENT / OPENCLAW_AGENT. resolveCurrentAgentIdentity also honors
1882
+ // BRAINCLAW_AGENT_NAME, and dispatched workers set both. Normalize here so
1883
+ // an env-declared name is a first-class signal to the auto-repair path.
1884
+ const envAgentName = explicitName
1885
+ ?? (process.env.BRAINCLAW_AGENT_NAME?.trim() || undefined)
1886
+ ?? (process.env.BRAINCLAW_AGENT?.trim() || undefined);
1887
+ let identity;
1888
+ let autoRegistered;
1889
+ try {
1890
+ const outcome = resolveOrAutoRegisterAgentIdentity({
1891
+ agentName: envAgentName,
1892
+ agentId: explicitId,
1893
+ cwd,
1894
+ allowCurrent: true,
1895
+ allowEnv: true,
1896
+ });
1897
+ identity = outcome.identity;
1898
+ autoRegistered = outcome.auto_registered;
1899
+ }
1900
+ catch (err) {
1901
+ // Genuine ambiguity — no derivable name. Stays a hard error (KEEP: doctrine
1902
+ // boundary is "ambiguous intent → refuse with next_action", not silence).
1903
+ const detail = err instanceof Error ? err.message : (strictError?.message ?? String(err));
1904
+ throw new Error(`cannot resolve mutation author: ${detail} `
1905
+ + 'Pass a registered agent, set $BRAINCLAW_AGENT_NAME, '
1906
+ + 'or register with `brainclaw register-agent <name>` before writing.', { cause: err });
1907
+ }
1908
+ const explicitSessionId = connectionSessionId?.trim() || explicitSessionIdFromEnv();
1909
+ const hadSessionBefore = explicitSessionId
1910
+ ? Boolean(loadSessionById(explicitSessionId, cwd))
1911
+ : Boolean(loadCurrentSession(cwd));
1912
+ let sessionAutoCreated;
1913
+ try {
1914
+ const opIdentity = buildOperationalIdentity(identity.agent_name, cwd, {
1915
+ agentId: identity.agent_id,
1916
+ sessionId: explicitSessionId,
1917
+ persistImplicitSession: true,
1918
+ });
1919
+ if (!hadSessionBefore && opIdentity.session_id) {
1920
+ sessionAutoCreated = opIdentity.session_id;
1921
+ const session = loadSessionById(opIdentity.session_id, cwd);
1922
+ if (session && !session.auto_created) {
1923
+ saveCurrentSession({ ...session, auto_created: true }, cwd);
1924
+ }
1925
+ }
1926
+ }
1927
+ catch { /* best-effort — write can still proceed without a persisted session */ }
1928
+ const autoRepair = (autoRegistered || sessionAutoCreated)
1929
+ ? {
1930
+ ...(autoRegistered ? { agent_auto_registered: true } : {}),
1931
+ ...(sessionAutoCreated ? { session_auto_created: sessionAutoCreated } : {}),
1932
+ }
1933
+ : undefined;
1934
+ return {
1935
+ agent_name: identity.agent_name,
1936
+ agent_id: identity.agent_id,
1937
+ ...(autoRepair ? { auto_repair: autoRepair } : {}),
1938
+ };
1939
+ }
1940
+ function renderAutoRepairWarning(auto_repair, agent_name) {
1941
+ const parts = [];
1942
+ if (auto_repair.agent_auto_registered) {
1943
+ parts.push(`agent '${agent_name}' auto-registered (first use). Run \`brainclaw register-agent ${agent_name}\` to set capabilities and trust level.`);
1944
+ }
1945
+ if (auto_repair.session_auto_created) {
1946
+ parts.push(`session ${auto_repair.session_auto_created} auto-created for this write.`);
1947
+ }
1948
+ return `⚠️ auto-repair: ${parts.join(' ')}`;
1848
1949
  }
1849
1950
  function explicitSessionIdFromEnv() {
1850
1951
  return process.env.BRAINCLAW_SESSION_ID?.trim()
@@ -3561,18 +3662,43 @@ async function _executeMcpToolCallInner(payload) {
3561
3662
  catch {
3562
3663
  return { response: createToolErrorResponse('not_found', `Claim not found: ${claimId}`) };
3563
3664
  }
3564
- // pln#562 step 5 — release is ownership-checked like acquisition and
3565
- // adoption. The resolved principal must own the claim; trusted+ callers
3566
- // (coordinators) may release others' claims, with an audit entry.
3665
+ // pln#562 step 5 + trp#928 — release is ownership-checked like acquisition
3666
+ // and adoption. Under the trp#928 tightening the coordinator override is
3667
+ // OPT-IN via coordinator_override:true (implicit "trusted+ = always
3668
+ // override" was too magic — a coordinator releasing a worker's claim
3669
+ // should be a visible act, not a silent side-effect of trust). The
3670
+ // ownership check still enforces:
3671
+ // - owner-of-claim releases (identity matches): allowed, no override needed
3672
+ // - non-owner releases without coordinator_override: rejected loudly (the
3673
+ // error message points the caller at coordinator_override so it is
3674
+ // executable — pln#607 rule).
3675
+ // - non-owner releases with coordinator_override:true but not trusted+:
3676
+ // trust_error (privilege escalation prevention).
3677
+ // - non-owner releases with coordinator_override:true and trusted+:
3678
+ // allowed, audited (auditReleaseOverride).
3567
3679
  const releaseIdentity = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
3568
- const releaseAuth = 'identity' in releaseIdentity && releaseIdentity.identity
3569
- ? {
3570
- agent: releaseIdentity.identity.agent_name,
3571
- agent_id: releaseIdentity.identity.agent_id,
3572
- session_id: connectionSessionId,
3573
- override: hasMinimumTrustLevel(releaseIdentity.identity.trust_level ?? 'contributor', 'trusted'),
3680
+ if ('error' in releaseIdentity && releaseIdentity.error) {
3681
+ const { kind, message, details } = releaseIdentity.error;
3682
+ return { response: createToolErrorResponse(kind, message, details) };
3683
+ }
3684
+ if (!('identity' in releaseIdentity) || !releaseIdentity.identity) {
3685
+ return { response: createToolErrorResponse('identity_error', 'No registered agent identity resolved for bclaw_release_claim.') };
3686
+ }
3687
+ const coordinatorOverrideRequested = args.coordinator_override === true;
3688
+ if (coordinatorOverrideRequested) {
3689
+ const trustLevel = releaseIdentity.identity.trust_level ?? 'contributor';
3690
+ if (!hasMinimumTrustLevel(trustLevel, 'trusted')) {
3691
+ return {
3692
+ response: createToolErrorResponse('trust_error', `coordinator_override:true requires trust_level 'trusted' or higher — caller is '${trustLevel}'. Ask a curator to elevate the agent, or have the claim owner release it.`),
3693
+ };
3574
3694
  }
3575
- : undefined;
3695
+ }
3696
+ const releaseAuth = {
3697
+ agent: releaseIdentity.identity.agent_name,
3698
+ agent_id: releaseIdentity.identity.agent_id,
3699
+ session_id: connectionSessionId,
3700
+ override: coordinatorOverrideRequested,
3701
+ };
3576
3702
  let cascadeResult;
3577
3703
  try {
3578
3704
  cascadeResult = releaseClaimWithCascade(claimId, {
@@ -4067,6 +4193,39 @@ async function _executeMcpToolCallInner(payload) {
4067
4193
  actor: callerAgent,
4068
4194
  actor_id: resolved.identity.agent_id,
4069
4195
  }, cwd);
4196
+ // trp#928 — cascade-release the assignment's linked claim on completion.
4197
+ // Before this landing an obedient worker had to make TWO calls to close
4198
+ // the loop (bclaw_assignment_update status=completed AND
4199
+ // bclaw_release_claim); dispatch briefs enumerate both, but not every
4200
+ // sandboxed worker gets through both, and the coordinator's harvest path
4201
+ // only releases on --integrate — so contributor-driven completions left
4202
+ // claims active. The worker's own identity owns the claim (session
4203
+ // adoption), so ownership matches and no coordinator_override is needed.
4204
+ // Silent success/failure is unacceptable: log per-claim outcome.
4205
+ if (status === 'completed' && assignment.claim_id) {
4206
+ try {
4207
+ const { releaseClaimsCascade, logCascadeReleaseResult } = await import('../core/claims.js');
4208
+ const cascade = releaseClaimsCascade([assignment.claim_id], {
4209
+ cwd,
4210
+ planStatus: 'done',
4211
+ auth: {
4212
+ agent: callerAgent,
4213
+ agent_id: resolved.identity.agent_id,
4214
+ session_id: effectiveSessionId,
4215
+ override: false,
4216
+ },
4217
+ });
4218
+ logCascadeReleaseResult({
4219
+ actor: callerAgent,
4220
+ trigger: 'assignment_completed',
4221
+ assignment_id: assignmentId,
4222
+ claim_id: assignment.claim_id,
4223
+ cascade,
4224
+ cwd,
4225
+ });
4226
+ }
4227
+ catch { /* never block the update on cascade release */ }
4228
+ }
4070
4229
  // When accepted: auto-acknowledge the inbox message (replaces bclaw_ack_message)
4071
4230
  if (status === 'accepted' && assignment.message_id) {
4072
4231
  try {
@@ -6515,17 +6674,38 @@ async function _executeMcpToolCallInner(payload) {
6515
6674
  // only checks known keys), letting the caller believe the filter had
6516
6675
  // applied when it hadn't. Under the new contract, an unknown key is
6517
6676
  // a validation_error listing the keys actually honored.
6677
+ // trp#928 — the entity-scoping error is now first-class: the doc says
6678
+ // assignment_id/claim_id/message_id are entity='agent_run' only, but
6679
+ // before this the rejection message called them 'unknown', misleading
6680
+ // callers who'd cross-reference the description. Now the message names
6681
+ // the constraint AND the entity that DOES accept the key so the user
6682
+ // can fix the call without hunting through docs. (pln#599 docs-vs-facts.)
6518
6683
  const KNOWN_FILTER_KEYS = new Set([
6519
6684
  'status', 'tag', 'tags', 'author', 'plan_id', 'source', 'auto_generated',
6520
6685
  'assignment_id', 'claim_id', 'message_id',
6521
6686
  'limit', 'offset', 'includeLegacy', 'minAutoReflectConfidence',
6522
6687
  ]);
6523
6688
  const agentRunOnlyFilterKeys = new Set(['assignment_id', 'claim_id', 'message_id']);
6524
- const unknownKeys = Object.keys(filter).filter((k) => !KNOWN_FILTER_KEYS.has(k) || (agentRunOnlyFilterKeys.has(k) && entity !== 'agent_run'));
6525
- if (unknownKeys.length > 0) {
6689
+ const providedKeys = Object.keys(filter);
6690
+ const unknownKeys = providedKeys.filter((k) => !KNOWN_FILTER_KEYS.has(k));
6691
+ const misScopedKeys = providedKeys.filter((k) => agentRunOnlyFilterKeys.has(k) && entity !== 'agent_run');
6692
+ if (unknownKeys.length > 0 || misScopedKeys.length > 0) {
6693
+ const parts = [];
6694
+ if (unknownKeys.length > 0) {
6695
+ parts.push(`Unknown filter key(s): ${unknownKeys.map((k) => `"${k}"`).join(', ')}. Accepted keys: ${[...KNOWN_FILTER_KEYS].sort().join(', ')}.`);
6696
+ }
6697
+ if (misScopedKeys.length > 0) {
6698
+ parts.push(`Filter key(s) ${misScopedKeys.map((k) => `"${k}"`).join(', ')} are only valid for entity="agent_run" `
6699
+ + `(this call used entity="${entity}"). `
6700
+ + `Retry with entity="agent_run", or drop the ${misScopedKeys.join('/')} filter.`);
6701
+ }
6526
6702
  return {
6527
- response: createToolErrorResponse('validation_error', `Unknown filter key(s): ${unknownKeys.map((k) => `"${k}"`).join(', ')}. ` +
6528
- `Accepted keys: ${[...KNOWN_FILTER_KEYS].sort().join(', ')}.`, { unknown_keys: unknownKeys, accepted_keys: [...KNOWN_FILTER_KEYS].sort() }),
6703
+ response: createToolErrorResponse('validation_error', parts.join(' '), {
6704
+ unknown_keys: unknownKeys,
6705
+ mis_scoped_keys: misScopedKeys,
6706
+ accepted_keys: [...KNOWN_FILTER_KEYS].sort(),
6707
+ agent_run_only_keys: [...agentRunOnlyFilterKeys],
6708
+ }),
6529
6709
  };
6530
6710
  }
6531
6711
  const result = listEntities(entity, targetCwd, filter);
@@ -6664,29 +6844,36 @@ async function _executeMcpToolCallInner(payload) {
6664
6844
  const data = { ...rawData };
6665
6845
  let actor = typeof data.author === 'string' ? data.author : undefined;
6666
6846
  let actorId = typeof data.agent_id === 'string' ? data.agent_id : undefined;
6847
+ let autoRepair;
6667
6848
  if (data.author === undefined) {
6668
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6669
- data.author = agent_name;
6849
+ const author = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6850
+ data.author = author.agent_name;
6670
6851
  if (data.agent === undefined)
6671
- data.agent = agent_name;
6672
- if (data.agent_id === undefined && agent_id)
6673
- data.agent_id = agent_id;
6674
- actor = agent_name;
6675
- actorId = agent_id;
6852
+ data.agent = author.agent_name;
6853
+ if (data.agent_id === undefined && author.agent_id)
6854
+ data.agent_id = author.agent_id;
6855
+ actor = author.agent_name;
6856
+ actorId = author.agent_id;
6857
+ autoRepair = author.auto_repair;
6676
6858
  }
6677
6859
  else if (data.agent === undefined) {
6678
6860
  data.agent = data.author;
6679
6861
  }
6680
6862
  const result = createEntity(entity, data, targetCwd);
6681
6863
  appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
6864
+ const createText = `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
6865
+ const createContent = autoRepair
6866
+ ? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }]
6867
+ : [{ type: 'text', text: createText }];
6682
6868
  return {
6683
6869
  response: toolResponse({
6684
- content: [{ type: 'text', text: `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}` }],
6870
+ content: createContent,
6685
6871
  structuredContent: {
6686
6872
  ...result,
6687
6873
  resolved_project: targetScope.resolved_project,
6688
6874
  active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
6689
6875
  ...(autoSwitched ? { auto_switched: true } : {}),
6876
+ ...(autoRepair ? { auto_repair: autoRepair } : {}),
6690
6877
  },
6691
6878
  }),
6692
6879
  };
@@ -6702,16 +6889,21 @@ async function _executeMcpToolCallInner(payload) {
6702
6889
  const patch = (args.patch ?? {});
6703
6890
  const targetCwd = resolveProjectCwd(args.project, cwd);
6704
6891
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6705
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6892
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6706
6893
  const result = updateEntity(entity, id, patch, targetCwd);
6707
6894
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'update', item_id: id, item_type: entity }, targetCwd);
6895
+ const updateText = `✔ updated ${entity} ${id}`;
6896
+ const updateContent = auto_repair
6897
+ ? [{ type: 'text', text: updateText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6898
+ : [{ type: 'text', text: updateText }];
6708
6899
  return {
6709
6900
  response: toolResponse({
6710
- content: [{ type: 'text', text: `✔ updated ${entity} ${id}` }],
6901
+ content: updateContent,
6711
6902
  structuredContent: {
6712
6903
  ...result,
6713
6904
  resolved_project: targetScope.resolved_project,
6714
6905
  active_source: targetScope.active_source,
6906
+ ...(auto_repair ? { auto_repair } : {}),
6715
6907
  },
6716
6908
  }),
6717
6909
  };
@@ -6727,16 +6919,21 @@ async function _executeMcpToolCallInner(payload) {
6727
6919
  const purge = args.purge === true;
6728
6920
  const targetCwd = resolveProjectCwd(args.project, cwd);
6729
6921
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6730
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6922
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6731
6923
  const result = removeEntity(entity, id, targetCwd, purge);
6732
6924
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'delete', item_id: id, item_type: entity, reason: purge ? 'purged' : 'archived' }, targetCwd);
6925
+ const removeText = `✔ removed ${entity} ${id}`;
6926
+ const removeContent = auto_repair
6927
+ ? [{ type: 'text', text: removeText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6928
+ : [{ type: 'text', text: removeText }];
6733
6929
  return {
6734
6930
  response: toolResponse({
6735
- content: [{ type: 'text', text: `✔ removed ${entity} ${id}` }],
6931
+ content: removeContent,
6736
6932
  structuredContent: {
6737
6933
  ...result,
6738
6934
  resolved_project: targetScope.resolved_project,
6739
6935
  active_source: targetScope.active_source,
6936
+ ...(auto_repair ? { auto_repair } : {}),
6740
6937
  },
6741
6938
  }),
6742
6939
  };
@@ -6752,13 +6949,20 @@ async function _executeMcpToolCallInner(payload) {
6752
6949
  const toProject = String(args.to_project ?? '');
6753
6950
  const fromProject = typeof args.from_project === 'string' ? args.from_project : undefined;
6754
6951
  const force = args.force === true;
6755
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6952
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6756
6953
  const result = relocateEntity({ entity, id, toProject, fromProject, force, cwd, actor: agent_name, actorId: agent_id });
6757
6954
  const warn = result.warnings.length ? ` (${result.warnings.length} warning(s))` : '';
6955
+ const moveText = `✔ moved ${entity} ${id} → ${result.to}${warn}`;
6956
+ const moveContent = auto_repair
6957
+ ? [{ type: 'text', text: moveText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6958
+ : [{ type: 'text', text: moveText }];
6758
6959
  return {
6759
6960
  response: toolResponse({
6760
- content: [{ type: 'text', text: `✔ moved ${entity} ${id} → ${result.to}${warn}` }],
6761
- structuredContent: { ...result },
6961
+ content: moveContent,
6962
+ structuredContent: {
6963
+ ...result,
6964
+ ...(auto_repair ? { auto_repair } : {}),
6965
+ },
6762
6966
  }),
6763
6967
  };
6764
6968
  }
@@ -6788,17 +6992,48 @@ async function _executeMcpToolCallInner(payload) {
6788
6992
  const to = String(args.to ?? '');
6789
6993
  const reason = args.reason;
6790
6994
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6791
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6792
- const result = transitionEntity(entity, id, to, targetCwd, reason);
6995
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6996
+ // trp#928 claim transitions consume the ReleaseClaimAuth ownership
6997
+ // check (released/stale both mutate a claim owned by SOME agent). Reuse
6998
+ // the same coordinator_override opt-in as bclaw_release_claim so both
6999
+ // paths have identical trust semantics and the same executable error.
7000
+ let transitionAuth;
7001
+ if (entity === 'claim') {
7002
+ const transitionIdentity = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, targetCwd, connectionSessionId);
7003
+ const coordinatorOverrideRequested = args.coordinator_override === true;
7004
+ if (coordinatorOverrideRequested) {
7005
+ const identity = 'identity' in transitionIdentity ? transitionIdentity.identity : undefined;
7006
+ const trustLevel = identity?.trust_level ?? 'contributor';
7007
+ if (!hasMinimumTrustLevel(trustLevel, 'trusted')) {
7008
+ return {
7009
+ response: createToolErrorResponse('trust_error', `coordinator_override:true requires trust_level 'trusted' or higher — caller is '${trustLevel}'.`),
7010
+ };
7011
+ }
7012
+ }
7013
+ transitionAuth = 'identity' in transitionIdentity && transitionIdentity.identity
7014
+ ? {
7015
+ agent: transitionIdentity.identity.agent_name,
7016
+ agent_id: transitionIdentity.identity.agent_id,
7017
+ session_id: connectionSessionId,
7018
+ override: coordinatorOverrideRequested,
7019
+ }
7020
+ : undefined;
7021
+ }
7022
+ const result = transitionEntity(entity, id, to, targetCwd, reason, transitionAuth);
6793
7023
  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);
7024
+ const transitionText = `✔ ${entity} ${id}: ${result.from} → ${to}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
7025
+ const transitionContent = auto_repair
7026
+ ? [{ type: 'text', text: transitionText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
7027
+ : [{ type: 'text', text: transitionText }];
6794
7028
  return {
6795
7029
  response: toolResponse({
6796
- content: [{ type: 'text', text: `✔ ${entity} ${id}: ${result.from} → ${to}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}` }],
7030
+ content: transitionContent,
6797
7031
  structuredContent: {
6798
7032
  ...result,
6799
7033
  resolved_project: targetScope.resolved_project,
6800
7034
  active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
6801
7035
  ...(autoSwitched ? { auto_switched: true } : {}),
7036
+ ...(auto_repair ? { auto_repair } : {}),
6802
7037
  },
6803
7038
  }),
6804
7039
  };
@@ -3,16 +3,36 @@ import { mutate } from '../core/mutation-pipeline.js';
3
3
  import { loadClaim, listClaims, releaseClaim } from '../core/claims.js';
4
4
  import { rebuildProjectMd } from '../core/markdown.js';
5
5
  import { loadState, mutateState } from '../core/state.js';
6
+ import { requireMinimumTrustLevel, requireRegisteredAgentIdentity } from '../core/agent-registry.js';
6
7
  export function runReleaseClaim(id, options = {}) {
7
8
  if (!memoryExists(options.cwd)) {
8
9
  console.error('Error: .brainclaw/ not found. Run `brainclaw init` first.');
9
10
  process.exit(1);
10
11
  }
11
12
  try {
13
+ // Surface split (trp#928 follow-up): the ownership gate lives on the MCP
14
+ // surface (bclaw_release_claim / bclaw_transition), where agent callers
15
+ // carry a session-bound identity. The CLI `release-claim <id>` is the
16
+ // operator/scripting surface and keeps its historic unguarded semantics —
17
+ // the e2e contract (collaboration.test.ts) has always released cross-agent
18
+ // claims from an env-identified CLI. Deriving an ambient identity here and
19
+ // gating on it turned every operator release into a false ownership
20
+ // mismatch (silent-default anti-pattern, pln#607). `--coordinator-override`
21
+ // stays available to make a cross-agent release explicit and audited.
22
+ let releaseAuth;
23
+ if (options.coordinatorOverride) {
24
+ const identity = requireRegisteredAgentIdentity({ cwd: options.cwd, allowCurrent: true, allowEnv: true });
25
+ requireMinimumTrustLevel(identity, 'trusted');
26
+ releaseAuth = {
27
+ agent: identity.agent_name,
28
+ agent_id: identity.agent_id,
29
+ override: true,
30
+ };
31
+ }
12
32
  let claim = loadClaim(id, options.cwd);
13
33
  mutate({ cwd: options.cwd }, () => {
14
34
  const existing = loadClaim(id, options.cwd);
15
- claim = releaseClaim(id, options.cwd);
35
+ claim = releaseClaim(id, options.cwd, releaseAuth);
16
36
  if (existing.plan_id) {
17
37
  const updated = mutateState((state) => {
18
38
  const plan = state.plan_items.find((item) => item.id === existing.plan_id);
@@ -175,6 +175,11 @@ const PROFILES = {
175
175
  // Aligning with the regular spawn template (workspace-write) is the
176
176
  // accepted pattern per agent_spawn_inventory memory.
177
177
  invoke_review_template: 'codex exec -c approval_policy="never" --sandbox workspace-write "{prompt}"',
178
+ // pln#606: `codex exec -m <MODEL>` / `--model` (verified empirically on
179
+ // codex 0.130). We use the long form `--model` for symmetry with the
180
+ // other agent profiles and readability.
181
+ model_flag: '--model',
182
+ model_flag_insert_index: 2,
178
183
  },
179
184
  antigravity: {
180
185
  name: 'antigravity', category: 'code-agent', workflowModel: 'interactive',
@@ -204,6 +209,10 @@ const PROFILES = {
204
209
  invoke_template: 'copilot -p "{prompt}" --allow-all --no-ask-user',
205
210
  invoke_binary: 'copilot',
206
211
  invoke_review_template: 'copilot -p "{prompt}" --allow-all --no-ask-user',
212
+ // pln#606: `copilot --model <model>` (verified on Copilot CLI 1.0.35+).
213
+ // 'auto' lets Copilot pick automatically; concrete ids come from the
214
+ // entitled catalog fetched by the CLI at startup.
215
+ model_flag: '--model',
207
216
  },
208
217
  kilocode: {
209
218
  name: 'kilocode', category: 'code-agent', workflowModel: 'interactive',
@@ -564,11 +573,13 @@ export function buildInvokeCommand(name, prompt, options = {}) {
564
573
  const rawTokens = parseTemplateString(templateStr);
565
574
  if (rawTokens.length === 0)
566
575
  return undefined;
567
- // pln#520 step 3: inject the resolved model right after the binary so model
568
- // choice is decoupled from agent identity. Only when the profile declares a
569
- // `model_flag` and the template doesn't already pin a model (don't double it).
576
+ // pln#520 step 3: inject the resolved model at the profile's model argument
577
+ // position so model choice is decoupled from agent identity. Only when the
578
+ // profile declares a `model_flag` and the template doesn't already pin a model
579
+ // (don't double it).
570
580
  if (options.model && profile.model_flag && !rawTokens.includes(profile.model_flag)) {
571
- rawTokens.splice(1, 0, profile.model_flag, options.model);
581
+ const insertIndex = Math.min(Math.max(profile.model_flag_insert_index ?? 1, 1), rawTokens.length);
582
+ rawTokens.splice(insertIndex, 0, profile.model_flag, options.model);
572
583
  }
573
584
  const executable = rawTokens[0];
574
585
  const interpolatedTokens = rawTokens.slice(1).map((tok) => tok === '{prompt}' ? embeddedPrompt : tok);
@@ -150,7 +150,13 @@ function buildIdentityKey(agentId, env = process.env, forceRegenerate = false) {
150
150
  let publicKeyPem;
151
151
  if (!forceRegenerate && fs.existsSync(filepath)) {
152
152
  const privateKey = crypto.createPrivateKey(fs.readFileSync(filepath, 'utf-8'));
153
- publicKeyPem = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString();
153
+ // @types/node 26 dropped the KeyObject overload from createPublicKey's signature
154
+ // (regression — Node accepts a private KeyObject to derive its public key, as documented).
155
+ // Cast to a parameter type the .d.ts still accepts; runtime behaviour is unchanged.
156
+ publicKeyPem = crypto
157
+ .createPublicKey(privateKey)
158
+ .export({ type: 'spki', format: 'pem' })
159
+ .toString();
154
160
  }
155
161
  else {
156
162
  const generated = crypto.generateKeyPairSync('ed25519');