brainclaw 1.12.0 → 1.14.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.
@@ -9,6 +9,10 @@ import { generatedSchemas } from './mcp-schemas.generated.js';
9
9
  import { getTriggeredItems, renderTriggeredItems } from '../core/lifecycle.js';
10
10
  import { resolveCrossProjectLinks, resolveCrossProjectWritableTarget, resolveProjectCwd, writeCrossProjectSignal } from '../core/cross-project.js';
11
11
  import { buildContext, renderContextMarkdown, renderContextPromptTemplate, renderContextBriefing } from '../core/context.js';
12
+ import { ageStaleWarnings, ageWorkflowHints, loadServeRegistry } from '../core/hint-aging.js';
13
+ import { loadHygienePolicy } from '../core/hygiene-policy.js';
14
+ import { sweepAssignmentsAtReadPath, selectReadPathSweepCandidates } from '../core/assignment-sweeper.js';
15
+ import { loadAssignment } from '../core/assignments.js';
12
16
  import { buildCoordinationSnapshot } from '../core/coordination.js';
13
17
  import { checkBrainclawInstallableUpdate, getInstalledBrainclawVersion, readDiskBrainclawVersion, renderBrainclawInstallableUpdateNotice } from '../core/brainclaw-version.js';
14
18
  import { loadConfig } from '../core/config.js';
@@ -31,13 +35,14 @@ import { rejectCandidate } from './reject.js';
31
35
  import { startSession } from './session-start.js';
32
36
  import { endSession } from './session-end.js';
33
37
  import { applyHandoffUpdates } from './update-handoff.js';
34
- import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, hasMinimumTrustLevel, normalizeAgentName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, } from '../core/agent-registry.js';
38
+ import { AgentIdentityResolutionError, AgentTrustError, findAgentIdentityById, findAgentIdentityByName, hasMinimumTrustLevel, normalizeAgentName, requireMinimumTrustLevel, requireRegisteredAgentIdentity, resolveCurrentAgentIdentity, resolveCurrentModel, ensureAgentRegisteredForDispatch, resolveOrAutoRegisterAgentIdentity, } from '../core/agent-registry.js';
35
39
  import { appendAuditEntry } from '../core/audit.js';
36
40
  import { nowISO, generateId } from '../core/ids.js';
37
- import { buildOperationalIdentity, loadAllSessions, loadSessionById } from '../core/identity.js';
41
+ import { buildOperationalIdentity, loadAllSessions, loadCurrentSession, loadSessionById, saveCurrentSession } from '../core/identity.js';
38
42
  import { validateMcpInput, validateMcpField } from '../core/input-validation.js';
39
43
  import { createCapability, createTool as createRegistryTool } from '../core/registries.js';
40
44
  import { detectAiAgent } from '../core/ai-agent-detection.js';
45
+ import { isObserverMode } from '../core/observer-mode.js';
41
46
  import { checkGitPresence, scanGitRepos, parseRoots, parseRepoSelection, parseAgentSelection, getDetectedSetupAgentNames, getInstalledAgentNames, runGlobalInstall, initReposAndConfigureAgents, readSetupState, ALL_KNOWN_AGENTS, } from './setup.js';
42
47
  import { buildAgentInventory } from '../core/agent-inventory.js';
43
48
  import { findOutermostBrainclawRoot, resolveEffectiveCwd, resolveEffectiveCwdInfo, resolveProjectRef, resolveTargetStore } from '../core/store-resolution.js';
@@ -656,13 +661,17 @@ const MCP_WRITE_TOOLS = [
656
661
  },
