pmx-canvas 0.1.36 → 0.2.1

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 (91) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/Readme.md +2 -2
  3. package/dist/json-render/index.js +89 -334
  4. package/dist/types/mcp/canvas-access.d.ts +5 -171
  5. package/dist/types/server/ax-state-manager.d.ts +267 -0
  6. package/dist/types/server/ax-state.d.ts +3 -1
  7. package/dist/types/server/canvas-db.d.ts +13 -0
  8. package/dist/types/server/canvas-operations.d.ts +1 -12
  9. package/dist/types/server/canvas-state.d.ts +8 -23
  10. package/dist/types/server/index.d.ts +6 -24
  11. package/dist/types/server/operations/composites.d.ts +121 -0
  12. package/dist/types/server/operations/http.d.ts +7 -0
  13. package/dist/types/server/operations/index.d.ts +8 -0
  14. package/dist/types/server/operations/invoker.d.ts +13 -0
  15. package/dist/types/server/operations/mcp.d.ts +15 -0
  16. package/dist/types/server/operations/ops/annotation.d.ts +2 -0
  17. package/dist/types/server/operations/ops/app.d.ts +33 -0
  18. package/dist/types/server/operations/ops/ax-await.d.ts +2 -0
  19. package/dist/types/server/operations/ops/ax-shared.d.ts +31 -0
  20. package/dist/types/server/operations/ops/ax-state.d.ts +2 -0
  21. package/dist/types/server/operations/ops/ax-timeline.d.ts +2 -0
  22. package/dist/types/server/operations/ops/ax-work.d.ts +2 -0
  23. package/dist/types/server/operations/ops/batch.d.ts +19 -0
  24. package/dist/types/server/operations/ops/edges.d.ts +2 -0
  25. package/dist/types/server/operations/ops/groups.d.ts +2 -0
  26. package/dist/types/server/operations/ops/json-render.d.ts +31 -0
  27. package/dist/types/server/operations/ops/nodes.d.ts +62 -0
  28. package/dist/types/server/operations/ops/query.d.ts +2 -0
  29. package/dist/types/server/operations/ops/snapshots.d.ts +2 -0
  30. package/dist/types/server/operations/ops/validate.d.ts +2 -0
  31. package/dist/types/server/operations/ops/viewport.d.ts +2 -0
  32. package/dist/types/server/operations/ops/webview.d.ts +2 -0
  33. package/dist/types/server/operations/registry.d.ts +15 -0
  34. package/dist/types/server/operations/types.d.ts +116 -0
  35. package/dist/types/server/operations/webview-runner.d.ts +69 -0
  36. package/docs/RELEASE.md +5 -0
  37. package/docs/adr-001-bun-only-runtime.md +46 -0
  38. package/docs/api-stability.md +57 -0
  39. package/docs/ax-host-adapter-contract.md +19 -1
  40. package/docs/ax-state-contract.md +72 -0
  41. package/docs/http-api.md +4 -0
  42. package/docs/mcp.md +61 -12
  43. package/docs/plans/plan-005-operation-registry.md +84 -0
  44. package/docs/plans/plan-006-mcp-tool-consolidation.md +109 -0
  45. package/docs/plans/plan-007-ax-domain.md +99 -0
  46. package/docs/plans/plan-008-registry-finish.md +91 -0
  47. package/docs/tech-debt-assessment-2026-06.md +90 -0
  48. package/package.json +3 -3
  49. package/skills/pmx-canvas/SKILL.md +221 -193
  50. package/skills/pmx-canvas/evals/evals.json +3 -3
  51. package/skills/pmx-canvas/references/ax-html-control-surface.md +93 -0
  52. package/skills/pmx-canvas/references/codex-app-adapter.md +13 -14
  53. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +26 -11
  54. package/src/cli/agent.ts +52 -31
  55. package/src/mcp/canvas-access.ts +30 -830
  56. package/src/mcp/server.ts +162 -2014
  57. package/src/server/ax-context.ts +8 -1
  58. package/src/server/ax-state-manager.ts +826 -0
  59. package/src/server/ax-state.ts +10 -2
  60. package/src/server/canvas-db.ts +35 -0
  61. package/src/server/canvas-operations.ts +2 -328
  62. package/src/server/canvas-schema.ts +2 -2
  63. package/src/server/canvas-state.ts +103 -465
  64. package/src/server/index.ts +54 -190
  65. package/src/server/operations/composites.ts +355 -0
  66. package/src/server/operations/http.ts +103 -0
  67. package/src/server/operations/index.ts +65 -0
  68. package/src/server/operations/invoker.ts +87 -0
  69. package/src/server/operations/mcp.ts +221 -0
  70. package/src/server/operations/ops/annotation.ts +60 -0
  71. package/src/server/operations/ops/app.ts +447 -0
  72. package/src/server/operations/ops/ax-await.ts +216 -0
  73. package/src/server/operations/ops/ax-shared.ts +38 -0
  74. package/src/server/operations/ops/ax-state.ts +249 -0
  75. package/src/server/operations/ops/ax-timeline.ts +381 -0
  76. package/src/server/operations/ops/ax-work.ts +635 -0
  77. package/src/server/operations/ops/batch.ts +365 -0
  78. package/src/server/operations/ops/edges.ts +166 -0
  79. package/src/server/operations/ops/groups.ts +176 -0
  80. package/src/server/operations/ops/json-render.ts +691 -0
  81. package/src/server/operations/ops/nodes.ts +1047 -0
  82. package/src/server/operations/ops/query.ts +281 -0
  83. package/src/server/operations/ops/snapshots.ts +366 -0
  84. package/src/server/operations/ops/validate.ts +37 -0
  85. package/src/server/operations/ops/viewport.ts +219 -0
  86. package/src/server/operations/ops/webview.ts +339 -0
  87. package/src/server/operations/registry.ts +79 -0
  88. package/src/server/operations/types.ts +150 -0
  89. package/src/server/operations/webview-runner.ts +77 -0
  90. package/src/server/server.ts +158 -2255
  91. package/src/server/web-artifacts.ts +6 -2
