@davidorex/pi-context 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +48 -21
  3. package/dist/block-api.d.ts +25 -3
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +17 -6
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/content-hash.d.ts +13 -0
  8. package/dist/content-hash.d.ts.map +1 -1
  9. package/dist/content-hash.js +16 -0
  10. package/dist/content-hash.js.map +1 -1
  11. package/dist/context-dir.d.ts +12 -0
  12. package/dist/context-dir.d.ts.map +1 -1
  13. package/dist/context-dir.js +14 -0
  14. package/dist/context-dir.js.map +1 -1
  15. package/dist/context-sdk.d.ts +99 -4
  16. package/dist/context-sdk.d.ts.map +1 -1
  17. package/dist/context-sdk.js +183 -13
  18. package/dist/context-sdk.js.map +1 -1
  19. package/dist/context.d.ts +103 -0
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +85 -0
  22. package/dist/context.js.map +1 -1
  23. package/dist/index.d.ts +534 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1879 -20
  26. package/dist/index.js.map +1 -1
  27. package/dist/migration-registry-loader.d.ts +16 -0
  28. package/dist/migration-registry-loader.d.ts.map +1 -1
  29. package/dist/migration-registry-loader.js +33 -0
  30. package/dist/migration-registry-loader.js.map +1 -1
  31. package/dist/ops-registry.d.ts +196 -4
  32. package/dist/ops-registry.d.ts.map +1 -1
  33. package/dist/ops-registry.js +795 -110
  34. package/dist/ops-registry.js.map +1 -1
  35. package/dist/pending-blocked-store.d.ts +83 -0
  36. package/dist/pending-blocked-store.d.ts.map +1 -0
  37. package/dist/pending-blocked-store.js +93 -0
  38. package/dist/pending-blocked-store.js.map +1 -0
  39. package/dist/read-element.d.ts +48 -0
  40. package/dist/read-element.d.ts.map +1 -1
  41. package/dist/read-element.js +88 -30
  42. package/dist/read-element.js.map +1 -1
  43. package/dist/schema-merge.d.ts +26 -0
  44. package/dist/schema-merge.d.ts.map +1 -0
  45. package/dist/schema-merge.js +176 -0
  46. package/dist/schema-merge.js.map +1 -0
  47. package/dist/write-schema-migration-tool.d.ts +2 -1
  48. package/dist/write-schema-migration-tool.d.ts.map +1 -1
  49. package/dist/write-schema-migration-tool.js +13 -10
  50. package/dist/write-schema-migration-tool.js.map +1 -1
  51. package/package.json +2 -1
  52. package/samples/conception.json +50 -0
  53. package/samples/schemas/framework-gaps.schema.json +1 -1
  54. package/samples/schemas/issues.schema.json +2 -2
  55. package/samples/schemas/layer-plans.schema.json +2 -2
  56. package/samples/schemas/research.schema.json +1 -1
  57. package/samples/schemas/work-orders.schema.json +2 -2
  58. package/schemas/config.schema.json +25 -1
  59. package/schemas/pending-blocked.schema.json +190 -0
  60. package/skill-narrative.md +7 -5
  61. package/skills/pi-context/SKILL.md +146 -8
  62. package/skills/pi-context/references/bundled-resources.md +2 -1
@@ -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
@@ -29,15 +29,92 @@ import { gatherExecutionContext } from "./execution-context.js";
29
29
  // registerAll runs at extension-load time, after both modules' top-level
30
30
  // function bindings exist, and the helpers are only referenced inside op `run`
31
31
  // closures (lazy), never at this module's top level.
