brainclaw 1.14.0 → 1.16.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.
Files changed (63) hide show
  1. package/README.md +16 -263
  2. package/dist/brainclaw-vscode.vsix +0 -0
  3. package/dist/cli/register-capture.js +209 -0
  4. package/dist/cli/register-code-map.js +19 -0
  5. package/dist/cli/register-coordination.js +472 -0
  6. package/dist/cli/register-federation.js +258 -0
  7. package/dist/cli/register-lifecycle.js +436 -0
  8. package/dist/cli/register-memory-context.js +502 -0
  9. package/dist/cli/register-planning.js +167 -0
  10. package/dist/cli/register-review.js +149 -0
  11. package/dist/cli/shared.js +5 -0
  12. package/dist/cli.js +212 -2015
  13. package/dist/commands/dispatch-watch.js +25 -2
  14. package/dist/commands/harvest.js +31 -6
  15. package/dist/commands/mcp-catalog.js +1438 -0
  16. package/dist/commands/mcp-contract.js +33 -0
  17. package/dist/commands/mcp-presentation.js +27 -0
  18. package/dist/commands/mcp-read-handlers.js +72 -36
  19. package/dist/commands/mcp-write-admin.js +328 -0
  20. package/dist/commands/mcp-write-claims.js +864 -0
  21. package/dist/commands/mcp-write-coordination.js +1825 -0
  22. package/dist/commands/mcp-write-entities.js +620 -0
  23. package/dist/commands/mcp-write-memory.js +451 -0
  24. package/dist/commands/mcp-write-sequences.js +116 -0
  25. package/dist/commands/mcp-write-support.js +367 -0
  26. package/dist/commands/mcp.js +261 -5570
  27. package/dist/commands/update-handoff.js +28 -42
  28. package/dist/core/agent-capability.js +31 -14
  29. package/dist/core/agent-files.js +1 -1
  30. package/dist/core/agent-registry.js +51 -3
  31. package/dist/core/claims.js +18 -0
  32. package/dist/core/coordination.js +5 -2
  33. package/dist/core/cross-project.js +35 -1
  34. package/dist/core/dispatcher.js +34 -20
  35. package/dist/core/entity-operations.js +335 -12
  36. package/dist/core/entity-registry.js +72 -9
  37. package/dist/core/execution.js +28 -4
  38. package/dist/core/facade-schema.js +30 -4
  39. package/dist/core/federation-cloud.js +142 -11
  40. package/dist/core/federation-outbox.js +292 -0
  41. package/dist/core/federation-signing.js +115 -0
  42. package/dist/core/handoff-review.js +35 -0
  43. package/dist/core/io.js +6 -0
  44. package/dist/core/protocol-tool-policy.js +113 -0
  45. package/dist/core/review-loop-close.js +115 -0
  46. package/dist/core/schema.js +25 -2
  47. package/dist/core/security-detectors.js +35 -6
  48. package/dist/core/security.js +32 -12
  49. package/dist/core/worktree.js +98 -9
  50. package/dist/facts.js +13 -11
  51. package/dist/facts.json +12 -10
  52. package/docs/PROTOCOL.md +7 -3
  53. package/docs/concepts/coordinator-runbook.md +3 -0
  54. package/docs/concepts/dispatch-lifecycle.md +4 -4
  55. package/docs/concepts/loop-engine.md +3 -1
  56. package/docs/concepts/troubleshooting.md +1 -1
  57. package/docs/integrations/codex.md +3 -3
  58. package/docs/integrations/overview.md +1 -1
  59. package/docs/mcp-schema-changelog.md +153 -2
  60. package/docs/playbooks/orchestration.md +1 -1
  61. package/docs/product/entity-model-audit.md +3 -2
  62. package/docs/security.md +22 -1
  63. package/package.json +3 -1
@@ -6,10 +6,12 @@
6
6
  * Phase 3 slice 3b (pln_c6472192). Keeps imperative code where it lives
7
7
  * (per P6.2) — this module only routes.
8
8
  *
9
- * MVP wiring (this landing): plan, decision, constraint, trap,
10
- * runtime_note, candidate. Other entities throw
11
- * `EntityOperationUnsupportedError` with a pointer at the legacy tool
12
- * until later slices wire them in.
9
+ * Write-verb wiring is per entity. An unwired write verb picks its error from
10
+ * the registry's writePolicy (pln#625 Phase 2): a `system` entity (session,
11
+ * inbox_message, instruction, assignment, agent_run, action) reports the
12
+ * curated `SystemManagedError` naming its authorized path; an agent-ownable one
13
+ * (default) reports `EntityOperationUnsupportedError` ("not yet wired"). An
14
+ * unknown entity name is rejected at the front door with `UnknownEntityError`.
13
15
  */
14
16
  import path from 'node:path';
15
17
  import { loadState, mutateState } from './state.js';
@@ -17,6 +19,11 @@ import { archiveCandidate, listCandidates, loadCandidate, saveCandidate, } from
17
19
  import { addCrossProjectLink, removeCrossProjectLink, resolveCrossProjectLinks, } from './cross-project.js';
