@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.
- package/CHANGELOG.md +73 -0
- package/README.md +16 -14
- package/dist/block-api.d.ts +20 -33
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +154 -3
- package/dist/block-api.js.map +1 -1
- package/dist/content-hash.d.ts +9 -0
- package/dist/content-hash.d.ts.map +1 -1
- package/dist/content-hash.js +11 -0
- package/dist/content-hash.js.map +1 -1
- package/dist/context-sdk.d.ts +157 -39
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +896 -251
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +158 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +75 -5
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +151 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +590 -97
- package/dist/index.js.map +1 -1
- package/dist/lens-view.d.ts +0 -5
- package/dist/lens-view.d.ts.map +1 -1
- package/dist/lens-view.js +43 -1
- package/dist/lens-view.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +20 -12
- package/dist/migration-registry-loader.d.ts.map +1 -1
- package/dist/migration-registry-loader.js +46 -17
- package/dist/migration-registry-loader.js.map +1 -1
- package/dist/migrations-store.d.ts +45 -18
- package/dist/migrations-store.d.ts.map +1 -1
- package/dist/migrations-store.js +56 -22
- package/dist/migrations-store.js.map +1 -1
- package/dist/ops-registry.d.ts +18 -0
- package/dist/ops-registry.d.ts.map +1 -1
- package/dist/ops-registry.js +474 -130
- package/dist/ops-registry.js.map +1 -1
- package/dist/promote-item.d.ts.map +1 -1
- package/dist/promote-item.js +41 -12
- package/dist/promote-item.js.map +1 -1
- package/dist/roadmap-plan.d.ts +121 -99
- package/dist/roadmap-plan.d.ts.map +1 -1
- package/dist/roadmap-plan.js +281 -345
- package/dist/roadmap-plan.js.map +1 -1
- package/dist/status-vocab.d.ts +12 -2
- package/dist/status-vocab.d.ts.map +1 -1
- package/dist/status-vocab.js +14 -1
- package/dist/status-vocab.js.map +1 -1
- package/package.json +2 -2
- package/samples/blocks/milestone.json +3 -0
- package/samples/blocks/session-notes.json +1 -0
- package/samples/conception.json +315 -15
- package/samples/migrations.json +8 -0
- package/samples/schemas/context-contracts.schema.json +4 -0
- package/samples/schemas/conventions.schema.json +4 -0
- package/samples/schemas/decisions.schema.json +4 -0
- package/samples/schemas/features.schema.json +4 -0
- package/samples/schemas/framework-gaps.schema.json +9 -0
- package/samples/schemas/issues.schema.json +28 -0
- package/samples/schemas/layer-plans.schema.json +4 -0
- package/samples/schemas/milestone.schema.json +79 -0
- package/samples/schemas/phase.schema.json +4 -0
- package/samples/schemas/rationale.schema.json +4 -0
- package/samples/schemas/requirements.schema.json +4 -0
- package/samples/schemas/research.schema.json +45 -2
- package/samples/schemas/session-notes.schema.json +89 -0
- package/samples/schemas/spec-reviews.schema.json +4 -0
- package/samples/schemas/story.schema.json +8 -0
- package/samples/schemas/tasks.schema.json +4 -0
- package/samples/schemas/verification.schema.json +4 -0
- package/samples/schemas/work-orders.schema.json +4 -0
- package/schemas/config.schema.json +90 -4
- package/schemas/migrations.schema.json +25 -0
- package/skill-narrative.md +8 -6
- package/skills/pi-context/SKILL.md +70 -63
- package/skills/pi-context/references/bundled-resources.md +5 -1
package/dist/ops-registry.js
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
148
|
-
|
|
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
|
-
|
|
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:
|
|
215
|
-
"
|
|
216
|
-
"
|
|
217
|
-
"
|
|
218
|
-
|
|
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({
|
|
224
|
-
|
|
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
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 ${
|
|
249
|
-
: `would no-op (duplicate): relation ${
|
|
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 ${
|
|
253
|
-
: `Relation ${
|
|
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.
|
|
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
|
-
"{
|
|
360
|
-
"
|
|
361
|
-
"
|
|
362
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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 —
|
|
1471
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1636
|
-
promptSnippet: "Load
|
|
1637
|
-
examples: [`pi-context context-roadmap-load --
|
|
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,
|
|
1643
|
-
const view = loadRoadmap(cwd
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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
|
|
1661
|
-
promptSnippet: "Render
|
|
1662
|
-
examples: [`pi-context context-roadmap-render
|
|
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,
|
|
1668
|
-
const view = loadRoadmap(cwd
|
|
1845
|
+
run(cwd, _params) {
|
|
1846
|
+
const view = loadRoadmap(cwd);
|
|
1669
1847
|
if ("error" in view) {
|
|
1670
1848
|
return { json: view };
|
|
1671
1849
|
}
|
|
1672
|
-
|
|
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
|
|
1679
|
-
description: "Validate
|
|
1680
|
-
promptSnippet: "Validate
|
|
1681
|
-
examples: [`pi-context context-roadmap-validate --
|
|
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:
|
|
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
|