@@ -0,0 +1,826 @@
1
+ /**
2
+ * AX state manager.
3
+ *
4
+ * Owns the agent-experience (AX) state that previously lived inline in
5
+ * `CanvasStateManager`. Splits cleanly into the three documented partitions
6
+ * (see `docs/ax-state-contract.md`):
7
+ *
8
+ * • Canvas-bound (`_axState`): focus, work items, approval gates, review
9
+ * annotations, elicitations, mode requests, policy. Snapshotted, cleared by
10
+ * `canvas_clear`, replaced by `restore`. Mutators record undo/redo history
11
+ * (via the injected `recordMutation` / `suppressed` callbacks).
12
+ * • Timeline (audit-only): agent events, evidence, steering. DB-direct, NOT in
13
+ * `_axState`, NOT history-recorded, NOT snapshotted, retention-bounded.
14
+ * • Host/session: a single host-capability row in its own table.
15
+ *
16
+ * `CanvasStateManager` holds one of these and DELEGATES its public AX methods to
17
+ * it, so the SDK/HTTP/MCP surface is byte-stable. The manager takes injected
18
+ * callbacks for everything it does not own: a node-id-validity provider (used by
19
+ * normalization on write), the live DB handle, `scheduleSave`, `notifyChange`,
20
+ * `recordMutation`, and a `suppressed` wrapper for history closures.
21
+ */
22
+
23
+ import {
24
+ appendAxEventToDB,
25
+ appendAxEvidenceToDB,
26
+ appendAxSteeringToDB,
27
+ markAxSteeringDeliveredInDB,
28
+ loadAxEventsFromDB,
29
+ loadAxEvidenceFromDB,
30
+ loadAxSteeringFromDB,
31
+ loadPendingAxSteeringFromDB,
32
+ loadNewestPendingAxSteeringFromDB,
33
+ countPendingAxSteeringFromDB,
34
+ loadAxTimelineSummaryFromDB,
35
+ upsertAxHostCapabilityToDB,
36
+ loadAxHostCapabilityFromDB,
37
+ type AxTimelineQuery,
38
+ } from './canvas-db.js';
39
+ import {
40
+ createEmptyAxState,
41
+ createEmptyAxHostCapability,
42
+ normalizeAxState,
43
+ normalizeAxHostCapability,
44
+ createAxWorkItem,
45
+ createAxApprovalGate,
46
+ createAxReviewAnnotation,
47
+ createAxEvent,
48
+ createAxEvidence,
49
+ createAxSteeringMessage,
50
+ createAxElicitation,
51
+ createAxModeRequest,
52
+ isAxCommand,
53
+ listAxCommands,
54
+ AX_COMMAND_REGISTRY,
55
+ normalizeAxPolicy,
56
+ mapAxActivityKindToEventKind,
57
+ type PmxAxActivityKind,
58
+ type PmxAxElicitation,
59
+ type PmxAxModeRequest,
60
+ type PmxAxMode,
61
+ type PmxAxCommandDescriptor,
62
+ type PmxAxPolicy,
63
+ type PmxAxFocusState,
64
+ type PmxAxSource,
65
+ type PmxAxState,
66
+ type PmxAxWorkItem,
67
+ type PmxAxWorkItemStatus,
68
+ type PmxAxApprovalGate,
69
+ type PmxAxReviewAnnotation,
70
+ type PmxAxReviewKind,
71
+ type PmxAxReviewSeverity,
72
+ type PmxAxReviewStatus,
73
+ type PmxAxReviewAnchorType,
74
+ type PmxAxReviewRegion,
75
+ type PmxAxEvent,
76
+ type PmxAxEventKind,
77
+ type PmxAxEvidence,
78
+ type PmxAxEvidenceKind,
79
+ type PmxAxSteeringMessage,
80
+ type PmxAxHostCapability,
81
+ type PmxAxTimelineSummary,
82
+ } from './ax-state.js';
83
+ import type { CanvasChangeType, MutationRecordInfo } from './canvas-state.js';
84
+
85
+ type Database = import('bun:sqlite').Database;
86
+
87
+ /** Host-environment hooks the AX manager needs from its owner (CanvasStateManager). */
88
+ export interface AxStateManagerDeps {
89
+ /** Current valid node-id set — used by normalization on write to prune dangling refs. */
90
+ getNodeIds(): Set<string>;
91
+ /** Live DB handle for the timeline tables / host-capability table (null when no workspace). */
92
+ getDb(): Database | null;
93
+ /** Debounced save of the canvas-bound blob (timeline ops do NOT trigger this — they are DB-direct). */
94
+ scheduleSave(): void;
95
+ /** Emit a change notification (drives MCP resource notifications + blocking-wait endpoints). */
96
+ notifyChange(type: CanvasChangeType): void;
97
+ /** Record an undo/redo history entry. */
98
+ recordMutation(info: MutationRecordInfo): void;
99
+ /** Wrap a closure so it runs with mutation recording suppressed (for undo/redo replay). */
100
+ suppressed(fn: () => void): () => void;
101
+ }
102
+
103
+ function replaceById<T extends { id: string }>(list: T[], item: T): T[] {
104
+ const idx = list.findIndex((x) => x.id === item.id);
105
+ if (idx === -1) return [...list, item];
106
+ const copy = list.slice();
107
+ copy[idx] = item;
108
+ return copy;
109
+ }
110
+
111
+ function isRecord(value: unknown): value is Record<string, unknown> {
112
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
113
+ }
114
+
115
+ function logAxStateWarning(action: string, error: unknown, details?: Record<string, unknown>): void {
116
+ console.warn(`[ax-state] ${action}`, { error, ...(details ?? {}) });
117
+ }
118
+
119
+ export class AxStateManager {
120
+ private _axState: PmxAxState = createEmptyAxState();
121
+ private _axHostCapability: PmxAxHostCapability | null = null;
122
+
123
+ constructor(private readonly deps: AxStateManagerDeps) {}
124
+
125
+ // ── Normalization (against the owner's current node set) ──────────
126
+ private normalizeForCurrentNodes(state: unknown): PmxAxState {
127
+ return normalizeAxState(state, this.deps.getNodeIds());
128
+ }
129
+
130
+ private applyAxState(state: PmxAxState): void {
131
+ this._axState = this.normalizeForCurrentNodes(state);
132
+ }
133
+
134
+ // ── Load / snapshot / clear integration (called by CanvasStateManager) ──
135
+
136
+ /** Reset the canvas-bound partition to empty (used by clear() and applyPersistedState()). */
137
+ resetCanvasBound(): void {
138
+ this._axState = createEmptyAxState();
139
+ }
140
+
141
+ /** Replace the canvas-bound partition from a persisted/restored blob, normalized against current nodes. */
142
+ applyPersistedAx(ax: unknown): void {
143
+ this._axState = this.normalizeForCurrentNodes(ax);
144
+ }
145
+
146
+ /** Load the host-capability row from its own table (own partition; not snapshotted). */
147
+ loadHostCapabilityFromDb(): void {
148
+ const db = this.deps.getDb();
149
+ if (!db) return;
150
+ try {
151
+ this._axHostCapability = loadAxHostCapabilityFromDB(db);
152
+ } catch (error) {
153
+ logAxStateWarning('load host capability failed', error, {});
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Re-normalize the canvas-bound partition against the current node set after a
159
+ * node was removed, and report what the removal orphaned. Work items / approval
160
+ * gates / elicitations / mode requests keep the item but strip the dangling node
161
+ * id ("re-anchored"); node-anchored review annotations are dropped ("removed").
162
+ * Returns the affected ids so the owner can record one audit timeline event.
163
+ */
164
+ revalidateAfterNodeRemoval(removedNodeId: string): {
165
+ reanchoredIds: string[];
166
+ removedReviewIds: string[];
167
+ reanchoredFocus: boolean;
168
+ } {
169
+ const before = this._axState;
170
+ const referencedNode = (ids: string[]): boolean => ids.includes(removedNodeId);
171
+ const reanchoredIds: string[] = [];
172
+ for (const w of before.workItems) if (referencedNode(w.nodeIds)) reanchoredIds.push(w.id);
173
+ for (const g of before.approvalGates) if (referencedNode(g.nodeIds)) reanchoredIds.push(g.id);
174
+ for (const e of before.elicitations) if (referencedNode(e.nodeIds)) reanchoredIds.push(e.id);
175
+ for (const m of before.modeRequests) if (referencedNode(m.nodeIds)) reanchoredIds.push(m.id);
176
+ const removedReviewIds = before.reviewAnnotations
177
+ .filter((r) => r.anchorType === 'node' && r.nodeId === removedNodeId)
178
+ .map((r) => r.id);
179
+ // Focus is re-anchored too (the dangling id is normalized out) — reported so
180
+ // the audit note does not undercount what the deletion changed.
181
+ const reanchoredFocus = before.focus.nodeIds.includes(removedNodeId);
182
+
183
+ // The actual re-normalization (strips dangling refs, drops node-anchored reviews).
184
+ this.applyAxState(before);
185
+
186
+ return { reanchoredIds, removedReviewIds, reanchoredFocus };
187
+ }
188
+
189
+ // ── Canvas-bound readers ──────────────────────────────────────────
190
+ getAxState(): PmxAxState {
191
+ return structuredClone(this.normalizeForCurrentNodes(this._axState));
192
+ }
193
+
194
+ getAxFocus(): PmxAxFocusState {
195
+ return this.getAxState().focus;
196
+ }
197
+
198
+ setAxFocus(nodeIds: string[], options: { source?: PmxAxSource; recordHistory?: boolean } = {}): PmxAxFocusState {
199
+ const oldAxState = this.getAxState();
200
+ const nextAxState: PmxAxState = {
201
+ ...oldAxState,
202
+ focus: {
203
+ nodeIds,
204
+ primaryNodeId: nodeIds[0] ?? null,
205
+ updatedAt: new Date().toISOString(),
206
+ source: options.source ?? 'api',
207
+ },
208
+ };
209
+ this.applyAxState(nextAxState);
210
+ const appliedAxState = this.getAxState();
211
+ this.deps.scheduleSave();
212
+ this.deps.notifyChange('ax');
213
+ if (options.recordHistory === false) return appliedAxState.focus;
214
+ this.deps.recordMutation({
215
+ operationType: 'setAxFocus',
216
+ description: `Set AX focus (${appliedAxState.focus.nodeIds.length} nodes)`,
217
+ forward: this.deps.suppressed(() => {
218
+ this.applyAxState(appliedAxState);
219
+ this.deps.scheduleSave();
220
+ this.deps.notifyChange('ax');
221
+ }),
222
+ inverse: this.deps.suppressed(() => {
223
+ this.applyAxState(oldAxState);
224
+ this.deps.scheduleSave();
225
+ this.deps.notifyChange('ax');
226
+ }),
227
+ });
228
+ return appliedAxState.focus;
229
+ }
230
+
231
+ clearAxFocus(): PmxAxFocusState {
232
+ return this.setAxFocus([], { source: 'system' });
233
+ }
234
+
235
+ // ── Work items (canvas-bound; snapshotted via getAxState blob) ────
236
+ getWorkItems(): PmxAxWorkItem[] {
237
+ return this.getAxState().workItems;
238
+ }
239
+
240
+ addWorkItem(
241
+ input: { title: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
242
+ options: { source?: PmxAxSource } = {},
243
+ ): PmxAxWorkItem {
244
+ const oldAxState = this.getAxState();
245
+ const item = createAxWorkItem(input, options.source ?? 'api', this.deps.getNodeIds());
246
+ this.applyAxState({ ...oldAxState, workItems: [...oldAxState.workItems, item] });
247
+ const applied = this.getAxState();
248
+ this.deps.scheduleSave();
249
+ this.deps.notifyChange('ax');
250
+ this.deps.recordMutation({
251
+ operationType: 'addWorkItem',
252
+ description: `Added work item "${item.title}"`,
253
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
254
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
255
+ });
256
+ return applied.workItems.find((w) => w.id === item.id) ?? item;
257
+ }
258
+
259
+ updateWorkItem(
260
+ id: string,
261
+ patch: { title?: string; status?: PmxAxWorkItemStatus; detail?: string | null; nodeIds?: string[] },
262
+ options: { source?: PmxAxSource } = {},
263
+ ): PmxAxWorkItem | null {
264
+ const oldAxState = this.getAxState();
265
+ const existing = oldAxState.workItems.find((w) => w.id === id);
266
+ if (!existing) return null;
267
+ const validNodeIds = this.deps.getNodeIds();
268
+ const merged: PmxAxWorkItem = {
269
+ ...existing,
270
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
271
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
272
+ ...(patch.detail !== undefined ? { detail: patch.detail } : {}),
273
+ ...(patch.nodeIds !== undefined ? { nodeIds: patch.nodeIds.filter((n) => validNodeIds.has(n)) } : {}),
274
+ updatedAt: new Date().toISOString(),
275
+ source: options.source ?? existing.source,
276
+ };
277
+ this.applyAxState({ ...oldAxState, workItems: replaceById(oldAxState.workItems, merged) });
278
+ const applied = this.getAxState();
279
+ this.deps.scheduleSave();
280
+ this.deps.notifyChange('ax');
281
+ this.deps.recordMutation({
282
+ operationType: 'updateWorkItem',
283
+ description: `Updated work item ${id}`,
284
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
285
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
286
+ });
287
+ return applied.workItems.find((w) => w.id === id) ?? null;
288
+ }
289
+
290
+ // ── Approval gates (canvas-bound) ─────────────────────────────────
291
+ getApprovalGates(): PmxAxApprovalGate[] {
292
+ return this.getAxState().approvalGates;
293
+ }
294
+
295
+ requestApproval(
296
+ input: { title: string; detail?: string | null; action?: string | null; nodeIds?: string[] },
297
+ options: { source?: PmxAxSource } = {},
298
+ ): PmxAxApprovalGate {
299
+ const oldAxState = this.getAxState();
300
+ const gate = createAxApprovalGate(input, options.source ?? 'api', this.deps.getNodeIds());
301
+ this.applyAxState({ ...oldAxState, approvalGates: [...oldAxState.approvalGates, gate] });
302
+ const applied = this.getAxState();
303
+ this.deps.scheduleSave();
304
+ this.deps.notifyChange('ax');
305
+ this.deps.recordMutation({
306
+ operationType: 'requestApproval',
307
+ description: `Requested approval "${gate.title}"`,
308
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
309
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
310
+ });
311
+ return applied.approvalGates.find((g) => g.id === gate.id) ?? gate;
312
+ }
313
+
314
+ resolveApproval(
315
+ id: string,
316
+ decision: 'approved' | 'rejected',
317
+ options: { resolution?: string; source?: PmxAxSource } = {},
318
+ ): PmxAxApprovalGate | null {
319
+ const oldAxState = this.getAxState();
320
+ const gate = oldAxState.approvalGates.find((g) => g.id === id);
321
+ if (!gate || gate.status !== 'pending') return null;
322
+ const resolved: PmxAxApprovalGate = {
323
+ ...gate,
324
+ status: decision,
325
+ resolvedAt: new Date().toISOString(),
326
+ resolution: options.resolution ?? null,
327
+ source: options.source ?? gate.source,
328
+ };
329
+ this.applyAxState({ ...oldAxState, approvalGates: replaceById(oldAxState.approvalGates, resolved) });
330
+ const applied = this.getAxState();
331
+ this.deps.scheduleSave();
332
+ this.deps.notifyChange('ax');
333
+ this.deps.recordMutation({
334
+ operationType: 'resolveApproval',
335
+ description: `Resolved approval ${id} -> ${decision}`,
336
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
337
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
338
+ });
339
+ return applied.approvalGates.find((g) => g.id === id) ?? null;
340
+ }
341
+
342
+ // ── Review annotations (canvas-bound) ─────────────────────────────
343
+ getReviewAnnotations(): PmxAxReviewAnnotation[] {
344
+ return this.getAxState().reviewAnnotations;
345
+ }
346
+
347
+ addReviewAnnotation(
348
+ input: {
349
+ body: string;
350
+ kind?: PmxAxReviewKind;
351
+ severity?: PmxAxReviewSeverity;
352
+ anchorType?: PmxAxReviewAnchorType;
353
+ nodeId?: string | null;
354
+ file?: string | null;
355
+ region?: PmxAxReviewRegion | null;
356
+ author?: string | null;
357
+ },
358
+ options: { source?: PmxAxSource } = {},
359
+ ): PmxAxReviewAnnotation | null {
360
+ // Validate the node anchor up front. A node-anchored review whose nodeId is
361
+ // missing or unknown would otherwise be silently dropped by
362
+ // normalizeForCurrentNodes after apply, yet still returned as a phantom
363
+ // success object — false success / silent data loss. Reject instead so the
364
+ // HTTP/MCP layers surface ok:false / 4xx.
365
+ // Context-aware default: only fall back to a node anchor when a usable nodeId
366
+ // is present; otherwise treat it as an unanchored (body-only) note so a
367
+ // `{ body }`-only annotation succeeds (anchorType is documented optional).
368
+ const anchorType = input.anchorType ?? (typeof input.nodeId === 'string' && input.nodeId ? 'node' : 'file');
369
+ // An EXPLICIT node anchor still requires a real nodeId — reject a phantom
370
+ // node-anchored review rather than silently dropping it post-apply.
371
+ if (anchorType === 'node' && (typeof input.nodeId !== 'string' || !this.deps.getNodeIds().has(input.nodeId))) {
372
+ return null;
373
+ }
374
+ const oldAxState = this.getAxState();
375
+ const annotation = createAxReviewAnnotation(input, options.source ?? 'api');
376
+ this.applyAxState({ ...oldAxState, reviewAnnotations: [...oldAxState.reviewAnnotations, annotation] });
377
+ const applied = this.getAxState();
378
+ this.deps.scheduleSave();
379
+ this.deps.notifyChange('ax');
380
+ this.deps.recordMutation({
381
+ operationType: 'addReviewAnnotation',
382
+ description: `Added review ${annotation.kind} (${annotation.severity})`,
383
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
384
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
385
+ });
386
+ return applied.reviewAnnotations.find((r) => r.id === annotation.id) ?? annotation;
387
+ }
388
+
389
+ updateReviewAnnotation(
390
+ id: string,
391
+ patch: { body?: string; status?: PmxAxReviewStatus; severity?: PmxAxReviewSeverity; kind?: PmxAxReviewKind },
392
+ options: { source?: PmxAxSource } = {},
393
+ ): PmxAxReviewAnnotation | null {
394
+ const oldAxState = this.getAxState();
395
+ const existing = oldAxState.reviewAnnotations.find((r) => r.id === id);
396
+ if (!existing) return null;
397
+ const merged: PmxAxReviewAnnotation = {
398
+ ...existing,
399
+ ...(patch.body !== undefined ? { body: patch.body } : {}),
400
+ ...(patch.status !== undefined ? { status: patch.status } : {}),
401
+ ...(patch.severity !== undefined ? { severity: patch.severity } : {}),
402
+ ...(patch.kind !== undefined ? { kind: patch.kind } : {}),
403
+ updatedAt: new Date().toISOString(),
404
+ source: options.source ?? existing.source,
405
+ };
406
+ this.applyAxState({ ...oldAxState, reviewAnnotations: replaceById(oldAxState.reviewAnnotations, merged) });
407
+ const applied = this.getAxState();
408
+ this.deps.scheduleSave();
409
+ this.deps.notifyChange('ax');
410
+ this.deps.recordMutation({
411
+ operationType: 'updateReviewAnnotation',
412
+ description: `Updated review ${id}`,
413
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
414
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
415
+ });
416
+ return applied.reviewAnnotations.find((r) => r.id === id) ?? null;
417
+ }
418
+
419
+ // ── Host capability (own table; reported by adapters) ─────────────
420
+ getHostCapability(): PmxAxHostCapability | null {
421
+ return this._axHostCapability;
422
+ }
423
+
424
+ // ── Elicitations (canvas-bound) ───────────────────────────────────
425
+ getElicitations(): PmxAxElicitation[] {
426
+ return this.getAxState().elicitations;
427
+ }
428
+
429
+ requestElicitation(
430
+ input: { prompt: string; fields?: string[]; nodeIds?: string[] },
431
+ options: { source?: PmxAxSource } = {},
432
+ ): PmxAxElicitation {
433
+ const oldAxState = this.getAxState();
434
+ const elicitation = createAxElicitation(input, options.source ?? 'api', this.deps.getNodeIds());
435
+ this.applyAxState({ ...oldAxState, elicitations: [...oldAxState.elicitations, elicitation] });
436
+ const applied = this.getAxState();
437
+ this.deps.scheduleSave();
438
+ this.deps.notifyChange('ax');
439
+ this.deps.recordMutation({
440
+ operationType: 'requestElicitation',
441
+ description: `Requested elicitation "${elicitation.prompt}"`,
442
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
443
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
444
+ });
445
+ return applied.elicitations.find((e) => e.id === elicitation.id) ?? elicitation;
446
+ }
447
+
448
+ respondElicitation(
449
+ id: string,
450
+ response: Record<string, unknown>,
451
+ options: { source?: PmxAxSource } = {},
452
+ ): PmxAxElicitation | null {
453
+ const oldAxState = this.getAxState();
454
+ const existing = oldAxState.elicitations.find((e) => e.id === id);
455
+ if (!existing || existing.status !== 'pending') return null;
456
+ const merged: PmxAxElicitation = {
457
+ ...existing,
458
+ status: 'answered',
459
+ response,
460
+ resolvedAt: new Date().toISOString(),
461
+ source: options.source ?? existing.source,
462
+ };
463
+ this.applyAxState({ ...oldAxState, elicitations: replaceById(oldAxState.elicitations, merged) });
464
+ const applied = this.getAxState();
465
+ this.deps.scheduleSave();
466
+ this.deps.notifyChange('ax');
467
+ this.deps.recordMutation({
468
+ operationType: 'respondElicitation',
469
+ description: `Answered elicitation ${id}`,
470
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
471
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
472
+ });
473
+ return applied.elicitations.find((e) => e.id === id) ?? null;
474
+ }
475
+
476
+ // ── Mode requests (canvas-bound) ──────────────────────────────────
477
+ getModeRequests(): PmxAxModeRequest[] {
478
+ return this.getAxState().modeRequests;
479
+ }
480
+
481
+ requestMode(
482
+ input: { mode: PmxAxMode; reason?: string | null; nodeIds?: string[] },
483
+ options: { source?: PmxAxSource } = {},
484
+ ): PmxAxModeRequest {
485
+ const oldAxState = this.getAxState();
486
+ const request = createAxModeRequest(input, options.source ?? 'api', this.deps.getNodeIds());
487
+ this.applyAxState({ ...oldAxState, modeRequests: [...oldAxState.modeRequests, request] });
488
+ const applied = this.getAxState();
489
+ this.deps.scheduleSave();
490
+ this.deps.notifyChange('ax');
491
+ this.deps.recordMutation({
492
+ operationType: 'requestMode',
493
+ description: `Requested mode "${request.mode}"`,
494
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
495
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
496
+ });
497
+ return applied.modeRequests.find((m) => m.id === request.id) ?? request;
498
+ }
499
+
500
+ resolveModeRequest(
501
+ id: string,
502
+ decision: 'approved' | 'rejected',
503
+ options: { resolution?: string; source?: PmxAxSource } = {},
504
+ ): PmxAxModeRequest | null {
505
+ const oldAxState = this.getAxState();
506
+ const existing = oldAxState.modeRequests.find((m) => m.id === id);
507
+ if (!existing || existing.status !== 'pending') return null;
508
+ const merged: PmxAxModeRequest = {
509
+ ...existing,
510
+ status: decision,
511
+ resolvedAt: new Date().toISOString(),
512
+ resolution: options.resolution ?? null,
513
+ source: options.source ?? existing.source,
514
+ };
515
+ this.applyAxState({ ...oldAxState, modeRequests: replaceById(oldAxState.modeRequests, merged) });
516
+ const applied = this.getAxState();
517
+ this.deps.scheduleSave();
518
+ this.deps.notifyChange('ax');
519
+ this.deps.recordMutation({
520
+ operationType: 'resolveModeRequest',
521
+ description: `Resolved mode request ${id} -> ${decision}`,
522
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
523
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
524
+ });
525
+ return applied.modeRequests.find((m) => m.id === id) ?? null;
526
+ }
527
+
528
+ // ── Single-item AX readers (canvas-bound; for the blocking-wait endpoints) ──
529
+ getApproval(id: string): PmxAxApprovalGate | null {
530
+ return this.getAxState().approvalGates.find((g) => g.id === id) ?? null;
531
+ }
532
+
533
+ getElicitation(id: string): PmxAxElicitation | null {
534
+ return this.getAxState().elicitations.find((e) => e.id === id) ?? null;
535
+ }
536
+
537
+ getModeRequest(id: string): PmxAxModeRequest | null {
538
+ return this.getAxState().modeRequests.find((m) => m.id === id) ?? null;
539
+ }
540
+
541
+ getCommandRegistry(): PmxAxCommandDescriptor[] {
542
+ return listAxCommands();
543
+ }
544
+
545
+ /** Invoke a registry-gated PMX command intent — records a timeline event (no execution). */
546
+ invokeCommand(name: string, args: Record<string, unknown> | null = null, options: { source?: PmxAxSource } = {}): PmxAxEvent | null {
547
+ if (!isAxCommand(name)) return null;
548
+ return this.recordAxEvent(
549
+ { kind: 'command', summary: name, detail: AX_COMMAND_REGISTRY[name].description, data: { command: name, ...(args ? { args } : {}) } },
550
+ options,
551
+ );
552
+ }
553
+
554
+ getPolicy(): PmxAxPolicy {
555
+ return this.getAxState().policy;
556
+ }
557
+
558
+ /** Merge a declarative tool/prompt policy patch (canvas-bound, snapshotted). */
559
+ setPolicy(
560
+ patch: { tools?: Partial<PmxAxPolicy['tools']>; prompt?: Partial<PmxAxPolicy['prompt']> },
561
+ _options: { source?: PmxAxSource } = {},
562
+ ): PmxAxPolicy {
563
+ const oldAxState = this.getAxState();
564
+ const merged = normalizeAxPolicy({
565
+ tools: { ...oldAxState.policy.tools, ...(patch.tools ?? {}) },
566
+ prompt: { ...oldAxState.policy.prompt, ...(patch.prompt ?? {}) },
567
+ });
568
+ this.applyAxState({ ...oldAxState, policy: merged });
569
+ const applied = this.getAxState();
570
+ this.deps.scheduleSave();
571
+ this.deps.notifyChange('ax');
572
+ this.deps.recordMutation({
573
+ operationType: 'setPolicy',
574
+ description: 'Updated AX policy',
575
+ forward: this.deps.suppressed(() => { this.applyAxState(applied); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
576
+ inverse: this.deps.suppressed(() => { this.applyAxState(oldAxState); this.deps.scheduleSave(); this.deps.notifyChange('ax'); }),
577
+ });
578
+ return applied.policy;
579
+ }
580
+
581
+ setHostCapability(input: unknown, _options: { source?: PmxAxSource } = {}): PmxAxHostCapability {
582
+ const cap = normalizeAxHostCapability(
583
+ isRecord(input)
584
+ ? { ...input, reportedAt: new Date().toISOString() }
585
+ : { reportedAt: new Date().toISOString() },
586
+ ) ?? createEmptyAxHostCapability();
587
+ this._axHostCapability = cap;
588
+ const db = this.deps.getDb();
589
+ if (db) {
590
+ try {
591
+ upsertAxHostCapabilityToDB(db, cap);
592
+ } catch (error) {
593
+ logAxStateWarning('save host capability failed', error, {});
594
+ }
595
+ }
596
+ this.deps.notifyChange('ax');
597
+ return cap;
598
+ }
599
+
600
+ // ── Timeline (DB-direct; NOT in _axState; NOT history-recorded) ───
601
+ recordAxEvent(
602
+ input: { kind: PmxAxEventKind; summary: string; detail?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
603
+ options: { source?: PmxAxSource } = {},
604
+ ): PmxAxEvent {
605
+ const draft = createAxEvent(input, options.source ?? 'api');
606
+ const db = this.deps.getDb();
607
+ if (db) {
608
+ try {
609
+ const ev = appendAxEventToDB(db, draft);
610
+ this.deps.notifyChange('ax-timeline');
611
+ return ev;
612
+ } catch (error) {
613
+ logAxStateWarning('record ax event failed', error, { id: draft.id });
614
+ }
615
+ }
616
+ this.deps.notifyChange('ax-timeline');
617
+ return { ...draft, seq: 0 };
618
+ }
619
+
620
+ addEvidence(
621
+ input: { kind: PmxAxEvidenceKind; title: string; body?: string | null; ref?: string | null; nodeIds?: string[]; data?: Record<string, unknown> | null },
622
+ options: { source?: PmxAxSource } = {},
623
+ ): PmxAxEvidence {
624
+ const draft = createAxEvidence(input, options.source ?? 'api');
625
+ const db = this.deps.getDb();
626
+ if (db) {
627
+ try {
628
+ const ev = appendAxEvidenceToDB(db, draft);
629
+ this.deps.notifyChange('ax-timeline');
630
+ return ev;
631
+ } catch (error) {
632
+ logAxStateWarning('add evidence failed', error, { id: draft.id });
633
+ }
634
+ }
635
+ this.deps.notifyChange('ax-timeline');
636
+ return { ...draft, seq: 0 };
637
+ }
638
+
639
+ recordSteeringMessage(message: string, options: { source?: PmxAxSource } = {}): PmxAxSteeringMessage {
640
+ const draft = createAxSteeringMessage(message, options.source ?? 'api');
641
+ const db = this.deps.getDb();
642
+ if (db) {
643
+ try {
644
+ const s = appendAxSteeringToDB(db, draft);
645
+ this.deps.notifyChange('ax-timeline');
646
+ return s;
647
+ } catch (error) {
648
+ logAxStateWarning('record steering failed', error, { id: draft.id });
649
+ }
650
+ }
651
+ this.deps.notifyChange('ax-timeline');
652
+ return { ...draft, seq: 0 };
653
+ }
654
+
655
+ markSteeringDelivered(id: string): boolean {
656
+ const db = this.deps.getDb();
657
+ if (!db) return false;
658
+ try {
659
+ const ok = markAxSteeringDeliveredInDB(db, id);
660
+ if (ok) this.deps.notifyChange('ax-timeline');
661
+ return ok;
662
+ } catch (error) {
663
+ logAxStateWarning('mark steering delivered failed', error, { id });
664
+ return false;
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Ingest a normalized agent activity (a tool/session event a harness forwards)
670
+ * and apply kind-driven board reactions, so the agent's real work flows back into
671
+ * the board without it remembering to push each item (report primitive A — makes
672
+ * AX bidirectional). Always records a timeline event; then, unless the caller
673
+ * overrides/suppresses via `reactions`, applies defaults by kind/outcome:
674
+ * • failure | error | outcome==='failure' → work item (blocked) + review
675
+ * (finding/error, anchored to a valid nodeId else the `ref` file) + evidence (logs)
676
+ * • tool-result + outcome==='success' → evidence (tool-result)
677
+ * • everything else (tool-start, session-*, command, note) → event only
678
+ * A reaction value of `false` suppresses it; an object overrides its fields/forces it on.
679
+ */
680
+ ingestActivity(
681
+ input: {
682
+ kind: PmxAxActivityKind;
683
+ title: string;
684
+ summary?: string | null;
685
+ outcome?: 'success' | 'failure';
686
+ ref?: string | null;
687
+ nodeIds?: string[];
688
+ data?: Record<string, unknown> | null;
689
+ reactions?: {
690
+ workItem?: false | { status?: PmxAxWorkItemStatus; detail?: string | null };
691
+ evidence?: false | { kind?: PmxAxEvidenceKind; body?: string | null };
692
+ review?: false | { severity?: PmxAxReviewSeverity; kind?: PmxAxReviewKind; anchorType?: PmxAxReviewAnchorType; nodeId?: string | null };
693
+ };
694
+ },
695
+ options: { source?: PmxAxSource } = {},
696
+ ): { event: PmxAxEvent; workItem: PmxAxWorkItem | null; evidence: PmxAxEvidence | null; review: PmxAxReviewAnnotation | null } {
697
+ const source = options.source ?? 'api';
698
+ const summary = input.summary ?? input.title;
699
+ const isFailure = input.kind === 'failure' || input.kind === 'error' || input.outcome === 'failure';
700
+ const isToolSuccess = input.kind === 'tool-result' && input.outcome === 'success';
701
+ const nodeIds = input.nodeIds ?? [];
702
+ const validNodeIds = this.deps.getNodeIds();
703
+ const anchorNodeId = nodeIds.find((n) => validNodeIds.has(n)) ?? null;
704
+
705
+ // (1) Always record the activity on the timeline (precise kind on data.activityKind).
706
+ const event = this.recordAxEvent(
707
+ {
708
+ kind: mapAxActivityKindToEventKind(input.kind),
709
+ summary: input.title,
710
+ detail: input.summary ?? null,
711
+ nodeIds,
712
+ // Caller data first so the canonical fields always win — a malformed/hostile
713
+ // payload can't overwrite activityKind/outcome/ref (which the docstring +
714
+ // reaction logic treat as authoritative).
715
+ data: {
716
+ ...(input.data ?? {}),
717
+ activityKind: input.kind,
718
+ ...(input.outcome ? { outcome: input.outcome } : {}),
719
+ ...(input.ref ? { ref: input.ref } : {}),
720
+ },
721
+ },
722
+ { source },
723
+ );
724
+
725
+ // (2) Resolve reactions: kind-driven defaults, overridable per call.
726
+ const r = input.reactions ?? {};
727
+ const wantWorkItem = r.workItem === false ? null : (r.workItem ?? (isFailure ? {} : null));
728
+ const wantEvidence = r.evidence === false
729
+ ? null
730
+ : (r.evidence ?? (isFailure ? { kind: 'logs' as PmxAxEvidenceKind } : isToolSuccess ? { kind: 'tool-result' as PmxAxEvidenceKind } : null));
731
+ const wantReview = r.review === false ? null : (r.review ?? (isFailure ? {} : null));
732
+
733
+ let workItem: PmxAxWorkItem | null = null;
734
+ if (wantWorkItem) {
735
+ workItem = this.addWorkItem(
736
+ { title: input.title, status: wantWorkItem.status ?? 'blocked', detail: wantWorkItem.detail ?? summary, nodeIds },
737
+ { source },
738
+ );
739
+ }
740
+
741
+ let evidence: PmxAxEvidence | null = null;
742
+ if (wantEvidence) {
743
+ evidence = this.addEvidence(
744
+ { kind: wantEvidence.kind ?? 'logs', title: input.title, body: wantEvidence.body ?? input.summary ?? null, ref: input.ref ?? null, nodeIds },
745
+ { source },
746
+ );
747
+ }
748
+
749
+ let review: PmxAxReviewAnnotation | null = null;
750
+ if (wantReview) {
751
+ const reviewNodeId = wantReview.nodeId ?? anchorNodeId;
752
+ // addReviewAnnotation returns null on a bad node anchor — that just skips the
753
+ // review; it never fails the whole ingest (the event + other reactions stand).
754
+ review = this.addReviewAnnotation(
755
+ {
756
+ body: summary,
757
+ kind: wantReview.kind ?? 'finding',
758
+ severity: wantReview.severity ?? 'error',
759
+ ...(wantReview.anchorType ? { anchorType: wantReview.anchorType } : {}),
760
+ ...(reviewNodeId ? { nodeId: reviewNodeId } : {}),
761
+ ...(input.ref ? { file: input.ref } : {}),
762
+ },
763
+ { source },
764
+ );
765
+ }
766
+
767
+ return { event, workItem, evidence, review };
768
+ }
769
+
770
+ getAxEvents(q: AxTimelineQuery = {}): PmxAxEvent[] {
771
+ const db = this.deps.getDb();
772
+ return db ? loadAxEventsFromDB(db, q) : [];
773
+ }
774
+
775
+ getAxEvidence(q: AxTimelineQuery = {}): PmxAxEvidence[] {
776
+ const db = this.deps.getDb();
777
+ return db ? loadAxEvidenceFromDB(db, q) : [];
778
+ }
779
+
780
+ getAxSteering(q: AxTimelineQuery & { onlyPending?: boolean } = {}): PmxAxSteeringMessage[] {
781
+ const db = this.deps.getDb();
782
+ return db ? loadAxSteeringFromDB(db, q) : [];
783
+ }
784
+
785
+ /**
786
+ * Undelivered steering for a consumer (Phase 4 delivery). Excludes messages
787
+ * whose source equals the consumer to prevent delivery loops (e.g. Copilot
788
+ * should not be handed back steering it originated).
789
+ */
790
+ getPendingSteering(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
791
+ const db = this.deps.getDb();
792
+ return db ? loadPendingAxSteeringFromDB(db, options) : [];
793
+ }
794
+
795
+ /**
796
+ * NEWEST undelivered steering first, for the compact AX context lead block (report
797
+ * #57) — so a fresh steer is visible even behind a long backlog. Loop-safe like
798
+ * getPendingSteering, but ordered DESC instead of the FIFO ASC delivery queue.
799
+ */
800
+ getPendingSteeringForContext(options: { consumer?: string; limit?: number } = {}): PmxAxSteeringMessage[] {
801
+ const db = this.deps.getDb();
802
+ return db ? loadNewestPendingAxSteeringFromDB(db, options) : [];
803
+ }
804
+
805
+ /** Total undelivered steering for a consumer (loop-safe), for the context backlog counts. */
806
+ getPendingSteeringCount(consumer?: string): number {
807
+ const db = this.deps.getDb();
808
+ return db ? countPendingAxSteeringFromDB(db, consumer) : 0;
809
+ }
810
+
811
+ getAxTimelineSummary(): PmxAxTimelineSummary {
812
+ const db = this.deps.getDb();
813
+ return db
814
+ ? loadAxTimelineSummaryFromDB(db)
815
+ : { recentEvents: [], recentEvidence: [], pendingSteering: [], counts: { events: 0, evidence: 0, steering: 0 } };
816
+ }
817
+
818
+ getAxTimeline(q: AxTimelineQuery = {}): { events: PmxAxEvent[]; evidence: PmxAxEvidence[]; steering: PmxAxSteeringMessage[]; summary: PmxAxTimelineSummary } {
819
+ return {
820
+ events: this.getAxEvents(q),
821
+ evidence: this.getAxEvidence(q),
822
+ steering: this.getAxSteering(q),
823
+ summary: this.getAxTimelineSummary(),
824
+ };
825
+ }
826
+ }