18
20
  import { findActiveClaimsForPlan, listClaims, loadClaim, logCascadeReleaseResult, markClaimStale, releaseClaimsCascade, releaseClaimWithCascade, saveClaim, } from './claims.js';
19
21
  import { listActionRequired } from './actions.js';
22
+ import { listAgentIdentities } from './agent-registry.js';
23
+ import { getCapabilityProfile, getSpawnableAgents } from './agent-capability.js';
24
+ import { buildReputationSnapshot, toPublicReputationSummary } from './reputation.js';
25
+ import { loadAllSessions } from './identity.js';
26
+ import { loadInstructions } from './instructions.js';
20
27
  import { deleteAssignment, listAssignments, loadAssignment, saveAssignment, transitionAssignment } from './assignments.js';
21
28
  import { listAgentRuns } from './agentruns.js';
22
29
  import { reconcileAgentRun, reconcileDeadPidRunningAgentRunAtRead, TERMINAL_STATUSES } from './agentrun-reconciler.js';
@@ -26,9 +33,10 @@ import { createSequence, deleteSequence, listSequences, updateSequence, } from '
26
33
  import { createConstraint, createDecision, createTrap, } from './operations/memory-write.js';
27
34
  import { deleteMemoryItem, findMemoryItemInChain, updateMemoryItem, } from './operations/memory-mutation.js';
28
35
  import { createPlan, deletePlan, updatePlan, } from './operations/plan.js';
29
- import { ENTITY_REGISTRY, isValidTransition, } from './entity-registry.js';
36
+ import { ENTITY_NAMES, ENTITY_REGISTRY, isValidTransition, } from './entity-registry.js';
30
37
  import { generateId } from './ids.js';
31
- import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SequenceStatusSchema, SeveritySchema, } from './schema.js';
38
+ import { mergeHandoffReview } from './handoff-review.js';
39
+ import { CandidateTypeSchema, ConstraintCategorySchema, DecisionOutcomeSchema, HandoffContractSchema, HandoffReviewSchema, MemoryVisibilitySchema, PlanTypeEnumSchema, PrioritySchema, RuntimeNoteTypeSchema, SequenceStatusSchema, SeveritySchema, } from './schema.js';
32
40
  /**
33
41
  * Default provenance stamp applied on create when the caller does not
34
42
  * supply one. `user` kind with whatever author is in the payload; the
@@ -88,12 +96,114 @@ export class EntityNotFoundError extends Error {
88
96
  this.name = 'EntityNotFoundError';
89
97
  }
90
98
  }
99
+ /**
100
+ * Thrown when a write verb targets a `writePolicy:'system'` entity via a verb
101
+ * that is not agent-wired: the runtime owns these records, so this is a
102
+ * deliberate "not agent-writable" boundary, NOT a "coming soon" gap. Names the
103
+ * authorized path (writePolicyNote) so the caller knows where the write really
104
+ * happens. pln#625 Phase 2 — replaces the misleading "not yet wired. Use the
105
+ * legacy tool" for system entities.
106
+ */
107
+ export class SystemManagedError extends Error {
108
+ constructor(entity, verb, note) {
109
+ super(
110
+ // Verb-scoped, not entity-scoped: some system entities have OTHER wired
111
+ // verbs (e.g. assignment transition/update), so don't claim the whole
112
+ // entity is unwritable — only that THIS verb is not an agent-facing
113
+ // grammar path for it.
114
+ `bclaw_${verb}(entity='${entity}'): ${entity} is system-managed — bclaw_${verb} is not available for it via the canonical grammar.`
115
+ + (note ? ` ${note}.` : ''));
116
+ this.name = 'SystemManagedError';
117
+ }
118
+ }
119
+ /**
120
+ * Pick the right "this write verb isn't available" error for an unwired entity,
121
+ * from the registry data (no per-switch string drift): a system-managed entity
122
+ * gets the SystemManagedError boundary; an agent-ownable one gets the
123
+ * "not yet wired" signal. Call this from a write verb's switch DEFAULT only —
124
+ * explicitly-wired verbs return before reaching it.
125
+ */
126
+ function writeUnsupported(name, verb) {
127
+ const spec = ENTITY_REGISTRY[name];
128
+ if (spec?.writePolicy === 'system') {
129
+ return new SystemManagedError(name, verb, spec.writePolicyNote);
130
+ }
131
+ // Preserve the transition-specific hint the old default carried, so an
132
+ // agent-ownable-but-unwired transition (e.g. handoff) keeps its precise
133
+ // message rather than the generic "use the legacy tool" filler.
134
+ const hint = verb === 'transition' ? `Lifecycle transitions for ${name} not yet wired.` : undefined;
135
+ return new EntityOperationUnsupportedError(name, verb, hint);
136
+ }
137
+ /**
138
+ * Thrown when a canonical verb is called with an entity name that is not in the
139
+ * registry at all (e.g. bclaw_update(entity='agent')). Previously such a name
140
+ * reached `ENTITY_REGISTRY[name].updatable` and died on a raw TypeError
141
+ * ("Cannot read properties of undefined") — a leaked internal, not an
142
+ * operator-legible error. This front-door guard turns it into a curated message
143
+ * that lists the addressable entities (pln#625 Phase 1).
144
+ */
145
+ export class UnknownEntityError extends Error {
146
+ constructor(entity, verb) {
147
+ super(`bclaw_${verb}(entity='${entity}') — unknown entity. ` +
148
+ // Deliberately "registered", not "supported": some listed entities are
149
+ // not yet wired for every verb (they return EntityOperationUnsupportedError,
150
+ // a different, already-curated signal). Don't imply all are writable here.
151
+ // NB agent is now a registered read-only entity (pln#625 Phase 2c): a write
152
+ // verb on it reaches the SystemManagedError boundary, not this front door.
153
+ `Registered entities (not all are wired for every verb yet): ${ENTITY_NAMES.join(', ')}.`);
154
+ this.name = 'UnknownEntityError';
155
+ }
156
+ }
157
+ /**
158
+ * Front-door guard for every canonical verb: reject an entity name that is not
159
+ * in the registry with a curated UnknownEntityError instead of letting it fall
160
+ * through to a raw property access. `name` is typed EntityName at the call
161
+ * sites, but the MCP layer passes an unvalidated string (entity is a free
162
+ * string on the published surface), so this runtime check is load-bearing.
163
+ */
164
+ function assertKnownEntity(name, verb) {
165
+ if (!Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, name)) {
166
+ throw new UnknownEntityError(name, verb);
167
+ }
168
+ }
91
169
  export class InvalidTransitionError extends Error {
92
170
  constructor(entity, from, to) {
93
171
  super(`Invalid transition for ${entity}: ${from} -> ${to}`);
94
172
  this.name = 'InvalidTransitionError';
95
173
  }
96
174
  }
