@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.
- package/CHANGELOG.md +15 -0
- package/README.md +4 -3
- package/dist/block-api.d.ts +20 -33
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +127 -1
- 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 +88 -8
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +363 -116
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +5 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -3
- package/dist/index.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 +388 -17
- package/dist/ops-registry.js.map +1 -1
- package/package.json +2 -2
- package/samples/conception.json +7 -0
- package/samples/schemas/framework-gaps.schema.json +5 -0
- package/samples/schemas/research.schema.json +41 -2
- package/schemas/config.schema.json +13 -1
- package/skill-narrative.md +1 -1
- package/skills/pi-context/SKILL.md +30 -18
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 } 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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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 —
|
|
1474
|
-
|
|
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
|
|
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
|
-
|
|
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
|