@davidorex/pi-context 0.28.1 → 0.30.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.
@@ -18,10 +18,10 @@
18
18
  */
19
19
  import path from "node:path";
20
20
  import { Type } from "typebox";
21
- import { appendToBlock, appendToNestedArray, nextId, readBlock, readBlockDir, removeFromBlock, removeFromNestedArray, updateItemInBlock, updateNestedArrayItem, writeBlock, } from "./block-api.js";
21
+ import { appendToBlock, appendToNestedArray, nextId, readBlock, readBlockDir, removeFromBlock, removeFromNestedArray, updateItemInBlock, updateNestedArrayItem, upsertItemInBlock, writeBlock, } from "./block-api.js";
22
22
  import { adoptConception, amendConfigEntry, loadConfig, loadContext } from "./context.js";
23
23
  import { BootstrapNotFoundError, schemaPath, tryResolveContextDir } from "./context-dir.js";
24
- import { appendRelationByRef, completeTask, contextState, currentState, deriveBootstrapState, filterBlockItems, joinBlocks, readBlockItem, readBlockPage, resolveItemById, resolveItemsByIds, validateContext, } from "./context-sdk.js";
24
+ import { appendRelationByRef, appendRelationsByRef, completeTask, contextState, currentState, deriveBootstrapState, filterBlockItems, joinBlocks, readBlockItem, readBlockPage, removeRelationByRef, replaceRelationByRef, resolveItemById, resolveItemsByIds, validateContext, } from "./context-sdk.js";
25
25
  import { gatherExecutionContext } from "./execution-context.js";
26
26
  // initProject + the switch/list/archive helpers are defined in index.ts (shared
27
27
  // with the /context command handlers + the context-* tools). This is a cyclic
@@ -32,12 +32,89 @@ import { gatherExecutionContext } from "./execution-context.js";
32
32
  import { archiveSubstrate, initProject, listSubstrates, switchAndCreate, switchToExisting, switchToPrevious, } from "./index.js";
33
33
  import { edgesForLensByName, findReferencesInRepo, validateContextRelations, walkAncestorsByLens, walkLensDescendants, } from "./lens-view.js";
34
34
  import { promoteItem } from "./promote-item.js";
35
- import { addressInto, serializeForRead } from "./read-element.js";
35
+ import { addressInto, renderReadText, structureForRead } from "./read-element.js";
36
36
  import { renameCanonicalId } from "./rename-canonical-id.js";
37
37
  import { listRoadmaps, loadRoadmap, renderRoadmap, validateRoadmaps } from "./roadmap-plan.js";
38
38
  import { samplesCatalog } from "./samples-catalog.js";
39
39
  import { readSchema, writeSchemaChecked } from "./schema-write.js";
40
+ import { truncateHead } from "./truncate.js";
40
41
  import { writeSchemaMigrationExecute } from "./write-schema-migration-tool.js";
42
+ /**
43
+ * Collapse an {@link OpResult} to the text the default CLI surface + the in-pi
44
+ * Pi-tool surface emit. This reproduces, byte-for-byte, what each op's `run`
45
+ * returned before the TASK-012 split: prose → itself; `{json}` →
46
+ * `JSON.stringify(x, null, 2)`; `{read}` → `renderReadText` (== the old
47
+ * `serializeForRead().content`).
48
+ */
49
+ /**
50
+ * The unbypassable output-boundary cap (TASK-013 / FGAP-015). The 50KB read cap
51
+ * (`DEFAULT_MAX_BYTES` + `truncateHead`) previously lived ONLY in the `{read}`
52
+ * channel (structureForRead / renderReadText); the prose `string` and `{json}`
53
+ * channels emitted unbounded. A `{json}` op embedding substrate content (e.g.
54
+ * resolve-item-by-id, promote-item) therefore leaked that content uncapped on
55
+ * BOTH surfaces — the CLI `--json` `output` and the shared text renderer used by
56
+ * the default CLI surface AND the in-pi Pi-tool surface. These two helpers move
57
+ * the cap to the emission boundary so it fires for EVERY channel regardless of
58
+ * which op shape produced the value.
59
+ *
60
+ * `{read}` is already fail-closed at structureForRead (over-cap → data null +
61
+ * tiny metadata / refusal text), so both helpers pass it through untouched — it
62
+ * is never double-handled here.
63
+ */
64
+ /** True when `s` exceeds the 50KB read cap (shared byte-count/threshold logic). */
65
+ function overReadCap(s) {
66
+ const totalBytes = Buffer.byteLength(s, "utf-8");
67
+ return { over: truncateHead(s).truncated, totalBytes };
68
+ }
69
+ /**
70
+ * REFUSAL prose for an over-cap `{json}` or prose `string` result — no narrowing
71
+ * tool/addressing is available at this boundary (unlike `{read}`'s
72
+ * overCapDirective), so this mirrors renderReadText's REFUSAL wording without a
73
+ * tool name and returns NO payload body.
74
+ */
75
+ function overCapRefusalText(totalBytes) {
76
+ return (`⚠️ OUTPUT REFUSED — this result is ${totalBytes} bytes, over the 50KB read cap. ` +
77
+ `Nothing was returned (a partial read would mislead). Narrow your read.`);
78
+ }
79
+ /**
80
+ * Collapse an {@link OpResult} to the text the default CLI surface + the in-pi
81
+ * Pi-tool surface emit, NOW BOUNDED at the 50KB read cap (TASK-013 / FGAP-015).
82
+ * `{read}` → renderReadText (already capped); prose `string` → itself when under
83
+ * cap, else the REFUSAL prose; `{json}` → `JSON.stringify(x, null, 2)` when under
84
+ * cap, else the REFUSAL prose (no partial body).
85
+ */
86
+ export function renderOpResultText(r) {
87
+ if (typeof r === "string") {
88
+ const { over, totalBytes } = overReadCap(r);
89
+ return over ? overCapRefusalText(totalBytes) : r;
90
+ }
91
+ if ("read" in r)
92
+ return renderReadText(r.read);
93
+ const s = JSON.stringify(r.json, null, 2);
94
+ const { over, totalBytes } = overReadCap(s);
95
+ return over ? overCapRefusalText(totalBytes) : s;
96
+ }
97
+ /**
98
+ * The JSON VALUE for the CLI `--json` envelope `output` field, NOW BOUNDED at the
99
+ * 50KB read cap (TASK-013 / FGAP-015). Prose `string` → itself when under cap,
100
+ * else the REFUSAL string; `{read}` → its ReadStructured (already fail-closed —
101
+ * serializes tiny on over-cap); `{json}` → the raw value when under cap, else a
102
+ * fail-closed envelope that MIRRORS {@link ReadStructured}'s over-cap shape
103
+ * (`{ data: null, truncated: true, totalBytes, complete: false }`) so `--json`
104
+ * consumers see one uniform fail-closed envelope across `{read}` and bounded
105
+ * `{json}`. No partial payload is ever emitted past the cap.
106
+ */
107
+ export function boundedJsonOutput(r) {
108
+ if (typeof r === "string") {
109
+ const { over, totalBytes } = overReadCap(r);
110
+ return over ? overCapRefusalText(totalBytes) : r;
111
+ }
112
+ if ("read" in r)
113
+ return r.read;
114
+ const s = JSON.stringify(r.json, null, 2);
115
+ const { over, totalBytes } = overReadCap(s);
116
+ return over ? { data: null, truncated: true, totalBytes, complete: false } : r.json;
117
+ }
41
118
  // ── serializeRoadmapView ────────────────────────────────────────────────────
42
119
  // Strip non-serializable fields (suggestionTemplate fn, grouped Map) from the
43
120
  // embedded LoadedLensView records before tool serialization. Relocated verbatim
@@ -78,7 +155,7 @@ export const ops = [
78
155
  })),
79
156
  }),
80
157
  surface: "use",
