@davidorex/pi-context 0.32.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.
@@ -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 } 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, endpointKey, 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,7 +30,7 @@ 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";
@@ -115,14 +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
  }
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.",
225
+ })),
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)" })),
228
+ });
118
229
  export const ops = [
119
230
  {
120
231
  name: "append-block-item",
121
232
  label: "Append Block Item",
122
- 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.",
123
- 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",
124
243
  examples: [
125
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`,
126
246
  ],
127
247
  parameters: Type.Object({
128
248
  block: Type.String({ description: "Block name (e.g., 'issues', 'decisions')" }),
@@ -131,6 +251,9 @@ export const ops = [
131
251
  autoId: Type.Optional(Type.Boolean({
132
252
  description: "When true and the item has no id, allocate the next id from the block's id pattern",
133
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
+ })),
134
257
  }),
135
258
  surface: "use",
136
259
  run(cwd, params, ctx) {
@@ -143,17 +266,27 @@ export const ops = [
143
266
  throw new Error(`item parameter must be a JSON object, got unparseable string`);
144
267
  }
145
268
  }
269
+ const relations = coerceBirthRelations(params.relations);
146
270
  // Auto-id allocation (FGAP-084 dual-surface twin of file-block-item --auto-id)
147
271
  if (params.autoId && params.item && typeof params.item === "object" && !params.item.id) {
148
272
  params.item.id = nextId(cwd, params.block);
149
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
+ }
150
277
  // Id-uniqueness is enforced atomically inside appendToBlock's
151
278
  // withBlockLock critical section (block-api assertAppendIdUnique) —
152
279
  // the single enforcement point. The prior racy readBlock-then-append
153
280
  // tool-layer check was removed in favour of that library guard.
154
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);
155
287
  const id = params.item?.id ? ` '${params.item.id}'` : "";
156
- 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}`;
157
290
  },
158
291
  },
159
292
  {
@@ -401,8 +534,16 @@ export const ops = [
401
534
  label: "Upsert Block Item",
402
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 " +
403
536
  "REPLACED (full-shape replacement, not shallow-merge — use update-block-item for merge); otherwise the item is " +
404
- "appended. Schema validation is automatic. idField defaults to 'id'.",
405
- 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",
406
547
  examples: [
407
548
  `pi-context upsert-block-item --block tasks --arrayKey tasks --item @/tmp/task.json --writer '{"kind":"human","user":"you@example.com"}' --json`,
408
549
  ],
@@ -412,6 +553,9 @@ export const ops = [
412
553
  item: Type.Unknown({ description: "Full item object to upsert — must conform to block schema" }),
413
554
  idField: Type.Optional(Type.String({ description: "Field used as the upsert key (default 'id')" })),
414
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
+ })),
415
559
  }),
416
560
  surface: "use",
417
561
  run(cwd, params, ctx) {
@@ -424,17 +568,36 @@ export const ops = [
424
568
  throw new Error(`item parameter must be a JSON object, got unparseable string`);
425
569
  }
426
570
  }
571
+ const relations = coerceBirthRelations(params.relations);
427
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
+ }
428
577
  // Under dryRun upsertItemInBlock computes mode + builds + validates the prospective
429
578
  // whole block, writing nothing (TASK-011 shared preview path).
430
579
  const { mode } = upsertItemInBlock(cwd, params.block, params.arrayKey, params.item, idField, ctx, {
431
580
  dryRun: params.dryRun,
432
581
  });
433
- 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
+ }
434
588
  const idDesc = idVal !== undefined ? ` '${idVal}'` : "";
435
- return params.dryRun
436
- ? `would upsert item${idDesc} (${mode}) in ${params.block}.${params.arrayKey}`
437
- : `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}`;
438
601
  },
439
602
  },
440
603
  {
@@ -871,7 +1034,7 @@ export const ops = [
871
1034
  {
872
1035
  name: "context-current-state",
873
1036
  label: "Context Current State",
874
- 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 has not reached the complete bucket is reported in blockedBy and held out of nextActions; a target reaching its complete status releases it. A substrate whose config declares no `state_derivation` reports focus 'state-derivation not configured' with empty arrays. No writes; nothing hand-stored.",
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.",
875
1038
  promptSnippet: "Derive current project state from the config-declared state_derivation registry — focus, in-flight, ranked next actions, blocked, milestone rollups",
876
1039
  examples: [`pi-context context-current-state --json`],
877
1040
  parameters: Type.Object({}),
@@ -1193,6 +1356,24 @@ export const ops = [
1193
1356
  return { json: result };
1194
1357
  },
1195
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
+ },
1196
1377
  {
1197
1378
  name: "validate-block-items",
1198
1379
  label: "Validate Block Items",
@@ -1470,21 +1651,26 @@ export const ops = [
1470
1651
  {
1471
1652
  name: "complete-task",
1472
1653
  label: "Complete Task",
1473
- description: "Complete a task with verification gate — requires a passing verification entry targeting the task.",
1474
- 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)",
1475
1660
  examples: [
1476
1661
  `pi-context complete-task --taskId TASK-001 --verificationId VER-001 --writer '{"kind":"human","user":"you@example.com"}' --json`,
1477
1662
  ],
1478
1663
  parameters: Type.Object({
1479
1664
  taskId: Type.String({ description: "Task ID to complete" }),
1480
1665
  verificationId: Type.String({
1481
- 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)",
1482
1667
  }),
1483
1668
  }),
1484
1669
  surface: "use",
1485
1670
  run(cwd, params, ctx) {
1486
1671
  const result = completeTask(cwd, params.taskId, params.verificationId, ctx);
1487
- 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})`;
1488
1674
  },
1489
1675
  },
1490
1676
  {
@@ -1708,6 +1894,10 @@ export const gatedTools = ops.filter((o) => o.authGated).map((o) => o.name);
1708
1894
  * covered when its cwd-form sibling is covered.
1709
1895
  */
1710
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
+ },
1711
1901
  { libraryFn: "writeConfig", safeOp: "amend-config", reason: "scoped guarded config mutation" },
1712
1902
  { libraryFn: "writeSchema", safeOp: "write-schema", reason: "raw bypasses the create/replace + migration check" },
1713
1903
  { libraryFn: "updateSchema", safeOp: "write-schema", reason: "no mutator-scripting surface" },
@@ -1853,6 +2043,187 @@ export function buildDispatchContextFromExecute(params, extCtx) {
1853
2043
  const modelId = extCtx.model?.id;
1854
2044
  return { writer: { kind: "agent", agent_id: modelId && modelId.length > 0 ? modelId : "pi-agent" } };
1855
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
+ }
1856
2227
  /**
1857
2228
  * Register every op in `ops` as a pi tool. Each tool's execute body is the
1858
2229
  * uniform wrapper around the op's run(): coerce params, build the attestation