32
- import { archiveSubstrate, initProject, listSubstrates, switchAndCreate, switchToExisting, switchToPrevious, } from "./index.js";
33
- import { edgesForLensByName, findReferencesInRepo, validateContextRelations, walkAncestorsByLens, walkLensDescendants, } from "./lens-view.js";
32
+ import { archiveSubstrate, checkStatus, initProject, installContext, listSubstrates, readCatalogSchemaText, resolveBlocked, resolveConflict, switchAndCreate, switchToExisting, switchToPrevious, updateContext, validateBlockItemsAgainstCatalog, } from "./index.js";
33
+ import { edgesForLensByName, findReferencesInRepo, loadLensView, 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, pageArray, 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
@@ -69,6 +146,9 @@ export const ops = [
69
146
  label: "Append Block Item",
70
147
  description: "Append an item to an array in a project block file. Schema validation is automatic. Set autoId:true to allocate the next id from the block's id pattern when the item has no id.",
71
148
  promptSnippet: "Append items to project blocks (issues, decisions, or any user-defined block)",
149
+ examples: [
150
+ `pi-context append-block-item --block framework-gaps --arrayKey gaps --autoId true --item @/tmp/fgap.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
151
+ ],
72
152
  parameters: Type.Object({
73
153
  block: Type.String({ description: "Block name (e.g., 'issues', 'decisions')" }),
74
154
  arrayKey: Type.String({ description: "Array key in the block (e.g., 'issues', 'decisions')" }),
@@ -78,7 +158,7 @@ export const ops = [
78
158
  })),
79
159
  }),
80
160
  surface: "use",
81
- run(cwd, params) {
161
+ run(cwd, params, ctx) {
82
162
  // Type.Unknown() params may arrive as JSON strings — parse if needed
83
163
  if (typeof params.item === "string") {
84
164
  try {
@@ -96,7 +176,7 @@ export const ops = [
96
176
  // withBlockLock critical section (block-api assertAppendIdUnique) —
97
177
  // the single enforcement point. The prior racy readBlock-then-append
98
178
  // tool-layer check was removed in favour of that library guard.
99
- appendToBlock(cwd, params.block, params.arrayKey, params.item);
179
+ appendToBlock(cwd, params.block, params.arrayKey, params.item, ctx);
100
180
  const id = params.item?.id ? ` '${params.item.id}'` : "";
101
181
  return `Appended item${id} to ${params.block}.${params.arrayKey}`;
102
182
  },
@@ -106,6 +186,9 @@ export const ops = [
106
186
  label: "Update Block Item",
107
187
  description: "Update fields on an item in a project block array. Finds by predicate field match.",
108
188
  promptSnippet: "Update items in project blocks — change status, add details, mark resolved",
189
+ examples: [
190
+ `pi-context update-block-item --block tasks --arrayKey tasks --match '{"id":"TASK-001"}' --updates '{"status":"in-progress"}' --writer '{"kind":"human","user":"you@example.com"}' --json`,
191
+ ],
109
192
  parameters: Type.Object({
110
193
  block: Type.String({ description: "Block name (e.g., 'issues', 'decisions')" }),
111
194
  arrayKey: Type.String({ description: "Array key in the block" }),
@@ -115,12 +198,12 @@ export const ops = [
115
198
  }),
116
199
  }),
117
200
  surface: "use",
118
- run(cwd, params) {
201
+ run(cwd, params, ctx) {
119
202
  if (Object.keys(params.updates).length === 0) {
120
203
  throw new Error("No fields to update — updates parameter is empty");
121
204
  }
122
205
  const matchEntries = Object.entries(params.match);
123
- updateItemInBlock(cwd, params.block, params.arrayKey, (item) => matchEntries.every(([k, v]) => item[k] === v), params.updates);
206
+ updateItemInBlock(cwd, params.block, params.arrayKey, (item) => matchEntries.every(([k, v]) => item[k] === v), params.updates, ctx);
124
207
  const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
125
208
  return `Updated item (${matchDesc}) in ${params.block}.${params.arrayKey}: ${Object.keys(params.updates).join(", ")}`;
126
209
  },
@@ -133,6 +216,9 @@ export const ops = [
133
216
  "integrity (endpoints resolve, relation_type registered, no cycle) is NOT checked here — run context-validate " +
134
217
  "after. Creates relations.json if absent.",
135
218
  promptSnippet: "Create a relation/edge between two items (parent→child under a relation_type)",
219
+ examples: [
220
+ `pi-context append-relation --parent VER-001 --child TASK-001 --relation_type verification_verifies_item --writer '{"kind":"human","user":"you@example.com"}' --json`,
221
+ ],
136
222
  parameters: Type.Object({
137
223
  parent: Type.String({ description: "Canonical id (or lens bin name) of the parent endpoint" }),
138
224
  child: Type.String({ description: "Canonical id of the child endpoint" }),
@@ -140,25 +226,214 @@ export const ops = [
140
226
  description: "Registered relation_type canonical_id / hierarchy edge type / lens id",
141
227
  }),
142
228
  ordinal: Type.Optional(Type.Integer({ description: "Optional sibling-ordering within (parent, relation_type)" })),
229
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
143
230
  }),
144
231
  surface: "use",
145
- run(cwd, params) {
232
+ run(cwd, params, ctx) {
146
233
  // Cycle-5 porcelain: STRING selectors (bare refname / <alias>:<refname> /
147
234
  // lens-bin) are resolved to structured EdgeEndpoints and written via the
148
235
  // raw plumbing. The param surface stays string-typed; messaging uses the
149
- // raw selectors (params.*), not the resolved structured endpoints.
236
+ // raw selectors (params.*), not the resolved structured endpoints. Under
237
+ // dryRun the byRef fn validates the prospective relations + dedup-checks
238
+ // without writing (TASK-010 shared preview path).
150
239
  const { appended } = appendRelationByRef(cwd, {
151
240
  parent: params.parent,
152
241
  child: params.child,
153
242
  relation_type: params.relation_type,
154
243
  ...(params.ordinal !== undefined ? { ordinal: params.ordinal } : {}),
155
- });
244
+ }, ctx, { dryRun: params.dryRun });
156
245
  const ordinalNote = params.ordinal !== undefined ? ` (ordinal ${params.ordinal})` : "";
246
+ if (params.dryRun) {
247
+ return appended
248
+ ? `would append relation ${params.parent} -[${params.relation_type}]-> ${params.child}${ordinalNote}`
249
+ : `would no-op (duplicate): relation ${params.parent} -[${params.relation_type}]-> ${params.child}`;
250
+ }
157
251
  return appended
158
252
  ? `Appended relation ${params.parent} -[${params.relation_type}]-> ${params.child}${ordinalNote}`
159
253
  : `Relation ${params.parent} -[${params.relation_type}]-> ${params.child} already exists — no-op`;
160
254
  },
161
255
  },
256
+ {
257
+ name: "remove-relation",
258
+ label: "Remove Relation",
259
+ description: "Remove the single closure-table relation (edge) matching parent+child+relation_type from relations.json. " +
260
+ "Matches on the SAME (parent, child, relation_type) dedup identity append-relation uses, so it is the symmetric " +
261
+ "inverse of append-relation (ordinal is NOT part of identity). An absent edge is an idempotent no-op. " +
262
+ "Reference integrity is NOT checked here — run context-validate after if the removal changes resolvability.",
263
+ promptSnippet: "Remove a relation/edge between two items (the inverse of append-relation)",
264
+ examples: [
265
+ `pi-context remove-relation --parent VER-001 --child TASK-001 --relation_type verification_verifies_item --writer '{"kind":"human","user":"you@example.com"}' --json`,
266
+ ],
267
+ parameters: Type.Object({
268
+ parent: Type.String({ description: "Canonical id (or lens bin name) of the parent endpoint" }),
269
+ child: Type.String({ description: "Canonical id of the child endpoint" }),
270
+ relation_type: Type.String({
271
+ description: "Registered relation_type canonical_id / hierarchy edge type / lens id",
272
+ }),
273
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
274
+ }),
275
+ surface: "use",
276
+ run(cwd, params, ctx) {
277
+ // Cycle-5 porcelain: STRING selectors are resolved to structured
278
+ // EdgeEndpoints, then matched on the identityKey dedup identity. Messaging
279
+ // uses the raw selectors (params.*), not the resolved structured endpoints.
280
+ // Under dryRun the byRef fn validates the prospective post-removal
281
+ // relations + match-checks without writing (TASK-010 shared preview path).
282
+ const { removed } = removeRelationByRef(cwd, { parent: params.parent, child: params.child, relation_type: params.relation_type }, ctx, { dryRun: params.dryRun });
283
+ if (params.dryRun) {
284
+ return removed
285
+ ? `would remove relation ${params.parent} -[${params.relation_type}]-> ${params.child}`
286
+ : `would no-op (no matching relation): ${params.parent} -[${params.relation_type}]-> ${params.child}`;
287
+ }
288
+ return removed
289
+ ? `Removed relation ${params.parent} -[${params.relation_type}]-> ${params.child}`
290
+ : `Relation ${params.parent} -[${params.relation_type}]-> ${params.child} — no matching relation — no-op`;
291
+ },
292
+ },
293
+ {
294
+ name: "replace-relation",
295
+ label: "Replace Relation",
296
+ description: "Atomically replace one closure-table relation with another in a SINGLE write (no half-state: the old edge and " +
297
+ "the new edge never coexist on disk). The old edge is matched on the (parent, child, relation_type) dedup identity; " +
298
+ "the new edge is written with its optional ordinal. If the old edge is absent the call is effectively an append of " +
299
+ "the new edge. Reference integrity is NOT checked here — run context-validate after.",
300
+ promptSnippet: "Atomically swap one relation/edge for another in a single write",
301
+ examples: [
302
+ `pi-context replace-relation --old_parent TASK-001 --old_child DEC-0001 --old_relation_type task_informed_by_decision --parent TASK-001 --child DEC-0002 --relation_type task_informed_by_decision --writer '{"kind":"human","user":"you@example.com"}' --json`,
303
+ ],
304
+ parameters: Type.Object({
305
+ old_parent: Type.String({ description: "Parent endpoint selector of the edge to remove" }),
306
+ old_child: Type.String({ description: "Child endpoint selector of the edge to remove" }),
307
+ old_relation_type: Type.String({ description: "relation_type of the edge to remove" }),
308
+ parent: Type.String({ description: "Parent endpoint selector of the replacement edge" }),
309
+ child: Type.String({ description: "Child endpoint selector of the replacement edge" }),
310
+ relation_type: Type.String({ description: "relation_type of the replacement edge" }),
311
+ ordinal: Type.Optional(Type.Integer({ description: "Optional sibling-ordering within (parent, relation_type) for the new edge" })),
312
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
313
+ }),
314
+ surface: "use",
315
+ run(cwd, params, ctx) {
316
+ // Under dryRun the byRef fn validates the prospective post-replace
317
+ // relations and computes the same removed/replaced would-decisions
318
+ // without writing (TASK-010 shared preview path).
319
+ const { replaced, removed } = replaceRelationByRef(cwd, {
320
+ old: { parent: params.old_parent, child: params.old_child, relation_type: params.old_relation_type },
321
+ new: {
322
+ parent: params.parent,
323
+ child: params.child,
324
+ relation_type: params.relation_type,
325
+ ...(params.ordinal !== undefined ? { ordinal: params.ordinal } : {}),
326
+ },
327
+ }, ctx, { dryRun: params.dryRun });
328
+ const ordinalNote = params.ordinal !== undefined ? ` (ordinal ${params.ordinal})` : "";
329
+ const oldDesc = `${params.old_parent} -[${params.old_relation_type}]-> ${params.old_child}`;
330
+ const newDesc = `${params.parent} -[${params.relation_type}]-> ${params.child}${ordinalNote}`;
331
+ if (params.dryRun) {
332
+ if (!removed && !replaced) {
333
+ return `would no-op — old edge ${oldDesc} absent and new edge ${newDesc} already present`;
334
+ }
335
+ if (!removed) {
336
+ return `would append new relation ${newDesc} (old ${oldDesc} absent)`;
337
+ }
338
+ if (!replaced) {
339
+ return `would remove relation ${oldDesc}; new relation ${newDesc} already present (no duplicate written)`;
340
+ }
341
+ return `would replace relation ${oldDesc} with ${newDesc}`;
342
+ }
343
+ if (!removed && !replaced) {
344
+ return `Replace relation no-op — old edge ${oldDesc} absent and new edge ${newDesc} already present`;
345
+ }
346
+ if (!removed) {
347
+ return `Old relation ${oldDesc} absent — appended new relation ${newDesc}`;
348
+ }
349
+ if (!replaced) {
350
+ return `Removed relation ${oldDesc}; new relation ${newDesc} already present (no duplicate written)`;
351
+ }
352
+ return `Replaced relation ${oldDesc} with ${newDesc}`;
353
+ },
354
+ },
355
+ {
356
+ name: "append-relations",
357
+ label: "Append Relations (bulk)",
358
+ description: "Append MANY closure-table relations to relations.json in a single write. Each edge is an object " +
359
+ "{ parent, child, relation_type, ordinal? }. Per-(parent, child, relation_type) duplicates are skipped (against " +
360
+ "on-disk edges AND earlier edges in the same batch). Returns appended/skipped counts. Reference integrity is NOT " +
361
+ "checked here — run context-validate after. Creates relations.json if absent.",
362
+ promptSnippet: "Create many relations/edges between items in one write",
363
+ examples: [
364
+ `pi-context append-relations --edges '[{"parent":"FEAT-008","child":"TASK-042","relation_type":"feature_decomposed_into_task"}]' --writer '{"kind":"human","user":"you@example.com"}' --json`,
365
+ ],
366
+ parameters: Type.Object({
367
+ edges: Type.Unknown({
368
+ description: "JSON array of { parent, child, relation_type, ordinal? } selector objects (parent/child are id/lens-bin selectors)",
369
+ }),
370
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
371
+ }),
372
+ surface: "use",
373
+ run(cwd, params, ctx) {
374
+ // Type.Unknown() params may arrive as JSON strings — parse if needed.
375
+ let edges = params.edges;
376
+ if (typeof edges === "string") {
377
+ try {
378
+ edges = JSON.parse(edges);
379
+ }
380
+ catch {
381
+ throw new Error(`edges parameter must be a JSON array, got unparseable string`);
382
+ }
383
+ }
384
+ if (!Array.isArray(edges)) {
385
+ throw new Error(`edges parameter must be a JSON array of { parent, child, relation_type, ordinal? } objects`);
386
+ }
387
+ // Under dryRun the byRef fn replays the on-disk + in-batch dedup and
388
+ // validates the prospective relations without writing (TASK-010 shared
389
+ // preview path).
390
+ const { appended, skipped } = appendRelationsByRef(cwd, edges, ctx, { dryRun: params.dryRun });
391
+ return params.dryRun
392
+ ? `would append ${appended}, skip ${skipped} (duplicates)`
393
+ : `appended ${appended}, skipped ${skipped} (duplicates)`;
394
+ },
395
+ },
396
+ {
397
+ name: "upsert-block-item",
398
+ label: "Upsert Block Item",
399
+ description: "Append-or-replace an item in a project block array by id: if an item with the same idField value exists it is " +
400
+ "REPLACED (full-shape replacement, not shallow-merge — use update-block-item for merge); otherwise the item is " +
401
+ "appended. Schema validation is automatic. idField defaults to 'id'.",
402
+ promptSnippet: "Append-or-replace a full block item by id (replacement, not merge)",
403
+ examples: [
404
+ `pi-context upsert-block-item --block tasks --arrayKey tasks --item @/tmp/task.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
405
+ ],
406
+ parameters: Type.Object({
407
+ block: Type.String({ description: "Block name (e.g., 'issues', 'decisions')" }),
408
+ arrayKey: Type.String({ description: "Array key in the block (e.g., 'issues', 'decisions')" }),
409
+ item: Type.Unknown({ description: "Full item object to upsert — must conform to block schema" }),
410
+ idField: Type.Optional(Type.String({ description: "Field used as the upsert key (default 'id')" })),
411
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview the upsert without writing" })),
412
+ }),
413
+ surface: "use",
414
+ run(cwd, params, ctx) {
415
+ // Type.Unknown() params may arrive as JSON strings — parse if needed.
416
+ if (typeof params.item === "string") {
417
+ try {
418
+ params.item = JSON.parse(params.item);
419
+ }
420
+ catch {
421
+ throw new Error(`item parameter must be a JSON object, got unparseable string`);
422
+ }
423
+ }
424
+ const idField = params.idField ?? "id";
425
+ // Under dryRun upsertItemInBlock computes mode + builds + validates the prospective
426
+ // whole block, writing nothing (TASK-011 shared preview path).
427
+ const { mode } = upsertItemInBlock(cwd, params.block, params.arrayKey, params.item, idField, ctx, {
428
+ dryRun: params.dryRun,
429
+ });
430
+ const idVal = params.item?.[idField];
431
+ const idDesc = idVal !== undefined ? ` '${idVal}'` : "";
432
+ return params.dryRun
433
+ ? `would upsert item${idDesc} (${mode}) in ${params.block}.${params.arrayKey}`
434
+ : `Upserted item${idDesc} (${mode}) to ${params.block}.${params.arrayKey}`;
435
+ },
436
+ },
162
437
  {
163
438
  name: "promote-item",
164
439
  label: "Promote Item",
@@ -169,6 +444,9 @@ export const ops = [
169
444
  "(unresolvable/non-item source, unregistered destination alias, unregistered destination relation_type, refname " +
170
445
  "collision) throw. Pass dryRun to compute the destination without writing.",
171
446
  promptSnippet: "Promote an item into another substrate as a derived copy with a lineage edge",
447
+ examples: [
448
+ `pi-context promote-item --source DEC-0001 --destinationSubstrate .context --writer '{"kind":"human","user":"you@example.com"}' --json`,
449
+ ],
172
450
  parameters: Type.Object({
173
451
  source: Type.String({ description: "Source item selector (bare refname / <alias>:<refname>)" }),
174
452
  destinationSubstrate: Type.String({ description: "Registered destination substrate alias" }),
@@ -180,17 +458,26 @@ export const ops = [
180
458
  }, { description: "DispatchContext.writer per pi-context/src/dispatch-context.ts." }),
181
459
  }),
182
460
  surface: "use",
183
- run(cwd, params) {
184
- if (!params.writer?.user) {
185
- throw new Error("promote-item: writer.user is required.");
461
+ run(cwd, params, ctx) {
462
+ // The DispatchContext now arrives via the op contract — registerAll
463
+ // (in-pi) builds it from the auth-gate-stamped `params.writer`, and the
464
+ // CLI builds it from its resolved identity. The schema `writer` field is
465
+ // retained (the in-pi auth-gate stamps it), but lineage attestation reads
466
+ // the contract ctx, not params.writer.
467
+ if (!ctx?.writer) {
468
+ throw new Error("promote-item: a DispatchContext writer is required.");
186
469
  }
187
470
  const result = promoteItem(cwd, {
188
471
  source: params.source,
189
472
  destinationSubstrate: params.destinationSubstrate,
190
473
  ...(params.newRefname !== undefined ? { newRefname: params.newRefname } : {}),
191
474
  ...(params.dryRun !== undefined ? { dryRun: params.dryRun } : {}),
192
- }, { writer: { kind: "human", user: params.writer.user } });
193
- return JSON.stringify(result, null, 2);
475
+ }, ctx);
476
+ // TASK-013 / FGAP-015: route through {read} so the embedded
477
+ // ResolvedRef.loc.item is bounded at the 50KB cap; over-cap fails closed
478
+ // with metadata. Under-cap text stays the same JSON (renderReadText
479
+ // under-cap returns JSON.stringify(serialized, null, 2) with no footer).
480
+ return { read: structureForRead(result, { whole: true, label: "promote-item result" }) };
194
481
  },
195
482
  },
196
483
  {
@@ -198,6 +485,9 @@ export const ops = [
198
485
  label: "Append Block Nested Item",
199
486
  description: "Append an item to a nested array on a parent-array item in a project block. Schema validation is automatic.",
200
487
  promptSnippet: "Append items to nested arrays inside parent items (e.g., findings inside a review)",
488
+ examples: [
489
+ `pi-context append-block-nested-item --block spec-reviews --arrayKey reviews --match '{"id":"REVIEW-001"}' --nestedKey findings --item @/tmp/finding.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
490
+ ],
201
491
  parameters: Type.Object({
202
492
  block: Type.String({ description: "Block name (e.g., 'spec-reviews')" }),
203
493
  arrayKey: Type.String({ description: "Parent array key (e.g., 'reviews')" }),
@@ -208,7 +498,7 @@ export const ops = [
208
498
  item: Type.Unknown({ description: "Item object to append to the nested array — must conform to schema" }),
209
499
  }),
210
500
  surface: "use",
211
- run(cwd, params) {
501
+ run(cwd, params, ctx) {
212
502
  if (typeof params.item === "string") {
213
503
  try {
214
504
  params.item = JSON.parse(params.item);
@@ -219,7 +509,7 @@ export const ops = [
219
509
  }
220
510
  const matchEntries = Object.entries(params.match);
221
511
  const predicate = (i) => matchEntries.every(([k, v]) => i[k] === v);
222
- appendToNestedArray(cwd, params.block, params.arrayKey, predicate, params.nestedKey, params.item);
512
+ appendToNestedArray(cwd, params.block, params.arrayKey, predicate, params.nestedKey, params.item, ctx);
223
513
  const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
224
514
  const id = params.item?.id ? ` '${params.item.id}'` : "";
225
515
  return `Appended item${id} to ${params.block}.${params.arrayKey}[${matchDesc}].${params.nestedKey}`;
@@ -230,6 +520,9 @@ export const ops = [
230
520
  label: "Update Block Nested Item",
231
521
  description: "Update fields on a nested-array item inside a parent-array item in a project block. Finds parent and nested by predicate field match. Throws on parent or nested miss (mirrors update-block-item semantics).",
232
522
  promptSnippet: "Update items inside nested arrays — change finding state, mark resolved",
523
+ examples: [
524
+ `pi-context update-block-nested-item --block spec-reviews --arrayKey reviews --match '{"id":"REVIEW-001"}' --nestedKey findings --nestedMatch '{"id":"F-001"}' --updates '{"state":"resolved"}' --writer '{"kind":"human","user":"you@example.com"}' --json`,
525
+ ],
233
526
  parameters: Type.Object({
234
527
  block: Type.String({ description: "Block name (e.g., 'spec-reviews')" }),
235
528
  arrayKey: Type.String({ description: "Parent array key (e.g., 'reviews')" }),
@@ -245,7 +538,7 @@ export const ops = [
245
538
  }),
246
539
  }),
247
540
  surface: "use",
248
- run(cwd, params) {
541
+ run(cwd, params, ctx) {
249
542
  if (Object.keys(params.updates).length === 0) {
250
543
  throw new Error("No fields to update — updates parameter is empty");
251
544
  }
@@ -253,7 +546,7 @@ export const ops = [
253
546
  const nestedEntries = Object.entries(params.nestedMatch);
254
547
  const parentPred = (i) => parentEntries.every(([k, v]) => i[k] === v);
255
548
  const nestedPred = (i) => nestedEntries.every(([k, v]) => i[k] === v);
256
- updateNestedArrayItem(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred, params.updates);
549
+ updateNestedArrayItem(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred, params.updates, ctx);
257
550
  const parentDesc = parentEntries.map(([k, v]) => `${k}=${v}`).join(", ");
258
551
  const nestedDesc = nestedEntries.map(([k, v]) => `${k}=${v}`).join(", ");
259
552
  return `Updated nested item (${nestedDesc}) in ${params.block}.${params.arrayKey}[${parentDesc}].${params.nestedKey}: ${Object.keys(params.updates).join(", ")}`;
@@ -264,16 +557,19 @@ export const ops = [
264
557
  label: "Remove Block Item",
265
558
  description: "Remove items matching a predicate from a top-level array in a project block. Idempotent — returns { removed: 0 } on no match without throwing. Schema validation runs after removal.",
266
559
  promptSnippet: "Remove items from project blocks — prune retracted issues, dedupe entries",
560
+ examples: [
561
+ `pi-context remove-block-item --block issues --arrayKey issues --match '{"id":"ISSUE-001"}' --writer '{"kind":"human","user":"you@example.com"}' --json`,
562
+ ],
267
563
  parameters: Type.Object({
268
564
  block: Type.String({ description: "Block name (e.g., 'issues')" }),
269
565
  arrayKey: Type.String({ description: "Top-level array key (e.g., 'issues')" }),
270
566
  match: Type.Record(Type.String(), Type.Unknown(), { description: "Fields to match (e.g., { id: 'ISSUE-NNN' })" }),
271
567
  }),
272
568
  surface: "use",
273
- run(cwd, params) {
569
+ run(cwd, params, ctx) {
274
570
  const matchEntries = Object.entries(params.match);
275
571
  const predicate = (i) => matchEntries.every(([k, v]) => i[k] === v);
276
- const result = removeFromBlock(cwd, params.block, params.arrayKey, predicate);
572
+ const result = removeFromBlock(cwd, params.block, params.arrayKey, predicate, ctx);
277
573
  const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
278
574
  return `Removed ${result.removed} item(s) matching (${matchDesc}) from ${params.block}.${params.arrayKey}`;
279
575
  },
@@ -283,6 +579,9 @@ export const ops = [
283
579
  label: "Remove Block Nested Item",
284
580
  description: "Remove items matching a predicate from a nested array on a parent-array item in a project block. Throws on parent miss; returns { removed: 0 } on nested miss without throwing.",
285
581
  promptSnippet: "Remove nested items — drop rejected findings, retract nested references",
582
+ examples: [
583
+ `pi-context remove-block-nested-item --block spec-reviews --arrayKey reviews --match '{"id":"REVIEW-001"}' --nestedKey findings --nestedMatch '{"id":"F-001"}' --writer '{"kind":"human","user":"you@example.com"}' --json`,
584
+ ],
286
585
  parameters: Type.Object({
287
586
  block: Type.String({ description: "Block name (e.g., 'spec-reviews')" }),
288
587
  arrayKey: Type.String({ description: "Parent array key (e.g., 'reviews')" }),
@@ -295,12 +594,12 @@ export const ops = [
295
594
  }),
296
595
  }),
297
596
  surface: "use",
298
- run(cwd, params) {
597
+ run(cwd, params, ctx) {
299
598
  const parentEntries = Object.entries(params.match);
300
599
  const nestedEntries = Object.entries(params.nestedMatch);
301
600
  const parentPred = (i) => parentEntries.every(([k, v]) => i[k] === v);
302
601
  const nestedPred = (i) => nestedEntries.every(([k, v]) => i[k] === v);
303
- const result = removeFromNestedArray(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred);
602
+ const result = removeFromNestedArray(cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred, ctx);
304
603
  const parentDesc = parentEntries.map(([k, v]) => `${k}=${v}`).join(", ");
305
604
  const nestedDesc = nestedEntries.map(([k, v]) => `${k}=${v}`).join(", ");
306
605
  return `Removed ${result.removed} nested item(s) matching (${nestedDesc}) from ${params.block}.${params.arrayKey}[${parentDesc}].${params.nestedKey}`;
@@ -311,14 +610,15 @@ export const ops = [
311
610
  label: "Read Block Dir",
312
611
  description: "Enumerate and parse all .json files in a <substrate-dir>/<subdir>/ directory, returned as a sorted array. Missing directories return [].",
313
612
  promptSnippet: "Enumerate project block subdirectories (phases, schemas, etc.) as parsed JSON",
613
+ examples: [`pi-context read-block-dir --subdir phases --json`],
314
614
  parameters: Type.Object({
315
615
  subdir: Type.String({ description: "Subdirectory under the substrate dir (e.g., 'phases', 'schemas')" }),
316
616
  }),
317
617
  surface: "use",
318
618
  run(cwd, params) {
319
619
  const result = readBlockDir(cwd, params.subdir);
320
- const envelope = serializeForRead(result, { label: `<substrate-dir>/${params.subdir}/` });
321
- return envelope.content;
620
+ const read = structureForRead(result, { label: `<substrate-dir>/${params.subdir}/` });
621
+ return { read };
322
622
  },
323
623
  },
324
624
  {
@@ -326,13 +626,14 @@ export const ops = [
326
626
  label: "Read Block",
327
627
  description: "Read a project block file as structured JSON.",
328
628
  promptSnippet: "Read a project block as structured JSON",
629
+ examples: [`pi-context read-block --block tasks --json`],
329
630
  parameters: Type.Object({
330
631
  block: Type.String({ description: "Block name (e.g., 'issues', 'tasks', 'requirements')" }),
331
632
  }),
332
633
  surface: "use",
333
634
  run(cwd, params) {
334
635
  const result = readBlock(cwd, params.block);
335
- const envelope = serializeForRead(result, {
636
+ const read = structureForRead(result, {
336
637
  label: `<substrate-dir>/${params.block}.json`,
337
638
  overCapDirective: {
338
639
  tool: "read-block-page",
@@ -340,7 +641,7 @@ export const ops = [
340
641
  hint: "or read-block-item with id=<id>",
341
642
  },
342
643
  });
343
- return envelope.content;
644
+ return { read };
344
645
  },
345
646
  },
346
647
  {
@@ -348,15 +649,18 @@ export const ops = [
348
649
  label: "Write Block",
349
650
  description: "Write or replace an entire project block with schema validation.",
350
651
  promptSnippet: "Write or replace a project block with schema validation",
652
+ examples: [
653
+ `pi-context write-block --block architecture --data @/tmp/architecture.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
654
+ ],
351
655
  parameters: Type.Object({
352
656
  block: Type.String({ description: "Block name (e.g., 'project', 'architecture')" }),
353
657
  data: Type.Unknown({ description: "Complete block data — must conform to block schema" }),
354
658
  }),
355
659
  surface: "use",
356
660
  authGated: true,
357
- run(cwd, params) {
661
+ run(cwd, params, ctx) {
358
662
  const data = typeof params.data === "string" ? JSON.parse(params.data) : params.data;
359
- writeBlock(cwd, params.block, data);
663
+ writeBlock(cwd, params.block, data, ctx);
360
664
  return `Wrote block '${params.block}' successfully`;
361
665
  },
362
666
  },
@@ -365,11 +669,24 @@ export const ops = [
365
669
  label: "Context Status",
366
670
  description: "Get derived context state — source metrics, block summaries, planning lifecycle status.",
367
671
  promptSnippet: "Get context state — source metrics, block summaries, planning lifecycle status",
672
+ examples: [`pi-context context-status --json`],
368
673
  parameters: Type.Object({}),
369
674
  surface: "use",
370
675
  run(cwd, _params) {
371
676
  const result = contextState(cwd);
372
- return JSON.stringify(result, null, 2);
677
+ return { json: result };
678
+ },
679
+ },
680
+ {
681
+ name: "context-check-status",
682
+ label: "Context Check Status",
683
+ description: "Read-only installed-vs-catalog schema drift report — per installed schema the drift state, the baseline and catalog versions, and for behind schemas (catalog-ahead / both-diverged) the version delta (baseline -> catalog) or the content-only basis when the version string is unchanged. The front of the check-status -> update --dryRun -> update sequence; writes nothing.",
684
+ promptSnippet: "Report installed-vs-catalog schema drift + the version gap for behind schemas (read-only)",
685
+ examples: [`pi-context context-check-status --json`],
686
+ parameters: Type.Object({}),
687
+ surface: "use",
688
+ run(cwd, _params) {
689
+ return { json: checkStatus(cwd) };
373
690
  },
374
691
  },
375
692
  {
@@ -377,11 +694,12 @@ export const ops = [
377
694
  label: "Context Validate",
378
695
  description: "Validate cross-block referential integrity — check that IDs referenced across blocks exist.",
379
696
  promptSnippet: "Validate cross-block referential integrity",
697
+ examples: [`pi-context context-validate --json`],
380
698
  parameters: Type.Object({}),
381
699
  surface: "use",
382
700
  run(cwd, _params) {
383
701
  const result = validateContext(cwd);
384
- return JSON.stringify(result, null, 2);
702
+ return { json: result };
385
703
  },
386
704
  },
387
705
  {
@@ -389,6 +707,7 @@ export const ops = [
389
707
  label: "Read Config",
390
708
  description: "Read the substrate config.json as structured JSON — vocabulary, lenses, relation_types, status_buckets, display_strings, layers, block_kinds, installed_schemas, installed_blocks. Address ONE registry/map via `registry` (e.g. relation_types) and ONE entry within it via `id` (canonical_id) instead of reading the whole config.",
391
709
  promptSnippet: "Read project config — vocabulary, lenses, relation_types, status_buckets",
710
+ examples: [`pi-context read-config --registry block_kinds --id tasks --json`],
392
711
  parameters: Type.Object({
393
712
  registry: Type.Optional(Type.String({
394
713
  description: "Address ONE config registry/map by key (e.g. 'relation_types', 'lenses', 'block_kinds', 'status_buckets')",
@@ -410,10 +729,14 @@ export const ops = [
410
729
  if (!entry.found) {
411
730
  return `read-config: entry not found in ${params.registry} — ${entry.resolved}`;
412
731
  }
413
- const envEntry = serializeForRead(entry.value, { label: `config.${params.registry}.${params.id}` });
414
- return envEntry.content;
732
+ const read = structureForRead(entry.value, {
733
+ whole: true,
734
+ label: `config.${params.registry}.${params.id}`,
735
+ });
736
+ return { read };
415
737
  }
416
- const envReg = serializeForRead(reg.value, {
738
+ const read = structureForRead(reg.value, {
739
+ whole: true,
417
740
  label: `config.${params.registry}`,
418
741
  overCapDirective: {
419
742
  tool: "read-config",
@@ -421,17 +744,17 @@ export const ops = [
421
744
  hint: "add id=<entry canonical_id>",
422
745
  },
423
746
  });
424
- return envReg.content;
747
+ return { read };
425
748
  }
426
749
  const result = { config, configPath };
427
- const envelope = serializeForRead(result, {
750
+ const read = structureForRead(result, {
428
751
  label: configPath ?? "config.json",
429
752
  overCapDirective: {
430
753
  tool: "read-config",
431
754
  hint: "registry=<name> (block_kinds|relation_types|lenses|invariants|…)",
432
755
  },
433
756
  });
434
- return envelope.content;
757
+ return { read };
435
758
  },
436
759
  },
437
760
  {
@@ -467,8 +790,8 @@ export const ops = [
467
790
  if (tool === undefined) {
468
791
  return `list-tools: tool not found — name=${params.name}`;
469
792
  }
470
- const envOne = serializeForRead(tool, { label: `tool ${params.name}` });
471
- return envOne.content;
793
+ const read = structureForRead(tool, { whole: true, label: `tool ${params.name}` });
794
+ return { read };
472
795
  }
473
796
  // Default: compact index (FGAP-101) — name + param count + one-line description.
474
797
  const index = all.map((t) => {
@@ -482,11 +805,11 @@ export const ops = [
482
805
  // The compact index is one line per tool — small enough to serialize whole
483
806
  // (no paging); keep the wrapper fields (active/total) on the result object.
484
807
  const result = { tools: index, active, total: all.length, activeCount: active.length };
485
- const envelope = serializeForRead(result, {
808
+ const read = structureForRead(result, {
486
809
  label: "tool index — pass name= for detail",
487
810
  overCapDirective: { tool: "list-tools", hint: "name=<tool>" },
488
811
  });
489
- return envelope.content;
812
+ return { read };
490
813
  },
491
814
  },
492
815
  {
@@ -494,6 +817,7 @@ export const ops = [
494
817
  label: "Read Samples Catalog",
495
818
  description: "Enumerate installable sample block kinds (packaged view): per kind — title, description, item shape, applicable relation_types (as source/target), invariants, lenses — plus top-level relation_type/lens/invariant/layer/status_bucket registries. Package-intrinsic: reads the extension's bundled samples catalog, independent of any project. Optional `kind` returns one packaged kind.",
496
819
  promptSnippet: "Discover installable sample block kinds — title, shape, relation_types, invariants, lenses",
820
+ examples: [`pi-context read-samples-catalog --kind tasks --json`],
497
821
  parameters: Type.Object({
498
822
  kind: Type.Optional(Type.String({ description: "Filter to one block_kind canonical_id (e.g. 'tasks')" })),
499
823
  }),
@@ -502,13 +826,43 @@ export const ops = [
502
826
  // Package-intrinsic: the catalog reads the extension's bundled samples
503
827
  // directory, not the project substrate — cwd is unused.
504
828
  const catalog = samplesCatalog(params.kind ? { kind: params.kind } : undefined);
505
- const envelope = serializeForRead(catalog, {
829
+ const read = structureForRead(catalog, {
506
830
  label: params.kind ? `samples kind=${params.kind}` : "samples catalog",
507
831
  // Whole catalog → narrow by kind; a single kind has no finer
508
- // addressing (edge → head-leading marker, no directive).
509
- ...(params.kind ? {} : { overCapDirective: { tool: "read-samples-catalog", hint: "kind=<canonical_id>" } }),
832
+ // addressing (edge → head-leading marker, no directive). The
833
+ // single-kind read is an addressed node return it whole (capped),
834
+ // never a 50-item page of an incidental array child.
835
+ ...(params.kind
836
+ ? { whole: true }
837
+ : { overCapDirective: { tool: "read-samples-catalog", hint: "kind=<canonical_id>" } }),
510
838
  });
511
- return envelope.content;
839
+ return { read };
840
+ },
841
+ },
842
+ {
843
+ name: "read-catalog-schema",
844
+ label: "Read Catalog Schema",
845
+ description: "Fetch and print the verbatim catalog schema body (raw JSON Schema: properties/definitions/$id) for a named block kind — diffable locally against the installed `<substrate>/schemas/<name>.schema.json` without touching node_modules. Read-only; the projection-returning sibling is read-samples-catalog.",
846
+ promptSnippet: "Fetch and print the verbatim catalog schema body for a named block kind (raw JSON Schema, diffable locally)",
847
+ examples: ["pi-context read-catalog-schema --kind tasks", "pi-context read-catalog-schema --kind tasks --json"],
848
+ parameters: Type.Object({
849
+ kind: Type.String({ description: "Catalog block_kind canonical_id (e.g. 'tasks')" }),
850
+ }),
851
+ surface: "use",
852
+ // The catalog schema file carries its own trailing newline (`}\n`); emit the file
853
+ // bytes exactly — preserving that single newline, appending none — so
854
+ // `read-catalog-schema --kind <k> | diff <installed> -` shows no phantom line when
855
+ // content matches (the pre-flag defect was the print path appending a second
856
+ // newline, doubling it to `}\n\n`).
857
+ verbatimText: true,
858
+ run(_cwd, params) {
859
+ // Package-intrinsic: reads the extension's bundled catalog schema file,
860
+ // not the project substrate — cwd is unused (like read-samples-catalog).
861
+ // Returns the RAW TEXT bytes as a prose-string OpResult so the verbatim
862
+ // catalog body prints as-is (renderOpResultText) and rides the --json
863
+ // envelope as a string; no {json}/{read} wrap that would re-serialize and
864
+ // alter the bytes the operator diffs.
865
+ return readCatalogSchemaText(params.kind).text;
512
866
  },
513
867
  },
514
868
  {
@@ -516,11 +870,12 @@ export const ops = [
516
870
  label: "Context Current State",
517
871
  description: "Derive 'where are we + what's next' purely from the substrate — focus, in-flight tasks, ranked atomic-next actions (open framework-gaps then unblocked planned tasks), and blocked tasks. No writes; nothing hand-stored.",
518
872
  promptSnippet: "Derive current project state — focus, in-flight, next actions, blocked",
873
+ examples: [`pi-context context-current-state --json`],
519
874
  parameters: Type.Object({}),
520
875
  surface: "use",
521
876
  run(cwd, _params) {
522
877
  const state = currentState(cwd);
523
- return JSON.stringify(state, null, 2);
878
+ return { json: state };
524
879
  },
525
880
  },
526
881
  {
@@ -528,11 +883,12 @@ export const ops = [
528
883
  label: "Context Bootstrap State",
529
884
  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.",
530
885
  promptSnippet: "Derive substrate bootstrap state — no-pointer | no-config | skeleton | not-installed | ready (never throws pre-bootstrap)",
886
+ examples: [`pi-context context-bootstrap-state --json`],
531
887
  parameters: Type.Object({}),
532
888
  surface: "use",
533
889
  run(cwd, _params) {
534
890
  const status = deriveBootstrapState(cwd);
535
- return JSON.stringify(status, null, 2);
891
+ return { json: status };
536
892
  },
537
893
  },
538
894
  {
@@ -540,6 +896,7 @@ export const ops = [
540
896
  label: "Rename Canonical Id",
541
897
  description: "Rename a canonical_id (kind: item | relation_type | lens | layer) from oldId to newId across all substrate surfaces that carry it as DATA — item home block + relations.json edges, or the relevant config registries. Out-of-substrate occurrences (analysis MDs, git history) are REPORTED, never rewritten. block_kind renames are unsupported (filesystem cascade). Use dryRun to preview the would-change counts without writing.",
542
898
  promptSnippet: "Rename a canonical_id (item/relation_type/lens/layer) across substrate; dryRun to preview",
899
+ examples: [`pi-context rename-canonical-id --kind item --oldId TASK-001 --newId TASK-100 --dryRun true --json`],
543
900
  parameters: Type.Object({
544
901
  kind: Type.String({ description: "One of: item | relation_type | lens | layer" }),
545
902
  oldId: Type.String({ description: "Current canonical_id to rename from" }),
@@ -550,7 +907,7 @@ export const ops = [
550
907
  authGated: true,
551
908
  run(cwd, params) {
552
909
  const report = renameCanonicalId(cwd, params.kind, params.oldId, params.newId, { dryRun: params.dryRun });
553
- return JSON.stringify(report, null, 2);
910
+ return { json: report };
554
911
  },
555
912
  },
556
913
  {
@@ -563,6 +920,9 @@ export const ops = [
563
920
  "relation_type / lens / layer / block_kind) is NOT checked here — run context-validate after. dryRun previews " +
564
921
  "without writing.",
565
922
  promptSnippet: "Add/replace/remove one entry in a config.json registry (vocabulary, lenses, invariants, status_buckets)",
923
+ examples: [
924
+ `pi-context amend-config --registry relation_types --operation add --key task_blocks_task --entry @/tmp/relation-type.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
925
+ ],
566
926
  parameters: Type.Object({
567
927
  registry: Type.String({
568
928
  description: "One of: block_kinds | relation_types | lenses | layers | invariants | status_buckets | display_strings | naming | installed_schemas | installed_blocks | hierarchy",
@@ -580,7 +940,7 @@ export const ops = [
580
940
  }),
581
941
  surface: "use",
582
942
  authGated: true,
583
- run(cwd, params) {
943
+ run(cwd, params, ctx) {
584
944
  // Type.Unknown() params may arrive as JSON strings. Parse if possible; on
585
945
  // failure KEEP the raw string (valid for map-value registries whose value
586
946
  // is a bare string, e.g. naming/display_strings/status_buckets).
@@ -593,10 +953,11 @@ export const ops = [
593
953
  /* keep raw string — valid for map-value registries */
594
954
  }
595
955
  }
596
- const result = amendConfigEntry(cwd, params.registry, params.operation, params.key, entry, undefined, {
956
+ const result = amendConfigEntry(cwd, params.registry, params.operation, params.key, entry, ctx, {
597
957
  dryRun: params.dryRun,
598
958
  });
599
- const verb = result.modified ? (params.dryRun ? `would ${result.operation}` : `${result.operation}d`) : "no-op";
959
+ const pastTense = result.operation === "add" ? "added" : `${result.operation}d`;
960
+ const verb = result.modified ? (params.dryRun ? `would ${result.operation}` : pastTense) : "no-op";
600
961
  return `amend-config: ${verb} ${result.registry}[${result.key}]`;
601
962
  },
602
963
  },
@@ -605,6 +966,7 @@ export const ops = [
605
966
  label: "Read Schema",
606
967
  description: "Read a substrate schema by name as parsed JSON. Returns null when the schema file is absent. Address ONE property via `path` (dotted/bracket, e.g. properties.tasks.items.properties.status) instead of reading the whole schema.",
607
968
  promptSnippet: "Read a block schema as structured JSON — optionally address one property via `path`",
969
+ examples: [`pi-context read-schema --schemaName framework-gaps --path properties.gaps.items.required --json`],
608
970
  parameters: Type.Object({
609
971
  schemaName: Type.String({
610
972
  description: "Schema name without extension (e.g., 'tasks', 'decisions', 'issues')",
@@ -622,11 +984,11 @@ export const ops = [
622
984
  if (!addr.found) {
623
985
  return `read-schema: property not found — ${addr.resolved}`;
624
986
  }
625
- const envProp = serializeForRead(addr.value, { label: `${params.schemaName} ${addr.resolved}` });
626
- return envProp.content;
987
+ const read = structureForRead(addr.value, { whole: true, label: `${params.schemaName} ${addr.resolved}` });
988
+ return { read };
627
989
  }
628
990
  const result = { schema, schemaPath: schemaPathStr };
629
- const envelope = serializeForRead(result, {
991
+ const read = structureForRead(result, {
630
992
  label: schemaPathStr,
631
993
  overCapDirective: {
632
994
  tool: "read-schema",
@@ -634,7 +996,7 @@ export const ops = [
634
996
  hint: "path=<dotted json-path>",
635
997
  },
636
998
  });
637
- return envelope.content;
999
+ return { read };
638
1000
  },
639
1001
  },
640
1002
  {
@@ -646,6 +1008,9 @@ export const ops = [
646
1008
  "read/write of items declaring an older schema_version throws version-mismatch. Registering the block_kind " +
647
1009
  "that points at this schema is a separate step (amend-config block_kinds).",
648
1010
  promptSnippet: "Create or replace a block-kind JSON Schema (meta-validated, atomic)",
1011
+ examples: [
1012
+ `pi-context write-schema --operation create --schemaName tasks --schema @/tmp/tasks.schema.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
1013
+ ],
649
1014
  parameters: Type.Object({
650
1015
  operation: Type.String({ description: "create | replace" }),
651
1016
  schemaName: Type.String({ description: "Schema name without extension (e.g., 'tasks')" }),
@@ -656,7 +1021,7 @@ export const ops = [
656
1021
  }),
657
1022
  surface: "use",
658
1023
  authGated: true,
659
- run(cwd, params) {
1024
+ run(cwd, params, ctx) {
660
1025
  // Type.Unknown() params may arrive as JSON strings. Parse if possible; on
661
1026
  // failure KEEP the raw value (meta-validation rejects a non-object body).
662
1027
  let schema = params.schema;
@@ -668,16 +1033,57 @@ export const ops = [
668
1033
  /* keep raw string — meta-validation will reject a non-object */
669
1034
  }
670
1035
  }
671
- const result = writeSchemaChecked(cwd, params.schemaName, schema, params.operation, undefined, { dryRun: params.dryRun });
1036
+ const result = writeSchemaChecked(cwd, params.schemaName, schema, params.operation, ctx, { dryRun: params.dryRun });
672
1037
  const verb = result.written ? `${result.operation}d` : `would ${result.operation}`;
673
1038
  return `write-schema: ${verb} schema '${params.schemaName}' at ${result.schemaPath}`;
674
1039
  },
675
1040
  },
1041
+ {
1042
+ name: "resolve-conflict",
1043
+ label: "Resolve Schema Conflict",
1044
+ description: "Commit the reconciliation of a schema merge conflict surfaced by update. Run this AFTER reconciling a both-diverged conflict update reported: it writes the reconciled schema body (meta-validated, atomic, operation 'replace') AND advances the merge base for that schema to the packaged catalog body. Advancing the base is the step a bare write-schema lacks — without it, update's 3-way merge re-derives the SAME conflict on every subsequent run because the base never moves off the original pre-conflict body. With the base advanced to the catalog, the next update sees the schema as locally-modified (base === catalog ≠ your body) and the deterministic merge takes your reconciled body (base === theirs → ours) — auto-merging with zero conflicts and preserving your resolution. If schema is omitted, the current on-disk schema is treated as already reconciled and only the base is advanced. The calling agent runs this; no subordinate resolver is spawned.",
1045
+ promptSnippet: "Commit a reconciled schema conflict: write the resolved body + advance the merge base to the catalog so update stops re-reporting it (run after reconciling an update conflict)",
1046
+ examples: [
1047
+ `pi-context resolve-conflict --schemaName tasks --schema @/tmp/tasks.reconciled.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
1048
+ ],
1049
+ parameters: Type.Object({
1050
+ schemaName: Type.String({ description: "Schema name without extension (e.g., 'tasks')" }),
1051
+ schema: Type.Optional(Type.Unknown({
1052
+ description: "The reconciled schema body R (whole JSON Schema object, draft-07; accepts a JSON string). If omitted, the current on-disk schema is treated as already reconciled and only the merge base is advanced.",
1053
+ })),
1054
+ }),
1055
+ surface: "use",
1056
+ authGated: true,
1057
+ run(cwd, params, ctx) {
1058
+ const result = resolveConflict(cwd, params.schemaName, params.schema, ctx);
1059
+ return { json: result };
1060
+ },
1061
+ },
1062
+ {
1063
+ name: "resolve-blocked",
1064
+ label: "Resolve Blocked",
1065
+ description: "Commit the resolution of a blocked schema surfaced by update. Run AFTER fixing the block's items (or widening the local schema): when the block file carries git-style failure markers (written by update), strips the full-line marker sentinels first, then re-validates the corrected block against the pinned target schema from the pending-blocked record; on pass registers the migration chain, writes the target schema, advances the merge base to the target (so a subsequent update converges instead of re-blocking), and clears the pending entry; on fail reports the remaining per-item failures and writes nothing.",
1066
+ promptSnippet: "Commit a blocked schema's resolution: strip any git-style failure markers, re-validate the corrected block against the pinned target, then write the target schema + advance the base + clear the pending record (run after fixing the items update reported blocked)",
1067
+ examples: [`pi-context resolve-blocked --schemaName tasks --yes --json`],
1068
+ parameters: Type.Object({
1069
+ schemaName: Type.String({
1070
+ description: "Schema name with a pending-blocked entry (from update's blocked report)",
1071
+ }),
1072
+ }),
1073
+ surface: "use",
1074
+ authGated: true,
1075
+ run(cwd, params, ctx) {
1076
+ return { json: resolveBlocked(cwd, params.schemaName, ctx) };
1077
+ },
1078
+ },
676
1079
  {
677
1080
  name: "write-schema-migration",
678
1081
  label: "Write Schema Migration",
679
1082
  description: "Declare a schema version-bump migration into substrate (migrations.json). operation 'create' appends a new declaration; 'replace' overwrites an existing declaration matched by (schemaName, fromVersion); 'remove' drops a declaration. kind='identity' asserts the bump is shape-compatible (no data transform); kind='declarative-transform' carries a TransformSpec of rename/set/delete/coerce operations on dotted JSON paths. The loaded MigrationRegistry resolves the recorded edge at next read/write so block items declaring an older schema_version walk forward without process restart. Requires user authorization via interactive confirmation at the pi-dispatch auth-gate; on confirm, the verified terminal-operator identity is stamped as writer.",
680
1083
  promptSnippet: "Declare a schema version-bump migration (identity or declarative-transform) into migrations.json",
1084
+ examples: [
1085
+ `pi-context write-schema-migration --operation create --schemaName tasks --fromVersion 1.0.0 --toVersion 1.1.0 --kind identity --writer '{"kind":"human","user":"you@example.com"}' --json`,
1086
+ ],
681
1087
  parameters: Type.Object({
682
1088
  operation: Type.String({ description: "create | replace | remove" }),
683
1089
  schemaName: Type.String({ description: "Schema name without extension (e.g., 'tasks')." }),
@@ -698,8 +1104,8 @@ export const ops = [
698
1104
  }),
699
1105
  surface: "use",
700
1106
  authGated: true,
701
- async run(cwd, params) {
702
- const result = await writeSchemaMigrationExecute(cwd, params);
1107
+ async run(cwd, params, ctx) {
1108
+ const result = await writeSchemaMigrationExecute(cwd, params, ctx);
703
1109
  // writeSchemaMigrationExecute returns the uniform AgentToolResult; the
704
1110
  // op contract is the text payload, which registerAll re-wraps identically.
705
1111
  const part = result.content[0];
@@ -711,6 +1117,7 @@ export const ops = [
711
1117
  label: "Context Init",
712
1118
  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).",
713
1119
  promptSnippet: "Initialize the substrate dir (bootstrap pointer + dirs + skeleton config; onward via accept-all OR amend-config/edit)",
1120
+ examples: [`pi-context context-init --contextDir .context --json`],
714
1121
  parameters: Type.Object({
715
1122
  contextDir: Type.String({
716
1123
  description: "Substrate dir name (e.g. .context). Required — no default.",
@@ -720,7 +1127,7 @@ export const ops = [
720
1127
  authGated: true,
721
1128
  run(cwd, params) {
722
1129
  const result = initProject(cwd, params.contextDir);
723
- return JSON.stringify(result, null, 2);
1130
+ return { json: result };
724
1131
  },
725
1132
  },
726
1133
  {
@@ -728,6 +1135,7 @@ export const ops = [
728
1135
  label: "Accept-All Conception",
729
1136
  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
1137
  promptSnippet: "Adopt the canonical conception as config (accept-all)",
1138
+ examples: [`pi-context context-accept-all --json`],
731
1139
  parameters: Type.Object({}),
732
1140
  surface: "use",
733
1141
  authGated: true,
@@ -742,7 +1150,58 @@ export const ops = [
742
1150
  }
743
1151
  throw err;
744
1152
  }
745
- return JSON.stringify(result);
1153
+ return { json: result };
1154
+ },
1155
+ },
1156
+ {
1157
+ name: "context-install",
1158
+ label: "Context Install",
1159
+ description: "Install (materialize) the schemas and starter blocks declared in config.json's installed_schemas / installed_blocks from the package samples catalog. Default skip-if-exists (installed files never overwritten without --update); populated block data is always preserved (even with --update); empty or absent blocks get the catalog starter. Records the install baseline (config.installed_from: catalog source + per-schema fingerprint) for installed-vs-catalog drift detection (schemas only). A re-install on an unchanged substrate is idempotent.",
1160
+ promptSnippet: "Install declared schemas + starter blocks from the samples catalog (skip-if-exists; --update re-syncs schemas + replaces empty blocks; records the config.installed_from baseline)",
1161
+ examples: ["pi-context context-install --json", "pi-context context-install --update true --json"],
1162
+ parameters: Type.Object({
1163
+ update: Type.Optional(Type.Boolean({
1164
+ description: "When true, re-sync existing installed schemas (migration-aware) and replace empty blocks with the catalog starter; populated block data is never overwritten. When false (default), skip existing files.",
1165
+ })),
1166
+ }),
1167
+ surface: "use",
1168
+ authGated: true,
1169
+ run(cwd, params) {
1170
+ const result = installContext(cwd, { overwrite: params.update === true });
1171
+ if (result.error)
1172
+ return result.error;
1173
+ return { json: result };
1174
+ },
1175
+ },
1176
+ {
1177
+ name: "update",
1178
+ label: "Update Installed Model",
1179
+ description: "Bring the installed substrate model (schemas) current with the packaged catalog. Per installed schema, consults the read-only drift check and routes by state: an already-current (in-sync) schema is a no-op; a schema the package shipped a newer version of (catalog-ahead) is re-synced through the migration-aware path; a schema edited locally (locally-modified / both-diverged) is reconciled by a deterministic 3-way merge of base (the as-installed body in the object store, keyed by the recorded baseline content_hash) × ours (the installed schema) × theirs (the catalog schema) — disjoint edits auto-merge so both the user's and the catalog's changes survive (required / enum / array-valued type nodes merge as sets), and a schema with irreconcilable per-path conflicts is left unmodified — the conflict set is returned in the op output (under conflicts) alongside a readable report, and the calling agent reconciles it then commits via resolve-conflict — which writes the reconciled body AND advances the merge base to the catalog so update stops re-reporting it (no subordinate resolver is spawned); undecidable / absent schemas (no-baseline / missing-catalog / missing-installed) are reported, not touched. Update also additively propagates catalog-new config-registry entries (relation_types / invariants / block_kinds / lenses) that are absent from the substrate config, preserving every user-authored entry and any locally-diverged body of an existing entry (additive-only — present entries are never overwritten). Update reports, under migrationsRegistered, the migration declarations a version-bump resync registers into migrations.json (each as schema / from / to). A blocked (refused) catalog-ahead schema additionally carries its diagnostic detail under blockedDetail (one entry per blocked schema): the refusal reason — no-migration-chain (no shipped chain reaches the catalog version) vs validation-failed (the forward-migrated items fail the catalog schema) — the installed -> catalog version pair, and for a validation failure the per-item failures naming the failing item id, field, and constraint. A live blocked resync also persists a pending-blocked record (pinning the target catalog schema + the chain reaching it) consumable by resolve-blocked, which commits the resolution once the block's items are fixed. Pass dryRun to preview the per-schema action plan; dryRun predicts the precise per-schema catalog-ahead outcome (resync / migrate / block / merge / conflict) by running the forward-migration + re-validation in memory, the per-blocked-schema diagnostic detail, the config-registry entries that would be added, AND the migration declarations that would be registered, writing nothing. When a catalog-ahead resync is blocked because the block's items fail the catalog schema (validation-failed), update inscribes git-style failure markers INTO the block file at the offending items (full-line `<<<<<<< BLOCKED …` / `>>>>>>> target: …` sentinels), pinning the pre-marker bytes so resolve-blocked can strip the markers and re-validate; the schema and migrations.json stay byte-unchanged. A dryRun preview writes no markers.",
1180
+ promptSnippet: "Update the installed schema model from the catalog (3-way merges locally-modified schemas, preserving non-conflicting edits; conflicts → returned in the op output + a report for the calling agent to reconcile and commit via resolve-conflict; a blocked resync carries blockedDetail — reason, version pair, per-item failures — and persists a pending-blocked record (target catalog schema + the chain reaching it) resolved via resolve-blocked once the block's items are fixed; a validation-failed block is marked in place with git-style failure markers (recoverable; stripped + re-validated by resolve-blocked); --dry-run predicts the precise per-schema outcome — resync / migrate / block / merge / conflict — via in-memory forward-migration + re-validation, writing nothing)",
1181
+ examples: [`pi-context update --dryRun true --json`],
1182
+ parameters: Type.Object({
1183
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview the per-schema action plan without writing anything." })),
1184
+ }),
1185
+ surface: "use",
1186
+ run(cwd, params) {
1187
+ const result = updateContext(cwd, { dryRun: params.dryRun === true });
1188
+ if (result.error)
1189
+ return result.error;
1190
+ return { json: result };
1191
+ },
1192
+ },
1193
+ {
1194
+ name: "validate-block-items",
1195
+ label: "Validate Block Items",
1196
+ description: "Validate a block's items against the catalog schema version — returns the per-item failures (item id, field, constraint) without writing. Resolves the block's catalog block_kind, loads the installed block, forward-migrates its items in memory through the shipped chain when the block lags the catalog version (a fresh registry; never warms the project's cache), and validates against the catalog schema body. Returns block / from (the block's declared version) / to (the catalog version) / valid / failures[] (each: itemId — the failing item's id when the instancePath resolves to one — instancePath, keyword, message). Read-only: never overwrites the schema, the block, or migrations.json. An unknown block or a missing installed block file throws.",
1197
+ promptSnippet: "Validate a block's items against the catalog schema version — returns the per-item failures (item id, field, constraint) without writing",
1198
+ examples: [`pi-context validate-block-items --block tasks --json`],
1199
+ parameters: Type.Object({
1200
+ block: Type.String({ description: "Block name (e.g. 'tasks')" }),
1201
+ }),
1202
+ surface: "use",
1203
+ run(cwd, params) {
1204
+ return { json: validateBlockItemsAgainstCatalog(cwd, params.block) };
746
1205
  },
747
1206
  },
748
1207
  {
@@ -750,6 +1209,9 @@ export const ops = [
750
1209
  label: "Context Switch",
751
1210
  description: "Flip the bootstrap pointer to a different substrate dir (parallel to git switch). Default: flip to an existing substrate at target_dir (requires config.json present). create_new=true: bootstrap a fresh substrate at target_dir AND flip in one operation. to_previous=true: flip back to the pointer's previous_contextDir (target_dir ignored).",
752
1211
  promptSnippet: "Switch the bootstrap pointer to a different substrate dir",
1212
+ examples: [
1213
+ `pi-context context-switch --target_dir .context --writer '{"kind":"human","user":"you@example.com"}' --json`,
1214
+ ],
753
1215
  parameters: Type.Object({
754
1216
  target_dir: Type.String({
755
1217
  description: "Substrate dir name to switch to (e.g. '.context'). Required for default + create_new modes; ignored for to_previous mode.",
@@ -784,14 +1246,14 @@ export const ops = [
784
1246
  try {
785
1247
  if (params.to_previous === true) {
786
1248
  const { from, to } = switchToPrevious(cwd, writerIdentity);
787
- return JSON.stringify({ mode: "to_previous", from, to }, null, 2);
1249
+ return { json: { mode: "to_previous", from, to } };
788
1250
  }
789
1251
  if (params.create_new === true) {
790
1252
  const { created } = switchAndCreate(cwd, params.target_dir, writerIdentity);
791
- return JSON.stringify({ mode: "create_new", target_dir: params.target_dir, created }, null, 2);
1253
+ return { json: { mode: "create_new", target_dir: params.target_dir, created } };
792
1254
  }
793
1255
  switchToExisting(cwd, params.target_dir, writerIdentity);
794
- return JSON.stringify({ mode: "existing", target_dir: params.target_dir }, null, 2);
1256
+ return { json: { mode: "existing", target_dir: params.target_dir } };
795
1257
  }
796
1258
  catch (err) {
797
1259
  const msg = err instanceof Error ? err.message : String(err);
@@ -804,11 +1266,12 @@ export const ops = [
804
1266
  label: "Context List",
805
1267
  description: "Enumerate top-level dirs under cwd containing a config.json (switchable substrates). Marks the active one with isActive=true. Read-only.",
806
1268
  promptSnippet: "List switchable substrate dirs under cwd",
1269
+ examples: [`pi-context context-list --json`],
807
1270
  parameters: Type.Object({}),
808
1271
  surface: "use",
809
1272
  run(cwd, _params) {
810
1273
  const subs = listSubstrates(cwd);
811
- return JSON.stringify(subs, null, 2);
1274
+ return { json: subs };
812
1275
  },
813
1276
  },
814
1277
  {
@@ -816,25 +1279,20 @@ export const ops = [
816
1279
  label: "Context Archive",
817
1280
  description: "Move a non-active substrate dir to archive/<dir>/. Refuses to archive the active substrate (the dir the bootstrap pointer currently names) or to clobber an existing archive/<dir>/.",
818
1281
  promptSnippet: "Archive a non-active substrate dir to archive/<dir>/",
1282
+ examples: [
1283
+ `pi-context context-archive --target_dir .context-old --writer '{"kind":"human","user":"you@example.com"}' --json`,
1284
+ ],
819
1285
  parameters: Type.Object({
820
1286
  target_dir: Type.String({
821
1287
  description: "Substrate dir name to archive (e.g. '.project'). Refused if it is the active substrate.",
822
1288
  }),
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
1289
  }),
832
1290
  surface: "use",
833
1291
  authGated: true,
834
1292
  run(cwd, params) {
835
1293
  try {
836
1294
  const { from, to } = archiveSubstrate(cwd, params.target_dir);
837
- return JSON.stringify({ from, to }, null, 2);
1295
+ return { json: { from, to } };
838
1296
  }
839
1297
  catch (err) {
840
1298
  const msg = err instanceof Error ? err.message : String(err);
@@ -847,6 +1305,7 @@ export const ops = [
847
1305
  label: "Filter Block Items",
848
1306
  description: "Filter the array items of a block by a single-field predicate (eq / neq / in / matches). Discovers the single top-level array property in the block; items missing the predicate field are never matched. Wraps the canonical readBlock + caller-side filter into one queryable surface; never mutates the block.",
849
1307
  promptSnippet: "Filter a block's items by a predicate — eq / neq / in / matches against a single field",
1308
+ examples: [`pi-context filter-block-items --block framework-gaps --field status --op eq --value '"open"' --json`],
850
1309
  parameters: Type.Object({
851
1310
  block: Type.String({
852
1311
  description: "Block name (e.g., 'tasks', 'decisions', 'framework-gaps', 'context-contracts')",
@@ -866,11 +1325,11 @@ export const ops = [
866
1325
  op: params.op,
867
1326
  value: params.value,
868
1327
  });
869
- const envelope = serializeForRead(result, {
1328
+ const read = structureForRead(result, {
870
1329
  label: `${params.block} filtered`,
871
1330
  overCapDirective: { tool: "read-block-page", hint: "or refine the predicate" },
872
1331
  });
873
- return envelope.content;
1332
+ return { read };
874
1333
  },
875
1334
  },
876
1335
  {
@@ -878,13 +1337,24 @@ export const ops = [
878
1337
  label: "Resolve Item By Id",
879
1338
  description: "Look up the block, array key, and item payload for a given ID across all blocks in the substrate dir. Returns null when no item matches. Mirrors the resolveItemById SDK function and shares its prefix-vs-block invariant — IDs whose prefix maps to a known block but live elsewhere throw at index-build time.",
880
1339
  promptSnippet: "Resolve a kind-prefixed ID (DEC-/FEAT-/FGAP-/issue-/REQ-/TASK-/etc.) to its owning block and item",
1340
+ examples: [`pi-context resolve-item-by-id --id TASK-001 --json`],
881
1341
  parameters: Type.Object({
882
1342
  id: Type.String({ description: "Kind-prefixed ID, e.g., DEC-NNNN / FEAT-NNN / FGAP-NNN / ISSUE-NNN" }),
883
1343
  }),
884
1344
  surface: "use",
885
1345
  run(cwd, params) {
886
1346
  const result = resolveItemById(cwd, params.id);
887
- return JSON.stringify(result, null, 2);
1347
+ // TASK-013 / FGAP-015: route through {read} so the embedded full
1348
+ // ItemLocation is bounded at the 50KB cap and over-cap fails closed with
1349
+ // a narrowing directive (mirrors read-block-item). `result` is
1350
+ // ItemLocation | null — structureForRead handles both.
1351
+ return {
1352
+ read: structureForRead(result, {
1353
+ whole: true,
1354
+ label: `resolve ${params.id}`,
1355
+ overCapDirective: { tool: "read-block-item", hint: "narrow to one block" },
1356
+ }),
1357
+ };
888
1358
  },
889
1359
  },
890
1360
  {
@@ -892,6 +1362,7 @@ export const ops = [
892
1362
  label: "Read Block Item",
893
1363
  description: "Read a single item from a named block by its id — returns the item or null. Block-scoped (unlike resolve-item-by-id, which searches all blocks by kind-prefixed id). Avoids fetching a whole large block to get one item.",
894
1364
  promptSnippet: "Read one item from a block by id (block-scoped; null if absent)",
1365
+ examples: [`pi-context read-block-item --block tasks --id TASK-001 --json`],
895
1366
  parameters: Type.Object({
896
1367
  block: Type.String({ description: "Block name (e.g., 'tasks', 'decisions', 'framework-gaps')" }),
897
1368
  id: Type.String({ description: "Item id within the block (e.g., 'TASK-NNN')" }),
@@ -901,8 +1372,8 @@ export const ops = [
901
1372
  const result = readBlockItem(cwd, params.block, params.id);
902
1373
  // whole: the item is already the addressed element — don't re-page its
903
1374
  // 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;
1375
+ const read = structureForRead(result, { whole: true, label: `${params.block} ${params.id}` });
1376
+ return { read };
906
1377
  },
907
1378
  },
908
1379
  {
@@ -910,6 +1381,7 @@ export const ops = [
910
1381
  label: "Read Block Page",
911
1382
  description: "Paginate a block's items: returns { items, total, hasMore }. offset default 0, limit default 50. Use for blocks too large to fetch whole (past the 50KB read-block cap). total is the full item count; hasMore signals another page.",
912
1383
  promptSnippet: "Paginate a block's items — offset + limit; returns {items,total,hasMore}",
1384
+ examples: [`pi-context read-block-page --block tasks --limit 20 --json`],
913
1385
  parameters: Type.Object({
914
1386
  block: Type.String({ description: "Block name (e.g., 'framework-gaps', 'decisions', 'issues')" }),
915
1387
  offset: Type.Optional(Type.Integer({ minimum: 0, description: "Start index (default 0)" })),
@@ -920,8 +1392,8 @@ export const ops = [
920
1392
  const result = readBlockPage(cwd, params.block, { offset: params.offset, limit: params.limit });
921
1393
  // whole: readBlockPage ALREADY paged — preserve the {items,total,hasMore}
922
1394
  // 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;
1395
+ const read = structureForRead(result, { whole: true, label: `${params.block} page` });
1396
+ return { read };
925
1397
  },
926
1398
  },
927
1399
  {
@@ -929,6 +1401,9 @@ export const ops = [
929
1401
  label: "Join Blocks",
930
1402
  description: "Join two blocks in one call. EDGE mode: pass `relationType` — pairs left items with right-block items connected by that relations.json edge (`leftEndpoint` parent|child, default parent). FIELD mode: pass `leftField`+`rightField` — pairs where left[leftField] === right[rightField]. Optional left pre-filter via where{Field,Op,Value}. Returns [{left, right:[]}] (right always an array; one-to-many). Use instead of N+1 read-block + resolve calls.",
931
1403
  promptSnippet: "Join two blocks in one call — by relation edge or shared field; returns {left,right[]} pairs",
1404
+ examples: [
1405
+ `pi-context join-blocks --leftBlock tasks --rightBlock verification --relationType verification_verifies_item --leftEndpoint child --json`,
1406
+ ],
932
1407
  parameters: Type.Object({
933
1408
  leftBlock: Type.String({ description: "Left block name (e.g., 'tasks')" }),
934
1409
  rightBlock: Type.String({ description: "Right block name (e.g., 'verification')" }),
@@ -956,14 +1431,14 @@ export const ops = [
956
1431
  leftEndpoint: params.leftEndpoint,
957
1432
  leftPredicate,
958
1433
  });
959
- const envelope = serializeForRead(result, {
1434
+ const read = structureForRead(result, {
960
1435
  label: `${params.leftBlock} ⋈ ${params.rightBlock}`,
961
1436
  overCapDirective: {
962
1437
  tool: "join-blocks",
963
1438
  hint: "refine the relation/field or pre-filter the left block",
964
1439
  },
965
1440
  });
966
- return envelope.content;
1441
+ return { read };
967
1442
  },
968
1443
  },
969
1444
  {
@@ -971,6 +1446,7 @@ export const ops = [
971
1446
  label: "Resolve Items By Id (Bulk)",
972
1447
  description: "Bulk variant of resolve-item-by-id — resolve N kind-prefixed ids against a single buildIdIndex traversal. Returns an object mapping each input id to its ItemLocation (block / arrayKey / item) or null when not found. Coexists with the singular resolve-item-by-id tool; bulk collapses the N×singular-call pattern for callers resolving multiple ids in one render pass.",
973
1448
  promptSnippet: "Resolve a batch of kind-prefixed ids (DEC-/FGAP-/TASK-/issue-/REQ-/...) in one call",
1449
+ examples: [`pi-context resolve-items-by-id --ids '["TASK-001","DEC-0001","FGAP-042"]' --json`],
974
1450
  parameters: Type.Object({
975
1451
  ids: Type.Array(Type.String(), {
976
1452
  description: "Array of kind-prefixed ids (DEC-/FGAP-/TASK-/issue-/REQ-/...) to resolve in one call",
@@ -984,8 +1460,8 @@ export const ops = [
984
1460
  obj[id] = loc;
985
1461
  // whole: an id→location map keyed by arbitrary ids — not a pageable
986
1462
  // collection; serialize the map verbatim.
987
- const envelope = serializeForRead(obj, { whole: true, label: "resolved ids" });
988
- return envelope.content;
1463
+ const read = structureForRead(obj, { whole: true, label: "resolved ids" });
1464
+ return { read };
989
1465
  },
990
1466
  },
991
1467
  {
@@ -993,6 +1469,9 @@ export const ops = [
993
1469
  label: "Complete Task",
994
1470
  description: "Complete a task with verification gate — requires a passing verification entry targeting the task.",
995
1471
  promptSnippet: "Complete a task — gates on passing verification before updating status",
1472
+ examples: [
1473
+ `pi-context complete-task --taskId TASK-001 --verificationId VER-001 --writer '{"kind":"human","user":"you@example.com"}' --json`,
1474
+ ],
996
1475
  parameters: Type.Object({
997
1476
  taskId: Type.String({ description: "Task ID to complete" }),
998
1477
  verificationId: Type.String({
@@ -1000,8 +1479,8 @@ export const ops = [
1000
1479
  }),
1001
1480
  }),
1002
1481
  surface: "use",
1003
- run(cwd, params) {
1004
- const result = completeTask(cwd, params.taskId, params.verificationId);
1482
+ run(cwd, params, ctx) {
1483
+ const result = completeTask(cwd, params.taskId, params.verificationId, ctx);
1005
1484
  return `Task '${result.taskId}' completed (was '${result.previousStatus}'). Verification: ${result.verificationId} (${result.verificationStatus})`;
1006
1485
  },
1007
1486
  },
@@ -1010,11 +1489,12 @@ export const ops = [
1010
1489
  label: "Context Validate Relations",
1011
1490
  description: "Validate substrate relations.json edges against config-declared lenses + hierarchy + relation_types and the cross-block id index. Returns SubstrateValidationResult with status (clean/warnings/invalid) and per-issue diagnostics.",
1012
1491
  promptSnippet: "Validate substrate relations against config + items",
1492
+ examples: [`pi-context context-validate-relations --json`],
1013
1493
  parameters: Type.Object({}),
1014
1494
  surface: "use",
1015
1495
  run(cwd, _params) {
1016
1496
  const result = validateContextRelations(cwd);
1017
- return JSON.stringify(result, null, 2);
1497
+ return { json: result };
1018
1498
  },
1019
1499
  },
1020
1500
  {
@@ -1022,14 +1502,52 @@ export const ops = [
1022
1502
  label: "Context Edges For Lens",
1023
1503
  description: "Materialize the Edge[] for a named lens — synthetic edges from derived_from_field for auto-derived lenses; authored edges filtered by relation_type for hand-curated lenses; unioned items from composition members for kind=composition lenses.",
1024
1504
  promptSnippet: "Materialize edges for a named lens (auto-derived or hand-curated)",
1505
+ examples: [`pi-context context-edges-for-lens --lensId feature-decomposition --json`],
1025
1506
  parameters: Type.Object({
1026
1507
  lensId: Type.String({ description: "Lens id from config.lenses[].id" }),
1027
1508
  }),
1028
1509
  surface: "use",
1029
1510
  run(cwd, params) {
1030
1511
  const result = edgesForLensByName(cwd, params.lensId);
1031
- const envelope = serializeForRead(result, { label: `edges for lens ${params.lensId}` });
1032
- return envelope.content;
1512
+ const read = structureForRead(result, { label: `edges for lens ${params.lensId}` });
1513
+ return { read };
1514
+ },
1515
+ },
1516
+ {
1517
+ name: "context-lens-view",
1518
+ label: "Context Lens View",
1519
+ description: "Project a config-declared lens (config.lenses[]) as a binned item-view. Without --bin, a bin->count summary (always under the read cap). With --bin, that bin's items paged by --offset/--limit. Serves target, composition, and hand-curated lenses.",
1520
+ promptSnippet: "Project a config-declared lens as a binned item-view — bin->count summary, or one bin's items paged",
1521
+ examples: [
1522
+ `pi-context context-lens-view --lensId gaps-by-status --json`,
1523
+ `pi-context context-lens-view --lensId gaps-by-status --bin identified --limit 20 --json`,
1524
+ ],
1525
+ parameters: Type.Object({
1526
+ lensId: Type.String({ description: "Lens id from config.lenses[].id" }),
1527
+ bin: Type.Optional(Type.String({ description: "Return this bin's items paged; omit for a bin->count summary" })),
1528
+ offset: Type.Optional(Type.Integer({ minimum: 0, description: "Per-bin page start index (default 0)" })),
1529
+ limit: Type.Optional(Type.Integer({ minimum: 1, description: "Per-bin page size (default 50)" })),
1530
+ }),
1531
+ surface: "use",
1532
+ run(cwd, params) {
1533
+ const view = loadLensView(cwd, params.lensId);
1534
+ if ("error" in view)
1535
+ throw new Error(view.error); // unknown lens / no-config -> non-zero exit
1536
+ if (params.bin !== undefined) {
1537
+ const items = view.grouped.get(params.bin);
1538
+ if (items === undefined)
1539
+ throw new Error(`Bin '${params.bin}' not declared on lens '${params.lensId}'. Bins: ${view.lens.bins.join(", ")}`);
1540
+ const page = pageArray(items, { offset: params.offset, limit: params.limit });
1541
+ return { read: structureForRead(page, { whole: true, label: `lens ${params.lensId} bin ${params.bin}` }) };
1542
+ }
1543
+ const summary = {
1544
+ lens: view.lens.id,
1545
+ kind: view.lens.kind ?? "target",
1546
+ bins: Object.fromEntries(view.lens.bins.map((b) => [b, (view.grouped.get(b) ?? []).length])),
1547
+ uncategorized: view.uncategorized.length,
1548
+ total: view.items.length,
1549
+ };
1550
+ return { read: structureForRead(summary, { whole: true, label: `lens ${params.lensId} bins` }) };
1033
1551
  },
1034
1552
  },
1035
1553
  {
@@ -1037,6 +1555,9 @@ export const ops = [
1037
1555
  label: "Context Walk Descendants",
1038
1556
  description: "Walk closure-table descendants of a parent id under a given relation_type. Returns string[] of descendant ids (may be empty if no children or relations.json absent).",
1039
1557
  promptSnippet: "Walk closure-table descendants under a relation_type",
1558
+ examples: [
1559
+ `pi-context context-walk-descendants --parentId FEAT-008 --relationType feature_decomposed_into_task --json`,
1560
+ ],
1040
1561
  parameters: Type.Object({
1041
1562
  parentId: Type.String({ description: "Parent id (canonical id or lens bin name)" }),
1042
1563
  relationType: Type.String({ description: "Relation type from config.relation_types[].canonical_id" }),
@@ -1044,7 +1565,7 @@ export const ops = [
1044
1565
  surface: "use",
1045
1566
  run(cwd, params) {
1046
1567
  const result = walkLensDescendants(cwd, params.parentId, params.relationType);
1047
- return JSON.stringify(result, null, 2);
1568
+ return { json: result };
1048
1569
  },
1049
1570
  },
1050
1571
  {
@@ -1052,6 +1573,7 @@ export const ops = [
1052
1573
  label: "Walk Ancestors",
1053
1574
  description: "Walk closure-table ancestors of an item id under a given relation_type — reverse-direction counterpart to context-walk-descendants. Returns string[] of ancestor ids (may be empty if no parents or relations.json absent).",
1054
1575
  promptSnippet: "Walk closure-table ancestors under a relation_type",
1576
+ examples: [`pi-context walk-ancestors --itemId TASK-042 --relationType feature_decomposed_into_task --json`],
1055
1577
  parameters: Type.Object({
1056
1578
  itemId: Type.String({ description: "Child item id whose ancestors are sought" }),
1057
1579
  relationType: Type.String({ description: "Relation type from config.relation_types[].canonical_id" }),
@@ -1059,8 +1581,8 @@ export const ops = [
1059
1581
  surface: "use",
1060
1582
  run(cwd, params) {
1061
1583
  const result = walkAncestorsByLens(cwd, params.itemId, params.relationType);
1062
- const envelope = serializeForRead(result, { label: `ancestors of ${params.itemId}` });
1063
- return envelope.content;
1584
+ const read = structureForRead(result, { label: `ancestors of ${params.itemId}` });
1585
+ return { read };
1064
1586
  },
1065
1587
  },
1066
1588
  {
@@ -1068,6 +1590,7 @@ export const ops = [
1068
1590
  label: "Find References",
1069
1591
  description: "Find all closure-table edges incident on an item id (inbound, outbound, or both). Returns Edge[] preserving relation_type + ordinal per record — edge-level view, not the id-chain projection that walk-ancestors / context-walk-descendants emit.",
1070
1592
  promptSnippet: "Find closure-table edges incident on an item id",
1593
+ examples: [`pi-context find-references --itemId TASK-001 --direction both --json`],
1071
1594
  parameters: Type.Object({
1072
1595
  itemId: Type.String({ description: "Item id whose incident edges are sought" }),
1073
1596
  direction: Type.Optional(Type.Union([Type.Literal("inbound"), Type.Literal("outbound"), Type.Literal("both")], {
@@ -1077,8 +1600,8 @@ export const ops = [
1077
1600
  surface: "use",
1078
1601
  run(cwd, params) {
1079
1602
  const result = findReferencesInRepo(cwd, params.itemId, params.direction);
1080
- const envelope = serializeForRead(result, { label: `edges on ${params.itemId}` });
1081
- return envelope.content;
1603
+ const read = structureForRead(result, { label: `edges on ${params.itemId}` });
1604
+ return { read };
1082
1605
  },
1083
1606
  },
1084
1607
  {
@@ -1086,6 +1609,7 @@ export const ops = [
1086
1609
  label: "Gather Execution Context",
1087
1610
  description: "Compose a ContextBundle for a work-unit by reading its context-contract (by unit_kind) and walking declared relation_types bidirectionally per direction semantic. Returns unit + perRelationType buckets of resolved items + traversal_depth + scoped_at. Substrate primitive serving harness-confined dispatch.",
1088
1611
  promptSnippet: "Compose ContextBundle for unit + context-contract-declared bundle_relation_types",
1612
+ examples: [`pi-context gather-execution-context --unitId TASK-001 --kind task --json`],
1089
1613
  parameters: Type.Object({
1090
1614
  unitId: Type.String({ description: "Work-unit id (e.g. TASK-NNN / DEC-NNNN / FGAP-NNN)" }),
1091
1615
  kind: Type.String({
@@ -1101,8 +1625,8 @@ export const ops = [
1101
1625
  const result = gatherExecutionContext(cwd, params);
1102
1626
  // whole: a structured ContextBundle (unit + perRelationType buckets) —
1103
1627
  // 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;
1628
+ const read = structureForRead(result, { whole: true, label: `bundle ${params.unitId}` });
1629
+ return { read };
1106
1630
  },
1107
1631
  },
1108
1632
  {
@@ -1110,6 +1634,7 @@ export const ops = [
1110
1634
  label: "Context: load roadmap",
1111
1635
  description: "Load a roadmap by id and return the materialized RoadmapView (phases, lens-views, status rollup, milestone resolution, scoped phase_depends_on edges, topo-ordered phaseOrder + cycles). Phase ordering lives in relations.json with relation_type='phase_depends_on'.",
1112
1636
  promptSnippet: "Load a roadmap by id",
1637
+ examples: [`pi-context context-roadmap-load --roadmapId ROADMAP-001 --json`],
1113
1638
  parameters: Type.Object({
1114
1639
  roadmapId: Type.String({ description: "ROADMAP-NNN id from <config.root>/roadmap.json" }),
1115
1640
  }),
@@ -1117,16 +1642,16 @@ export const ops = [
1117
1642
  run(cwd, params) {
1118
1643
  const view = loadRoadmap(cwd, params.roadmapId);
1119
1644
  if ("error" in view) {
1120
- const envErr = serializeForRead(view, { whole: true, label: `roadmap ${params.roadmapId} (error)` });
1121
- return envErr.content;
1645
+ const read = structureForRead(view, { whole: true, label: `roadmap ${params.roadmapId} (error)` });
1646
+ return { read };
1122
1647
  }
1123
1648
  // whole: a structured RoadmapView (phases + lens-views + rollups) — keep
1124
1649
  // the view shape intact rather than paging an inner array.
1125
- const envelope = serializeForRead(serializeRoadmapView(view), {
1650
+ const read = structureForRead(serializeRoadmapView(view), {
1126
1651
  whole: true,
1127
1652
  label: `roadmap ${params.roadmapId}`,
1128
1653
  });
1129
- return envelope.content;
1654
+ return { read };
1130
1655
  },
1131
1656
  },
1132
1657
  {
@@ -1134,6 +1659,7 @@ export const ops = [
1134
1659
  label: "Context: render roadmap",
1135
1660
  description: "Render a roadmap by id as pure-textual markdown — phase order list, per-phase adjacency lines (sourced from view.edges, alphabetically sorted), status rollup counts, milestone resolution, exit criteria. NO mermaid / graph syntax: per-phase **Depends on:** lines come strictly from authored phase_depends_on edges scoped to in-roadmap phases.",
1136
1661
  promptSnippet: "Render a roadmap as markdown",
1662
+ examples: [`pi-context context-roadmap-render --roadmapId ROADMAP-001`],
1137
1663
  parameters: Type.Object({
1138
1664
  roadmapId: Type.String({ description: "ROADMAP-NNN id from <config.root>/roadmap.json" }),
1139
1665
  }),
@@ -1141,7 +1667,7 @@ export const ops = [
1141
1667
  run(cwd, params) {
1142
1668
  const view = loadRoadmap(cwd, params.roadmapId);
1143
1669
  if ("error" in view) {
1144
- return JSON.stringify(view, null, 2);
1670
+ return { json: view };
1145
1671
  }
1146
1672
  const naming = loadContext(cwd).config?.naming;
1147
1673
  return renderRoadmap(view, naming);
@@ -1152,6 +1678,7 @@ export const ops = [
1152
1678
  label: "Context: validate roadmap(s)",
1153
1679
  description: "Validate every roadmap × phase × milestone in <config.root>/roadmap.json. Codes: roadmap_lens_missing, roadmap_phase_dep_missing, roadmap_phase_cycle, roadmap_composition_cycle, roadmap_milestone_evidence_block_missing, roadmap_milestone_query_invalid, roadmap_status_unknown_value. Display strings flow through config.display_strings (pi-context divergence). Optional roadmapId filter restricts issue list to a single roadmap.",
1154
1680
  promptSnippet: "Validate roadmaps",
1681
+ examples: [`pi-context context-roadmap-validate --roadmapId ROADMAP-001 --json`],
1155
1682
  parameters: Type.Object({
1156
1683
  roadmapId: Type.Optional(Type.String({ description: "Filter to issues matching this roadmap_id (omit for full-project validation)" })),
1157
1684
  }),
@@ -1161,7 +1688,7 @@ export const ops = [
1161
1688
  const filtered = params.roadmapId
1162
1689
  ? result.issues.filter((i) => !i.roadmap_id || i.roadmap_id === params.roadmapId)
1163
1690
  : result.issues;
1164
- return JSON.stringify({ status: result.status, issues: filtered }, null, 2);
1691
+ return { json: { status: result.status, issues: filtered } };
1165
1692
  },
1166
1693
  },
1167
1694
  {
@@ -1169,10 +1696,11 @@ export const ops = [
1169
1696
  label: "Context: list roadmaps",
1170
1697
  description: "List every roadmap in <config.root>/roadmap.json with id, title, optional status, and phase count. Returns [] when roadmap.json absent (opt-in block; absence is the truthful answer).",
1171
1698
  promptSnippet: "List roadmaps",
1699
+ examples: [`pi-context context-roadmap-list --json`],
1172
1700
  parameters: Type.Object({}),
1173
1701
  surface: "use",
1174
1702
  run(cwd, _params) {
1175
- return JSON.stringify(listRoadmaps(cwd), null, 2);
1703
+ return { json: listRoadmaps(cwd) };
1176
1704
  },
1177
1705
  },
1178
1706
  ];
@@ -1187,6 +1715,137 @@ export const ops = [
1187
1715
  * point; this list is the source of pi-context's contribution to it.
1188
1716
  */
1189
1717
  export const gatedTools = ops.filter((o) => o.authGated).map((o) => o.name);
1718
+ /**
1719
+ * The FGAP-009 non-exposure allowlist: every library write function that is
1720
+ * deliberately NOT op-backed, with the reason it is withheld. This is the
1721
+ * closure contract γ (TASK-008) WILL consume: γ's parity test — not yet written
1722
+ * (no executable parity test exists in β; β defines the contract, γ implements
1723
+ * the test against it) — WILL assert that EVERY library writer is either op-backed
1724
+ * (appears in {@link ops}, directly or transitively) OR named here, so a
1725
+ * newly-added library writer with neither an op nor an allowlist entry will fail
1726
+ * that test, keeping the op surface and the library write surface in lockstep.
1727
+ *
1728
+ * The `*ForDir` twins of op-backed writers (e.g. `appendRelationForDir`,
1729
+ * `writeRelationsForDir`, `upsertItemInBlockForDir`, `appendToBlockForDir`, …)
1730
+ * are NOT enumerated individually: each is the dir-targeted internal twin of a
1731
+ * cwd-form writer that IS op-backed, and is covered by that cwd-form op (the op
1732
+ * resolves the active substrate dir then delegates to the same shared
1733
+ * typed-file primitive the `*ForDir` twin calls). The contract is that γ's
1734
+ * parity test (TASK-008, not yet written) WILL treat a `*ForDir` writer as
1735
+ * covered when its cwd-form sibling is covered.
1736
+ */
1737
+ export const INTENTIONALLY_UNEXPOSED_WRITERS = [
1738
+ { libraryFn: "writeConfig", safeOp: "amend-config", reason: "scoped guarded config mutation" },
1739
+ { libraryFn: "writeSchema", safeOp: "write-schema", reason: "raw bypasses the create/replace + migration check" },
1740
+ { libraryFn: "updateSchema", safeOp: "write-schema", reason: "no mutator-scripting surface" },
1741
+ { libraryFn: "writeBootstrapPointer", safeOp: "context-init", reason: "raw bypasses target validation" },
1742
+ { libraryFn: "flipBootstrapPointer", safeOp: "context-switch", reason: "raw splits the safe switch" },
1743
+ { libraryFn: "writeRegistry", reason: "internal; registry writes flow through registerSubstrate callers" },
1744
+ {
1745
+ libraryFn: "registerSubstrate",
1746
+ reason: "manual/foreign registration is the clone arc (DEC-0002); normal paths auto-register",
1747
+ },
1748
+ {
1749
+ libraryFn: "rollbackBlockFiles",
1750
+ reason: "workflow-executor transactional rollback (graduated-failure undo); internal recovery path with no operator-facing op by design",
1751
+ },
1752
+ ];
1753
+ /**
1754
+ * The five mutually-exhaustive ways a library write function is COVERED by the
1755
+ * FGAP-009 op-surface ↔ library-write-surface parity contract. A writer that
1756
+ * matches NONE of these is a silent gap that γ's (TASK-008) parity test — once
1757
+ * written — MUST fail on. Coverage is the DISJUNCTION over these classes — a
1758
+ * writer needs ANY one, not all.
1759
+ */
1760
+ export var CoverageClass;
1761
+ (function (CoverageClass) {
1762
+ /** An op's `run` calls the writer directly (e.g. `append-block-item` → `appendToBlock`). */
1763
+ CoverageClass["OpBackedDirect"] = "op-backed-direct";
1764
+ /**
1765
+ * An op's `run` reaches the writer TRANSITIVELY — through any helper / wrapper
1766
+ * chain, not just a direct call. Two sub-shapes both land here:
1767
+ * - `*ByRef` / SDK relation porcelain: the `remove-relation` / `replace-relation`
1768
+ * / `append-relations` ops call `removeRelationByRef` / `replaceRelationByRef`
1769
+ * / `appendRelationsByRef`, which call `writeRelations`.
1770
+ * - init / switch → internal-helper chains: `context-init` → `initProject` →
1771
+ * `writeSkeletonConfig`; `context-switch` → `switchToExisting` /
1772
+ * `switchAndCreate` → `reconcileActiveSubstrateRegistration`. The writer is
1773
+ * not a `*ByRef` porcelain and is not allowlisted, but it IS reachable from an
1774
+ * op's `run` via a helper the op calls.
1775
+ * Coverage condition: reachable from some op's `run` via any helper/wrapper chain.
1776
+ */
1777
+ CoverageClass["OpBackedTransitive"] = "op-backed-transitive";
1778
+ /**
1779
+ * A `*ForDir` dir-targeted twin of a covered cwd-form writer (e.g.
1780
+ * `appendToBlockForDir` is the twin of the op-backed `appendToBlock`). Covered
1781
+ * by its cwd-form sibling — both delegate to the same shared typed-file
1782
+ * primitive; the cwd-form op resolves the active dir then calls it.
1783
+ */
1784
+ CoverageClass["ForDirTwin"] = "for-dir-twin";
1785
+ /**
1786
+ * On {@link INTENTIONALLY_UNEXPOSED_WRITERS}: a raw write deliberately given NO
1787
+ * direct op (a scoped op supersedes it, or it is internal/foreign-only).
1788
+ */
1789
+ CoverageClass["IntentionallyUnexposed"] = "intentionally-unexposed";
1790
+ /**
1791
+ * A block-api internal primitive below the op layer — the `*TypedFile` read/
1792
+ * write layer (`readTypedFile` / `writeTypedFile`), `prepareItemIdentityForWrite`,
1793
+ * and the identity / content-hash helpers. Never op-backed by design; the ops
1794
+ * compose over these.
1795
+ */
1796
+ CoverageClass["InternalPrimitive"] = "internal-primitive";
1797
+ })(CoverageClass || (CoverageClass = {}));
1798
+ /**
1799
+ * The FGAP-009 coverage RULE, made explicit so γ (TASK-008) will import the
1800
+ * contract rather than re-derive it. A library write function is COVERED iff it
1801
+ * matches ANY clause below (the disjunction); a writer matching none is a silent
1802
+ * gap that γ's parity test — when written — MUST fail on. β fixes the contract
1803
+ * here; no executable parity test exists yet (that is γ).
1804
+ *
1805
+ * Why `writeConfig` is allowlisted but `writeRelations` is NOT — the distinction
1806
+ * a strict name-parity reading mis-saw as inconsistent:
1807
+ * - `writeConfig` has NO direct wholesale-config op. The scoped surface is
1808
+ * `amend-config` (one-entry-in-one-registry add/replace/remove, AJV-validated),
1809
+ * which deliberately does NOT expose a raw whole-config overwrite. So
1810
+ * `writeConfig` is `intentionally-unexposed` (the scoped op supersedes the raw
1811
+ * writer; a raw wholesale overwrite is withheld by design).
1812
+ * - `writeRelations` IS reached — transitively — by the relation ops:
1813
+ * `remove-relation` / `replace-relation` / `append-relations` call
1814
+ * `removeRelationByRef` / `replaceRelationByRef` / `appendRelationsByRef`, each
1815
+ * of which calls `writeRelations`. It is therefore `op-backed-transitive`
1816
+ * and needs NO allowlist entry. The asymmetry is real and correct: one writer
1817
+ * has an op path (via a helper/wrapper chain), the other does not.
1818
+ *
1819
+ * The `op-backed-transitive` clause covers BOTH the `*ByRef` relation porcelain
1820
+ * AND the init/switch → internal-helper chains: `writeSkeletonConfig` (reached
1821
+ * via `context-init` → `initProject`) and `reconcileActiveSubstrateRegistration`
1822
+ * (reached via `context-switch` → `switchToExisting` / `switchAndCreate`) are
1823
+ * neither `*ByRef` porcelain nor allowlisted, yet each is reachable from an op's
1824
+ * `run` through a helper that op calls — so each classifies cleanly as
1825
+ * `op-backed-transitive`, not as a gap.
1826
+ */
1827
+ export const OP_COVERAGE_RULE = [
1828
+ {
1829
+ coverageClass: CoverageClass.OpBackedDirect,
1830
+ test: "an op's run() calls the writer directly",
1831
+ },
1832
+ {
1833
+ coverageClass: CoverageClass.OpBackedTransitive,
1834
+ 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)",
1835
+ },
1836
+ {
1837
+ coverageClass: CoverageClass.ForDirTwin,
1838
+ test: "a *ForDir twin of a covered cwd-form writer (covered by its cwd-form sibling)",
1839
+ },
1840
+ {
1841
+ coverageClass: CoverageClass.IntentionallyUnexposed,
1842
+ test: "named on INTENTIONALLY_UNEXPOSED_WRITERS — a raw bypass with no direct op",
1843
+ },
1844
+ {
1845
+ coverageClass: CoverageClass.InternalPrimitive,
1846
+ test: "a block-api internal primitive below the op layer (the *TypedFile layer, prepareItemIdentityForWrite, identity / content-hash helpers)",
1847
+ },
1848
+ ];
1190
1849
  /**
1191
1850
  * The factory PI handle captured at registerAll time. The list-tools op needs
1192
1851
  * the introspection surface (getAllTools / getActiveTools) which lives on
@@ -1196,11 +1855,36 @@ export const gatedTools = ops.filter((o) => o.authGated).map((o) => o.name);
1196
1855
  * threading the handle through every signature.
1197
1856
  */
1198
1857
  let boundPi = null;
1858
+ /**
1859
+ * Build the DispatchContext threaded into an op's `run` from the in-pi tool
1860
+ * execute boundary (registerAll). Two derivation branches:
1861
+ *
1862
+ * - When `params.writer.user` is a non-empty string — the shape the
1863
+ * pi-agent-dispatch auth-gate stamps onto authGated op params on operator
1864
+ * confirm — the writer is a human identity. (The smuggle-ops promote-item /
1865
+ * write-schema-migration / context-switch carry a `writer` schema field
1866
+ * precisely so the gate has somewhere to stamp; this converts that field
1867
+ * into the contract ctx the op now consumes via its 3rd `run` arg.)
1868
+ * - Otherwise the writer is the running agent, identified by the active
1869
+ * model's id (`ExtensionContext.model.id`); falls back to "pi-agent" when
1870
+ * no model (or no id) is resolvable.
1871
+ *
1872
+ * Exported for unit testing — the two branches are asserted directly against
1873
+ * synthetic params + a minimal ExtensionContext-shaped object.
1874
+ */
1875
+ export function buildDispatchContextFromExecute(params, extCtx) {
1876
+ const writerUser = params?.writer?.user;
1877
+ if (typeof writerUser === "string" && writerUser.length > 0) {
1878
+ return { writer: { kind: "human", user: writerUser } };
1879
+ }
1880
+ const modelId = extCtx.model?.id;
1881
+ return { writer: { kind: "agent", agent_id: modelId && modelId.length > 0 ? modelId : "pi-agent" } };
1882
+ }
1199
1883
  /**
1200
1884
  * 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.
1885
+ * uniform wrapper around the op's run(): coerce params, build the attestation
1886
+ * DispatchContext from the auth-gate-stamped writer (human) or the running
1887
+ * model (agent), await run, place the returned string at content[0].text.
1204
1888
  */
1205
1889
  export function registerAll(pi) {
1206
1890
  boundPi = pi;
@@ -1212,9 +1896,10 @@ export function registerAll(pi) {
1212
1896
  promptSnippet: op.promptSnippet,
1213
1897
  parameters: op.parameters,
1214
1898
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1899
+ const dctx = buildDispatchContextFromExecute(params, ctx);
1215
1900
  return {
1216
1901
  details: undefined,
1217
- content: [{ type: "text", text: await op.run(ctx.cwd, params) }],
1902
+ content: [{ type: "text", text: renderOpResultText(await op.run(ctx.cwd, params, dctx)) }],
1218
1903
  };
1219
1904
  },
1220
1905
  });