81
- run(cwd, params) {
158
+ run(cwd, params, ctx) {
82
159
  // Type.Unknown() params may arrive as JSON strings — parse if needed
83
160
  if (typeof params.item === "string") {
84
161
  try {
@@ -96,7 +173,7 @@ export const ops = [
96
173
  // withBlockLock critical section (block-api assertAppendIdUnique) —
97
174
  // the single enforcement point. The prior racy readBlock-then-append
98
175
  // tool-layer check was removed in favour of that library guard.
99
- appendToBlock(cwd, params.block, params.arrayKey, params.item);
176
+ appendToBlock(cwd, params.block, params.arrayKey, params.item, ctx);
100
177
  const id = params.item?.id ? ` '${params.item.id}'` : "";
101
178
  return `Appended item${id} to ${params.block}.${params.arrayKey}`;
102
179
  },
@@ -115,12 +192,12 @@ export const ops = [
115
192
  }),
116
193
  }),
117
194
  surface: "use",
118
- run(cwd, params) {
195
+ run(cwd, params, ctx) {
119
196
  if (Object.keys(params.updates).length === 0) {
120
197
  throw new Error("No fields to update — updates parameter is empty");
121
198
  }
122
199
  const matchEntries = Object.entries(params.match);
123
- updateItemInBlock(cwd, params.block, params.arrayKey, (item) => matchEntries.every(([k, v]) => item[k] === v), params.updates);
200
+ updateItemInBlock(cwd, params.block, params.arrayKey, (item) => matchEntries.every(([k, v]) => item[k] === v), params.updates, ctx);
124
201
  const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
125
202
  return `Updated item (${matchDesc}) in ${params.block}.${params.arrayKey}: ${Object.keys(params.updates).join(", ")}`;
126
203
  },
@@ -140,25 +217,202 @@ export const ops = [
140
217
  description: "Registered relation_type canonical_id / hierarchy edge type / lens id",
141
218
  }),
142
219
  ordinal: Type.Optional(Type.Integer({ description: "Optional sibling-ordering within (parent, relation_type)" })),
220
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
143
221
  }),
144
222
  surface: "use",
145
- run(cwd, params) {
223
+ run(cwd, params, ctx) {
146
224
  // Cycle-5 porcelain: STRING selectors (bare refname / <alias>:<refname> /
147
225
  // lens-bin) are resolved to structured EdgeEndpoints and written via the
148
226
  // raw plumbing. The param surface stays string-typed; messaging uses the
149
- // raw selectors (params.*), not the resolved structured endpoints.
227
+ // raw selectors (params.*), not the resolved structured endpoints. Under
228
+ // dryRun the byRef fn validates the prospective relations + dedup-checks
229
+ // without writing (TASK-010 shared preview path).
150
230
  const { appended } = appendRelationByRef(cwd, {
151
231
  parent: params.parent,
152
232
  child: params.child,
153
233
  relation_type: params.relation_type,
154
234
  ...(params.ordinal !== undefined ? { ordinal: params.ordinal } : {}),
155
- });
235
+ }, ctx, { dryRun: params.dryRun });
156
236
  const ordinalNote = params.ordinal !== undefined ? ` (ordinal ${params.ordinal})` : "";
237
+ if (params.dryRun) {
238
+ return appended
239
+ ? `would append relation ${params.parent} -[${params.relation_type}]-> ${params.child}${ordinalNote}`
240
+ : `would no-op (duplicate): relation ${params.parent} -[${params.relation_type}]-> ${params.child}`;
241
+ }
157
242
  return appended
158
243
  ? `Appended relation ${params.parent} -[${params.relation_type}]-> ${params.child}${ordinalNote}`
159
244
  : `Relation ${params.parent} -[${params.relation_type}]-> ${params.child} already exists — no-op`;
160
245
  },
161
246
  },
247
+ {
248
+ name: "remove-relation",
249
+ label: "Remove Relation",
250
+ description: "Remove the single closure-table relation (edge) matching parent+child+relation_type from relations.json. " +
251
+ "Matches on the SAME (parent, child, relation_type) dedup identity append-relation uses, so it is the symmetric " +
252
+ "inverse of append-relation (ordinal is NOT part of identity). An absent edge is an idempotent no-op. " +
253
+ "Reference integrity is NOT checked here — run context-validate after if the removal changes resolvability.",
254
+ promptSnippet: "Remove a relation/edge between two items (the inverse of append-relation)",
255
+ parameters: Type.Object({
256
+ parent: Type.String({ description: "Canonical id (or lens bin name) of the parent endpoint" }),
257
+ child: Type.String({ description: "Canonical id of the child endpoint" }),
258
+ relation_type: Type.String({
259
+ description: "Registered relation_type canonical_id / hierarchy edge type / lens id",
260
+ }),
261
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
262
+ }),
263
+ surface: "use",
264
+ run(cwd, params, ctx) {
265
+ // Cycle-5 porcelain: STRING selectors are resolved to structured
266
+ // EdgeEndpoints, then matched on the identityKey dedup identity. Messaging
267
+ // uses the raw selectors (params.*), not the resolved structured endpoints.
268
+ // Under dryRun the byRef fn validates the prospective post-removal
269
+ // relations + match-checks without writing (TASK-010 shared preview path).
270
+ const { removed } = removeRelationByRef(cwd, { parent: params.parent, child: params.child, relation_type: params.relation_type }, ctx, { dryRun: params.dryRun });
271
+ if (params.dryRun) {
272
+ return removed
273
+ ? `would remove relation ${params.parent} -[${params.relation_type}]-> ${params.child}`
274
+ : `would no-op (no matching relation): ${params.parent} -[${params.relation_type}]-> ${params.child}`;
275
+ }
276
+ return removed
277
+ ? `Removed relation ${params.parent} -[${params.relation_type}]-> ${params.child}`
278
+ : `Relation ${params.parent} -[${params.relation_type}]-> ${params.child} — no matching relation — no-op`;
279
+ },
280
+ },
281
+ {
282
+ name: "replace-relation",
283
+ label: "Replace Relation",
284
+ description: "Atomically replace one closure-table relation with another in a SINGLE write (no half-state: the old edge and " +
285
+ "the new edge never coexist on disk). The old edge is matched on the (parent, child, relation_type) dedup identity; " +
286
+ "the new edge is written with its optional ordinal. If the old edge is absent the call is effectively an append of " +
287
+ "the new edge. Reference integrity is NOT checked here — run context-validate after.",
288
+ promptSnippet: "Atomically swap one relation/edge for another in a single write",
289
+ parameters: Type.Object({
290
+ old_parent: Type.String({ description: "Parent endpoint selector of the edge to remove" }),
291
+ old_child: Type.String({ description: "Child endpoint selector of the edge to remove" }),
292
+ old_relation_type: Type.String({ description: "relation_type of the edge to remove" }),
293
+ parent: Type.String({ description: "Parent endpoint selector of the replacement edge" }),
294
+ child: Type.String({ description: "Child endpoint selector of the replacement edge" }),
295
+ relation_type: Type.String({ description: "relation_type of the replacement edge" }),
296
+ ordinal: Type.Optional(Type.Integer({ description: "Optional sibling-ordering within (parent, relation_type) for the new edge" })),
297
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
298
+ }),
299
+ surface: "use",
300
+ run(cwd, params, ctx) {
301
+ // Under dryRun the byRef fn validates the prospective post-replace
302
+ // relations and computes the same removed/replaced would-decisions
303
+ // without writing (TASK-010 shared preview path).
304
+ const { replaced, removed } = replaceRelationByRef(cwd, {
305
+ old: { parent: params.old_parent, child: params.old_child, relation_type: params.old_relation_type },
306
+ new: {
307
+ parent: params.parent,
308
+ child: params.child,
309
+ relation_type: params.relation_type,
310
+ ...(params.ordinal !== undefined ? { ordinal: params.ordinal } : {}),
311
+ },
312
+ }, ctx, { dryRun: params.dryRun });
313
+ const ordinalNote = params.ordinal !== undefined ? ` (ordinal ${params.ordinal})` : "";
314
+ const oldDesc = `${params.old_parent} -[${params.old_relation_type}]-> ${params.old_child}`;
315
+ const newDesc = `${params.parent} -[${params.relation_type}]-> ${params.child}${ordinalNote}`;
316
+ if (params.dryRun) {
317
+ if (!removed && !replaced) {
318
+ return `would no-op — old edge ${oldDesc} absent and new edge ${newDesc} already present`;
319
+ }
320
+ if (!removed) {
321
+ return `would append new relation ${newDesc} (old ${oldDesc} absent)`;
322
+ }
323
+ if (!replaced) {
324
+ return `would remove relation ${oldDesc}; new relation ${newDesc} already present (no duplicate written)`;
325
+ }
326
+ return `would replace relation ${oldDesc} with ${newDesc}`;
327
+ }
328
+ if (!removed && !replaced) {
329
+ return `Replace relation no-op — old edge ${oldDesc} absent and new edge ${newDesc} already present`;
330
+ }
331
+ if (!removed) {
332
+ return `Old relation ${oldDesc} absent — appended new relation ${newDesc}`;
333
+ }
334
+ if (!replaced) {
335
+ return `Removed relation ${oldDesc}; new relation ${newDesc} already present (no duplicate written)`;
336
+ }
337
+ return `Replaced relation ${oldDesc} with ${newDesc}`;
338
+ },
339
+ },
340
+ {
341
+ name: "append-relations",
342
+ label: "Append Relations (bulk)",
343
+ description: "Append MANY closure-table relations to relations.json in a single write. Each edge is an object " +
344
+ "{ parent, child, relation_type, ordinal? }. Per-(parent, child, relation_type) duplicates are skipped (against " +
345
+ "on-disk edges AND earlier edges in the same batch). Returns appended/skipped counts. Reference integrity is NOT " +
346
+ "checked here — run context-validate after. Creates relations.json if absent.",
347
+ promptSnippet: "Create many relations/edges between items in one write",
348
+ parameters: Type.Object({
349
+ edges: Type.Unknown({
350
+ description: "JSON array of { parent, child, relation_type, ordinal? } selector objects (parent/child are id/lens-bin selectors)",
351
+ }),
352
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
353
+ }),
354
+ surface: "use",
355
+ run(cwd, params, ctx) {
356
+ // Type.Unknown() params may arrive as JSON strings — parse if needed.
357
+ let edges = params.edges;
358
+ if (typeof edges === "string") {
359
+ try {
360
+ edges = JSON.parse(edges);
361
+ }
362
+ catch {
363
+ throw new Error(`edges parameter must be a JSON array, got unparseable string`);
364
+ }
365
+ }
366
+ if (!Array.isArray(edges)) {
367
+ throw new Error(`edges parameter must be a JSON array of { parent, child, relation_type, ordinal? } objects`);
368
+ }
369
+ // Under dryRun the byRef fn replays the on-disk + in-batch dedup and
370
+ // validates the prospective relations without writing (TASK-010 shared
371
+ // preview path).
372
+ const { appended, skipped } = appendRelationsByRef(cwd, edges, ctx, { dryRun: params.dryRun });
373
+ return params.dryRun
374
+ ? `would append ${appended}, skip ${skipped} (duplicates)`
375
+ : `appended ${appended}, skipped ${skipped} (duplicates)`;
376
+ },
377
+ },
378
+ {
379
+ name: "upsert-block-item",
380
+ label: "Upsert Block Item",
381
+ description: "Append-or-replace an item in a project block array by id: if an item with the same idField value exists it is " +
382
+ "REPLACED (full-shape replacement, not shallow-merge — use update-block-item for merge); otherwise the item is " +
383
+ "appended. Schema validation is automatic. idField defaults to 'id'.",
384
+ promptSnippet: "Append-or-replace a full block item by id (replacement, not merge)",
385
+ parameters: Type.Object({
386
+ block: Type.String({ description: "Block name (e.g., 'issues', 'decisions')" }),
387
+ arrayKey: Type.String({ description: "Array key in the block (e.g., 'issues', 'decisions')" }),
388
+ item: Type.Unknown({ description: "Full item object to upsert — must conform to block schema" }),
389
+ idField: Type.Optional(Type.String({ description: "Field used as the upsert key (default 'id')" })),
390
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview the upsert without writing" })),
391
+ }),
392
+ surface: "use",
393
+ run(cwd, params, ctx) {
394
+ // Type.Unknown() params may arrive as JSON strings — parse if needed.
395
+ if (typeof params.item === "string") {
396
+ try {
397
+ params.item = JSON.parse(params.item);
398
+ }
399
+ catch {
400
+ throw new Error(`item parameter must be a JSON object, got unparseable string`);
401
+ }
402
+ }
403
+ const idField = params.idField ?? "id";
404
+ // Under dryRun upsertItemInBlock computes mode + builds + validates the prospective
405
+ // whole block, writing nothing (TASK-011 shared preview path).
406
+ const { mode } = upsertItemInBlock(cwd, params.block, params.arrayKey, params.item, idField, ctx, {
407
+ dryRun: params.dryRun,
408
+ });
409
+ const idVal = params.item?.[idField];
410
+ const idDesc = idVal !== undefined ? ` '${idVal}'` : "";
411
+ return params.dryRun
412
+ ? `would upsert item${idDesc} (${mode}) in ${params.block}.${params.arrayKey}`
413
+ : `Upserted item${idDesc} (${mode}) to ${params.block}.${params.arrayKey}`;
414
+ },
415
+ },
162
416
  {
163
417
  name: "promote-item",
164
418
  label: "Promote Item",
@@ -180,17 +434,26 @@ export const ops = [
180
434
  }, { description: "DispatchContext.writer per pi-context/src/dispatch-context.ts." }),
