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.
- package/README.md +16 -263
- package/dist/brainclaw-vscode.vsix +0 -0
- package/dist/cli/register-capture.js +209 -0
- package/dist/cli/register-code-map.js +19 -0
- package/dist/cli/register-coordination.js +472 -0
- package/dist/cli/register-federation.js +258 -0
- package/dist/cli/register-lifecycle.js +436 -0
- package/dist/cli/register-memory-context.js +502 -0
- package/dist/cli/register-planning.js +167 -0
- package/dist/cli/register-review.js +149 -0
- package/dist/cli/shared.js +5 -0
- package/dist/cli.js +212 -2015
- package/dist/commands/dispatch-watch.js +25 -2
- package/dist/commands/harvest.js +31 -6
- package/dist/commands/mcp-catalog.js +1438 -0
- package/dist/commands/mcp-contract.js +33 -0
- package/dist/commands/mcp-presentation.js +27 -0
- package/dist/commands/mcp-read-handlers.js +72 -36
- package/dist/commands/mcp-write-admin.js +328 -0
- package/dist/commands/mcp-write-claims.js +864 -0
- package/dist/commands/mcp-write-coordination.js +1825 -0
- package/dist/commands/mcp-write-entities.js +620 -0
- package/dist/commands/mcp-write-memory.js +451 -0
- package/dist/commands/mcp-write-sequences.js +116 -0
- package/dist/commands/mcp-write-support.js +367 -0
- package/dist/commands/mcp.js +261 -5570
- package/dist/commands/update-handoff.js +28 -42
- package/dist/core/agent-capability.js +31 -14
- package/dist/core/agent-files.js +1 -1
- package/dist/core/agent-registry.js +51 -3
- package/dist/core/claims.js +18 -0
- package/dist/core/coordination.js +5 -2
- package/dist/core/cross-project.js +35 -1
- package/dist/core/dispatcher.js +34 -20
- package/dist/core/entity-operations.js +335 -12
- package/dist/core/entity-registry.js +72 -9
- package/dist/core/execution.js +28 -4
- package/dist/core/facade-schema.js +30 -4
- package/dist/core/federation-cloud.js +142 -11
- package/dist/core/federation-outbox.js +292 -0
- package/dist/core/federation-signing.js +115 -0
- package/dist/core/handoff-review.js +35 -0
- package/dist/core/io.js +6 -0
- package/dist/core/protocol-tool-policy.js +113 -0
- package/dist/core/review-loop-close.js +115 -0
- package/dist/core/schema.js +25 -2
- package/dist/core/security-detectors.js +35 -6
- package/dist/core/security.js +32 -12
- package/dist/core/worktree.js +98 -9
- package/dist/facts.js +13 -11
- package/dist/facts.json +12 -10
- package/docs/PROTOCOL.md +7 -3
- package/docs/concepts/coordinator-runbook.md +3 -0
- package/docs/concepts/dispatch-lifecycle.md +4 -4
- package/docs/concepts/loop-engine.md +3 -1
- package/docs/concepts/troubleshooting.md +1 -1
- package/docs/integrations/codex.md +3 -3
- package/docs/integrations/overview.md +1 -1
- package/docs/mcp-schema-changelog.md +153 -2
- package/docs/playbooks/orchestration.md +1 -1
- package/docs/product/entity-model-audit.md +3 -2
- package/docs/security.md +22 -1
- 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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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: ['
|
|
349
|
+
updatable: ['title', 'prompt', 'tags'],
|
|
311
350
|
statusField: 'status',
|
|
312
351
|
transitions: {
|
|
313
|
-
|
|
314
|
-
in_progress: ['completed', 'dismissed'],
|
|
352
|
+
pending: ['resolved', 'rejected', 'cancelled', 'expired'],
|
|
315
353
|
},
|
|
316
|
-
terminal: ['
|
|
354
|
+
terminal: ['resolved', 'rejected', 'cancelled', 'expired'],
|
|
317
355
|
sideEffects: {
|
|
318
|
-
'
|
|
319
|
-
'
|
|
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);
|