@davidorex/pi-context 0.31.0 → 0.33.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 (77) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +16 -14
  3. package/dist/block-api.d.ts +20 -33
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +154 -3
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/content-hash.d.ts +9 -0
  8. package/dist/content-hash.d.ts.map +1 -1
  9. package/dist/content-hash.js +11 -0
  10. package/dist/content-hash.js.map +1 -1
  11. package/dist/context-sdk.d.ts +157 -39
  12. package/dist/context-sdk.d.ts.map +1 -1
  13. package/dist/context-sdk.js +896 -251
  14. package/dist/context-sdk.js.map +1 -1
  15. package/dist/context.d.ts +158 -5
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +75 -5
  18. package/dist/context.js.map +1 -1
  19. package/dist/index.d.ts +151 -19
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +590 -97
  22. package/dist/index.js.map +1 -1
  23. package/dist/lens-view.d.ts +0 -5
  24. package/dist/lens-view.d.ts.map +1 -1
  25. package/dist/lens-view.js +43 -1
  26. package/dist/lens-view.js.map +1 -1
  27. package/dist/migration-registry-loader.d.ts +20 -12
  28. package/dist/migration-registry-loader.d.ts.map +1 -1
  29. package/dist/migration-registry-loader.js +46 -17
  30. package/dist/migration-registry-loader.js.map +1 -1
  31. package/dist/migrations-store.d.ts +45 -18
  32. package/dist/migrations-store.d.ts.map +1 -1
  33. package/dist/migrations-store.js +56 -22
  34. package/dist/migrations-store.js.map +1 -1
  35. package/dist/ops-registry.d.ts +18 -0
  36. package/dist/ops-registry.d.ts.map +1 -1
  37. package/dist/ops-registry.js +474 -130
  38. package/dist/ops-registry.js.map +1 -1
  39. package/dist/promote-item.d.ts.map +1 -1
  40. package/dist/promote-item.js +41 -12
  41. package/dist/promote-item.js.map +1 -1
  42. package/dist/roadmap-plan.d.ts +121 -99
  43. package/dist/roadmap-plan.d.ts.map +1 -1
  44. package/dist/roadmap-plan.js +281 -345
  45. package/dist/roadmap-plan.js.map +1 -1
  46. package/dist/status-vocab.d.ts +12 -2
  47. package/dist/status-vocab.d.ts.map +1 -1
  48. package/dist/status-vocab.js +14 -1
  49. package/dist/status-vocab.js.map +1 -1
  50. package/package.json +2 -2
  51. package/samples/blocks/milestone.json +3 -0
  52. package/samples/blocks/session-notes.json +1 -0
  53. package/samples/conception.json +315 -15
  54. package/samples/migrations.json +8 -0
  55. package/samples/schemas/context-contracts.schema.json +4 -0
  56. package/samples/schemas/conventions.schema.json +4 -0
  57. package/samples/schemas/decisions.schema.json +4 -0
  58. package/samples/schemas/features.schema.json +4 -0
  59. package/samples/schemas/framework-gaps.schema.json +9 -0
  60. package/samples/schemas/issues.schema.json +28 -0
  61. package/samples/schemas/layer-plans.schema.json +4 -0
  62. package/samples/schemas/milestone.schema.json +79 -0
  63. package/samples/schemas/phase.schema.json +4 -0
  64. package/samples/schemas/rationale.schema.json +4 -0
  65. package/samples/schemas/requirements.schema.json +4 -0
  66. package/samples/schemas/research.schema.json +45 -2
  67. package/samples/schemas/session-notes.schema.json +89 -0
  68. package/samples/schemas/spec-reviews.schema.json +4 -0
  69. package/samples/schemas/story.schema.json +8 -0
  70. package/samples/schemas/tasks.schema.json +4 -0
  71. package/samples/schemas/verification.schema.json +4 -0
  72. package/samples/schemas/work-orders.schema.json +4 -0
  73. package/schemas/config.schema.json +90 -4
  74. package/schemas/migrations.schema.json +25 -0
  75. package/skill-narrative.md +8 -6
  76. package/skills/pi-context/SKILL.md +70 -63
  77. package/skills/pi-context/references/bundled-resources.md +5 -1
@@ -16,12 +16,13 @@
16
16
  * phase (the auth-gate at the pi-agent-dispatch layer remains the enforcement
17
17
  * point, unchanged by this relocation).
18
18
  */
19
+ import fs from "node:fs";
19
20
  import path from "node:path";
20
21
  import { Type } from "typebox";
21
22
  import { appendToBlock, appendToNestedArray, nextId, readBlock, readBlockDir, removeFromBlock, removeFromNestedArray, updateItemInBlock, updateNestedArrayItem, upsertItemInBlock, writeBlock, } from "./block-api.js";
22
- import { adoptConception, amendConfigEntry, loadConfig, loadContext } from "./context.js";
23
+ import { adoptConception, amendConfigEntry, loadConfig, loadRelations } from "./context.js";
23
24
  import { BootstrapNotFoundError, schemaPath, tryResolveContextDir } from "./context-dir.js";
24
- import { appendRelationByRef, appendRelationsByRef, completeTask, contextState, currentState, deriveBootstrapState, filterBlockItems, joinBlocks, readBlockItem, readBlockPage, removeRelationByRef, replaceRelationByRef, resolveItemById, resolveItemsByIds, validateContext, } from "./context-sdk.js";
25
+ import { appendRelationByRef, appendRelationsByRef, buildIdIndex, completeTask, contextState, currentState, deriveBootstrapState, endpointKey, evaluateConfigInvariants, filterBlockItems, joinBlocks, orientAppendInput, readBlockItem, readBlockPage, removeRelationByRef, replaceRelationByRef, resolveItemById, resolveItemsByIds, validateContext, } from "./context-sdk.js";
25
26
  import { gatherExecutionContext } from "./execution-context.js";
26
27
  // initProject + the switch/list/archive helpers are defined in index.ts (shared
27
28
  // with the /context command handlers + the context-* tools). This is a cyclic
@@ -29,12 +30,12 @@ import { gatherExecutionContext } from "./execution-context.js";
29
30
  // registerAll runs at extension-load time, after both modules' top-level
30
31
  // function bindings exist, and the helpers are only referenced inside op `run`
31
32
  // closures (lazy), never at this module's top level.
32
- import { archiveSubstrate, checkStatus, initProject, installContext, listSubstrates, readCatalogSchemaText, resolveBlocked, resolveConflict, switchAndCreate, switchToExisting, switchToPrevious, updateContext, validateBlockItemsAgainstCatalog, } from "./index.js";
33
+ import { archiveSubstrate, checkStatus, convergeDerivedStatusAfterWrite, initProject, installContext, listSubstrates, readCatalogSchemaText, reconcileContext, resolveBlocked, resolveConflict, switchAndCreate, switchToExisting, switchToPrevious, updateContext, validateBlockItemsAgainstCatalog, } from "./index.js";
33
34
  import { edgesForLensByName, findReferencesInRepo, loadLensView, validateContextRelations, walkAncestorsByLens, walkLensDescendants, } from "./lens-view.js";
34
35
  import { promoteItem } from "./promote-item.js";
35
36
  import { addressInto, pageArray, renderReadText, structureForRead } from "./read-element.js";
36
37
  import { renameCanonicalId } from "./rename-canonical-id.js";
37
- import { listRoadmaps, loadRoadmap, renderRoadmap, validateRoadmaps } from "./roadmap-plan.js";
38
+ import { loadRoadmap, renderRoadmap, validateRoadmap } from "./roadmap-plan.js";
38
39
  import { samplesCatalog } from "./samples-catalog.js";
39
40
  import { readSchema, writeSchemaChecked } from "./schema-write.js";
40
41
  import { truncateHead } from "./truncate.js";