181
435
  }),
182
436
  surface: "use",
183
- run(cwd, params) {
184
- if (!params.writer?.user) {
185
- throw new Error("promote-item: writer.user is required.");
437
+ run(cwd, params, ctx) {
438
+ // The DispatchContext now arrives via the op contract — registerAll
439
+ // (in-pi) builds it from the auth-gate-stamped `params.writer`, and the
440
+ // CLI builds it from its resolved identity. The schema `writer` field is
441
+ // retained (the in-pi auth-gate stamps it), but lineage attestation reads
442
+ // the contract ctx, not params.writer.
443
+ if (!ctx?.writer) {
444
+ throw new Error("promote-item: a DispatchContext writer is required.");
186
445
  }
187
446
  const result = promoteItem(cwd, {
188
447
  source: params.source,
189
448
  destinationSubstrate: params.destinationSubstrate,
190
449
  ...(params.newRefname !== undefined ? { newRefname: params.newRefname } : {}),
191
450
  ...(params.dryRun !== undefined ? { dryRun: params.dryRun } : {}),
192
- }, { writer: { kind: "human", user: params.writer.user } });
193
- return JSON.stringify(result, null, 2);
451
+ }, ctx);
452
+ // TASK-013 / FGAP-015: route through {read} so the embedded
453
+ // ResolvedRef.loc.item is bounded at the 50KB cap; over-cap fails closed
454
+ // with metadata. Under-cap text stays the same JSON (renderReadText
455
+ // under-cap returns JSON.stringify(serialized, null, 2) with no footer).
456
+ return { read: structureForRead(result, { whole: true, label: "promote-item result" }) };
194
457
  },
195
458
  },
196
459
  {
@@ -208,7 +471,7 @@ export const ops = [
208
471
  item: Type.Unknown({ description: "Item object to append to the nested array — must conform to schema" }),
209
472
  }),
210
473
  surface: "use",