175
+ /**
176
+ * Canonical declaration of the bclaw_find/get FILTER grammar — the single
177
+ * source of truth for BOTH the MCP handler's validation and the governance
178
+ * fingerprint. Entity reachability lives in ENTITY_NAMES; this covers the
179
+ * filter contract, which is otherwise invisible to the published inputSchema
180
+ * (`filter` is an unconstrained object). Folded into the governance fingerprint
181
+ * (pln#625, Codex review of PR #82) so adding/renaming/re-scoping a filter key
182
+ * — or changing an accepted value — forces a changelog entry, closing the same
183
+ * blind-spot class as free-string `entity`.
184
+ */
185
+ export const GRAMMAR_FILTER_CONTRACT = {
186
+ /** Accepted for ANY entity. */
187
+ common: [
188
+ 'status', 'tag', 'tags', 'author', 'plan_id', 'source', 'auto_generated',
189
+ 'limit', 'offset', 'includeLegacy', 'minAutoReflectConfidence',
190
+ ],
191
+ /** Keys accepted ONLY for the named entity (rejected with a validation_error elsewhere). */
192
+ entityScoped: {
193
+ agent_run: ['assignment_id', 'claim_id', 'message_id'],
194
+ agent: ['scope', 'includeReputation'],
195
+ },
196
+ /** Filter keys whose value is constrained to a fixed set. */
197
+ constrainedValues: {
198
+ scope: ['project', 'global'],
199
+ },
200
+ /**
201
+ * Filter keys whose value MUST be a boolean. Validated at the MCP front door
202
+ * so a stringy `"true"` is rejected loudly instead of silently coercing to a
203
+ * no-op (loadAgentsForRead requires a strict `=== true`). Codex review of #83.
204
+ */
205
+ booleanKeys: ['includeReputation'],
206
+ };
97
207
  // ─── FIND ─────────────────────────────────────────────────────────────