657
662
  {
658
663
  name: 'bclaw_release_claim',
659
- description: 'Release a work claim.',
664
+ 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
665
  annotations: { tier: 'standard', category: 'coordination', headlessApproval: 'auto' },
661
666
  inputSchema: {
662
667
  type: 'object',
663
668
  properties: {
664
669
  id: { type: 'string', description: 'Claim ID to release.' },
665
670
  planStatus: { type: 'string', description: 'Optional: update linked plan status.' },
671
+ coordinator_override: {
672
+ type: 'boolean',
673
+ 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.',
674
+ },
666
675
  },
667
676
  required: ['id'],
668
677
  },
@@ -1188,7 +1197,7 @@ const MCP_WRITE_TOOLS = [
1188
1197
  type: 'object',
1189
1198
  properties: {
1190
1199
  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.' },
1200
+ 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
1201
  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
1202
  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
1203
  },
@@ -1256,7 +1265,7 @@ const MCP_WRITE_TOOLS = [
1256
1265
  },
1257
1266
  {
1258
1267
  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.',
1268
+ 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
1269
  annotations: { tier: 'standard', category: 'memory', headlessApproval: 'prompt' },
1261
1270
  inputSchema: {
1262
1271
  type: 'object',
@@ -1266,6 +1275,7 @@ const MCP_WRITE_TOOLS = [
1266
1275
  to: { type: 'string', description: 'Target status.' },
1267
1276
  reason: { type: 'string', description: 'Optional free-text reason, audited alongside the transition.' },
1268
1277
  project: { type: 'string', description: 'Optional: name of a linked project to transition the entity in. Defaults to the current project.' },
1278
+ 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
1279
  },
1270
1280
  required: ['entity', 'id', 'to'],
1271
1281
  },
@@ -1829,10 +1839,22 @@ function ensureTrust(args, fields, level, cwd, sessionId) {
1829
1839
  * missing field — which would then be silently GC'd by the state sync loop
1830
1840
  * (see fix plan pln_5f44426c).
1831
1841
  *
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.
1842
+ * pln#562 step 3 — a write that would create a record with a missing/'unknown'
1843
+ * author must never be silent (that produced records that passed creation but
1844
+ * were schema-invalid on read and silently GC'd from disk).
1845
+ *
1846
+ * pln#608 — extended with auto-repair: when the caller has no session but a
1847
+ * derivable agent name (arg / $BRAINCLAW_AGENT_NAME / detected AI agent),
1848
+ * fall through to `resolveOrAutoRegisterAgentIdentity` and materialize the
1849
+ * session via `buildOperationalIdentity({ persistImplicitSession: true })`
1850
+ * (same mechanic as switchProject:86-106 and session-start). The freshly-
1851
+ * created session is tagged `auto_created` so aggressive harvesting can
1852
+ * distinguish it from operator sessions (pln#602). The caller receives
1853
+ * `auto_repair` and surfaces it as a warning — never silent.
1854
+ *
1855
+ * KEEP (still a hard error, doctrine boundary): the identity is ambiguous
1856
+ * (no name in args, no env signal, no detectable agent). We do not invent
1857
+ * an identity — invoke intent is unclear and the write would misattribute.
1836
1858
  */
1837
1859
  function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1838
1860
  const resolved = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, cwd, connectionSessionId);
@@ -1842,9 +1864,92 @@ function resolveCanonicalAuthor(args, cwd, connectionSessionId) {
1842
1864
  agent_id: resolved.identity.agent_id,
1843
1865
  };
1844
1866
  }
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.');
1867
+ const strictError = 'error' in resolved && resolved.error ? resolved.error : undefined;
1868
+ // KEEP (doctrine boundary): a pinned principal that rejected the caller args
1869
+ // is a SPOOF/MISMATCH, not an ambiguous first-write. Never auto-repair over
1870
+ // it — silently re-attributing would defeat pln#562 step 3. The strict error
1871
+ // already carries the pointer to a curator override.
1872
+ if (resolveConnectionPrincipal(cwd, connectionSessionId)) {
1873
+ throw new Error(`cannot resolve mutation author: ${strictError?.message ?? 'principal mismatch'}`);
1874
+ }
1875
+ // Observer processes are read-only dashboards/inspectors. Even when an env
1876
+ // variable leaks an agent name into the observer process, canonical writes
1877
+ // must not use the auto-repair path because it can mint identity/session
1878
+ // state as a side effect.
1879
+ if (isObserverMode()) {
1880
+ throw new Error(`cannot resolve mutation author: ${strictError?.message ?? 'observer mode cannot auto-repair identity/session state'}`);
1881
+ }
1882
+ const explicitName = typeof args.agent === 'string' ? args.agent : undefined;
1883
+ const explicitId = typeof args.agentId === 'string' ? args.agentId : undefined;
1884
+ // resolveOrAutoRegisterAgentIdentity's fall-through helper only reads
1885
+ // BRAINCLAW_AGENT / OPENCLAW_AGENT. resolveCurrentAgentIdentity also honors
1886
+ // BRAINCLAW_AGENT_NAME, and dispatched workers set both. Normalize here so
1887
+ // an env-declared name is a first-class signal to the auto-repair path.
1888
+ const envAgentName = explicitName
1889
+ ?? (process.env.BRAINCLAW_AGENT_NAME?.trim() || undefined)
1890
+ ?? (process.env.BRAINCLAW_AGENT?.trim() || undefined);
1891
+ let identity;
1892
+ let autoRegistered;
1893
+ try {
1894
+ const outcome = resolveOrAutoRegisterAgentIdentity({
1895
+ agentName: envAgentName,
1896
+ agentId: explicitId,
1897
+ cwd,
1898
+ allowCurrent: true,
1899
+ allowEnv: true,
1900
+ });
1901
+ identity = outcome.identity;
1902
+ autoRegistered = outcome.auto_registered;
1903
+ }
1904
+ catch (err) {
1905
+ // Genuine ambiguity — no derivable name. Stays a hard error (KEEP: doctrine
1906
+ // boundary is "ambiguous intent → refuse with next_action", not silence).
1907
+ const detail = err instanceof Error ? err.message : (strictError?.message ?? String(err));
1908
+ throw new Error(`cannot resolve mutation author: ${detail} `
1909
+ + 'Pass a registered agent, set $BRAINCLAW_AGENT_NAME, '
1910
+ + 'or register with `brainclaw register-agent <name>` before writing.', { cause: err });
1911
+ }
1912
+ const explicitSessionId = connectionSessionId?.trim() || explicitSessionIdFromEnv();
1913
+ const hadSessionBefore = explicitSessionId
1914
+ ? Boolean(loadSessionById(explicitSessionId, cwd))
1915
+ : Boolean(loadCurrentSession(cwd));
1916
+ let sessionAutoCreated;
1917
+ try {
1918
+ const opIdentity = buildOperationalIdentity(identity.agent_name, cwd, {
1919
+ agentId: identity.agent_id,
1920
+ sessionId: explicitSessionId,
1921
+ persistImplicitSession: true,
1922
+ });
1923
+ if (!hadSessionBefore && opIdentity.session_id) {
1924
+ sessionAutoCreated = opIdentity.session_id;
1925
+ const session = loadSessionById(opIdentity.session_id, cwd);
1926
+ if (session && !session.auto_created) {
1927
+ saveCurrentSession({ ...session, auto_created: true }, cwd);
1928
+ }
1929
+ }
1930
+ }
1931
+ catch { /* best-effort — write can still proceed without a persisted session */ }
1932
+ const autoRepair = (autoRegistered || sessionAutoCreated)
1933
+ ? {
1934
+ ...(autoRegistered ? { agent_auto_registered: true } : {}),
1935
+ ...(sessionAutoCreated ? { session_auto_created: sessionAutoCreated } : {}),
1936
+ }
1937
+ : undefined;
1938
+ return {
1939
+ agent_name: identity.agent_name,
1940
+ agent_id: identity.agent_id,
1941
+ ...(autoRepair ? { auto_repair: autoRepair } : {}),
1942
+ };
1943
+ }
1944
+ function renderAutoRepairWarning(auto_repair, agent_name) {
1945
+ const parts = [];
1946
+ if (auto_repair.agent_auto_registered) {
1947
+ parts.push(`agent '${agent_name}' auto-registered (first use). Run \`brainclaw register-agent ${agent_name}\` to set capabilities and trust level.`);
1948
+ }
1949
+ if (auto_repair.session_auto_created) {
1950
+ parts.push(`session ${auto_repair.session_auto_created} auto-created for this write.`);
1951
+ }
1952
+ return `⚠️ auto-repair: ${parts.join(' ')}`;
1848
1953
  }
1849
1954
  function explicitSessionIdFromEnv() {
1850
1955
  return process.env.BRAINCLAW_SESSION_ID?.trim()
@@ -3561,18 +3666,43 @@ async function _executeMcpToolCallInner(payload) {
3561
3666
  catch {
3562
3667
  return { response: createToolErrorResponse('not_found', `Claim not found: ${claimId}`) };
3563
3668
  }
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.
3669
+ // pln#562 step 5 + trp#928 — release is ownership-checked like acquisition
3670
+ // and adoption. Under the trp#928 tightening the coordinator override is
3671
+ // OPT-IN via coordinator_override:true (implicit "trusted+ = always
3672
+ // override" was too magic — a coordinator releasing a worker's claim
3673
+ // should be a visible act, not a silent side-effect of trust). The
3674
+ // ownership check still enforces:
3675
+ // - owner-of-claim releases (identity matches): allowed, no override needed
3676
+ // - non-owner releases without coordinator_override: rejected loudly (the
3677
+ // error message points the caller at coordinator_override so it is
3678
+ // executable — pln#607 rule).
3679
+ // - non-owner releases with coordinator_override:true but not trusted+:
3680
+ // trust_error (privilege escalation prevention).
3681
+ // - non-owner releases with coordinator_override:true and trusted+:
3682
+ // allowed, audited (auditReleaseOverride).
3567
3683
  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'),
3684
+ if ('error' in releaseIdentity && releaseIdentity.error) {
3685
+ const { kind, message, details } = releaseIdentity.error;
3686
+ return { response: createToolErrorResponse(kind, message, details) };
3687
+ }
3688
+ if (!('identity' in releaseIdentity) || !releaseIdentity.identity) {
3689
+ return { response: createToolErrorResponse('identity_error', 'No registered agent identity resolved for bclaw_release_claim.') };
3690
+ }
3691
+ const coordinatorOverrideRequested = args.coordinator_override === true;
3692
+ if (coordinatorOverrideRequested) {
3693
+ const trustLevel = releaseIdentity.identity.trust_level ?? 'contributor';
3694
+ if (!hasMinimumTrustLevel(trustLevel, 'trusted')) {
3695
+ return {
3696
+ 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.`),
3697
+ };
3574
3698
  }
3575
- : undefined;
3699
+ }
3700
+ const releaseAuth = {
3701
+ agent: releaseIdentity.identity.agent_name,
3702
+ agent_id: releaseIdentity.identity.agent_id,
3703
+ session_id: connectionSessionId,
3704
+ override: coordinatorOverrideRequested,
3705
+ };
3576
3706
  let cascadeResult;
3577
3707
  try {
3578
3708
  cascadeResult = releaseClaimWithCascade(claimId, {
@@ -4067,6 +4197,39 @@ async function _executeMcpToolCallInner(payload) {
4067
4197
  actor: callerAgent,
4068
4198
  actor_id: resolved.identity.agent_id,
4069
4199
  }, cwd);
4200
+ // trp#928 — cascade-release the assignment's linked claim on completion.
4201
+ // Before this landing an obedient worker had to make TWO calls to close
4202
+ // the loop (bclaw_assignment_update status=completed AND
4203
+ // bclaw_release_claim); dispatch briefs enumerate both, but not every
4204
+ // sandboxed worker gets through both, and the coordinator's harvest path
4205
+ // only releases on --integrate — so contributor-driven completions left
4206
+ // claims active. The worker's own identity owns the claim (session
4207
+ // adoption), so ownership matches and no coordinator_override is needed.
4208
+ // Silent success/failure is unacceptable: log per-claim outcome.
4209
+ if (status === 'completed' && assignment.claim_id) {
4210
+ try {
4211
+ const { releaseClaimsCascade, logCascadeReleaseResult } = await import('../core/claims.js');
4212
+ const cascade = releaseClaimsCascade([assignment.claim_id], {
4213
+ cwd,
4214
+ planStatus: 'done',
4215
+ auth: {
4216
+ agent: callerAgent,
4217
+ agent_id: resolved.identity.agent_id,
4218
+ session_id: effectiveSessionId,
4219
+ override: false,
4220
+ },
4221
+ });
4222
+ logCascadeReleaseResult({
4223
+ actor: callerAgent,
4224
+ trigger: 'assignment_completed',
4225
+ assignment_id: assignmentId,
4226
+ claim_id: assignment.claim_id,
4227
+ cascade,
4228
+ cwd,
4229
+ });
4230
+ }
4231
+ catch { /* never block the update on cascade release */ }
4232
+ }
4070
4233
  // When accepted: auto-acknowledge the inbox message (replaces bclaw_ack_message)
4071
4234
  if (status === 'accepted' && assignment.message_id) {
4072
4235
  try {
@@ -4936,6 +5099,45 @@ async function _executeMcpToolCallInner(payload) {
4936
5099
  }
4937
5100
  }
4938
5101
  }
5102
+ let stalePostAging;
5103
+ let staleAggregate;
5104
+ let hintsPostAging;
5105
+ let hintsAggregate;
5106
+ if (contextResult) {
5107
+ try {
5108
+ const policy = loadHygienePolicy(targetCwd);
5109
+ if (!policy.disabled) {
5110
+ // Filter candidates from the projection (no extra reads) — only
5111
+ // assignments whose surfaced last_heartbeat_at is old enough to
5112
+ // possibly cross a family TTL. Zero read overhead when the open
5113
+ // work is fresh (the common case).
5114
+ const openAssignments = contextResult.open_work?.active_assignments ?? [];
5115
+ // Codex PR#48 finding 3 (pln#578 guardrail): select candidate ids
5116
+ // from the already-surfaced projection — created/terminal rows are
5117
+ // dropped BEFORE any full loadAssignment, so a healthy store costs
5118
+ // zero extra file reads. Selection logic is unit-tested in
5119
+ // selectReadPathSweepCandidates.
5120
+ const candidateIds = selectReadPathSweepCandidates(openAssignments, policy, Date.now());
5121
+ if (candidateIds.length > 0) {
5122
+ const full = candidateIds
5123
+ .map((id) => loadAssignment(id, targetCwd))
5124
+ .filter((a) => a !== undefined);
5125
+ sweepAssignmentsAtReadPath(full, targetCwd, {
5126
+ actor: 'bclaw_work-readpath',
5127
+ policy,
5128
+ });
5129
+ }
5130
+ const registry = loadServeRegistry(targetCwd);
5131
+ const aged = ageStaleWarnings(contextResult.stale_warnings ?? [], targetCwd, { policy, registry });
5132
+ stalePostAging = aged.warnings;
5133
+ staleAggregate = aged.aggregate;
5134
+ const agedHints = ageWorkflowHints(contextResult.workflow_hints ?? [], targetCwd, { policy, registry });
5135
+ hintsPostAging = agedHints.hints;
5136
+ hintsAggregate = agedHints.aggregate;
5137
+ }
5138
+ }
5139
+ catch { /* non-fatal — hygiene must never break bclaw_work */ }
5140
+ }
4939
5141
  // Build the full context result, then compact it if requested.
4940
5142
  // Compact mode (default) strips the heavy ContextResult down to a
4941
5143
  // minimal summary that fits within MCP token limits (~25k chars).
@@ -4951,7 +5153,8 @@ async function _executeMcpToolCallInner(payload) {
4951
5153
  status: item.extra ?? 'unknown',
4952
5154
  plan_id: item.plan_id,
4953
5155
  }));
4954
- const staleTop3 = (contextResult.stale_warnings ?? []).slice(0, 3).map((w) => ({
5156
+ const stalePool = stalePostAging ?? contextResult.stale_warnings ?? [];
5157
+ const staleTop3 = stalePool.slice(0, 3).map((w) => ({
4955
5158
  id: w.id,
4956
5159
  entity: w.entity,
4957
5160
  text: w.text.slice(0, 80),
@@ -4975,6 +5178,7 @@ async function _executeMcpToolCallInner(payload) {
4975
5178
  : {}),
4976
5179
  }
4977
5180
  : undefined;
5181
+ const hintsPool = hintsPostAging ?? contextResult.workflow_hints ?? [];
4978
5182
  resultPayload = {
4979
5183
  context_schema: contextResult.context_schema,
4980
5184
  profile: contextResult.profile,
@@ -4983,7 +5187,9 @@ async function _executeMcpToolCallInner(payload) {
4983
5187
  context_diff: trimmedDiff ?? null,
4984
5188
  plan_summary: planItems,
4985
5189
  stale_warnings: staleTop3,
4986
- workflow_hints: (contextResult.workflow_hints ?? []).slice(0, 3),
5190
+ ...(staleAggregate ? { stale_warnings_aggregate: staleAggregate } : {}),
5191
+ workflow_hints: hintsPool.slice(0, 3),
5192
+ ...(hintsAggregate ? { workflow_hints_aggregate: hintsAggregate } : {}),
4987
5193
  claim_conflicts: contextResult.claim_conflicts ?? [],
4988
5194
  open_work: contextResult.open_work ?? null,
4989
5195
  _compact: true,
@@ -6515,17 +6721,38 @@ async function _executeMcpToolCallInner(payload) {
6515
6721
  // only checks known keys), letting the caller believe the filter had
6516
6722
  // applied when it hadn't. Under the new contract, an unknown key is
6517
6723
  // a validation_error listing the keys actually honored.
6724
+ // trp#928 — the entity-scoping error is now first-class: the doc says
6725
+ // assignment_id/claim_id/message_id are entity='agent_run' only, but
6726
+ // before this the rejection message called them 'unknown', misleading
6727
+ // callers who'd cross-reference the description. Now the message names
6728
+ // the constraint AND the entity that DOES accept the key so the user
6729
+ // can fix the call without hunting through docs. (pln#599 docs-vs-facts.)
6518
6730
  const KNOWN_FILTER_KEYS = new Set([
6519
6731
  'status', 'tag', 'tags', 'author', 'plan_id', 'source', 'auto_generated',
6520
6732
  'assignment_id', 'claim_id', 'message_id',
6521
6733
  'limit', 'offset', 'includeLegacy', 'minAutoReflectConfidence',
6522
6734
  ]);
6523
6735
  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) {
6736
+ const providedKeys = Object.keys(filter);
6737
+ const unknownKeys = providedKeys.filter((k) => !KNOWN_FILTER_KEYS.has(k));
6738
+ const misScopedKeys = providedKeys.filter((k) => agentRunOnlyFilterKeys.has(k) && entity !== 'agent_run');
6739
+ if (unknownKeys.length > 0 || misScopedKeys.length > 0) {
6740
+ const parts = [];
6741
+ if (unknownKeys.length > 0) {
6742
+ parts.push(`Unknown filter key(s): ${unknownKeys.map((k) => `"${k}"`).join(', ')}. Accepted keys: ${[...KNOWN_FILTER_KEYS].sort().join(', ')}.`);
6743
+ }
6744
+ if (misScopedKeys.length > 0) {
6745
+ parts.push(`Filter key(s) ${misScopedKeys.map((k) => `"${k}"`).join(', ')} are only valid for entity="agent_run" `
6746
+ + `(this call used entity="${entity}"). `
6747
+ + `Retry with entity="agent_run", or drop the ${misScopedKeys.join('/')} filter.`);
6748
+ }
6526
6749
  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() }),
6750
+ response: createToolErrorResponse('validation_error', parts.join(' '), {
6751
+ unknown_keys: unknownKeys,
6752
+ mis_scoped_keys: misScopedKeys,
6753
+ accepted_keys: [...KNOWN_FILTER_KEYS].sort(),
6754
+ agent_run_only_keys: [...agentRunOnlyFilterKeys],
6755
+ }),
6529
6756
  };
6530
6757
  }
6531
6758
  const result = listEntities(entity, targetCwd, filter);
@@ -6664,29 +6891,36 @@ async function _executeMcpToolCallInner(payload) {
6664
6891
  const data = { ...rawData };
6665
6892
  let actor = typeof data.author === 'string' ? data.author : undefined;
6666
6893
  let actorId = typeof data.agent_id === 'string' ? data.agent_id : undefined;
6894
+ let autoRepair;
6667
6895
  if (data.author === undefined) {
6668
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6669
- data.author = agent_name;
6896
+ const author = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6897
+ data.author = author.agent_name;
6670
6898
  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;
6899
+ data.agent = author.agent_name;
6900
+ if (data.agent_id === undefined && author.agent_id)
6901
+ data.agent_id = author.agent_id;
6902
+ actor = author.agent_name;
6903
+ actorId = author.agent_id;
6904
+ autoRepair = author.auto_repair;
6676
6905
  }
6677
6906
  else if (data.agent === undefined) {
6678
6907
  data.agent = data.author;
6679
6908
  }
6680
6909
  const result = createEntity(entity, data, targetCwd);
6681
6910
  appendAuditEntry({ actor: actor ?? 'unknown', ...(actorId ? { actor_id: actorId } : {}), action: 'create', item_id: result.id, item_type: entity }, targetCwd);
6911
+ const createText = `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
6912
+ const createContent = autoRepair
6913
+ ? [{ type: 'text', text: createText }, { type: 'text', text: renderAutoRepairWarning(autoRepair, actor ?? 'unknown') }]
6914
+ : [{ type: 'text', text: createText }];
6682
6915
  return {
6683
6916
  response: toolResponse({
6684
- content: [{ type: 'text', text: `✔ created ${entity} ${result.id}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}` }],
6917
+ content: createContent,
6685
6918
  structuredContent: {
6686
6919
  ...result,
6687
6920
  resolved_project: targetScope.resolved_project,
6688
6921
  active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
6689
6922
  ...(autoSwitched ? { auto_switched: true } : {}),
6923
+ ...(autoRepair ? { auto_repair: autoRepair } : {}),
6690
6924
  },
6691
6925
  }),
6692
6926
  };
@@ -6702,16 +6936,21 @@ async function _executeMcpToolCallInner(payload) {
6702
6936
  const patch = (args.patch ?? {});
6703
6937
  const targetCwd = resolveProjectCwd(args.project, cwd);
6704
6938
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6705
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6939
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6706
6940
  const result = updateEntity(entity, id, patch, targetCwd);
6707
6941
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'update', item_id: id, item_type: entity }, targetCwd);
6942
+ const updateText = `✔ updated ${entity} ${id}`;
6943
+ const updateContent = auto_repair
6944
+ ? [{ type: 'text', text: updateText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6945
+ : [{ type: 'text', text: updateText }];
6708
6946
  return {
6709
6947
  response: toolResponse({
6710
- content: [{ type: 'text', text: `✔ updated ${entity} ${id}` }],
6948
+ content: updateContent,
6711
6949
  structuredContent: {
6712
6950
  ...result,
6713
6951
  resolved_project: targetScope.resolved_project,
6714
6952
  active_source: targetScope.active_source,
6953
+ ...(auto_repair ? { auto_repair } : {}),
6715
6954
  },
6716
6955
  }),
6717
6956
  };
@@ -6727,16 +6966,21 @@ async function _executeMcpToolCallInner(payload) {
6727
6966
  const purge = args.purge === true;
6728
6967
  const targetCwd = resolveProjectCwd(args.project, cwd);
6729
6968
  const targetScope = scopeMetadataForTarget(args, targetCwd, scopeInfo);
6730
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6969
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6731
6970
  const result = removeEntity(entity, id, targetCwd, purge);
6732
6971
  appendAuditEntry({ actor: agent_name, ...(agent_id ? { actor_id: agent_id } : {}), action: 'delete', item_id: id, item_type: entity, reason: purge ? 'purged' : 'archived' }, targetCwd);
6972
+ const removeText = `✔ removed ${entity} ${id}`;
6973
+ const removeContent = auto_repair
6974
+ ? [{ type: 'text', text: removeText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
6975
+ : [{ type: 'text', text: removeText }];
6733
6976
  return {
6734
6977
  response: toolResponse({
6735
- content: [{ type: 'text', text: `✔ removed ${entity} ${id}` }],
6978
+ content: removeContent,
6736
6979
  structuredContent: {
6737
6980
  ...result,
6738
6981
  resolved_project: targetScope.resolved_project,
6739
6982
  active_source: targetScope.active_source,
6983
+ ...(auto_repair ? { auto_repair } : {}),
6740
6984
  },
6741
6985
  }),
6742
6986
  };
@@ -6752,13 +6996,20 @@ async function _executeMcpToolCallInner(payload) {
6752
6996
  const toProject = String(args.to_project ?? '');
6753
6997
  const fromProject = typeof args.from_project === 'string' ? args.from_project : undefined;
6754
6998
  const force = args.force === true;
6755
- const { agent_name, agent_id } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6999
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
6756
7000
  const result = relocateEntity({ entity, id, toProject, fromProject, force, cwd, actor: agent_name, actorId: agent_id });
6757
7001
  const warn = result.warnings.length ? ` (${result.warnings.length} warning(s))` : '';
7002
+ const moveText = `✔ moved ${entity} ${id} → ${result.to}${warn}`;
7003
+ const moveContent = auto_repair
7004
+ ? [{ type: 'text', text: moveText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
7005
+ : [{ type: 'text', text: moveText }];
6758
7006
  return {
6759
7007
  response: toolResponse({
6760
- content: [{ type: 'text', text: `✔ moved ${entity} ${id} → ${result.to}${warn}` }],
6761
- structuredContent: { ...result },
7008
+ content: moveContent,
7009
+ structuredContent: {
7010
+ ...result,
7011
+ ...(auto_repair ? { auto_repair } : {}),
7012
+ },
6762
7013
  }),
6763
7014
  };
6764
7015
  }
@@ -6788,17 +7039,48 @@ async function _executeMcpToolCallInner(payload) {
6788
7039
  const to = String(args.to ?? '');
6789
7040
  const reason = args.reason;
6790
7041
  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);
7042
+ const { agent_name, agent_id, auto_repair } = resolveCanonicalAuthor(args, cwd, connectionSessionId);
7043
+ // trp#928 claim transitions consume the ReleaseClaimAuth ownership
7044
+ // check (released/stale both mutate a claim owned by SOME agent). Reuse
7045
+ // the same coordinator_override opt-in as bclaw_release_claim so both
7046
+ // paths have identical trust semantics and the same executable error.
7047
+ let transitionAuth;
7048
+ if (entity === 'claim') {
7049
+ const transitionIdentity = resolveMutationIdentity(args, { nameField: 'agent', idField: 'agentId' }, targetCwd, connectionSessionId);
7050
+ const coordinatorOverrideRequested = args.coordinator_override === true;
7051
+ if (coordinatorOverrideRequested) {
7052
+ const identity = 'identity' in transitionIdentity ? transitionIdentity.identity : undefined;
7053
+ const trustLevel = identity?.trust_level ?? 'contributor';
7054
+ if (!hasMinimumTrustLevel(trustLevel, 'trusted')) {
7055
+ return {
7056
+ response: createToolErrorResponse('trust_error', `coordinator_override:true requires trust_level 'trusted' or higher — caller is '${trustLevel}'.`),
7057
+ };
7058
+ }
7059
+ }
7060
+ transitionAuth = 'identity' in transitionIdentity && transitionIdentity.identity
7061
+ ? {
7062
+ agent: transitionIdentity.identity.agent_name,
7063
+ agent_id: transitionIdentity.identity.agent_id,
7064
+ session_id: connectionSessionId,
7065
+ override: coordinatorOverrideRequested,
7066
+ }
7067
+ : undefined;
7068
+ }
7069
+ const result = transitionEntity(entity, id, to, targetCwd, reason, transitionAuth);
6793
7070
  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);
7071
+ const transitionText = `✔ ${entity} ${id}: ${result.from} → ${to}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}`;
7072
+ const transitionContent = auto_repair
7073
+ ? [{ type: 'text', text: transitionText }, { type: 'text', text: renderAutoRepairWarning(auto_repair, agent_name) }]
7074
+ : [{ type: 'text', text: transitionText }];
6794
7075
  return {
6795
7076
  response: toolResponse({
6796
- content: [{ type: 'text', text: `✔ ${entity} ${id}: ${result.from} → ${to}${autoSwitched ? ` (auto-switched → ${targetScope.resolved_project.name ?? targetScope.resolved_project.path})` : ''}` }],
7077
+ content: transitionContent,
6797
7078
  structuredContent: {
6798
7079
  ...result,
6799
7080
  resolved_project: targetScope.resolved_project,
6800
7081
  active_source: autoSwitched ? 'auto_switch' : targetScope.active_source,
6801
7082
  ...(autoSwitched ? { auto_switched: true } : {}),
7083
+ ...(auto_repair ? { auto_repair } : {}),
6802
7084
  },
6803
7085
  }),
6804
7086
  };
@@ -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);