211
- run(cwd, params) {
474
+ run(cwd, params, ctx) {
212
475
  if (typeof params.item === "string") {
213
476
  try {
214
477
  params.item = JSON.parse(params.item);
@@ -219,7 +482,7 @@ export const ops = [
219
482
  }
220
483
  const matchEntries = Object.entries(params.match);
221
484
  const predicate = (i) => matchEntries.every(([k, v]) => i[k] === v);
222
- appendToNestedArray(cwd, params.block, params.arrayKey, predicate, params.nestedKey, params.item);
485
+ appendToNestedArray(cwd, params.block, params.arrayKey, predicate, params.nestedKey, params.item, ctx);
223
486
  const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
224
487
  const id = params.item?.id ? ` '${params.item.id}'` : "";
225
488
  return `Appended item${id} to ${params.block}.${params.arrayKey}[${matchDesc}].${params.nestedKey}`;
@@ -245,7 +508,7 @@ export const ops = [
245
508
  }),
246
509
  }),
247
510
  surface: "use",
248
- run(cwd, params) {
511
+ run(cwd, params, ctx) {
249
512
  if (Object.keys(params.updates).length === 0) {
250
513
  throw new Error("No fields to update — updates parameter is empty");
251
514
  }
@@ -253,7 +516,7 @@ export const ops = [
253
516
  const nestedEntries = Object.entries(params.nestedMatch);
254
517
  const parentPred = (i) => parentEntries.every(([k, v]) => i[k] === v);
255
518
  const nestedPred = (i) => nestedEntries.every(([k, v]) => i[k] === v);
256
- updateNestedArrayItem(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred, params.updates);
519
+ updateNestedArrayItem(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred, params.updates, ctx);
257
520
  const parentDesc = parentEntries.map(([k, v]) => `${k}=${v}`).join(", ");
258
521
  const nestedDesc = nestedEntries.map(([k, v]) => `${k}=${v}`).join(", ");
259
522
  return `Updated nested item (${nestedDesc}) in ${params.block}.${params.arrayKey}[${parentDesc}].${params.nestedKey}: ${Object.keys(params.updates).join(", ")}`;
@@ -270,10 +533,10 @@ export const ops = [
270
533
  match: Type.Record(Type.String(), Type.Unknown(), { description: "Fields to match (e.g., { id: 'ISSUE-NNN' })" }),
271
534
  }),
272
535
  surface: "use",
273
- run(cwd, params) {
536
+ run(cwd, params, ctx) {
274
537
  const matchEntries = Object.entries(params.match);
275
538
  const predicate = (i) => matchEntries.every(([k, v]) => i[k] === v);
276
- const result = removeFromBlock(cwd, params.block, params.arrayKey, predicate);
539
+ const result = removeFromBlock(cwd, params.block, params.arrayKey, predicate, ctx);
277
540
  const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
278
541
  return `Removed ${result.removed} item(s) matching (${matchDesc}) from ${params.block}.${params.arrayKey}`;
279
542
  },
@@ -295,12 +558,12 @@ export const ops = [
295
558
  }),
296
559
  }),
297
560
  surface: "use",
298
- run(cwd, params) {
561
+ run(cwd, params, ctx) {
299
562
  const parentEntries = Object.entries(params.match);
300
563
  const nestedEntries = Object.entries(params.nestedMatch);
301
564
  const parentPred = (i) => parentEntries.every(([k, v]) => i[k] === v);
302
565
  const nestedPred = (i) => nestedEntries.every(([k, v]) => i[k] === v);
303
- const result = removeFromNestedArray(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred);
566
+ const result = removeFromNestedArray(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred, ctx);
304
567
  const parentDesc = parentEntries.map(([k, v]) => `${k}=${v}`).join(", ");
305
568
  const nestedDesc = nestedEntries.map(([k, v]) => `${k}=${v}`).join(", ");
306
569
  return `Removed ${result.removed} nested item(s) matching (${nestedDesc}) from ${params.block}.${params.arrayKey}[${parentDesc}].${params.nestedKey}`;
@@ -317,8 +580,8 @@ export const ops = [
317
580
  surface: "use",
318
581
  run(cwd, params) {
319
582
  const result = readBlockDir(cwd, params.subdir);
320
- const envelope = serializeForRead(result, { label: `<substrate-dir>/${params.subdir}/` });
321
- return envelope.content;
583
+ const read = structureForRead(result, { label: `<substrate-dir>/${params.subdir}/` });
584
+ return { read };
322
585
  },
323
586
  },
324
587
  {
@@ -332,7 +595,7 @@ export const ops = [
332
595
  surface: "use",
333
596
  run(cwd, params) {
334
597
  const result = readBlock(cwd, params.block);
335
- const envelope = serializeForRead(result, {
598
+ const read = structureForRead(result, {
336
599
  label: `<substrate-dir>/${params.block}.json`,
337
600
  overCapDirective: {
338
601
  tool: "read-block-page",
@@ -340,7 +603,7 @@ export const ops = [
340
603
  hint: "or read-block-item with id=<id>",
341
604
  },
342
605
  });
343
- return envelope.content;
606
+ return { read };
344
607
  },
345
608
  },
346
609
  {
@@ -354,9 +617,9 @@ export const ops = [
354
617
  }),
355
618
  surface: "use",
356
619
  authGated: true,
357
- run(cwd, params) {
620
+ run(cwd, params, ctx) {
358
621
  const data = typeof params.data === "string" ? JSON.parse(params.data) : params.data;
359
- writeBlock(cwd, params.block, data);
622
+ writeBlock(cwd, params.block, data, ctx);
360
623
  return `Wrote block '${params.block}' successfully`;
361
624
  },
362
625
  },
@@ -369,7 +632,7 @@ export const ops = [
369
632
  surface: "use",
370
633
  run(cwd, _params) {
371
634
  const result = contextState(cwd);
372
- return JSON.stringify(result, null, 2);
635
+ return { json: result };
373
636
  },
374
637
  },
375
638
  {
@@ -381,7 +644,7 @@ export const ops = [
381
644
  surface: "use",
382
645
  run(cwd, _params) {
383
646
  const result = validateContext(cwd);
384
- return JSON.stringify(result, null, 2);
647
+ return { json: result };
385
648
  },
386
649
  },
387
650
  {
@@ -410,10 +673,10 @@ export const ops = [
410
673
  if (!entry.found) {
411
674
  return `read-config: entry not found in ${params.registry} — ${entry.resolved}`;
412
675
  }
413
- const envEntry = serializeForRead(entry.value, { label: `config.${params.registry}.${params.id}` });
414
- return envEntry.content;
676
+ const read = structureForRead(entry.value, { label: `config.${params.registry}.${params.id}` });
677
+ return { read };
415
678
  }
416
- const envReg = serializeForRead(reg.value, {
679
+ const read = structureForRead(reg.value, {
417
680
  label: `config.${params.registry}`,
418
681
  overCapDirective: {
419
682
  tool: "read-config",
@@ -421,17 +684,17 @@ export const ops = [
421
684
  hint: "add id=<entry canonical_id>",
422
685
  },
423
686
  });
424
- return envReg.content;
687
+ return { read };
425
688
  }
426
689
  const result = { config, configPath };
427
- const envelope = serializeForRead(result, {
690
+ const read = structureForRead(result, {
428
691
  label: configPath ?? "config.json",
429
692
  overCapDirective: {
430
693
  tool: "read-config",
431
694
  hint: "registry=<name> (block_kinds|relation_types|lenses|invariants|…)",
432
695
  },
433
696
  });
434
- return envelope.content;
697
+ return { read };
435
698
  },
436
699
  },
437
700
  {
@@ -467,8 +730,8 @@ export const ops = [
467
730
  if (tool === undefined) {
468
731
  return `list-tools: tool not found — name=${params.name}`;
469
732
  }
470
- const envOne = serializeForRead(tool, { label: `tool ${params.name}` });
471
- return envOne.content;
733
+ const read = structureForRead(tool, { label: `tool ${params.name}` });
734
+ return { read };
472
735
  }
473
736
  // Default: compact index (FGAP-101) — name + param count + one-line description.
474
737
  const index = all.map((t) => {
@@ -482,11 +745,11 @@ export const ops = [
482
745
  // The compact index is one line per tool — small enough to serialize whole
483
746
  // (no paging); keep the wrapper fields (active/total) on the result object.
484
747
  const result = { tools: index, active, total: all.length, activeCount: active.length };
485
- const envelope = serializeForRead(result, {
748
+ const read = structureForRead(result, {
486
749
  label: "tool index — pass name= for detail",
487
750
  overCapDirective: { tool: "list-tools", hint: "name=<tool>" },
488
751
  });
489
- return envelope.content;
752
+ return { read };
490
753
  },
491
754
  },
492
755
  {
@@ -502,13 +765,13 @@ export const ops = [
502
765
  // Package-intrinsic: the catalog reads the extension's bundled samples
503
766
  // directory, not the project substrate — cwd is unused.
504
767
  const catalog = samplesCatalog(params.kind ? { kind: params.kind } : undefined);
505
- const envelope = serializeForRead(catalog, {
768
+ const read = structureForRead(catalog, {
506
769
  label: params.kind ? `samples kind=${params.kind}` : "samples catalog",
507
770
  // Whole catalog → narrow by kind; a single kind has no finer
508
771
  // addressing (edge → head-leading marker, no directive).
509
772
  ...(params.kind ? {} : { overCapDirective: { tool: "read-samples-catalog", hint: "kind=<canonical_id>" } }),
510
773
  });
511
- return envelope.content;
774
+ return { read };
512
775
  },
513
776
  },
514
777
  {
@@ -520,19 +783,19 @@ export const ops = [
520
783
  surface: "use",
521
784
  run(cwd, _params) {
522
785
  const state = currentState(cwd);
523
- return JSON.stringify(state, null, 2);
786
+ return { json: state };
524
787
  },
525
788
  },
526
789
  {
527
790
  name: "context-bootstrap-state",
528
791
  label: "Context Bootstrap State",
529
- description: "Derive the substrate bootstrap state for the cwd, purely from the filesystem: 'no-pointer' | 'no-config' | 'not-installed' | 'ready', plus the resolved contextDir and any declared-but-unmaterialized installed assets. Unlike every other tool, this NEVER throws on an un-bootstrapped substrate — it returns 'no-pointer' so you can detect a fresh substrate and tell the user to run /context init <substrate-dir> → /context accept-all → /context install (bootstrap requires user authorization via interactive confirmation). No writes.",
530
- promptSnippet: "Derive substrate bootstrap state — no-pointer | no-config | not-installed | ready (never throws pre-bootstrap)",
792
+ description: "Derive the substrate bootstrap state for the cwd, purely from the filesystem: 'no-pointer' | 'no-config' | 'skeleton' | 'not-installed' | 'ready', plus the resolved contextDir and any declared-but-unmaterialized installed assets. Bootstrap (/context init or /context switch -c <new-dir>) now writes a minimal schema-valid config empty of vocabulary, so a freshly-bootstrapped substrate lands at 'skeleton' — onward via /context accept-all (adopt the packaged catalog, then /context install) OR amend-config / edit (build a custom vocabulary). Unlike every other tool, this NEVER throws on an un-bootstrapped substrate — it returns 'no-pointer' so you can detect a fresh substrate and tell the user to run /context init <substrate-dir> → /context accept-all → /context install (bootstrap requires user authorization via interactive confirmation). No writes.",
793
+ promptSnippet: "Derive substrate bootstrap state — no-pointer | no-config | skeleton | not-installed | ready (never throws pre-bootstrap)",
531
794
  parameters: Type.Object({}),
532
795
  surface: "use",
533
796
  run(cwd, _params) {
534
797
  const status = deriveBootstrapState(cwd);
535
- return JSON.stringify(status, null, 2);
798
+ return { json: status };
536
799
  },
537
800
  },
538
801
  {
@@ -550,7 +813,7 @@ export const ops = [
550
813
  authGated: true,
551
814
  run(cwd, params) {
552
815
  const report = renameCanonicalId(cwd, params.kind, params.oldId, params.newId, { dryRun: params.dryRun });
553
- return JSON.stringify(report, null, 2);
816
+ return { json: report };
554
817
  },
555
818
  },
556
819
  {
@@ -580,7 +843,7 @@ export const ops = [
580
843
  }),
581
844
  surface: "use",
582
845
  authGated: true,
583
- run(cwd, params) {
846
+ run(cwd, params, ctx) {
584
847
  // Type.Unknown() params may arrive as JSON strings. Parse if possible; on
585
848
  // failure KEEP the raw string (valid for map-value registries whose value
586
849
  // is a bare string, e.g. naming/display_strings/status_buckets).
@@ -593,10 +856,11 @@ export const ops = [
593
856
  /* keep raw string — valid for map-value registries */
594
857
  }
595
858
  }
596
- const result = amendConfigEntry(cwd, params.registry, params.operation, params.key, entry, undefined, {
859
+ const result = amendConfigEntry(cwd, params.registry, params.operation, params.key, entry, ctx, {
597
860
  dryRun: params.dryRun,
598
861
  });
599
- const verb = result.modified ? (params.dryRun ? `would ${result.operation}` : `${result.operation}d`) : "no-op";
862
+ const pastTense = result.operation === "add" ? "added" : `${result.operation}d`;
863
+ const verb = result.modified ? (params.dryRun ? `would ${result.operation}` : pastTense) : "no-op";
600
864
  return `amend-config: ${verb} ${result.registry}[${result.key}]`;
601
865
  },
602
866
  },
@@ -622,11 +886,11 @@ export const ops = [
622
886
  if (!addr.found) {
623
887
  return `read-schema: property not found — ${addr.resolved}`;
624
888
  }
625
- const envProp = serializeForRead(addr.value, { label: `${params.schemaName} ${addr.resolved}` });
626
- return envProp.content;
889
+ const read = structureForRead(addr.value, { label: `${params.schemaName} ${addr.resolved}` });
890
+ return { read };
627
891
  }
628
892
  const result = { schema, schemaPath: schemaPathStr };
629
- const envelope = serializeForRead(result, {
893
+ const read = structureForRead(result, {
630
894
  label: schemaPathStr,
631
895
  overCapDirective: {
632
896
  tool: "read-schema",
@@ -634,7 +898,7 @@ export const ops = [
634
898
  hint: "path=<dotted json-path>",
635
899
  },
636
900
  });
637
- return envelope.content;
901
+ return { read };
638
902
  },
639
903
  },
640
904
  {
@@ -656,7 +920,7 @@ export const ops = [
656
920
  }),
657
921
  surface: "use",
658
922
  authGated: true,
659
- run(cwd, params) {
923
+ run(cwd, params, ctx) {
660
924
  // Type.Unknown() params may arrive as JSON strings. Parse if possible; on
661
925
  // failure KEEP the raw value (meta-validation rejects a non-object body).
662
926
  let schema = params.schema;
@@ -668,7 +932,7 @@ export const ops = [
668
932
  /* keep raw string — meta-validation will reject a non-object */
669
933
  }
670
934
  }
671
- const result = writeSchemaChecked(cwd, params.schemaName, schema, params.operation, undefined, { dryRun: params.dryRun });
935
+ const result = writeSchemaChecked(cwd, params.schemaName, schema, params.operation, ctx, { dryRun: params.dryRun });
672
936
  const verb = result.written ? `${result.operation}d` : `would ${result.operation}`;
673
937
  return `write-schema: ${verb} schema '${params.schemaName}' at ${result.schemaPath}`;
674
938
  },
@@ -698,8 +962,8 @@ export const ops = [
698
962
  }),
699
963
  surface: "use",
700
964
  authGated: true,
701
- async run(cwd, params) {
702
- const result = await writeSchemaMigrationExecute(cwd, params);
965
+ async run(cwd, params, ctx) {
966
+ const result = await writeSchemaMigrationExecute(cwd, params, ctx);
703
967
  // writeSchemaMigrationExecute returns the uniform AgentToolResult; the
704
968
  // op contract is the text payload, which registerAll re-wraps identically.
705
969
  const part = result.content[0];
@@ -709,8 +973,8 @@ export const ops = [
709
973
  {
710
974
  name: "context-init",
711
975
  label: "Context Init",
712
- description: "Initialize the substrate dir (bootstrap pointer + dirs only; run accept-all + install to populate).",
713
- promptSnippet: "Initialize the substrate dir (bootstrap pointer + dirs only; run accept-all + install to populate)",
976
+ description: "Initialize the substrate dir: bootstrap pointer + dirs + a minimal schema-valid SKELETON config empty of vocabulary. Lands at the 'skeleton' bootstrap state — onward via accept-all (adopt the packaged catalog, then install) OR amend-config / edit (build a custom vocabulary).",
977
+ promptSnippet: "Initialize the substrate dir (bootstrap pointer + dirs + skeleton config; onward via accept-all OR amend-config/edit)",
714
978
  parameters: Type.Object({
715
979
  contextDir: Type.String({
716
980
  description: "Substrate dir name (e.g. .context). Required — no default.",
@@ -720,13 +984,13 @@ export const ops = [
720
984
  authGated: true,
721
985
  run(cwd, params) {
722
986
  const result = initProject(cwd, params.contextDir);
723
- return JSON.stringify(result, null, 2);
987
+ return { json: result };
724
988
  },
725
989
  },
726
990
  {
727
991
  name: "context-accept-all",
728
992
  label: "Accept-All Conception",
729
- description: "Adopt the canonical packaged conception (samples/conception.json) as this substrate's config.json (accept-all). Writes config only — run install after. Idempotent: never overwrites an existing config.",
993
+ description: "Adopt the canonical packaged conception (samples/conception.json) as this substrate's config.json (accept-all). Writes config only — run install after. Skeleton-aware: overwrites a SKELETON config (the empty-of-vocabulary config init / switch -c writes) but never a POPULATED one.",
730
994
  promptSnippet: "Adopt the canonical conception as config (accept-all)",
731
995
  parameters: Type.Object({}),
732
996
  surface: "use",
@@ -742,7 +1006,7 @@ export const ops = [
742
1006
  }
743
1007
  throw err;
744
1008
  }
745
- return JSON.stringify(result);
1009
+ return { json: result };
746
1010
  },
747
1011
  },
748
1012
  {
@@ -755,7 +1019,7 @@ export const ops = [
755
1019
  description: "Substrate dir name to switch to (e.g. '.context'). Required for default + create_new modes; ignored for to_previous mode.",
756
1020
  }),
757
1021
  create_new: Type.Optional(Type.Boolean({
758
- description: "When true, bootstrap target_dir as a fresh substrate AND flip the pointer in one operation (parallel to 'git switch -c <branch>'). Default false (flip to existing substrate; fails if target_dir lacks config.json).",
1022
+ description: "When true, bootstrap target_dir as a fresh substrate (dirs + a minimal schema-valid SKELETON config empty of vocabulary — onward via accept-all OR amend/edit) AND flip the pointer in one operation (parallel to 'git switch -c <branch>'). Default false (flip to existing substrate; fails if target_dir lacks config.json).",
759
1023
  })),
760
1024
  to_previous: Type.Optional(Type.Boolean({
761
1025
  description: "When true, flip the pointer back to its previous_contextDir (parallel to 'git switch -'). Requires the pointer to carry a previous_contextDir (a prior switch must have populated it). When true, target_dir is ignored.",
@@ -784,14 +1048,14 @@ export const ops = [
784
1048
  try {
785
1049
  if (params.to_previous === true) {
786
1050
  const { from, to } = switchToPrevious(cwd, writerIdentity);
787
- return JSON.stringify({ mode: "to_previous", from, to }, null, 2);
1051
+ return { json: { mode: "to_previous", from, to } };
788
1052
  }
789
1053
  if (params.create_new === true) {
790
1054
  const { created } = switchAndCreate(cwd, params.target_dir, writerIdentity);
791
- return JSON.stringify({ mode: "create_new", target_dir: params.target_dir, created }, null, 2);
1055
+ return { json: { mode: "create_new", target_dir: params.target_dir, created } };
792
1056
  }
793
1057
  switchToExisting(cwd, params.target_dir, writerIdentity);
794
- return JSON.stringify({ mode: "existing", target_dir: params.target_dir }, null, 2);
1058
+ return { json: { mode: "existing", target_dir: params.target_dir } };
795
1059
  }
796
1060
  catch (err) {
797
1061
  const msg = err instanceof Error ? err.message : String(err);
@@ -808,7 +1072,7 @@ export const ops = [
808
1072
  surface: "use",
809
1073
  run(cwd, _params) {
810
1074
  const subs = listSubstrates(cwd);
811
- return JSON.stringify(subs, null, 2);
1075
+ return { json: subs };
812
1076
  },
813
1077
  },
814
1078
  {
@@ -820,21 +1084,13 @@ export const ops = [
820
1084
  target_dir: Type.String({
821
1085
  description: "Substrate dir name to archive (e.g. '.project'). Refused if it is the active substrate.",
822
1086
  }),
823
- writer: Type.Optional(Type.Object({
824
- kind: Type.String({
825
- description: "Writer kind discriminator — overwritten by auth-gate to 'human' on confirm.",
826
- }),
827
- user: Type.String({
828
- description: "Writer user — overwritten by auth-gate to the verified terminal-operator identity on confirm.",
829
- }),
830
- }, { description: "DispatchContext.writer — stamped by auth-gate on operator confirm." })),
831
1087
  }),
832
1088
  surface: "use",
833
1089
  authGated: true,
834
1090
  run(cwd, params) {
835
1091
  try {
836
1092
  const { from, to } = archiveSubstrate(cwd, params.target_dir);
837
- return JSON.stringify({ from, to }, null, 2);
1093
+ return { json: { from, to } };
838
1094
  }
839
1095
  catch (err) {
840
1096
  const msg = err instanceof Error ? err.message : String(err);
@@ -866,11 +1122,11 @@ export const ops = [
866
1122
  op: params.op,
867
1123
  value: params.value,
868
1124
  });
869
- const envelope = serializeForRead(result, {
1125
+ const read = structureForRead(result, {
870
1126
  label: `${params.block} filtered`,
871
1127
  overCapDirective: { tool: "read-block-page", hint: "or refine the predicate" },
872
1128
  });
873
- return envelope.content;
1129
+ return { read };
874
1130
  },
875
1131
  },
876
1132
  {
@@ -884,7 +1140,17 @@ export const ops = [
884
1140
  surface: "use",
885
1141
  run(cwd, params) {
886
1142
  const result = resolveItemById(cwd, params.id);
887
- return JSON.stringify(result, null, 2);
1143
+ // TASK-013 / FGAP-015: route through {read} so the embedded full
1144
+ // ItemLocation is bounded at the 50KB cap and over-cap fails closed with
1145
+ // a narrowing directive (mirrors read-block-item). `result` is
1146
+ // ItemLocation | null — structureForRead handles both.
1147
+ return {
1148
+ read: structureForRead(result, {
1149
+ whole: true,
1150
+ label: `resolve ${params.id}`,
1151
+ overCapDirective: { tool: "read-block-item", hint: "narrow to one block" },
1152
+ }),
1153
+ };
888
1154
  },
889
1155
  },
890
1156
  {
@@ -901,8 +1167,8 @@ export const ops = [
901
1167
  const result = readBlockItem(cwd, params.block, params.id);
902
1168
  // whole: the item is already the addressed element — don't re-page its
903
1169
  // intrinsic arrays; preserve the single-item|null output contract.
904
- const envelope = serializeForRead(result, { whole: true, label: `${params.block} ${params.id}` });
905
- return envelope.content;
1170
+ const read = structureForRead(result, { whole: true, label: `${params.block} ${params.id}` });
1171
+ return { read };
906
1172
  },
907
1173
  },
908
1174
  {
@@ -920,8 +1186,8 @@ export const ops = [
920
1186
  const result = readBlockPage(cwd, params.block, { offset: params.offset, limit: params.limit });
921
1187
  // whole: readBlockPage ALREADY paged — preserve the {items,total,hasMore}
922
1188
  // output contract; do not let serializeForRead re-page the items array.
923
- const envelope = serializeForRead(result, { whole: true, label: `${params.block} page` });
924
- return envelope.content;
1189
+ const read = structureForRead(result, { whole: true, label: `${params.block} page` });
1190
+ return { read };
925
1191
  },
926
1192
  },
927
1193
  {
@@ -956,14 +1222,14 @@ export const ops = [
956
1222
  leftEndpoint: params.leftEndpoint,
957
1223
  leftPredicate,
958
1224
  });
959
- const envelope = serializeForRead(result, {
1225
+ const read = structureForRead(result, {
960
1226
  label: `${params.leftBlock} ⋈ ${params.rightBlock}`,
961
1227
  overCapDirective: {
962
1228
  tool: "join-blocks",
963
1229
  hint: "refine the relation/field or pre-filter the left block",
964
1230
  },
965
1231
  });
966
- return envelope.content;
1232
+ return { read };
967
1233
  },
968
1234
  },
969
1235
  {
@@ -984,8 +1250,8 @@ export const ops = [
984
1250
  obj[id] = loc;
985
1251
  // whole: an id→location map keyed by arbitrary ids — not a pageable
986
1252
  // collection; serialize the map verbatim.
987
- const envelope = serializeForRead(obj, { whole: true, label: "resolved ids" });
988
- return envelope.content;
1253
+ const read = structureForRead(obj, { whole: true, label: "resolved ids" });
1254
+ return { read };
989
1255
  },
990
1256
  },
991
1257
  {
@@ -1000,8 +1266,8 @@ export const ops = [
1000
1266
  }),
1001
1267
  }),
1002
1268
  surface: "use",
1003
- run(cwd, params) {
1004
- const result = completeTask(cwd, params.taskId, params.verificationId);
1269
+ run(cwd, params, ctx) {
1270
+ const result = completeTask(cwd, params.taskId, params.verificationId, ctx);
1005
1271
  return `Task '${result.taskId}' completed (was '${result.previousStatus}'). Verification: ${result.verificationId} (${result.verificationStatus})`;
1006
1272
  },
1007
1273
  },
@@ -1014,7 +1280,7 @@ export const ops = [
1014
1280
  surface: "use",
1015
1281
  run(cwd, _params) {
1016
1282
  const result = validateContextRelations(cwd);
1017
- return JSON.stringify(result, null, 2);
1283
+ return { json: result };
1018
1284
  },
1019
1285
  },
1020
1286
  {
@@ -1028,8 +1294,8 @@ export const ops = [
1028
1294
  surface: "use",
1029
1295
  run(cwd, params) {
1030
1296
  const result = edgesForLensByName(cwd, params.lensId);
1031
- const envelope = serializeForRead(result, { label: `edges for lens ${params.lensId}` });
1032
- return envelope.content;
1297
+ const read = structureForRead(result, { label: `edges for lens ${params.lensId}` });
1298
+ return { read };
1033
1299
  },
1034
1300
  },
1035
1301
  {
@@ -1044,7 +1310,7 @@ export const ops = [
1044
1310
  surface: "use",
1045
1311
  run(cwd, params) {
1046
1312
  const result = walkLensDescendants(cwd, params.parentId, params.relationType);
1047
- return JSON.stringify(result, null, 2);
1313
+ return { json: result };
1048
1314
  },
1049
1315
  },
1050
1316
  {
@@ -1059,8 +1325,8 @@ export const ops = [
1059
1325
  surface: "use",
1060
1326
  run(cwd, params) {
1061
1327
  const result = walkAncestorsByLens(cwd, params.itemId, params.relationType);
1062
- const envelope = serializeForRead(result, { label: `ancestors of ${params.itemId}` });
1063
- return envelope.content;
1328
+ const read = structureForRead(result, { label: `ancestors of ${params.itemId}` });
1329
+ return { read };
1064
1330
  },
1065
1331
  },
1066
1332
  {
@@ -1077,8 +1343,8 @@ export const ops = [
1077
1343
  surface: "use",
1078
1344
  run(cwd, params) {
1079
1345
  const result = findReferencesInRepo(cwd, params.itemId, params.direction);
1080
- const envelope = serializeForRead(result, { label: `edges on ${params.itemId}` });
1081
- return envelope.content;
1346
+ const read = structureForRead(result, { label: `edges on ${params.itemId}` });
1347
+ return { read };
1082
1348
  },
1083
1349
  },
1084
1350
  {
@@ -1101,8 +1367,8 @@ export const ops = [
1101
1367
  const result = gatherExecutionContext(cwd, params);
1102
1368
  // whole: a structured ContextBundle (unit + perRelationType buckets) —
1103
1369
  // preserve the bundle shape rather than paging any single inner array.
1104
- const envelope = serializeForRead(result, { whole: true, label: `bundle ${params.unitId}` });
1105
- return envelope.content;
1370
+ const read = structureForRead(result, { whole: true, label: `bundle ${params.unitId}` });
1371
+ return { read };
1106
1372
  },
1107
1373
  },
1108
1374
  {
@@ -1117,16 +1383,16 @@ export const ops = [
1117
1383
  run(cwd, params) {
1118
1384
  const view = loadRoadmap(cwd, params.roadmapId);
1119
1385
  if ("error" in view) {
1120
- const envErr = serializeForRead(view, { whole: true, label: `roadmap ${params.roadmapId} (error)` });
1121
- return envErr.content;
1386
+ const read = structureForRead(view, { whole: true, label: `roadmap ${params.roadmapId} (error)` });
1387
+ return { read };
1122
1388
  }
1123
1389
  // whole: a structured RoadmapView (phases + lens-views + rollups) — keep
1124
1390
  // the view shape intact rather than paging an inner array.
1125
- const envelope = serializeForRead(serializeRoadmapView(view), {
1391
+ const read = structureForRead(serializeRoadmapView(view), {
1126
1392
  whole: true,
1127
1393
  label: `roadmap ${params.roadmapId}`,
1128
1394
  });
1129
- return envelope.content;
1395
+ return { read };
1130
1396
  },
1131
1397
  },
1132
1398
  {
@@ -1141,7 +1407,7 @@ export const ops = [
1141
1407
  run(cwd, params) {
1142
1408
  const view = loadRoadmap(cwd, params.roadmapId);
1143
1409
  if ("error" in view) {
1144
- return JSON.stringify(view, null, 2);
1410
+ return { json: view };
1145
1411
  }
1146
1412
  const naming = loadContext(cwd).config?.naming;
1147
1413
  return renderRoadmap(view, naming);
@@ -1161,7 +1427,7 @@ export const ops = [
1161
1427
  const filtered = params.roadmapId
1162
1428
  ? result.issues.filter((i) => !i.roadmap_id || i.roadmap_id === params.roadmapId)
1163
1429
  : result.issues;
1164
- return JSON.stringify({ status: result.status, issues: filtered }, null, 2);
1430
+ return { json: { status: result.status, issues: filtered } };
1165
1431
  },
1166
1432
  },
1167
1433
  {
@@ -1172,7 +1438,7 @@ export const ops = [
1172
1438
  parameters: Type.Object({}),
1173
1439
  surface: "use",
1174
1440
  run(cwd, _params) {
1175
- return JSON.stringify(listRoadmaps(cwd), null, 2);
1441
+ return { json: listRoadmaps(cwd) };
1176
1442
  },
1177
1443
  },
1178
1444
  ];
@@ -1187,6 +1453,137 @@ export const ops = [
1187
1453
  * point; this list is the source of pi-context's contribution to it.
1188
1454
  */
1189
1455
  export const gatedTools = ops.filter((o) => o.authGated).map((o) => o.name);
1456
+ /**
1457
+ * The FGAP-009 non-exposure allowlist: every library write function that is
1458
+ * deliberately NOT op-backed, with the reason it is withheld. This is the
1459
+ * closure contract γ (TASK-008) WILL consume: γ's parity test — not yet written
1460
+ * (no executable parity test exists in β; β defines the contract, γ implements
1461
+ * the test against it) — WILL assert that EVERY library writer is either op-backed
1462
+ * (appears in {@link ops}, directly or transitively) OR named here, so a
1463
+ * newly-added library writer with neither an op nor an allowlist entry will fail
1464
+ * that test, keeping the op surface and the library write surface in lockstep.
1465
+ *
1466
+ * The `*ForDir` twins of op-backed writers (e.g. `appendRelationForDir`,
1467
+ * `writeRelationsForDir`, `upsertItemInBlockForDir`, `appendToBlockForDir`, …)
1468
+ * are NOT enumerated individually: each is the dir-targeted internal twin of a
1469
+ * cwd-form writer that IS op-backed, and is covered by that cwd-form op (the op
1470
+ * resolves the active substrate dir then delegates to the same shared
1471
+ * typed-file primitive the `*ForDir` twin calls). The contract is that γ's
1472
+ * parity test (TASK-008, not yet written) WILL treat a `*ForDir` writer as
1473
+ * covered when its cwd-form sibling is covered.
1474
+ */
1475
+ export const INTENTIONALLY_UNEXPOSED_WRITERS = [
1476
+ { libraryFn: "writeConfig", safeOp: "amend-config", reason: "scoped guarded config mutation" },
1477
+ { libraryFn: "writeSchema", safeOp: "write-schema", reason: "raw bypasses the create/replace + migration check" },
1478
+ { libraryFn: "updateSchema", safeOp: "write-schema", reason: "no mutator-scripting surface" },
1479
+ { libraryFn: "writeBootstrapPointer", safeOp: "context-init", reason: "raw bypasses target validation" },
1480
+ { libraryFn: "flipBootstrapPointer", safeOp: "context-switch", reason: "raw splits the safe switch" },
1481
+ { libraryFn: "writeRegistry", reason: "internal; registry writes flow through registerSubstrate callers" },
1482
+ {
1483
+ libraryFn: "registerSubstrate",
1484
+ reason: "manual/foreign registration is the clone arc (DEC-0002); normal paths auto-register",
1485
+ },
1486
+ {
1487
+ libraryFn: "rollbackBlockFiles",
1488
+ reason: "workflow-executor transactional rollback (graduated-failure undo); internal recovery path with no operator-facing op by design",
1489
+ },
1490
+ ];
1491
+ /**
1492
+ * The five mutually-exhaustive ways a library write function is COVERED by the
1493
+ * FGAP-009 op-surface ↔ library-write-surface parity contract. A writer that
1494
+ * matches NONE of these is a silent gap that γ's (TASK-008) parity test — once
1495
+ * written — MUST fail on. Coverage is the DISJUNCTION over these classes — a
1496
+ * writer needs ANY one, not all.
1497
+ */
1498
+ export var CoverageClass;
1499
+ (function (CoverageClass) {
1500
+ /** An op's `run` calls the writer directly (e.g. `append-block-item` → `appendToBlock`). */
1501
+ CoverageClass["OpBackedDirect"] = "op-backed-direct";
1502
+ /**
1503
+ * An op's `run` reaches the writer TRANSITIVELY — through any helper / wrapper
1504
+ * chain, not just a direct call. Two sub-shapes both land here:
1505
+ * - `*ByRef` / SDK relation porcelain: the `remove-relation` / `replace-relation`
1506
+ * / `append-relations` ops call `removeRelationByRef` / `replaceRelationByRef`
1507
+ * / `appendRelationsByRef`, which call `writeRelations`.
1508
+ * - init / switch → internal-helper chains: `context-init` → `initProject` →
1509
+ * `writeSkeletonConfig`; `context-switch` → `switchToExisting` /
1510
+ * `switchAndCreate` → `reconcileActiveSubstrateRegistration`. The writer is
1511
+ * not a `*ByRef` porcelain and is not allowlisted, but it IS reachable from an
1512
+ * op's `run` via a helper the op calls.
1513
+ * Coverage condition: reachable from some op's `run` via any helper/wrapper chain.
1514
+ */
1515
+ CoverageClass["OpBackedTransitive"] = "op-backed-transitive";
1516
+ /**
1517
+ * A `*ForDir` dir-targeted twin of a covered cwd-form writer (e.g.
1518
+ * `appendToBlockForDir` is the twin of the op-backed `appendToBlock`). Covered
1519
+ * by its cwd-form sibling — both delegate to the same shared typed-file
1520
+ * primitive; the cwd-form op resolves the active dir then calls it.
1521
+ */
1522
+ CoverageClass["ForDirTwin"] = "for-dir-twin";
1523
+ /**
1524
+ * On {@link INTENTIONALLY_UNEXPOSED_WRITERS}: a raw write deliberately given NO
1525
+ * direct op (a scoped op supersedes it, or it is internal/foreign-only).
1526
+ */
1527
+ CoverageClass["IntentionallyUnexposed"] = "intentionally-unexposed";
1528
+ /**
1529
+ * A block-api internal primitive below the op layer — the `*TypedFile` read/
1530
+ * write layer (`readTypedFile` / `writeTypedFile`), `prepareItemIdentityForWrite`,
1531
+ * and the identity / content-hash helpers. Never op-backed by design; the ops
1532
+ * compose over these.
1533
+ */
1534
+ CoverageClass["InternalPrimitive"] = "internal-primitive";
1535
+ })(CoverageClass || (CoverageClass = {}));
1536
+ /**
1537
+ * The FGAP-009 coverage RULE, made explicit so γ (TASK-008) will import the
1538
+ * contract rather than re-derive it. A library write function is COVERED iff it
1539
+ * matches ANY clause below (the disjunction); a writer matching none is a silent
1540
+ * gap that γ's parity test — when written — MUST fail on. β fixes the contract
1541
+ * here; no executable parity test exists yet (that is γ).
1542
+ *
1543
+ * Why `writeConfig` is allowlisted but `writeRelations` is NOT — the distinction
1544
+ * a strict name-parity reading mis-saw as inconsistent:
1545
+ * - `writeConfig` has NO direct wholesale-config op. The scoped surface is
1546
+ * `amend-config` (one-entry-in-one-registry add/replace/remove, AJV-validated),
1547
+ * which deliberately does NOT expose a raw whole-config overwrite. So
1548
+ * `writeConfig` is `intentionally-unexposed` (the scoped op supersedes the raw
1549
+ * writer; a raw wholesale overwrite is withheld by design).
1550
+ * - `writeRelations` IS reached — transitively — by the relation ops:
1551
+ * `remove-relation` / `replace-relation` / `append-relations` call
1552
+ * `removeRelationByRef` / `replaceRelationByRef` / `appendRelationsByRef`, each
1553
+ * of which calls `writeRelations`. It is therefore `op-backed-transitive`
1554
+ * and needs NO allowlist entry. The asymmetry is real and correct: one writer
1555
+ * has an op path (via a helper/wrapper chain), the other does not.
1556
+ *
1557
+ * The `op-backed-transitive` clause covers BOTH the `*ByRef` relation porcelain
1558
+ * AND the init/switch → internal-helper chains: `writeSkeletonConfig` (reached
1559
+ * via `context-init` → `initProject`) and `reconcileActiveSubstrateRegistration`
1560
+ * (reached via `context-switch` → `switchToExisting` / `switchAndCreate`) are
1561
+ * neither `*ByRef` porcelain nor allowlisted, yet each is reachable from an op's
1562
+ * `run` through a helper that op calls — so each classifies cleanly as
1563
+ * `op-backed-transitive`, not as a gap.
1564
+ */
1565
+ export const OP_COVERAGE_RULE = [
1566
+ {
1567
+ coverageClass: CoverageClass.OpBackedDirect,
1568
+ test: "an op's run() calls the writer directly",
1569
+ },
1570
+ {
1571
+ coverageClass: CoverageClass.OpBackedTransitive,
1572
+ test: "reachable from some op's run() via any helper/wrapper chain — the *ByRef / SDK relation porcelain (writeRelations via removeRelationByRef / replaceRelationByRef / appendRelationsByRef) OR an init/switch → internal-helper chain (writeSkeletonConfig via context-init → initProject; reconcileActiveSubstrateRegistration via context-switch → switchToExisting / switchAndCreate)",
1573
+ },
1574
+ {
1575
+ coverageClass: CoverageClass.ForDirTwin,
1576
+ test: "a *ForDir twin of a covered cwd-form writer (covered by its cwd-form sibling)",
1577
+ },
1578
+ {
1579
+ coverageClass: CoverageClass.IntentionallyUnexposed,
1580
+ test: "named on INTENTIONALLY_UNEXPOSED_WRITERS — a raw bypass with no direct op",
1581
+ },
1582
+ {
1583
+ coverageClass: CoverageClass.InternalPrimitive,
1584
+ test: "a block-api internal primitive below the op layer (the *TypedFile layer, prepareItemIdentityForWrite, identity / content-hash helpers)",
1585
+ },
1586
+ ];
1190
1587
  /**
1191
1588
  * The factory PI handle captured at registerAll time. The list-tools op needs
1192
1589
  * the introspection surface (getAllTools / getActiveTools) which lives on
@@ -1196,11 +1593,36 @@ export const gatedTools = ops.filter((o) => o.authGated).map((o) => o.name);
1196
1593
  * threading the handle through every signature.
1197
1594
  */
1198
1595
  let boundPi = null;
1596
+ /**
1597
+ * Build the DispatchContext threaded into an op's `run` from the in-pi tool
1598
+ * execute boundary (registerAll). Two derivation branches:
1599
+ *
1600
+ * - When `params.writer.user` is a non-empty string — the shape the
1601
+ * pi-agent-dispatch auth-gate stamps onto authGated op params on operator
1602
+ * confirm — the writer is a human identity. (The smuggle-ops promote-item /
1603
+ * write-schema-migration / context-switch carry a `writer` schema field
1604
+ * precisely so the gate has somewhere to stamp; this converts that field
1605
+ * into the contract ctx the op now consumes via its 3rd `run` arg.)
1606
+ * - Otherwise the writer is the running agent, identified by the active
1607
+ * model's id (`ExtensionContext.model.id`); falls back to "pi-agent" when
1608
+ * no model (or no id) is resolvable.
1609
+ *
1610
+ * Exported for unit testing — the two branches are asserted directly against
1611
+ * synthetic params + a minimal ExtensionContext-shaped object.
1612
+ */
1613
+ export function buildDispatchContextFromExecute(params, extCtx) {
1614
+ const writerUser = params?.writer?.user;
1615
+ if (typeof writerUser === "string" && writerUser.length > 0) {
1616
+ return { writer: { kind: "human", user: writerUser } };
1617
+ }
1618
+ const modelId = extCtx.model?.id;
1619
+ return { writer: { kind: "agent", agent_id: modelId && modelId.length > 0 ? modelId : "pi-agent" } };
1620
+ }
1199
1621
  /**
1200
1622
  * Register every op in `ops` as a pi tool. Each tool's execute body is the
1201
- * uniform wrapper around the op's run(): coerce params, await run, place the
1202
- * returned string at content[0].text. Behavior-identical to the prior inline
1203
- * registrations.
1623
+ * uniform wrapper around the op's run(): coerce params, build the attestation
1624
+ * DispatchContext from the auth-gate-stamped writer (human) or the running
1625
+ * model (agent), await run, place the returned string at content[0].text.
1204
1626
  */
1205
1627
  export function registerAll(pi) {
1206
1628
  boundPi = pi;
@@ -1212,9 +1634,10 @@ export function registerAll(pi) {
1212
1634
  promptSnippet: op.promptSnippet,
1213
1635
  parameters: op.parameters,
1214
1636
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1637
+ const dctx = buildDispatchContextFromExecute(params, ctx);
1215
1638
  return {
1216
1639
  details: undefined,
1217
- content: [{ type: "text", text: await op.run(ctx.cwd, params) }],
1640
+ content: [{ type: "text", text: renderOpResultText(await op.run(ctx.cwd, params, dctx)) }],
1218
1641
  };
1219
1642
  },
1220
1643
  });