98
208
  /**
99
209
  * Lazy reconciliation pass on agent_run reads (pln#503 phase 3.2).
@@ -140,7 +250,8 @@ function loadAgentRunsWithReconciliation(cwd) {
140
250
  return listAgentRuns(cwd);
141
251
  }
142
252
  export function listEntities(name, cwd, filter = {}) {
143
- const all = loadAll(name, cwd);
253
+ assertKnownEntity(name, 'find');
254
+ const all = loadAll(name, cwd, filter);
144
255
  const fieldFiltered = applyFieldFilter(all, filter);
145
256
  const excludedLegacy = filter.includeLegacy === true
146
257
  ? 0
@@ -193,7 +304,101 @@ export function boundListResult(result, offset, charBudget = DEFAULT_FIND_CHAR_B
193
304
  }
194
305
  return bounded;
195
306
  }
196
- function loadAll(name, cwd) {
307
+ /**
308
+ * Redacted read-only projection of an agent identity (pln#625 Phase 2c;
309
+ * refined by ideation loop lop_f8e8d18cb8c27ada). Shared by the grammar
310
+ * (find/get) AND bclaw_list_agents so redaction has ONE source of truth.
311
+ *
312
+ * Strict ALLOW-LIST — any field added to AgentIdentityDocument later stays
313
+ * hidden until someone deliberately projects it here:
314
+ * - identity_key (private-ish key material: the PEM public_key) is dropped.
315
+ * - `fingerprint` is the sha256(PEM) — the PUBLIC canonical key id, equal to
316
+ * the cloud's agents.key_fingerprint — so it is exposed IN FULL: truncating
317
+ * it added no confidentiality (a hash never reveals its preimage; the id is
318
+ * public) and broke the whole point (matching a local key against the
319
+ * 256-bit remote fingerprint).
320
+ * - `invoke` is intentionally NOT projected: it is populated by no writer and
321
+ * read by no spawn path (buildInvokeCommand uses the capability profile, not
322
+ * identity.invoke), so exposing it was dead surface — and it would leak
323
+ * invoke.command (spawn flags, possibly tokens) verbatim.
324
+ * `short_label` mirrors agent_name so the generic getEntity matcher resolves an
325
+ * agent by id OR name (same trick as session's session_id→id alias).
326
+ */
327
+ export function projectAgentForRead(doc) {
328
+ return {
329
+ id: doc.agent_id,
330
+ short_label: doc.agent_name,
331
+ name: doc.agent_name,
332
+ kind: doc.kind,
333
+ trust_level: doc.trust_level,
334
+ capabilities: doc.capabilities,
335
+ fingerprint: doc.identity_key?.fingerprint,
336
+ model: doc.model,
337
+ context_profile: doc.context_profile,
338
+ created_at: doc.created_at,
339
+ };
340
+ }
341
+ /**
342
+ * Catalog-only stub for a spawnable agent that is NOT in this project's
343
+ * registry (scope='global' only). It has no identity document, so only the
344
+ * name + dispatchability are known.
345
+ */
346
+ function projectCatalogAgentForRead(name) {
347
+ return {
348
+ id: null,
349
+ short_label: name,
350
+ name,
351
+ kind: null,
352
+ trust_level: null,
353
+ capabilities: [],
354
+ dispatchable: true,
355
+ registered: false,
356
+ };
357
+ }
358
+ /**
359
+ * Load agents for a read (pln#625 Phase 2c, hybrid scope from lop_f8e8d18cb8c27ada).
360
+ * Default scope = the CURRENT PROJECT's registry (`.brainclaw/agents`, exactly
361
+ * what `list-agents` reads — there is NO global registry on disk). scope='global'
362
+ * additionally unions the static dispatchable catalog (getSpawnableAgents) and
363
+ * annotates each entry with `dispatchable` (canBeSpawnedCli) + `registered`
364
+ * (present in this project's registry) — the honest inventory a coordinator
365
+ * needs, since bclaw_dispatch resolves candidates from that catalog, NOT from
366
+ * the project registry.
367
+ */
368
+ function loadAgentsForRead(cwd, filter) {
369
+ // Opt-in reputation join (pln#625 — folds the sole capability bclaw_list_agents
370
+ // had that find(agent) lacked, so that tool can be retired). Keyed by agent_id,
371
+ // which the projection exposes as `id`; catalog-only agents carry no reputation.
372
+ const reputationById = filter?.includeReputation === true
373
+ ? new Map((buildReputationSnapshot(cwd).agents ?? []).map((a) => [a.agent_id ?? a.key, toPublicReputationSummary(a)]))
374
+ : undefined;
375
+ const project = (doc) => {
376
+ const row = projectAgentForRead(doc);
377
+ if (reputationById)
378
+ row.reputation = reputationById.get(String(row.id));
379
+ return row;
380
+ };
381
+ const registered = listAgentIdentities(cwd).map(project);
382
+ const scope = typeof filter?.scope === 'string' ? filter.scope : 'project';
383
+ if (scope !== 'global')
384
+ return registered;
385
+ const byName = new Map();
386
+ for (const a of registered) {
387
+ a.registered = true;
388
+ a.dispatchable = getCapabilityProfile(String(a.name))?.runtime.canBeSpawnedCli ?? false;
389
+ byName.set(String(a.name), a);
390
+ }
391
+ for (const { name } of getSpawnableAgents()) {
392
+ const existing = byName.get(name);
393
+ if (existing) {
394
+ existing.dispatchable = true;
395
+ continue;
396
+ }
397
+ byName.set(name, projectCatalogAgentForRead(name));
398
+ }
399
+ return [...byName.values()];
400
+ }
401
+ function loadAll(name, cwd, filter) {
197
402
  switch (name) {
198
403
  case 'plan': return loadState(cwd).plan_items;
199
404
  case 'decision': return loadState(cwd).recent_decisions;
@@ -207,7 +412,21 @@ function loadAll(name, cwd) {
207
412
  case 'action': return listActionRequired(cwd);
208
413
  case 'assignment': return listAssignments(cwd);
209
414
  case 'agent_run': return loadAgentRunsWithReconciliation(cwd);
415
+ // pln#625 Phase 2c — agent is READ-ONLY via the grammar, redacted projection.
416
+ // Default scope = current project's registry; filter.scope='global' unions
417
+ // the dispatchable catalog. See loadAgentsForRead.
418
+ case 'agent': return loadAgentsForRead(cwd, filter);
210
419
  case 'cross_project_link': return resolveCrossProjectLinks(cwd);
420
+ // pln#625 Phase 2 — wire the previously-unwired reads.
421
+ case 'step': return loadState(cwd).plan_items.flatMap((p) => p.steps ?? []);
422
+ // session is keyed by session_id, not id — alias it so get/find match on either.
423
+ case 'session': return loadAllSessions(cwd).map((s) => ({ ...s, id: s.session_id }));
424
+ case 'instruction': return loadInstructions(cwd);
425
+ case 'inbox_message':
426
+ // Messages are inherently per-agent; the canonical find/get has no agent
427
+ // scope and there is no cross-agent aggregate loader. Route reads to the
428
+ // dedicated per-agent tool instead of inventing a misleading global list.
429
+ throw new EntityOperationUnsupportedError(name, 'find', 'Messages are per-agent — read them via bclaw_read_inbox(agent=…), not the canonical grammar.');
211
430
  default:
212
431
  throw new EntityOperationUnsupportedError(name, 'find');
213
432
  }
@@ -258,6 +477,7 @@ function applyPaging(items, filter) {
258
477
  }
259
478
  // ─── GET ───────────────────────────────────────────────────────────────
260
479
  export function getEntity(name, idOrShortLabel, cwd) {
480
+ assertKnownEntity(name, 'get');
261
481
  if (name === 'cross_project_link') {
262
482
  const links = resolveCrossProjectLinks(cwd);
263
483
  const hit = links.find((l) => l.name === idOrShortLabel ||
@@ -277,6 +497,7 @@ export function getEntity(name, idOrShortLabel, cwd) {
277
497
  }
278
498
  // ─── CREATE ────────────────────────────────────────────────────────────
279
499
  export function createEntity(name, data, cwd) {
500
+ assertKnownEntity(name, 'create');
280
501
  switch (name) {
281
502
  case 'plan': {
282
503
  // Explicit field whitelist + required-author check brings plan create in line
@@ -403,12 +624,23 @@ export function createEntity(name, data, cwd) {
403
624
  return { entity: name, id: link.name ?? link.path };
404
625
  }
405
626
  default:
406
- throw new EntityOperationUnsupportedError(name, 'create');
627
+ throw writeUnsupported(name, 'create');
407
628
  }
408
629
  }
409
630
  // ─── UPDATE ────────────────────────────────────────────────────────────
410
631
  export function updateEntity(name, id, patch, cwd) {
632
+ assertKnownEntity(name, 'update');
411
633
  const spec = ENTITY_REGISTRY[name];
634
+ // An entity with NO updatable fields is not patchable via the grammar at all,
635
+ // so route to the curated boundary (SystemManagedError for a system entity,
636
+ // "not yet wired" otherwise) rather than the misleading "Fields not updatable
637
+ // … use bclaw_transition" — which assumes a lifecycle the entity may not have
638
+ // (e.g. agent, inbox_message have neither updatable fields nor transitions).
639
+ // Entities with a non-empty updatable list (incl. the wired case 'assignment'
640
+ // below) are unaffected. pln#625 Phase 2c.
641
+ if (spec.updatable.length === 0) {
642
+ throw writeUnsupported(name, 'update');
643
+ }
412
644
  const invalidFields = Object.keys(patch).filter((field) => !spec.updatable.includes(field));
413
645
  if (invalidFields.length > 0) {
414
646
  throw new Error(`Fields not updatable on ${name}: [${invalidFields.join(', ')}]. ` +
@@ -523,12 +755,75 @@ export function updateEntity(name, id, patch, cwd) {
523
755
  });
524
756
  return { entity: name, id: merged.name ?? merged.path };
525
757
  }
758
+ case 'handoff': {
759
+ // pln#625 Phase 3 — wire the handoff update path (previously unwired: the
760
+ // field check passed for narrative/tags but the switch fell to the default
761
+ // "not yet wired"). Restores the review-state write capability lost when
762
+ // update_handoff was removed at v1.0 — an agent can now write a review
763
+ // verdict via bclaw_update(entity='handoff', data={review:{verdict,…}}).
764
+ // review/contract are validated against their Zod schemas and MERGED onto
765
+ // the record (same field-merge semantics as the review loop's core
766
+ // applyHandoffUpdates); narrative/tags are set directly.
767
+ // Validate on the WRITE path with .strict() so an unknown key (e.g. a
768
+ // `review_verdict` typo that Zod would otherwise silently strip) is
769
+ // rejected loudly, and require at least one recognized field so an empty
770
+ // `{}` patch can't masquerade as a successful no-op (Codex review of #84).
771
+ // The base read schema stays non-strict — historical handoffs may carry
772
+ // extra fields.
773
+ let parsedReview;
774
+ let parsedContract;
775
+ if (patch.review !== undefined) {
776
+ const r = HandoffReviewSchema.strict().safeParse(patch.review);
777
+ if (!r.success) {
778
+ throw new Error(`Invalid handoff.review: ${r.error.issues.map((i) => i.message).join('; ')}`);
779
+ }
780
+ if (Object.keys(r.data).length === 0) {
781
+ throw new Error('handoff.review patch has no recognized fields (nothing to update).');
782
+ }
783
+ parsedReview = r.data;
784
+ }
785
+ if (patch.contract !== undefined) {
786
+ const c = HandoffContractSchema.strict().safeParse(patch.contract);
787
+ if (!c.success) {
788
+ throw new Error(`Invalid handoff.contract: ${c.error.issues.map((i) => i.message).join('; ')}`);
789
+ }
790
+ if (Object.keys(c.data).length === 0) {
791
+ throw new Error('handoff.contract patch has no recognized fields (nothing to update).');
792
+ }
793
+ parsedContract = c.data;
794
+ }
795
+ mutateState((state) => {
796
+ const item = state.open_handoffs.find((h) => h.id === id);
797
+ if (!item)
798
+ throw new EntityNotFoundError(name, id);
799
+ // Tip guard (mirrors transition): a superseded handoff is a frozen
800
+ // tombstone — refuse to mutate it, point at the tip.
801
+ if (typeof item.superseded_by === 'string' && item.superseded_by) {
802
+ throw new Error(`Handoff '${id}' was superseded by ${item.superseded_by} and is an immutable tombstone. `
803
+ + `Update the current tip (${item.superseded_by}) instead.`);
804
+ }
805
+ if (patch.narrative !== undefined)
806
+ item.narrative = patch.narrative;
807
+ if (patch.tags !== undefined)
808
+ item.tags = patch.tags;
809
+ // Review merge + reviewed_at stamping via the shared core helper (single
810
+ // source of truth with applyHandoffUpdates — no drift between paths).
811
+ if (parsedReview !== undefined) {
812
+ item.review = mergeHandoffReview(item.review, parsedReview);
813
+ }
814
+ if (parsedContract !== undefined) {
815
+ item.contract = { ...(item.contract ?? {}), ...parsedContract };
816
+ }
817
+ }, cwd);
818
+ return { entity: name, id };
819
+ }
526
820
  default:
527
- throw new EntityOperationUnsupportedError(name, 'update');
821
+ throw writeUnsupported(name, 'update');
528
822
  }
529
823
  }
530
824
  // ─── REMOVE ────────────────────────────────────────────────────────────
531
825
  export function removeEntity(name, id, cwd, purge = false) {
826
+ assertKnownEntity(name, 'remove');
532
827
  switch (name) {
533
828
  case 'plan': {
534
829
  deletePlan(id, cwd);
@@ -596,10 +891,11 @@ export function removeEntity(name, id, cwd, purge = false) {
596
891
  return { entity: name, id, archived: true, purged: false };
597
892
  }
598
893
  default:
599
- throw new EntityOperationUnsupportedError(name, 'remove');
894
+ throw writeUnsupported(name, 'remove');
600
895
  }
601
896
  }
602
897
  export function transitionEntity(name, id, to, cwd, _reason, auth) {
898
+ assertKnownEntity(name, 'transition');
603
899
  const spec = ENTITY_REGISTRY[name];
604
900
  if (!spec.statusField) {
605
901
  throw new Error(`${name} has no lifecycle (statusField is undefined)`);
@@ -704,8 +1000,35 @@ export function transitionEntity(name, id, to, cwd, _reason, auth) {
704
1000
  // isValidTransition already excluded every other target — belt-and-braces:
705
1001
  throw new InvalidTransitionError(name, from, to);
706
1002
  }
1003
+ case 'handoff': {
1004
+ // pln#625 Phase 2a — wire the handoff lifecycle (open→accepted|closed,
1005
+ // accepted→closed; the matrix is enforced by isValidTransition above).
1006
+ // This also repairs `brainclaw stale resolve <handoff-id>`, which routes
1007
+ // through bclaw_transition(entity='handoff') and previously fell to the
1008
+ // "not yet wired" default (trp_ed1a21eb).
1009
+ //
1010
+ // Tip guard: a handoff carrying `superseded_by` is the frozen original a
1011
+ // correction replaced — correctHandoff leaves it immutable and pushes the
1012
+ // correction as the new tip (mcp-write-entities.ts:356). Transitioning a
1013
+ // superseded record would mutate frozen history, so refuse it and point at
1014
+ // the tip, mirroring correctHandoff's own guard.
1015
+ if (typeof current.superseded_by === 'string' && current.superseded_by) {
1016
+ throw new Error(`Handoff '${id}' was superseded by ${current.superseded_by} and is an immutable tombstone. `
1017
+ + `Transition the current tip (${current.superseded_by}) instead.`);
1018
+ }
1019
+ mutateState((state) => {
1020
+ const item = state.open_handoffs.find((h) => h.id === id);
1021
+ if (!item)
1022
+ throw new EntityNotFoundError(name, id);
1023
+ item[statusField] = to;
1024
+ }, cwd);
1025
+ return { entity: name, id, from, to, side_effects: sideEffects };
1026
+ }
707
1027
  default:
708
- throw new EntityOperationUnsupportedError(name, 'transition', `Lifecycle transitions for ${name} not yet wired.`);
1028
+ // pln#625 Phase 2 — system-managed entities (action/agent_run) report the
1029
+ // curated "system-managed" boundary; agent-ownable-but-unwired ones keep
1030
+ // the "not yet wired" signal.
1031
+ throw writeUnsupported(name, 'transition');
709
1032
  }
710
1033
  }
711
1034
  // ─── Helpers ──────────────────────────────────────────────────────────
@@ -17,7 +17,7 @@
17
17
  * patch. Non-listed fields are set at create time or via a
18
18
  * transition — never by plain update.
19
19
  */
20
- import { CandidateSchema, ClaimSchema, ConstraintSchema, CrossProjectLinkSchema, CurrentSessionStateSchema, DecisionSchema, HandoffSchema, InboxMessageSchema, InstructionEntrySchema, PlanItemSchema, PlanStepSchema, RuntimeNoteSchema, SequenceSchema, TrapSchema, AssignmentSchema, AgentRunSchema, ActionRequiredSchema, } from './schema.js';
20
+ import { CandidateSchema, ClaimSchema, ConstraintSchema, CrossProjectLinkSchema, CurrentSessionStateSchema, DecisionSchema, HandoffSchema, InboxMessageSchema, InstructionEntrySchema, PlanItemSchema, PlanStepSchema, RuntimeNoteSchema, SequenceSchema, TrapSchema, AssignmentSchema, AgentRunSchema, ActionRequiredSchema, AgentIdentityDocumentSchema, } from './schema.js';
21
21
  /** Canonical plan lifecycle. */
22
22
  const plan = {
23
23
  name: 'plan',
@@ -80,6 +80,8 @@ const claim = {
80
80
  const session = {
81
81
  name: 'session',
82
82
  shortLabelPrefix: 'sess',
83
+ writePolicy: 'system',
84
+ writePolicyNote: 'sessions are opened/refreshed by the runtime (bclaw_work + the session lifecycle), not written via the grammar',
83
85
  schema: CurrentSessionStateSchema,
84
86
  updatable: ['last_seen_at'],
85
87
  transitions: {},
@@ -91,7 +93,12 @@ const handoff = {
91
93
  name: 'handoff',
92
94
  shortLabelPrefix: 'hnd',
93
95
  schema: HandoffSchema,
94
- updatable: ['narrative', 'tags'],
96
+ // pln#625 Phase 3 — review + contract are updatable so an agent can write a
97
+ // review verdict through the grammar (bclaw_update(entity='handoff',
98
+ // data={review:{…}})), restoring the capability lost when update_handoff was
99
+ // removed at v1.0. Validated against HandoffReviewSchema/HandoffContractSchema
100
+ // in updateEntity.
101
+ updatable: ['narrative', 'tags', 'review', 'contract'],
95
102
  statusField: 'status',
96
103
  transitions: {
97
104
  open: ['accepted', 'closed'],
@@ -225,6 +232,8 @@ const sequence = {
225
232
  const inbox_message = {
226
233
  name: 'inbox_message',
227
234
  shortLabelPrefix: 'msg',
235
+ writePolicy: 'system',
236
+ writePolicyNote: 'messages are created by bclaw_send_message and transitioned by bclaw_ack_message / bclaw_read_inbox',
228
237
  schema: InboxMessageSchema,
229
238
  updatable: [],
230
239
  statusField: 'status',
@@ -244,6 +253,8 @@ const inbox_message = {
244
253
  const instruction = {
245
254
  name: 'instruction',
246
255
  shortLabelPrefix: 'ins',
256
+ writePolicy: 'system',
257
+ writePolicyNote: 'instructions are managed via project setup / config (the instruction layer), not the grammar',
247
258
  schema: InstructionEntrySchema,
248
259
  updatable: ['text', 'tags', 'active'],
249
260
  transitions: {},
@@ -259,6 +270,8 @@ const instruction = {
259
270
  const assignment = {
260
271
  name: 'assignment',
261
272
  shortLabelPrefix: 'asgn',
273
+ writePolicy: 'system',
274
+ writePolicyNote: 'assignments are created by dispatch (bclaw_coordinate / bclaw_dispatch) and advanced via bclaw_assignment_update or the reconciler (transition IS wired)',
262
275
  schema: AssignmentSchema,
263
276
  updatable: ['description', 'status_reason', 'tags'],
264
277
  statusField: 'status',
@@ -286,6 +299,8 @@ const assignment = {
286
299
  const agent_run = {
287
300
  name: 'agent_run',
288
301
  shortLabelPrefix: 'run',
302
+ writePolicy: 'system',
303
+ writePolicyNote: 'agent_runs are created and reconciled by the dispatch/execution layer, not written via the grammar',
289
304
  schema: AgentRunSchema,
290
305
  updatable: ['output_summary', 'error_summary'],
291
306
  statusField: 'status',
@@ -302,23 +317,70 @@ const agent_run = {
302
317
  'running->failed': ['timestamp:completed_at', 'event:agent_run_failed'],
303
318
  },
304
319
  };
305
- /** Action — required follow-up item. */
320
+ /**
321
+ * Action — runtime pause/resume record (approval / user_input / clarification /
322
+ * plan_approval). Created `pending` by the runtime; resolved via
323
+ * src/core/actions.ts:resolveAction (outcome resolved|rejected|cancelled) or
324
+ * swept to `expired` on read past its TTL. resolveAction refuses any non-pending
325
+ * action, so `pending` is the ONLY non-terminal state.
326
+ *
327
+ * pln#625 Phase 0 — this spec previously declared open/in_progress/completed/
328
+ * dismissed, NONE of which are valid ActionRequired statuses
329
+ * (ActionRequiredStatusSchema = pending/resolved/rejected/cancelled/expired),
330
+ * and `updatable` named description/priority which are not fields on
331
+ * ActionRequiredSchema. Any canonical transition built on the old matrix would
332
+ * have been dead-on-arrival (InvalidTransitionError / schema reject). The
333
+ * EntitySpec↔Zod consistency test now pins the FSM to the persisted enum so it
334
+ * can never silently drift again.
335
+ *
336
+ * NB (pln#625 Phase 2): action is writePolicy:'system', so update/remove/
337
+ * transition(action) now return the curated SystemManagedError (managed via
338
+ * resolveAction / bclaw_assignment_action), not "not yet wired". If a later
339
+ * phase DOES wire an agent-facing update(action), it MUST gate patches to
340
+ * status==='pending' (patching title/prompt on a resolved action would rewrite
341
+ * the record of what a human already approved).
342
+ */
306
343
  const action = {
307
344
  name: 'action',
308
345
  shortLabelPrefix: 'act',
346
+ writePolicy: 'system',
347
+ writePolicyNote: 'actions are raised by the runtime and resolved via bclaw_assignment_action (resolveAction), not written via the grammar',
309
348
  schema: ActionRequiredSchema,
310
- updatable: ['description', 'priority'],
349
+ updatable: ['title', 'prompt', 'tags'],
311
350
  statusField: 'status',
312
351
  transitions: {
313
- open: ['in_progress', 'completed', 'dismissed'],
314
- in_progress: ['completed', 'dismissed'],
352
+ pending: ['resolved', 'rejected', 'cancelled', 'expired'],
315
353
  },
316
- terminal: ['completed', 'dismissed'],
354
+ terminal: ['resolved', 'rejected', 'cancelled', 'expired'],
317
355
  sideEffects: {
318
- 'open->completed': ['audit:action_completed'],
319
- 'in_progress->completed': ['audit:action_completed'],
356
+ 'pending->resolved': ['timestamp:resolved_at', 'audit:action_resolved', 'sync:agent_run_resume', 'sync:assignment_resume'],
357
+ 'pending->rejected': ['timestamp:resolved_at', 'audit:action_resolved', 'sync:agent_run_cancel', 'sync:assignment_fail'],
358
+ 'pending->cancelled': ['timestamp:resolved_at', 'audit:action_resolved', 'sync:agent_run_cancel', 'sync:assignment_fail'],
359
+ // The expire sweep (actions.ts) sets only status + updated_at — it does NOT
360
+ // stamp resolved_at (that is resolveActionRequired's job on the resolve/
361
+ // reject/cancel edges), so this edge intentionally carries no timestamp tag.
362
+ 'pending->expired': ['audit:action_expired', 'sync:agent_run_timeout', 'sync:assignment_fail'],
320
363
  },
321
364
  };
365
+ /**
366
+ * Agent — identity-registry entry (agents/ dir). READ-ONLY through the grammar
367
+ * (pln#625 Phase 2c): find/get return a REDACTED projection — identity_key and
368
+ * invoke.env are never surfaced, the key fingerprint is truncated. Agents are
369
+ * registered/managed by bclaw_setup / enable-agent (the agent registry), NOT
370
+ * the grammar, so writePolicy:'system' routes every write verb to the curated
371
+ * SystemManagedError boundary. Stateless: no lifecycle, no transitions.
372
+ */
373
+ const agent = {
374
+ name: 'agent',
375
+ shortLabelPrefix: 'agt',
376
+ schema: AgentIdentityDocumentSchema,
377
+ updatable: [],
378
+ writePolicy: 'system',
379
+ writePolicyNote: 'agents are registered/managed via bclaw_setup / enable-agent (the agent registry); the grammar exposes only a redacted read-only view via bclaw_find/get',
380
+ transitions: {},
381
+ terminal: [],
382
+ sideEffects: {},
383
+ };
322
384
  /**
323
385
  * Cross-project link — federation peer entry stored in config.yaml
324
386
  * (cross_project_links). Stateless: identified by `name`, no lifecycle.
@@ -341,6 +403,7 @@ export const ENTITY_REGISTRY = {
341
403
  decision, constraint, trap, candidate, runtime_note, sequence,
342
404
  inbox_message, instruction,
343
405
  assignment, agent_run, action,
406
+ agent,
344
407
  cross_project_link,
345
408
  };
346
409
  export const ENTITY_NAMES = Object.keys(ENTITY_REGISTRY);