@@ -115,39 +116,133 @@ export function boundedJsonOutput(r) {
115
116
  const { over, totalBytes } = overReadCap(s);
116
117
  return over ? { data: null, truncated: true, totalBytes, complete: false } : r.json;
117
118
  }
118
- // ── serializeRoadmapView ────────────────────────────────────────────────────
119
- // Strip non-serializable fields (suggestionTemplate fn, grouped Map) from the
120
- // embedded LoadedLensView records before tool serialization. Relocated verbatim
121
- // from the extension factory; consumed by the context-roadmap-load op.
122
- const serializeRoadmapView = (view) => ({
123
- roadmap: view.roadmap,
124
- phases: view.phases.map((pv) => ({
125
- phase: pv.phase,
126
- lensView: "error" in pv.lensView
127
- ? pv.lensView
128
- : {
129
- lens: pv.lensView.lens,
130
- items: pv.lensView.items,
131
- edges: pv.lensView.edges,
132
- grouped: Object.fromEntries(pv.lensView.grouped),
133
- uncategorized: pv.lensView.uncategorized,
134
- },
135
- status: pv.status,
136
- ...(pv.milestone ? { milestone: pv.milestone } : {}),
137
- ...(pv.milestoneSatisfied !== undefined ? { milestoneSatisfied: pv.milestoneSatisfied } : {}),
119
+ /**
120
+ * Coerce a filing op's optional `relations` param (may arrive as a JSON string
121
+ * from the CLI, like Type.Unknown item payloads) and shape-check each entry
122
+ * before any write happens a malformed entry must refuse the filing BEFORE
123
+ * the item lands, not mid-edge-loop. Enforces the direction/role mutual
124
+ * exclusion (exactly one per entry), mirroring the standalone porcelain's
125
+ * parent/child vs primary/counter pair exclusion.
126
+ */
127
+ function coerceBirthRelations(raw) {
128
+ let value = raw;
129
+ if (typeof value === "string") {
130
+ try {
131
+ value = JSON.parse(value);
132
+ }
133
+ catch {
134
+ throw new Error("relations parameter must be a JSON array, got unparseable string");
135
+ }
136
+ }
137
+ if (value === undefined || value === null)
138
+ return [];
139
+ if (!Array.isArray(value))
140
+ throw new Error("relations parameter must be a JSON array");
141
+ return value.map((entry, i) => {
142
+ const e = entry;
143
+ const shapeError = () => new Error(`relations[${i}] must be {relation_type: string, other: string, ordinal?: integer} plus EXACTLY ONE of direction: "as_parent"|"as_child" (raw form) or role: "primary"|"counter" (role-typed form)`);
144
+ if (e === null || typeof e !== "object" || typeof e.relation_type !== "string" || typeof e.other !== "string") {
145
+ throw shapeError();
146
+ }
147
+ const hasDirection = e.direction !== undefined;
148
+ const hasRole = e.role !== undefined;
149
+ if (hasDirection === hasRole)
150
+ throw shapeError(); // both or neither
151
+ if (hasDirection && e.direction !== "as_parent" && e.direction !== "as_child")
152
+ throw shapeError();
153
+ if (hasRole && e.role !== "primary" && e.role !== "counter")
154
+ throw shapeError();
155
+ return {
156
+ relation_type: e.relation_type,
157
+ other: e.other,
158
+ ...(hasDirection ? { direction: e.direction } : {}),
159
+ ...(hasRole ? { role: e.role } : {}),
160
+ ...(e.ordinal !== undefined ? { ordinal: e.ordinal } : {}),
161
+ };
162
+ });
163
+ }
164
+ /**
165
+ * Map one coerced birth entry to the {@link appendRelationByRef} input, with
166
+ * the new item at the entry's endpoint: the raw form fills parent/child, the
167
+ * role form fills primary/counter (the SAME role-typed pair the standalone
168
+ * append-relation flags fill), so orientAppendInput applies its mapping and
169
+ * guards verbatim — no orientation logic lives here.
170
+ */
171
+ function birthRelationToAppendInput(itemId, rel) {
172
+ const ordinalPart = rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {};
173
+ if (rel.role !== undefined) {
174
+ return {
175
+ relation_type: rel.relation_type,
176
+ ...(rel.role === "primary" ? { primary: itemId, counter: rel.other } : { primary: rel.other, counter: itemId }),
177
+ ...ordinalPart,
178
+ };
179
+ }
180
+ return {
181
+ relation_type: rel.relation_type,
182
+ parent: rel.direction === "as_parent" ? itemId : rel.other,
183
+ child: rel.direction === "as_parent" ? rel.other : itemId,
184
+ ...ordinalPart,
185
+ };
186
+ }
187
+ /**
188
+ * File a new item's birth edges through the same validated porcelain a
189
+ * standalone append-relation uses (registered-type + endpoint-kind gate,
190
+ * role-direction mapping + orientation-ambiguity guard, atomic write,
191
+ * exact-duplicate no-op), with the new item at each entry's endpoint. Runs
192
+ * AFTER the item write (its id must resolve as an endpoint) inside the same
193
+ * op run, so the write-time invariant gate judges item + edges as one
194
+ * transition atom; a throw here is byte-restored by the pipeline wrapper
195
+ * (all-or-nothing).
196
+ */
197
+ function appendBirthRelations(cwd, itemId, relations, ctx) {
198
+ for (const rel of relations) {
199
+ appendRelationByRef(cwd, birthRelationToAppendInput(itemId, rel), ctx);
200
+ }
201
+ }
202
+ /**
203
+ * Preview-parity orientation check for dryRun filings: run the SAME
204
+ * role-mapping + ambiguity guard the live edge write applies
205
+ * (orientAppendInput), against the registry only — endpoint resolution stays
206
+ * out because the item is unwritten under a preview. A preview thereby
207
+ * refuses exactly the entries the live run would orientation-refuse.
208
+ */
209
+ function assertBirthRelationsOrientable(cwd, itemId, relations) {
210
+ if (relations.length === 0)
211
+ return;
212
+ const config = loadConfig(cwd);
213
+ for (const rel of relations) {
214
+ orientAppendInput(config, birthRelationToAppendInput(itemId, rel));
215
+ }
216
+ }
217
+ /** Shared birth-relations entry schema for the filing ops (append + upsert). */
218
+ const BIRTH_RELATION_ENTRY = Type.Object({
219
+ relation_type: Type.String({ description: "Registered relation_type canonical_id" }),
220
+ direction: Type.Optional(Type.Union([Type.Literal("as_parent"), Type.Literal("as_child")], {
221
+ description: "RAW orientation — which edge endpoint the NEW item occupies. Exactly one of direction/role per entry.",
222
+ })),
223
+ role: Type.Optional(Type.Union([Type.Literal("primary"), Type.Literal("counter")], {
224
+ description: "ROLE-TYPED orientation — the semantic role the NEW item holds, mapped to parent/child via the relation's declared role_direction (same mapping as append-relation --primary/--counter). REQUIRED for role-bearing orientation-ambiguous relation_types (same-kind or wildcard endpoints), where the raw form is rejected. Exactly one of direction/role per entry.",
138
225
  })),
139
- phaseOrder: view.phaseOrder,
140
- cycles: view.cycles,
141
- edges: view.edges,
226
+ other: Type.String({ description: "Selector of the other endpoint (canonical id / <alias>:<refname>)" }),
227
+ ordinal: Type.Optional(Type.Integer({ description: "Optional sibling-ordering within (parent, relation_type)" })),
142
228
  });
143
229
  export const ops = [
144
230
  {
145
231
  name: "append-block-item",
146
232
  label: "Append Block Item",
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.",
148
- promptSnippet: "Append items to project blocks (issues, decisions, or any user-defined block)",
233
+ description: "Append an item to an array in a project block file. Schema validation is automatic. Set autoId:true to allocate " +
234
+ "the next id from the block's id pattern when the item has no id. Optional relations file the item's BIRTH edges " +
235
+ "in the same op run, after id allocation — each entry names the relation_type, the other endpoint's selector, and " +
236
+ "EXACTLY ONE orientation: direction (as_parent | as_child — the raw endpoint the new item occupies) or role " +
237
+ "(primary | counter — the semantic role the new item holds, mapped via the relation's declared role_direction; " +
238
+ "required for role-bearing orientation-ambiguous relation_types such as the gated-by / derived-from / supersedes " +
239
+ "/ depends families, where the raw form is rejected). Filing item + edges as one atom lets a new item satisfy " +
240
+ "error-severity birth-edge invariants (e.g. a decision must cite a forcing artifact) that would refuse the bare " +
241
+ "item under the write-time gate.",
242
+ promptSnippet: "Append items to project blocks (issues, decisions, or any user-defined block), with optional atomic birth edges",
149
243
  examples: [
150
244
  `pi-context append-block-item --block framework-gaps --arrayKey gaps --autoId true --item @/tmp/fgap.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
245
+ `pi-context append-block-item --block decisions --arrayKey decisions --autoId true --item @/tmp/dec.json --relations '[{"relation_type":"decision_addresses_gap","direction":"as_parent","other":"FGAP-001"},{"relation_type":"decision_derived_from_item","role":"counter","other":"TASK-001"}]' --writer '{"kind":"human","user":"you@example.com"}' --json`,
151
246
  ],
152
247
  parameters: Type.Object({
153
248
  block: Type.String({ description: "Block name (e.g., 'issues', 'decisions')" }),
@@ -156,6 +251,9 @@ export const ops = [
156
251
  autoId: Type.Optional(Type.Boolean({
157
252
  description: "When true and the item has no id, allocate the next id from the block's id pattern",
158
253
  })),
254
+ relations: Type.Optional(Type.Array(BIRTH_RELATION_ENTRY, {
255
+ description: "Birth edges filed atomically with the item, after id allocation, via the same validated append-relation porcelain (each entry oriented by direction OR role)",
256
+ })),
159
257
  }),
160
258
  surface: "use",
161
259
  run(cwd, params, ctx) {
@@ -168,17 +266,27 @@ export const ops = [
168
266
  throw new Error(`item parameter must be a JSON object, got unparseable string`);
169
267
  }
170
268
  }
269
+ const relations = coerceBirthRelations(params.relations);
171
270
  // Auto-id allocation (FGAP-084 dual-surface twin of file-block-item --auto-id)
172
271
  if (params.autoId && params.item && typeof params.item === "object" && !params.item.id) {
173
272
  params.item.id = nextId(cwd, params.block);
174
273
  }
274
+ if (relations.length > 0 && (typeof params.item?.id !== "string" || params.item.id.length === 0)) {
275
+ throw new Error("relations requires the appended item to carry an id (supply item.id or set autoId:true) — birth edges need the new item's endpoint selector");
276
+ }
175
277
  // Id-uniqueness is enforced atomically inside appendToBlock's
176
278
  // withBlockLock critical section (block-api assertAppendIdUnique) —
177
279
  // the single enforcement point. The prior racy readBlock-then-append
178
280
  // tool-layer check was removed in favour of that library guard.
179
281
  appendToBlock(cwd, params.block, params.arrayKey, params.item, ctx);
282
+ // Birth edges AFTER the item lands (its id must resolve as an endpoint),
283
+ // inside the same op run so the write-time gate judges item + edges as
284
+ // one transition atom; a failed edge throws and the pipeline wrapper
285
+ // byte-restores the whole write (all-or-nothing).
286
+ appendBirthRelations(cwd, String(params.item.id), relations, ctx);
180
287
  const id = params.item?.id ? ` '${params.item.id}'` : "";
181
- return `Appended item${id} to ${params.block}.${params.arrayKey}`;
288
+ const edges = relations.length > 0 ? ` with ${relations.length} birth relation(s)` : "";
289
+ return `Appended item${id} to ${params.block}.${params.arrayKey}${edges}`;
182
290
  },
183
291
  },
184
292
  {
@@ -211,17 +319,30 @@ export const ops = [
211
319
  {
212
320
  name: "append-relation",
213
321
  label: "Append Relation",
214
- description: "Append a closure-table relation (edge: parent, child, relation_type, optional ordinal) to relations.json. " +
215
- "Shape is AJV-validated; an exact-duplicate edge (same parent+child+relation_type) is a no-op. Reference " +
216
- "integrity (endpoints resolve, relation_type registered, no cycle) is NOT checked here run context-validate " +
217
- "after. Creates relations.json if absent.",
218
- promptSnippet: "Create a relation/edge between two items (parentchild under a relation_type)",
322
+ description: "Append a closure-table relation (edge: relation_type, optional ordinal) to relations.json. Orient the edge with " +
323
+ "EITHER raw --parent/--child OR the role-typed --primary/--counter (which maps to parent/child via the relation's " +
324
+ "declared role_direction); the two pairs are mutually exclusive. A bare --parent/--child append of a relation that " +
325
+ "is BOTH role-bearing and orientation-ambiguous (its source/target kinds overlap) is rejected — re-issue with " +
326
+ "--primary/--counter. Shape is AJV-validated; an exact-duplicate edge (same parent+child+relation_type) is a no-op. " +
327
+ "Reference integrity (endpoints resolve, relation_type registered, no cycle) is NOT checked here — run " +
328
+ "context-validate after. Creates relations.json if absent.",
329
+ promptSnippet: "Create a relation/edge between two items (raw --parent/--child, or role-typed --primary/--counter mapped via role_direction)",
219
330
  examples: [
220
331
  `pi-context append-relation --parent VER-001 --child TASK-001 --relation_type verification_verifies_item --writer '{"kind":"human","user":"you@example.com"}' --json`,
221
332
  ],
222
333
  parameters: Type.Object({
223
- parent: Type.String({ description: "Canonical id (or lens bin name) of the parent endpoint" }),
224
- child: Type.String({ description: "Canonical id of the child endpoint" }),
334
+ parent: Type.Optional(Type.String({
335
+ description: "Parent-endpoint selector (canonical id / <alias>:<refname> / lens bin) — RAW orientation. Mutually exclusive with --primary/--counter.",
336
+ })),
337
+ child: Type.Optional(Type.String({
338
+ description: "Child-endpoint selector — RAW orientation. Mutually exclusive with --primary/--counter.",
339
+ })),
340
+ primary: Type.Optional(Type.String({
341
+ description: "Selector of the endpoint holding the relation's PRIMARY semantic role (ROLE-TYPED orientation; mapped to parent/child via the relation's declared role_direction). Requires --counter; the relation_type must declare role_direction.",
342
+ })),
343
+ counter: Type.Optional(Type.String({
344
+ description: "Selector of the endpoint holding the relation's COUNTER role (ROLE-TYPED orientation). Requires --primary.",
345
+ })),
225
346
  relation_type: Type.String({
226
347
  description: "Registered relation_type canonical_id / hierarchy edge type / lens id",
227
348
  }),
@@ -231,26 +352,32 @@ export const ops = [
231
352
  surface: "use",
232
353
  run(cwd, params, ctx) {
233
354
  // Cycle-5 porcelain: STRING selectors (bare refname / <alias>:<refname> /
234
- // lens-bin) are resolved to structured EdgeEndpoints and written via the
235
- // raw plumbing. The param surface stays string-typed; messaging uses the
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).
239
- const { appended } = appendRelationByRef(cwd, {
240
- parent: params.parent,
241
- child: params.child,
355
+ // lens-bin) are resolved to structured EdgeEndpoints and written via the raw
356
+ // plumbing. The append accepts EITHER raw --parent/--child OR the role-typed
357
+ // --primary/--counter form (FGAP-113); messaging renders the RESOLVED stored
358
+ // orientation (endpointKey of the returned edge), so a role-typed call reports
359
+ // the parent/child it actually filed. Under dryRun the byRef fn validates the
360
+ // prospective relations + dedup-checks without writing (TASK-010 shared
361
+ // preview path).
362
+ const { appended, edge } = appendRelationByRef(cwd, {
363
+ ...(params.parent !== undefined ? { parent: params.parent } : {}),
364
+ ...(params.child !== undefined ? { child: params.child } : {}),
365
+ ...(params.primary !== undefined ? { primary: params.primary } : {}),
366
+ ...(params.counter !== undefined ? { counter: params.counter } : {}),
242
367
  relation_type: params.relation_type,
243
368
  ...(params.ordinal !== undefined ? { ordinal: params.ordinal } : {}),
244
369
  }, ctx, { dryRun: params.dryRun });
370
+ const from = endpointKey(edge.parent);
371
+ const to = endpointKey(edge.child);
245
372
  const ordinalNote = params.ordinal !== undefined ? ` (ordinal ${params.ordinal})` : "";
246
373
  if (params.dryRun) {
247
374
  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}`;
375
+ ? `would append relation ${from} -[${params.relation_type}]-> ${to}${ordinalNote}`
376
+ : `would no-op (duplicate): relation ${from} -[${params.relation_type}]-> ${to}`;
250
377
  }
251
378
  return appended
252
- ? `Appended relation ${params.parent} -[${params.relation_type}]-> ${params.child}${ordinalNote}`
253
- : `Relation ${params.parent} -[${params.relation_type}]-> ${params.child} already exists — no-op`;
379
+ ? `Appended relation ${from} -[${params.relation_type}]-> ${to}${ordinalNote}`
380
+ : `Relation ${from} -[${params.relation_type}]-> ${to} already exists — no-op`;
254
381
  },
255
382
  },
256
383
  {
@@ -296,7 +423,9 @@ export const ops = [
296
423
  description: "Atomically replace one closure-table relation with another in a SINGLE write (no half-state: the old edge and " +
297
424
  "the new edge never coexist on disk). The old edge is matched on the (parent, child, relation_type) dedup identity; " +
298
425
  "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.",
426
+ "the new edge. This op takes RAW parent/child (old + new) and BYPASSES the write-time orientation gate that " +
427
+ "append-relation applies — it writes the endpoints verbatim, so it is the affordance for re-orienting an existing " +
428
+ "edge; reference integrity is NOT checked here — run context-validate after.",
300
429
  promptSnippet: "Atomically swap one relation/edge for another in a single write",
301
430
  examples: [
302
431
  `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`,
@@ -355,17 +484,20 @@ export const ops = [
355
484
  {
356
485
  name: "append-relations",
357
486
  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",
487
+ description: "Append MANY closure-table relations to relations.json in a single write. Each edge is an object with " +
488
+ "{ relation_type, ordinal? } plus EITHER a raw { parent, child } pair OR the role-typed { primary, counter } pair " +
489
+ "(mapped to parent/child via the relation's declared role_direction); the two pairs are mutually exclusive per edge, " +
490
+ "and a bare { parent, child } for an orientation-ambiguous role-bearing relation rejects the whole batch before any " +
491
+ "write. Per-(parent, child, relation_type) duplicates are skipped (against on-disk edges AND earlier edges in the " +
492
+ "same batch). Returns appended/skipped counts. Reference integrity is NOT checked here — run context-validate " +
493
+ "after. Creates relations.json if absent.",
494
+ promptSnippet: "Create many relations/edges between items in one write (raw or role-typed per edge)",
363
495
  examples: [
364
496
  `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
497
  ],
366
498
  parameters: Type.Object({
367
499
  edges: Type.Unknown({
368
- description: "JSON array of { parent, child, relation_type, ordinal? } selector objects (parent/child are id/lens-bin selectors)",
500
+ description: "JSON array of edge objects. Each edge is { relation_type, ordinal? } plus EITHER a raw { parent, child } pair OR the role-typed { primary, counter } pair (mapped to parent/child via the relation's declared role_direction); the two orientation pairs are mutually exclusive per edge. Selectors are id / <alias>:<refname> / lens-bin.",
369
501
  }),
370
502
  dryRun: Type.Optional(Type.Boolean({ description: "Preview without writing relations.json" })),
371
503
  }),
@@ -382,12 +514,16 @@ export const ops = [
382
514
  }
383
515
  }
384
516
  if (!Array.isArray(edges)) {
385
- throw new Error(`edges parameter must be a JSON array of { parent, child, relation_type, ordinal? } objects`);
517
+ throw new Error(`edges parameter must be a JSON array of relation edge objects`);
386
518
  }
387
519
  // Under dryRun the byRef fn replays the on-disk + in-batch dedup and
388
520
  // validates the prospective relations without writing (TASK-010 shared
389
- // preview path).
390
- const { appended, skipped } = appendRelationsByRef(cwd, edges, ctx, { dryRun: params.dryRun });
521
+ // preview path). Each edge accepts raw {parent,child} or role-typed
522
+ // {primary,counter} (FGAP-113); orientation + the ambiguous-bare-append
523
+ // reject are applied inside appendRelationsByRef before any write.
524
+ const { appended, skipped } = appendRelationsByRef(cwd, edges, ctx, {
525
+ dryRun: params.dryRun,
526
+ });
391
527
  return params.dryRun
392
528
  ? `would append ${appended}, skip ${skipped} (duplicates)`
393
529
  : `appended ${appended}, skipped ${skipped} (duplicates)`;
@@ -398,8 +534,16 @@ export const ops = [
398
534
  label: "Upsert Block Item",
399
535
  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
536
  "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)",
537
+ "appended. Schema validation is automatic. idField defaults to 'id'. Optional relations file BIRTH edges in the " +
538
+ "same op run when the upsert resolves to an APPEND — each entry names the relation_type, the other endpoint's " +
539
+ "selector, and EXACTLY ONE orientation: direction (as_parent | as_child, raw) or role (primary | counter, mapped " +
540
+ "via the relation's declared role_direction; required for role-bearing orientation-ambiguous relation_types) — " +
541
+ "one atom under the write-time gate, so a new filing can satisfy error-severity birth-edge invariants. dryRun " +
542
+ "previews the upsert AND runs the same orientation guard over the entries (a preview refuses what the live run " +
543
+ "would orientation-refuse; endpoint resolution stays out — the item is unwritten). When the upsert resolves to a " +
544
+ "REPLACE, supplying relations refuses the write (birth edges are for new items; file edges on an existing item " +
545
+ "via append-relation).",
546
+ promptSnippet: "Append-or-replace a full block item by id (replacement, not merge), with optional atomic birth edges",
403
547
  examples: [
404
548
  `pi-context upsert-block-item --block tasks --arrayKey tasks --item @/tmp/task.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
405
549
  ],
@@ -409,6 +553,9 @@ export const ops = [
409
553
  item: Type.Unknown({ description: "Full item object to upsert — must conform to block schema" }),
410
554
  idField: Type.Optional(Type.String({ description: "Field used as the upsert key (default 'id')" })),
411
555
  dryRun: Type.Optional(Type.Boolean({ description: "Preview the upsert without writing" })),
556
+ relations: Type.Optional(Type.Array(BIRTH_RELATION_ENTRY, {
557
+ description: "Birth edges filed atomically with an APPEND-mode upsert, each entry oriented by direction OR role (refused on replace mode — use append-relation for existing items)",
558
+ })),
412
559
  }),
413
560
  surface: "use",
414
561
  run(cwd, params, ctx) {
@@ -421,17 +568,36 @@ export const ops = [
421
568
  throw new Error(`item parameter must be a JSON object, got unparseable string`);
422
569
  }
423
570
  }
571
+ const relations = coerceBirthRelations(params.relations);
424
572
  const idField = params.idField ?? "id";
573
+ const idVal = params.item?.[idField];
574
+ if (relations.length > 0 && (typeof idVal !== "string" || idVal.length === 0)) {
575
+ throw new Error(`relations requires the upserted item to carry a string '${idField}' — birth edges need the new item's endpoint selector`);
576
+ }
425
577
  // Under dryRun upsertItemInBlock computes mode + builds + validates the prospective
426
578
  // whole block, writing nothing (TASK-011 shared preview path).
427
579
  const { mode } = upsertItemInBlock(cwd, params.block, params.arrayKey, params.item, idField, ctx, {
428
580
  dryRun: params.dryRun,
429
581
  });
430
- const idVal = params.item?.[idField];
582
+ if (relations.length > 0 && mode === "updated") {
583
+ // Birth edges are a NEW-item affordance. Throwing here (post-write) is
584
+ // safe: the pipeline wrapper byte-restores the substrate on inner-op
585
+ // throw, so the replacement never persists (all-or-nothing).
586
+ throw new Error(`relations was supplied but the upsert resolved to REPLACE for '${idVal}' — birth edges are for new items; file edges on an existing item via append-relation`);
587
+ }
431
588
  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}`;
589
+ if (params.dryRun) {
590
+ // Preview parity: run the SAME role-mapping + orientation-ambiguity
591
+ // guard the live edge write applies, against the registry only — the
592
+ // item is unwritten, so endpoint resolution stays out. A preview
593
+ // refuses exactly the entries the live run would orientation-refuse.
594
+ assertBirthRelationsOrientable(cwd, String(idVal), relations);
595
+ const edgePreview = relations.length > 0 ? `; would file ${relations.length} birth relation(s)` : "";
596
+ return `would upsert item${idDesc} (${mode}) in ${params.block}.${params.arrayKey}${edgePreview}`;
597
+ }
598
+ appendBirthRelations(cwd, String(idVal), relations, ctx);
599
+ const edges = relations.length > 0 ? ` with ${relations.length} birth relation(s)` : "";
600
+ return `Upserted item${idDesc} (${mode}) to ${params.block}.${params.arrayKey}${edges}`;
435
601
  },
436
602
  },
437
603
  {
@@ -680,7 +846,7 @@ export const ops = [
680
846
  {
681
847
  name: "context-check-status",
682
848
  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.",
849
+ 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. Like every substrate-lifecycle ceremony it seeds the catalog's config migration declarations into migrations.json (idempotent) before its first config read, so a version-lagging legacy substrate is diagnosable; beyond that seed it writes nothing.",
684
850
  promptSnippet: "Report installed-vs-catalog schema drift + the version gap for behind schemas (read-only)",
685
851
  examples: [`pi-context context-check-status --json`],
686
852
  parameters: Type.Object({}),
@@ -868,8 +1034,8 @@ export const ops = [
868
1034
  {
869
1035
  name: "context-current-state",
870
1036
  label: "Context Current State",
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.",
872
- promptSnippet: "Derive current project state — focus, in-flight, next actions, blocked",
1037
+ description: "Derive 'where are we + what's next' purely from the substrate — focus, in-flight items, ranked atomic-next actions, blocked items, and milestone rollups. Every facet derives from the config-declared `state_derivation` registry: which block kinds + status bucket count as in-flight, the focus fallback kind + bucket, the ordered cross-kind next-actions push order with per-entry ranking (a named field + ordered value list, e.g. gap priority P0..P3) or topo ordering over the blocking-relation graph, the relation_types whose edges contribute blockers (the stock set being `task_depends_on_task` dependencies + `task_gated_by_item` gates), the membership rollups (e.g. milestones over `phase_positioned_in_milestone`) with their complete/incomplete status strings, and the next-actions head-size cap. A blocked item's dependency/gate target that is not complete is reported in blockedBy and held out of nextActions; completeness follows the target kind's truth model — a rollup-declared kind (state_derivation.rollups, e.g. milestone) completes by its DERIVED membership rollup, the same verdict the milestones facet reports, so one read never self-contradicts and a derived-status kind's stored status field is never consulted; every other kind completes by its status bucketing to complete. A substrate whose config declares no `state_derivation` reports focus 'state-derivation not configured' with empty arrays. No writes; nothing hand-stored.",
1038
+ promptSnippet: "Derive current project state from the config-declared state_derivation registry — focus, in-flight, ranked next actions, blocked, milestone rollups",
873
1039
  examples: [`pi-context context-current-state --json`],
874
1040
  parameters: Type.Object({}),
875
1041
  surface: "use",
@@ -1062,7 +1228,7 @@ export const ops = [
1062
1228
  {
1063
1229
  name: "resolve-blocked",
1064
1230
  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.",
1231
+ 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. The commit is all-or-nothing: a throw partway through it restores every touched file byte-exact — migrations.json, the installed schema, the block file, config.json, and the pending record — and reports the failure, never a partial commit. On a substrate whose config carries no substrate_id, resolve-blocked establishes the identity at entry (mints, persists, registers) before the commit's stamping write and reports it under substrateIdEstablished.",
1066
1232
  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
1233
  examples: [`pi-context resolve-blocked --schemaName tasks --yes --json`],
1068
1234
  parameters: Type.Object({
@@ -1079,7 +1245,7 @@ export const ops = [
1079
1245
  {
1080
1246
  name: "write-schema-migration",
1081
1247
  label: "Write Schema Migration",
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.",
1248
+ 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/map_each operations on dotted JSON paths; map_each addresses an array — table mode maps each string element through a lookup (unmatched elements become {relation_type, item_endpoint} with parent/child fallback), set-on-each mode sets a field on every object element. 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.",
1083
1249
  promptSnippet: "Declare a schema version-bump migration (identity or declarative-transform) into migrations.json",
1084
1250
  examples: [
1085
1251
  `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`,
@@ -1156,7 +1322,7 @@ export const ops = [
1156
1322
  {
1157
1323
  name: "context-install",
1158
1324
  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.",
1325
+ 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. On a substrate whose config carries no substrate_id, install establishes the identity at entry (mints, persists to config.json, registers in the project registry) and reports it under substrateIdEstablished; an established identity is never re-minted.",
1160
1326
  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
1327
  examples: ["pi-context context-install --json", "pi-context context-install --update true --json"],
1162
1328
  parameters: Type.Object({
@@ -1176,8 +1342,8 @@ export const ops = [
1176
1342
  {
1177
1343
  name: "update",
1178
1344
  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)",
1345
+ 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) vs write-failed (a non-validation throw at the write boundary, e.g. the block writer's duplicate-item-id guard; the failures entry carries the thrown message, the items were NOT flagged invalid, and no markers or pending-blocked record are produced) — 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 beyond the idempotent ceremony seed of the catalog's config migration declarations into migrations.json (every substrate-lifecycle ceremony seeds at entry, before its first config read, so a version-lagging legacy substrate heals instead of throwing). 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. Because update applies per-component (a blocked schema rolls back only itself; the additive registry propagation writes regardless), a run that refuses any schema while applying registry additions or other-schema resyncs/migrations/merges reports the partiality under partialApplication — applied and notApplied channel mirrors plus a one-line summary naming what was applied alongside what was refused and why — so a blocked run never reads as a no-op; dryRun reports the predicted partiality in the same shape. On a substrate whose config carries no substrate_id, a LIVE update establishes the identity at entry (mints, persists to config.json, registers in the project registry) before its first identity-stamping write — so a pre-identity substrate heals on the ceremony instead of refusing — and reports it under substrateIdEstablished; an established identity is never re-minted, and dryRun (no stamping writes) establishes nothing.",
1346
+ 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 (no-migration-chain / validation-failed / write-failed for a non-validation write-boundary throw), version pair, per-item failures — and a validation-failed block 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; a run that refuses any schema while applying registry additions or other-schema updates surfaces the partiality under partialApplication with a one-line summary, so a blocked run never reads as a no-op)",
1181
1347
  examples: [`pi-context update --dryRun true --json`],
1182
1348
  parameters: Type.Object({
1183
1349
  dryRun: Type.Optional(Type.Boolean({ description: "Preview the per-schema action plan without writing anything." })),
@@ -1190,6 +1356,24 @@ export const ops = [
1190
1356
  return { json: result };
1191
1357
  },
1192
1358
  },
1359
+ {
1360
+ name: "context-reconcile",
1361
+ label: "Context Reconcile",
1362
+ description: "Converge stored substrate state with its derivation (the repair half of the derived-status invariant class). For every block kind a derived-status invariant declares (paired with its state_derivation.rollups entry), computes each item's stored-vs-derived status delta using the SAME completeness helper the state derivation's gate satisfaction and context-validate use — the preview, the detector, and the repair cannot disagree. The sweep also includes declared-baseline STALENESS: every stale_conditions-bearing item whose status buckets complete and whose typed condition fired (item-status / file-changed / revision-moved) or whose citation content pin drifted transitions to stale — the same evaluateStalenessCandidates verdict context-validate flags with; flag-only anchor-drift items never transition. --dryRun returns the exact delta + transition sets a live run would apply (deltas: id, block, from stored value, to derived value, declaring invariant; stalenessTransitions: id, block, from, to stale, firing reasons), writing nothing. A live run applies exactly those sets through the standard validated write path — identity-stamped, envelope-stamped, attested to the invoking writer — and reports the applied counts; a converge-write is not authoring, the written value IS the derivation, and the stale transition applies a condition the item itself declared. Scope: derived-status deltas + declared-staleness transitions ONLY — the op never writes an authored-status kind (feature/gap/issue/task buckets are human judgment) and never touches prose; those classes are flagged for review by context-validate, not auto-repaired. Ceremony discipline: seeds the catalog config migration declarations at entry, and a live run on a substrate with no substrate_id establishes the identity first (reported under substrateIdEstablished). A converged substrate is a clean no-op both ways.",
1363
+ promptSnippet: "Converge stored rollup-kind statuses with their derivation and apply declared complete-to-stale transitions (--dryRun previews the exact sets; live applies through the validated write path; never touches authored statuses or prose)",
1364
+ examples: [`pi-context context-reconcile --dryRun true --json`],
1365
+ parameters: Type.Object({
1366
+ dryRun: Type.Optional(Type.Boolean({ description: "Preview the exact delta set without writing anything." })),
1367
+ }),
1368
+ surface: "use",
1369
+ authGated: true,
1370
+ run(cwd, params, ctx) {
1371
+ const result = reconcileContext(cwd, { dryRun: params.dryRun === true }, ctx);
1372
+ if (result.error)
1373
+ return result.error;
1374
+ return { json: result };
1375
+ },
1376
+ },
1193
1377
  {
1194
1378
  name: "validate-block-items",
1195
1379
  label: "Validate Block Items",
@@ -1467,21 +1651,26 @@ export const ops = [
1467
1651
  {
1468
1652
  name: "complete-task",
1469
1653
  label: "Complete Task",
1470
- description: "Complete a task with verification gate — requires a passing verification entry targeting the task.",
1471
- promptSnippet: "Complete a taskgates on passing verification before updating status",
1654
+ description: "Complete a task with verification gate — the closure ATOM. Requires a passing verification entry, then FILES " +
1655
+ "the verification_verifies_item edge itself (idempotent a pre-existing exact edge is a no-op) and flips the " +
1656
+ "task status to completed in one op run, so the write-time invariant gate judges the joint end-state. No prior " +
1657
+ "append-relation step is needed (a standalone edge or status write would be refused by error-severity closure " +
1658
+ "invariants; this op IS the legal transition).",
1659
+ promptSnippet: "Complete a task — gates on passing verification, files the verification edge itself, then flips status (one atom)",
1472
1660
  examples: [
1473
1661
  `pi-context complete-task --taskId TASK-001 --verificationId VER-001 --writer '{"kind":"human","user":"you@example.com"}' --json`,
1474
1662
  ],
1475
1663
  parameters: Type.Object({
1476
1664
  taskId: Type.String({ description: "Task ID to complete" }),
1477
1665
  verificationId: Type.String({
1478
- description: "Verification entry ID (must target this task with status 'passed')",
1666
+ description: "Verification entry ID (must have status 'passed'; the op files the linking edge itself)",
1479
1667
  }),
1480
1668
  }),
1481
1669
  surface: "use",
1482
1670
  run(cwd, params, ctx) {
1483
1671
  const result = completeTask(cwd, params.taskId, params.verificationId, ctx);
1484
- return `Task '${result.taskId}' completed (was '${result.previousStatus}'). Verification: ${result.verificationId} (${result.verificationStatus})`;
1672
+ const edge = result.edgeAppended ? "edge filed" : "edge pre-existing";
1673
+ return `Task '${result.taskId}' completed (was '${result.previousStatus}'). Verification: ${result.verificationId} (${result.verificationStatus}, ${edge})`;
1485
1674
  },
1486
1675
  },
1487
1676
  {
@@ -1553,7 +1742,7 @@ export const ops = [
1553
1742
  {
1554
1743
  name: "context-walk-descendants",
1555
1744
  label: "Context Walk Descendants",
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).",
1745
+ 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). For a DISJOINT-kind relation, querying from the wrong (target-kind) end THROWS naming walk-ancestors instead of silently returning []; same-kind / wildcard relations return [] honestly.",
1557
1746
  promptSnippet: "Walk closure-table descendants under a relation_type",
1558
1747
  examples: [
1559
1748
  `pi-context context-walk-descendants --parentId FEAT-008 --relationType feature_decomposed_into_task --json`,
@@ -1571,7 +1760,7 @@ export const ops = [
1571
1760
  {
1572
1761
  name: "walk-ancestors",
1573
1762
  label: "Walk Ancestors",
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).",
1763
+ 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). For a DISJOINT-kind relation, querying from the wrong (source-kind) end THROWS naming context-walk-descendants instead of silently returning []; same-kind / wildcard relations return [] honestly.",
1575
1764
  promptSnippet: "Walk closure-table ancestors under a relation_type",
1576
1765
  examples: [`pi-context walk-ancestors --itemId TASK-042 --relationType feature_decomposed_into_task --json`],
1577
1766
  parameters: Type.Object({
@@ -1632,75 +1821,45 @@ export const ops = [
1632
1821
  {
1633
1822
  name: "context-roadmap-load",
1634
1823
  label: "Context: load roadmap",
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'.",
1636
- promptSnippet: "Load a roadmap by id",
1637
- examples: [`pi-context context-roadmap-load --roadmapId ROADMAP-001 --json`],
1638
- parameters: Type.Object({
1639
- roadmapId: Type.String({ description: "ROADMAP-NNN id from <config.root>/roadmap.json" }),
1640
- }),
1824
+ description: "Load the derived roadmap view over the milestone_precedes_milestone DAG: milestone-block items topo-ordered by the authored precedes edges (order + cycles), each milestone carrying its derived status/phaseCount (currentState's milestone rollup), its member phases (parents of phase_positioned_in_milestone edges), each phase's tasks (parents of task_positioned_in_phase edges), and per-phase + per-milestone status rollups. Adjacency comes strictly from the authored edges — never inferred from order. Zero milestones is a valid empty view.",
1825
+ promptSnippet: "Load the derived milestone roadmap view",
1826
+ examples: [`pi-context context-roadmap-load --json`],
1827
+ parameters: Type.Object({}),
1641
1828
  surface: "use",
1642
- run(cwd, params) {
1643
- const view = loadRoadmap(cwd, params.roadmapId);
1644
- if ("error" in view) {
1645
- const read = structureForRead(view, { whole: true, label: `roadmap ${params.roadmapId} (error)` });
1646
- return { read };
1647
- }
1648
- // whole: a structured RoadmapView (phases + lens-views + rollups) — keep
1649
- // the view shape intact rather than paging an inner array.
1650
- const read = structureForRead(serializeRoadmapView(view), {
1651
- whole: true,
1652
- label: `roadmap ${params.roadmapId}`,
1653
- });
1829
+ run(cwd, _params) {
1830
+ const view = loadRoadmap(cwd);
1831
+ // whole: a structured MilestoneRoadmapView (milestones + rollups + edges)
1832
+ // keep the view shape intact rather than paging an inner array.
1833
+ const read = structureForRead(view, { whole: true, label: "roadmap (derived)" });
1654
1834
  return { read };
1655
1835
  },
1656
1836
  },
1657
1837
  {
1658
1838
  name: "context-roadmap-render",
1659
1839
  label: "Context: render roadmap",
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.",
1661
- promptSnippet: "Render a roadmap as markdown",
1662
- examples: [`pi-context context-roadmap-render --roadmapId ROADMAP-001`],
1663
- parameters: Type.Object({
1664
- roadmapId: Type.String({ description: "ROADMAP-NNN id from <config.root>/roadmap.json" }),
1665
- }),
1840
+ description: "Render the derived roadmap as pure-textual markdown — milestone order list (topo over the authored milestone_precedes_milestone edges), per-milestone sections with **Preceded by:** adjacency lines sourced strictly from those edges (alphabetically sorted; '—' when none), per-milestone rollup counts, and per-phase task tables. Cycle participants surface under a separate heading with a Cycles-detected line. NO mermaid / graph syntax; adjacency is never inferred from order consecutive pairs.",
1841
+ promptSnippet: "Render the derived milestone roadmap as markdown",
1842
+ examples: [`pi-context context-roadmap-render`],
1843
+ parameters: Type.Object({}),
1666
1844
  surface: "use",
1667
- run(cwd, params) {
1668
- const view = loadRoadmap(cwd, params.roadmapId);
1845
+ run(cwd, _params) {
1846
+ const view = loadRoadmap(cwd);
1669
1847
  if ("error" in view) {
1670
1848
  return { json: view };
1671
1849
  }
1672
- const naming = loadContext(cwd).config?.naming;
1673
- return renderRoadmap(view, naming);
1850
+ return renderRoadmap(view);
1674
1851
  },
1675
1852
  },
1676
1853
  {
1677
1854
  name: "context-roadmap-validate",
1678
- label: "Context: validate roadmap(s)",
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.",
1680
- promptSnippet: "Validate roadmaps",
1681
- examples: [`pi-context context-roadmap-validate --roadmapId ROADMAP-001 --json`],
1682
- parameters: Type.Object({
1683
- roadmapId: Type.Optional(Type.String({ description: "Filter to issues matching this roadmap_id (omit for full-project validation)" })),
1684
- }),
1685
- surface: "use",
1686
- run(cwd, params) {
1687
- const result = validateRoadmaps(cwd);
1688
- const filtered = params.roadmapId
1689
- ? result.issues.filter((i) => !i.roadmap_id || i.roadmap_id === params.roadmapId)
1690
- : result.issues;
1691
- return { json: { status: result.status, issues: filtered } };
1692
- },
1693
- },
1694
- {
1695
- name: "context-roadmap-list",
1696
- label: "Context: list roadmaps",
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).",
1698
- promptSnippet: "List roadmaps",
1699
- examples: [`pi-context context-roadmap-list --json`],
1855
+ label: "Context: validate roadmap",
1856
+ description: "Validate the derived roadmap over the milestone_precedes_milestone edges. Error codes: roadmap_precedes_endpoint_missing (a precedes-edge endpoint that is not a milestone-block item), roadmap_milestone_cycle (a cycle in the precedes graph), roadmap_milestone_missing (a phase_positioned_in_milestone edge whose child is not a known milestone). Warning: roadmap_status_unknown_value (a member phase whose task rollup buckets unknown with items present). Info: roadmap_milestone_isolated (a milestone with zero precedes edges while others are ordered) — info never affects status: invalid iff any error-code issue, warnings iff any warning-code issue, else clean. Display strings flow through config.display_strings (pi-context divergence).",
1857
+ promptSnippet: "Validate the derived milestone roadmap",
1858
+ examples: [`pi-context context-roadmap-validate --json`],
1700
1859
  parameters: Type.Object({}),
1701
1860
  surface: "use",
1702
1861
  run(cwd, _params) {
1703
- return { json: listRoadmaps(cwd) };
1862
+ return { json: validateRoadmap(cwd) };
1704
1863
  },
1705
1864
  },
1706
1865
  ];
@@ -1735,6 +1894,10 @@ export const gatedTools = ops.filter((o) => o.authGated).map((o) => o.name);
1735
1894
  * covered when its cwd-form sibling is covered.
1736
1895
  */
1737
1896
  export const INTENTIONALLY_UNEXPOSED_WRITERS = [
1897
+ {
1898
+ libraryFn: "convergeDerivedStatusAfterWrite",
1899
+ reason: "post-write convergence hook, invoked by the CONVERGE_AFTER_OPS module-init wrapper around every rollup-input-mutating op's run() — op-backed transitively at runtime, but the static classifier cannot see a wrapper-installed call; its explicit ceremony surface is context-reconcile",
1900
+ },
1738
1901
  { libraryFn: "writeConfig", safeOp: "amend-config", reason: "scoped guarded config mutation" },
1739
1902
  { libraryFn: "writeSchema", safeOp: "write-schema", reason: "raw bypasses the create/replace + migration check" },
1740
1903
  { libraryFn: "updateSchema", safeOp: "write-schema", reason: "no mutator-scripting surface" },
@@ -1880,6 +2043,187 @@ export function buildDispatchContextFromExecute(params, extCtx) {
1880
2043
  const modelId = extCtx.model?.id;
1881
2044
  return { writer: { kind: "agent", agent_id: modelId && modelId.length > 0 ? modelId : "pi-agent" } };
1882
2045
  }
2046
+ /**
2047
+ * Converge-on-write (FEAT-011 criterion 2 — FGAP-116): the sanctioned mutating
2048
+ * ops that can change a rollup INPUT (a member item's status; a membership
2049
+ * edge; an id every edge keys on) run the derived-status convergence hook
2050
+ * AFTER their own write lands — so every op-surface write leaves rollup-kind
2051
+ * stored statuses equal to their derivation. The hook is config-driven
2052
+ * opt-in (no derived-status invariant → empty set → no writes) and
2053
+ * best-effort (its failure never fails the landed write; the invariant +
2054
+ * context-reconcile remain the net — see convergeDerivedStatusAfterWrite).
2055
+ * Nested-item ops are excluded: item statuses are top-level fields, so a
2056
+ * nested write cannot change a rollup input. Wrapping happens ONCE here at
2057
+ * module init, over the ops DATA — no per-op body edits, no dispatch-path
2058
+ * fork; both consumers (registerAll pi tools + the reflecting CLI) execute
2059
+ * the wrapped run.
2060
+ */
2061
+ const CONVERGE_AFTER_OPS = new Set([
2062
+ "append-block-item",
2063
+ "upsert-block-item",
2064
+ "update-block-item",
2065
+ "remove-block-item",
2066
+ "write-block",
2067
+ "complete-task",
2068
+ "append-relation",
2069
+ "append-relations",
2070
+ "remove-relation",
2071
+ "replace-relation",
2072
+ "rename-canonical-id",
2073
+ "promote-item",
2074
+ ]);
2075
+ /**
2076
+ * Evaluate every config invariant against the substrate's current state,
2077
+ * keyed per violation instance (`code|field`) for delta comparison. Returns
2078
+ * null when the substrate is unreadable or declares no invariants — the gate
2079
+ * is config-driven opt-in and NEVER breaks a write on legacy/undeclared
2080
+ * substrates. Uses the SAME evaluateConfigInvariants path validateContext
2081
+ * runs, so write-side and validate-side verdicts are identical (TASK-062
2082
+ * lift pattern).
2083
+ */
2084
+ function invariantSnapshot(cwd) {
2085
+ try {
2086
+ const config = loadConfig(cwd);
2087
+ if (!config || (config.invariants ?? []).length === 0)
2088
+ return null;
2089
+ const index = buildIdIndex(cwd);
2090
+ const relations = loadRelations(cwd);
2091
+ const issues = evaluateConfigInvariants(cwd, config, index, relations);
2092
+ return new Map(issues.map((i) => [`${i.code}|${i.field ?? ""}`, { severity: i.severity, message: i.message }]));
2093
+ }
2094
+ catch {
2095
+ return null;
2096
+ }
2097
+ }
2098
+ /** Snapshot every top-level substrate *.json for a byte-exact refusal restore. */
2099
+ function substrateJsonSnapshot(cwd) {
2100
+ try {
2101
+ const dir = tryResolveContextDir(cwd);
2102
+ if (dir === null)
2103
+ return null;
2104
+ const files = new Map();
2105
+ for (const name of fs.readdirSync(dir)) {
2106
+ if (!name.endsWith(".json"))
2107
+ continue;
2108
+ files.set(name, fs.readFileSync(path.join(dir, name)));
2109
+ }
2110
+ return { dir, files };
2111
+ }
2112
+ catch {
2113
+ return null;
2114
+ }
2115
+ }
2116
+ function restoreSubstrateJson(snapshot) {
2117
+ for (const name of fs.readdirSync(snapshot.dir)) {
2118
+ if (!name.endsWith(".json"))
2119
+ continue;
2120
+ if (!snapshot.files.has(name))
2121
+ fs.unlinkSync(path.join(snapshot.dir, name));
2122
+ }
2123
+ for (const [name, bytes] of snapshot.files) {
2124
+ fs.writeFileSync(path.join(snapshot.dir, name), bytes);
2125
+ }
2126
+ }
2127
+ /** Attach write-side warnings to an op result without disturbing its shape class. */
2128
+ function attachWriteWarnings(result, warnings) {
2129
+ if (typeof result === "string") {
2130
+ return `${result}\n${warnings.map((w) => `write-warning: ${w}`).join("\n")}`;
2131
+ }
2132
+ if (result !== null && typeof result === "object" && "json" in result) {
2133
+ const jsonValue = result.json;
2134
+ if (jsonValue !== null && typeof jsonValue === "object" && !Array.isArray(jsonValue)) {
2135
+ return { ...result, json: { ...jsonValue, writeWarnings: warnings } };
2136
+ }
2137
+ }
2138
+ return result;
2139
+ }
2140
+ /**
2141
+ * The write pipeline (FEAT-011 criteria 2 + 5 — one module-init pass over the
2142
+ * ops DATA; both consumers — registerAll pi tools and the reflecting CLI —
2143
+ * execute the wrapped run):
2144
+ *
2145
+ * 1. CONVERGE-ON-WRITE: after the op's write lands, rollup-kind stored
2146
+ * statuses are converged with their derivation (config-driven opt-in;
2147
+ * best-effort — see convergeDerivedStatusAfterWrite). Runs BEFORE the
2148
+ * gate evaluation so the gate judges the final converged state and a
2149
+ * divergence the convergence itself repairs is never flagged.
2150
+ * 2. DELTA-SCOPED WRITE-TIME INVARIANT GATE: the config invariants are
2151
+ * evaluated before and after the write through the SAME helper
2152
+ * validateContext uses. Only violations ABSENT before and PRESENT after
2153
+ * (newly introduced by this write) act: error severity REFUSES the write
2154
+ * — every top-level substrate *.json is byte-restored from the pre-write
2155
+ * snapshot and the op throws naming the violations; warning severity is
2156
+ * surfaced on the op result (appended `write-warning:` lines on string
2157
+ * results; a `writeWarnings` array inside {json} object payloads).
2158
+ * Pre-existing violations never block or warn — legacy substrates stay
2159
+ * fully writable (the delta scope IS the expand-contract analogue).
2160
+ *
2161
+ * 3. ALL-OR-NOTHING ON THROW: an inner-op throw mid-composite (a birth edge
2162
+ * failing after the item landed; complete-task's status flip failing
2163
+ * after its edge landed) byte-restores the substrate from the same
2164
+ * pre-write snapshot before rethrowing, so a composite op never persists
2165
+ * a partial write the gate did not judge.
2166
+ *
2167
+ * dryRun invocations (append-relations --dryRun) preview without writing and
2168
+ * bypass the pipeline entirely. Ops THROW on failure and return success
2169
+ * strings / {json} on success, so any settled return means the write landed;
2170
+ * sync/async contracts are preserved per op. Nested-item ops are excluded
2171
+ * (item statuses are top-level fields).
2172
+ */
2173
+ for (const op of ops) {
2174
+ if (!CONVERGE_AFTER_OPS.has(op.name))
2175
+ continue;
2176
+ const inner = op.run.bind(op);
2177
+ op.run = (cwd, params, ctx) => {
2178
+ const isDryRun = params.dryRun === true;
2179
+ const pre = isDryRun ? null : invariantSnapshot(cwd);
2180
+ const snapshot = pre === null ? null : substrateJsonSnapshot(cwd);
2181
+ const finish = (settled) => {
2182
+ if (isDryRun)
2183
+ return settled;
2184
+ convergeDerivedStatusAfterWrite(cwd, ctx);
2185
+ if (pre === null)
2186
+ return settled;
2187
+ const post = invariantSnapshot(cwd);
2188
+ if (post === null)
2189
+ return settled;
2190
+ const fresh = [...post].filter(([key]) => !pre.has(key));
2191
+ if (fresh.length === 0)
2192
+ return settled;
2193
+ const freshErrors = fresh.filter(([, v]) => v.severity === "error");
2194
+ if (freshErrors.length > 0 && snapshot !== null) {
2195
+ restoreSubstrateJson(snapshot);
2196
+ throw new Error(`${op.name} refused — the write would introduce ${freshErrors.length} invariant violation(s) (substrate restored byte-exact): ${freshErrors
2197
+ .map(([, v]) => v.message)
2198
+ .join("; ")}`);
2199
+ }
2200
+ const freshWarnings = fresh.filter(([, v]) => v.severity !== "error").map(([, v]) => v.message);
2201
+ return freshWarnings.length > 0 ? attachWriteWarnings(settled, freshWarnings) : settled;
2202
+ };
2203
+ // Op-level all-or-nothing: an inner-op throw MID-COMPOSITE (e.g. a birth
2204
+ // edge or the complete-task status flip failing after an earlier write in
2205
+ // the same run landed) byte-restores the whole substrate before
2206
+ // rethrowing — without this, partial writes persist in states the gate
2207
+ // never judged. Restore is snapshot-gated like the refusal path (null on
2208
+ // dryRun / no-invariant substrates) and idempotent if finish() already
2209
+ // restored on a gate refusal.
2210
+ const restoreAndRethrow = (err) => {
2211
+ if (snapshot !== null)
2212
+ restoreSubstrateJson(snapshot);
2213
+ throw err;
2214
+ };
2215
+ let result;
2216
+ try {
2217
+ result = inner(cwd, params, ctx);
2218
+ }
2219
+ catch (err) {
2220
+ return restoreAndRethrow(err);
2221
+ }
2222
+ return result instanceof Promise ? result.then(finish, restoreAndRethrow) : finish(result);
2223
+ };
2224
+ op.description +=
2225
+ " Write pipeline: after this op's write, rollup-kind stored statuses converge with their derivation, and the config invariants are re-evaluated delta-scoped — a violation newly introduced by this write refuses it at error severity (substrate byte-restored) or is surfaced on the result at warning severity (write-warning lines / writeWarnings); pre-existing violations never block.";
2226
+ }
1883
2227
  /**
1884
2228
  * Register every op in `ops` as a pi tool. Each tool's execute body is the
1885
2229
  * uniform wrapper around the op's run(): coerce params, build the attestation