@davidorex/pi-context 0.26.0 → 0.28.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 +69 -15
- package/README.md +71 -18
- package/dist/block-api.d.ts +165 -18
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +800 -57
- package/dist/block-api.js.map +1 -1
- package/dist/citation-rot-scanner.d.ts +80 -0
- package/dist/citation-rot-scanner.d.ts.map +1 -0
- package/dist/citation-rot-scanner.js +421 -0
- package/dist/citation-rot-scanner.js.map +1 -0
- package/dist/content-hash.d.ts +25 -0
- package/dist/content-hash.d.ts.map +1 -0
- package/dist/content-hash.js +62 -0
- package/dist/content-hash.js.map +1 -0
- package/dist/context-dir.d.ts +126 -1
- package/dist/context-dir.d.ts.map +1 -1
- package/dist/context-dir.js +208 -4
- package/dist/context-dir.js.map +1 -1
- package/dist/context-registry.d.ts +122 -0
- package/dist/context-registry.d.ts.map +1 -0
- package/dist/context-registry.js +189 -0
- package/dist/context-registry.js.map +1 -0
- package/dist/context-sdk.d.ts +179 -12
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +536 -101
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +222 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +244 -56
- package/dist/context.js.map +1 -1
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.d.ts.map +1 -0
- package/dist/git-env.js +29 -0
- package/dist/git-env.js.map +1 -0
- package/dist/index.d.ts +124 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +440 -1125
- package/dist/index.js.map +1 -1
- package/dist/lens-view.d.ts.map +1 -1
- package/dist/lens-view.js +5 -3
- package/dist/lens-view.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +121 -0
- package/dist/migration-registry-loader.d.ts.map +1 -0
- package/dist/migration-registry-loader.js +309 -0
- package/dist/migration-registry-loader.js.map +1 -0
- package/dist/migrations-store.d.ts +141 -0
- package/dist/migrations-store.d.ts.map +1 -0
- package/dist/migrations-store.js +206 -0
- package/dist/migrations-store.js.map +1 -0
- package/dist/object-store.d.ts +26 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +104 -0
- package/dist/object-store.js.map +1 -0
- package/dist/ops-registry.d.ts +44 -0
- package/dist/ops-registry.d.ts.map +1 -0
- package/dist/ops-registry.js +1223 -0
- package/dist/ops-registry.js.map +1 -0
- package/dist/orientation.d.ts +16 -0
- package/dist/orientation.d.ts.map +1 -0
- package/dist/orientation.js +49 -0
- package/dist/orientation.js.map +1 -0
- package/dist/promote-item.d.ts +28 -0
- package/dist/promote-item.d.ts.map +1 -0
- package/dist/promote-item.js +257 -0
- package/dist/promote-item.js.map +1 -0
- package/dist/read-element.d.ts +129 -0
- package/dist/read-element.d.ts.map +1 -0
- package/dist/read-element.js +296 -0
- package/dist/read-element.js.map +1 -0
- package/dist/rename-canonical-id.d.ts.map +1 -1
- package/dist/rename-canonical-id.js +22 -13
- package/dist/rename-canonical-id.js.map +1 -1
- package/dist/roadmap-plan.d.ts.map +1 -1
- package/dist/roadmap-plan.js +23 -13
- package/dist/roadmap-plan.js.map +1 -1
- package/dist/schema-validator.d.ts +1 -0
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +7 -3
- package/dist/schema-validator.js.map +1 -1
- package/dist/schema-write.d.ts +78 -4
- package/dist/schema-write.d.ts.map +1 -1
- package/dist/schema-write.js +297 -34
- package/dist/schema-write.js.map +1 -1
- package/dist/status-vocab.d.ts +1 -0
- package/dist/status-vocab.d.ts.map +1 -1
- package/dist/status-vocab.js +4 -0
- package/dist/status-vocab.js.map +1 -1
- package/dist/truncate.d.ts +34 -0
- package/dist/truncate.d.ts.map +1 -0
- package/dist/truncate.js +85 -0
- package/dist/truncate.js.map +1 -0
- package/dist/write-schema-migration-tool.d.ts +46 -0
- package/dist/write-schema-migration-tool.d.ts.map +1 -0
- package/dist/write-schema-migration-tool.js +115 -0
- package/dist/write-schema-migration-tool.js.map +1 -0
- package/package.json +35 -2
- package/samples/blocks/work-orders.json +3 -0
- package/samples/conception.json +22 -3
- package/samples/migrations.json +133 -0
- package/samples/schemas/context-contracts.schema.json +19 -4
- package/samples/schemas/conventions.schema.json +68 -12
- package/samples/schemas/decisions.schema.json +125 -28
- package/samples/schemas/features.schema.json +140 -26
- package/samples/schemas/framework-gaps.schema.json +171 -26
- package/samples/schemas/issues.schema.json +86 -13
- package/samples/schemas/layer-plans.schema.json +195 -35
- package/samples/schemas/phase.schema.json +18 -3
- package/samples/schemas/rationale.schema.json +37 -7
- package/samples/schemas/requirements.schema.json +77 -10
- package/samples/schemas/research.schema.json +169 -36
- package/samples/schemas/spec-reviews.schema.json +88 -18
- package/samples/schemas/story.schema.json +18 -3
- package/samples/schemas/tasks.schema.json +66 -10
- package/samples/schemas/verification.schema.json +70 -12
- package/samples/schemas/work-orders.schema.json +195 -0
- package/schemas/bootstrap.schema.json +18 -6
- package/schemas/config.schema.json +58 -26
- package/schemas/context-registry.schema.json +46 -0
- package/schemas/migrations.schema.json +123 -0
- package/schemas/relations.schema.json +41 -10
- package/skill-narrative.md +42 -21
- package/skills/pi-context/SKILL.md +159 -65
- package/skills/pi-context/references/bundled-resources.md +7 -2
package/dist/index.js
CHANGED
|
@@ -2,21 +2,19 @@
|
|
|
2
2
|
* Extension entry point for pi-context — registers block tools and the
|
|
3
3
|
* /context command for project state management.
|
|
4
4
|
*/
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { renameCanonicalId } from "./rename-canonical-id.js";
|
|
9
|
+
import { readBlock } from "./block-api.js";
|
|
10
|
+
import { adoptConception, installedBlockDestPath, installedSchemaDestPath, loadConfig, loadContext, } from "./context.js";
|
|
11
|
+
import { BootstrapNotFoundError, flipBootstrapPointer, resolveContextDir, SCHEMAS_DIR, schemasDir, tryResolveContextDir, writeBootstrapPointer, } from "./context-dir.js";
|
|
12
|
+
import { contextState, findAppendableBlocks, validateContext } from "./context-sdk.js";
|
|
13
|
+
import { cleanGitEnv } from "./git-env.js";
|
|
14
|
+
import { buildCurationSuggestions, loadLensView, renderLensView } from "./lens-view.js";
|
|
15
|
+
import { registerAll } from "./ops-registry.js";
|
|
16
|
+
import { buildOrientationBlock, skillsDir } from "./orientation.js";
|
|
17
17
|
import { listRoadmaps, loadRoadmap, renderRoadmap, validateRoadmaps } from "./roadmap-plan.js";
|
|
18
|
-
import { samplesCatalog } from "./samples-catalog.js";
|
|
19
|
-
import { readSchema, writeSchemaChecked } from "./schema-write.js";
|
|
20
18
|
import { checkForUpdates } from "./update-check.js";
|
|
21
19
|
// ── Command handlers ────────────────────────────────────────────────────────
|
|
22
20
|
/**
|
|
@@ -82,7 +80,7 @@ function handleStatus(ctx, pi) {
|
|
|
82
80
|
lines.push(`- **Verifications:** ${v.total} (${v.passed} passed, ${v.failed} failed)`);
|
|
83
81
|
}
|
|
84
82
|
if (state.hasHandoff) {
|
|
85
|
-
lines.push(`- **Handoff:** active (
|
|
83
|
+
lines.push(`- **Handoff:** active (<substrate-dir>/handoff.json)`);
|
|
86
84
|
}
|
|
87
85
|
if (state.recentCommits.length > 0) {
|
|
88
86
|
lines.push("");
|
|
@@ -104,7 +102,7 @@ function handleStatus(ctx, pi) {
|
|
|
104
102
|
async function handleAddWork(args, ctx, pi) {
|
|
105
103
|
const workflowDir = tryResolveContextDir(ctx.cwd);
|
|
106
104
|
if (workflowDir === null) {
|
|
107
|
-
ctx.ui.notify("No .pi-context.json bootstrap pointer found. Run /context init first to bootstrap the substrate.", "error");
|
|
105
|
+
ctx.ui.notify("No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.", "error");
|
|
108
106
|
return;
|
|
109
107
|
}
|
|
110
108
|
const schemasDirPath = schemasDir(ctx.cwd);
|
|
@@ -164,23 +162,83 @@ ${blockInfo.join("\n\n")}
|
|
|
164
162
|
deliverAs: "followUp",
|
|
165
163
|
});
|
|
166
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Thrown by `initProject` when an existing `.pi-context.json` bootstrap pointer
|
|
167
|
+
* declares a different `contextDir` than the caller is requesting. Pre-FGAP-179
|
|
168
|
+
* the divergence was silent — `initProject` only wrote the pointer when absent
|
|
169
|
+
* and then operated against the EXISTING pointer's contextDir, completely
|
|
170
|
+
* dropping the caller's argument and emitting a misleading "Project
|
|
171
|
+
* initialized" message that scaffolded directories in the EXISTING substrate.
|
|
172
|
+
* The loud-fail surfaces the divergence and names `/context switch -c <new-dir>`
|
|
173
|
+
* as the correct command for changing the pointer to a new substrate dir.
|
|
174
|
+
*
|
|
175
|
+
* Carries `existing` (the pointer's current contextDir) and `requested` (the
|
|
176
|
+
* caller's arg) so callers can format diagnostic messages without re-deriving.
|
|
177
|
+
* Idempotent re-init (existing === requested) is preserved (no throw — falls
|
|
178
|
+
* through to the dir-scaffolding loop which is itself idempotent).
|
|
179
|
+
*/
|
|
180
|
+
export class ContextInitMismatchError extends Error {
|
|
181
|
+
existing;
|
|
182
|
+
requested;
|
|
183
|
+
constructor(existing, requested) {
|
|
184
|
+
super(`/context init: .pi-context.json already declares contextDir='${existing}' but caller requested '${requested}'. ` +
|
|
185
|
+
`Re-init with a different substrate dir is rejected — use '/context switch -c ${requested}' to bootstrap '${requested}' as a new substrate AND flip the bootstrap pointer to it in one operation. ` +
|
|
186
|
+
`To re-scaffold the existing '${existing}' substrate idempotently, re-run /context init with no argument change.`);
|
|
187
|
+
this.name = "ContextInitMismatchError";
|
|
188
|
+
this.existing = existing;
|
|
189
|
+
this.requested = requested;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
167
192
|
/**
|
|
168
193
|
* Initialize the substrate dir: write the bootstrap pointer and scaffold the
|
|
169
194
|
* substrate + schemas directories ONLY. No schema/block assets are copied here
|
|
170
195
|
* (FGAP-067 / DEC-0011: init must not impose a catalog). Run accept-all to adopt
|
|
171
|
-
* a config + install to materialize the declared assets.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
196
|
+
* a config + install to materialize the declared assets. Shared by the /context
|
|
197
|
+
* init command handler and the context-init tool.
|
|
198
|
+
*
|
|
199
|
+
* Hard-fail-on-mismatch (post-FGAP-179): when `.pi-context.json` already exists
|
|
200
|
+
* AND its declared contextDir differs from the caller's `contextDir` argument,
|
|
201
|
+
* throws ContextInitMismatchError naming `/context switch -c <new-dir>` as the
|
|
202
|
+
* correct command. When the existing pointer matches the caller's arg, behavior
|
|
203
|
+
* is idempotent re-init (dir-scaffolding loop skips existing dirs). When no
|
|
204
|
+
* pointer exists, the caller's arg writes a fresh pointer.
|
|
174
205
|
*/
|
|
175
|
-
function initProject(cwd, contextDir) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
206
|
+
export function initProject(cwd, contextDir) {
|
|
207
|
+
const bootstrapPath = path.join(cwd, ".pi-context.json");
|
|
208
|
+
if (fs.existsSync(bootstrapPath)) {
|
|
209
|
+
// Existing pointer — check for divergence with caller's arg before
|
|
210
|
+
// touching anything. Read the pointer directly (not via the cached
|
|
211
|
+
// resolveContextDir) so the error message reflects the actual on-disk
|
|
212
|
+
// state and is not masked by a stale cache entry.
|
|
213
|
+
let existingContextDir;
|
|
214
|
+
try {
|
|
215
|
+
const raw = fs.readFileSync(bootstrapPath, "utf-8");
|
|
216
|
+
const parsed = JSON.parse(raw);
|
|
217
|
+
if (typeof parsed.contextDir !== "string") {
|
|
218
|
+
throw new Error(`initProject: existing ${bootstrapPath} lacks a string contextDir; refuses to proceed against a malformed pointer`);
|
|
219
|
+
}
|
|
220
|
+
existingContextDir = parsed.contextDir;
|
|
221
|
+
}
|
|
222
|
+
catch (err) {
|
|
223
|
+
if (err instanceof Error && err.name === "ContextInitMismatchError")
|
|
224
|
+
throw err;
|
|
225
|
+
throw new Error(`initProject: failed to read existing ${bootstrapPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
226
|
+
}
|
|
227
|
+
if (existingContextDir !== contextDir) {
|
|
228
|
+
throw new ContextInitMismatchError(existingContextDir, contextDir);
|
|
229
|
+
}
|
|
230
|
+
// Matching pointer — fall through to idempotent dir scaffolding without
|
|
231
|
+
// re-writing the pointer (writeBootstrapPointer would stamp fresh
|
|
232
|
+
// created_at on every init, corrupting the bootstrap-timestamp
|
|
233
|
+
// forensic).
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
// No pointer — write a fresh one carrying the caller's contextDir
|
|
237
|
+
// (required per DEC-0015). writeBootstrapPointer is atomic + invalidates
|
|
238
|
+
// the bootstrapCache so the immediate-next resolveContextDir call reads
|
|
239
|
+
// the freshly-written value.
|
|
183
240
|
writeBootstrapPointer(cwd, contextDir);
|
|
241
|
+
}
|
|
184
242
|
const projectDirPath = resolveContextDir(cwd);
|
|
185
243
|
const schemasDirPath = schemasDir(cwd);
|
|
186
244
|
const created = [];
|
|
@@ -212,12 +270,13 @@ export function installContext(cwd, options = {}) {
|
|
|
212
270
|
const overwrite = options.overwrite === true;
|
|
213
271
|
const destRoot = tryResolveContextDir(cwd);
|
|
214
272
|
if (destRoot === null) {
|
|
215
|
-
result.error =
|
|
273
|
+
result.error =
|
|
274
|
+
"No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
|
|
216
275
|
return result;
|
|
217
276
|
}
|
|
218
277
|
const config = loadConfig(cwd);
|
|
219
278
|
if (!config) {
|
|
220
|
-
result.error = "No config.json found in substrate dir — run /context init first.";
|
|
279
|
+
result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
|
|
221
280
|
return result;
|
|
222
281
|
}
|
|
223
282
|
// destRoot is resolver-aware via tryResolveContextDir(cwd) — it already
|
|
@@ -293,10 +352,23 @@ export function installContext(cwd, options = {}) {
|
|
|
293
352
|
function handleInit(args, ctx) {
|
|
294
353
|
const contextDir = args.trim().split(/\s+/)[0];
|
|
295
354
|
if (!contextDir) {
|
|
296
|
-
ctx.ui.notify("/context init requires a substrate dir name (e.g. '/context init .
|
|
355
|
+
ctx.ui.notify("/context init requires a substrate dir name (e.g. '/context init .context'). No default — you choose the name.", "error");
|
|
297
356
|
return;
|
|
298
357
|
}
|
|
299
|
-
|
|
358
|
+
let result;
|
|
359
|
+
try {
|
|
360
|
+
result = initProject(ctx.cwd, contextDir);
|
|
361
|
+
}
|
|
362
|
+
catch (err) {
|
|
363
|
+
// Name-based catch per the cross-module-instance-instanceof-unreliable
|
|
364
|
+
// discipline used elsewhere in this file (tryResolveContextDir pattern).
|
|
365
|
+
if (err instanceof Error && err.name === "ContextInitMismatchError") {
|
|
366
|
+
ctx.ui.notify(err.message, "error");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
throw err;
|
|
370
|
+
}
|
|
371
|
+
const { created, skipped } = result;
|
|
300
372
|
const lines = [];
|
|
301
373
|
lines.push(`Project initialized`);
|
|
302
374
|
lines.push("");
|
|
@@ -325,7 +397,7 @@ function handleAcceptAll(_args, ctx) {
|
|
|
325
397
|
}
|
|
326
398
|
catch (err) {
|
|
327
399
|
if (err instanceof BootstrapNotFoundError) {
|
|
328
|
-
ctx.ui.notify("Run /context init <dir> first", "error");
|
|
400
|
+
ctx.ui.notify("Run /context init <substrate-dir> first", "error");
|
|
329
401
|
return;
|
|
330
402
|
}
|
|
331
403
|
throw err;
|
|
@@ -336,1111 +408,353 @@ function handleAcceptAll(_args, ctx) {
|
|
|
336
408
|
}
|
|
337
409
|
ctx.ui.notify(`Adopted canonical config (root: ${r.root}, ${r.schemaCount} schemas / ${r.blockCount} blocks declared). Run /context install to materialize them.`, "info");
|
|
338
410
|
}
|
|
411
|
+
// ── /context switch + list + archive — substrate-management primitives ─────
|
|
412
|
+
/**
|
|
413
|
+
* Resolve a writer identity for the slash command path. Slash commands run
|
|
414
|
+
* inside the operator's interactive Pi session and the user IS the terminal
|
|
415
|
+
* operator; the auth-gate identity-stamp at the Pi tool boundary does not
|
|
416
|
+
* apply here. Falls back to "operator" (plus an operator-visible warning via
|
|
417
|
+
* ctx.ui.notify) when neither git config user.email nor process.env.USER
|
|
418
|
+
* yields a value, so the pointer-history switched_by field always carries
|
|
419
|
+
* a non-empty string.
|
|
420
|
+
*
|
|
421
|
+
* NOTE: inlined locally rather than imported from pi-agent-dispatch's
|
|
422
|
+
* verified-identity.ts to avoid a circular dep (pi-agent-dispatch depends
|
|
423
|
+
* on pi-context). The discovery cascade matches the canonical resolver:
|
|
424
|
+
* git config user.email → process.env.USER → null+warning.
|
|
425
|
+
*/
|
|
426
|
+
function resolveSlashCommandWriter(ctx) {
|
|
427
|
+
let fromGit = null;
|
|
428
|
+
try {
|
|
429
|
+
const out = execSync("git config user.email", {
|
|
430
|
+
encoding: "utf8",
|
|
431
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
432
|
+
cwd: ctx.cwd,
|
|
433
|
+
env: cleanGitEnv(),
|
|
434
|
+
}).trim();
|
|
435
|
+
fromGit = out.length > 0 ? out : null;
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
fromGit = null;
|
|
439
|
+
}
|
|
440
|
+
if (fromGit !== null)
|
|
441
|
+
return fromGit;
|
|
442
|
+
const fromEnv = process.env.USER;
|
|
443
|
+
if (fromEnv && fromEnv.length > 0)
|
|
444
|
+
return fromEnv;
|
|
445
|
+
ctx.ui.notify("slash command writer-identity: neither git config user.email nor process.env.USER yielded a value; switched_by will be stamped 'operator' (unverified).", "warning");
|
|
446
|
+
return "operator";
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Bootstrap a new substrate dir + flip the pointer in one operation. The
|
|
450
|
+
* shared engine behind `/context switch -c <new-dir>` and the
|
|
451
|
+
* `context-switch` Pi tool with `create_new=true`.
|
|
452
|
+
*
|
|
453
|
+
* Sequence:
|
|
454
|
+
* 1. Caller-supplied target dir name validated (assertSubstrateName — same
|
|
455
|
+
* discipline as schema names; rejects path separators / dots / '..').
|
|
456
|
+
* NOTE: a leading-dot dir like '.context' fails assertSubstrateName, so
|
|
457
|
+
* target dirs that start with '.' bypass that check by stripping the dot
|
|
458
|
+
* before validation and re-prepending — preserves the existing convention
|
|
459
|
+
* in this repo (`.project` / `.context` style) while keeping the substrate-
|
|
460
|
+
* name discipline for the body of the name.
|
|
461
|
+
* 2. Writes a fresh bootstrap pointer FOR THE NEW DIR via the v1.0.0 single-
|
|
462
|
+
* arg writeBootstrapPointer overload — pointer carries the new dir as
|
|
463
|
+
* contextDir + a fresh created_at. This OVERWRITES the existing pointer
|
|
464
|
+
* intentionally because we then immediately flip; the flip preserves the
|
|
465
|
+
* new-dir's created_at since the freshly-written pointer's created_at is
|
|
466
|
+
* the only one in scope.
|
|
467
|
+
* Wait: this would lose the original substrate's created_at. The correct
|
|
468
|
+
* sequence is to flip FIRST (which preserves existing created_at into
|
|
469
|
+
* the new pointer + stamps previous_contextDir from existing), then
|
|
470
|
+
* scaffold the new dir's structure. flipBootstrapPointer does the pointer
|
|
471
|
+
* work; this helper then mkdir's the substrate root + schemas subdir.
|
|
472
|
+
*/
|
|
473
|
+
export function switchAndCreate(cwd, newContextDir, writerIdentity) {
|
|
474
|
+
// Validation: allow a leading '.' in the dir name (project convention) but
|
|
475
|
+
// require the rest to match the substrate-name discipline.
|
|
476
|
+
const nameBody = newContextDir.startsWith(".") ? newContextDir.slice(1) : newContextDir;
|
|
477
|
+
if (!/^[A-Za-z0-9_-]+$/.test(nameBody)) {
|
|
478
|
+
throw new Error(`/context switch -c: invalid target dir name '${newContextDir}' (only letters, digits, '-', '_' after an optional leading '.' are allowed; no path separators or '..')`);
|
|
479
|
+
}
|
|
480
|
+
// Require an existing pointer so we have something to flip FROM. The
|
|
481
|
+
// existence check + read live inside flipBootstrapPointer.
|
|
482
|
+
flipBootstrapPointer(cwd, newContextDir, writerIdentity);
|
|
483
|
+
// Scaffold the new substrate dir's structure (substrate root + schemas
|
|
484
|
+
// subdir). Mirrors initProject's dir-creation loop without writing the
|
|
485
|
+
// pointer again (flip already did that).
|
|
486
|
+
const projectDirPath = resolveContextDir(cwd);
|
|
487
|
+
const schemasDirPath = schemasDir(cwd);
|
|
488
|
+
const created = [];
|
|
489
|
+
for (const dir of [projectDirPath, schemasDirPath]) {
|
|
490
|
+
if (!fs.existsSync(dir)) {
|
|
491
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
492
|
+
created.push(`${path.relative(cwd, dir)}/`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { created };
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Flip the bootstrap pointer to an existing substrate dir. Shared engine
|
|
499
|
+
* behind `/context switch <existing-dir>` and the `context-switch` Pi tool
|
|
500
|
+
* default mode.
|
|
501
|
+
*
|
|
502
|
+
* Read-side safety check: verifies `<cwd>/<targetDir>/config.json` exists
|
|
503
|
+
* before flipping (the substrate must already be initialized; flipping to
|
|
504
|
+
* a non-substrate dir would leave the resolver pointing at an empty path).
|
|
505
|
+
* The check is a fail-fast; the flip itself is performed by
|
|
506
|
+
* flipBootstrapPointer.
|
|
507
|
+
*/
|
|
508
|
+
export function switchToExisting(cwd, targetDir, writerIdentity) {
|
|
509
|
+
const targetConfigPath = path.join(cwd, targetDir, "config.json");
|
|
510
|
+
if (!fs.existsSync(targetConfigPath)) {
|
|
511
|
+
throw new Error(`/context switch: target dir '${targetDir}' has no config.json at ${targetConfigPath} — refusing to flip the bootstrap pointer to a non-substrate dir. Use '/context switch -c ${targetDir}' to bootstrap a fresh substrate at that dir AND flip the pointer.`);
|
|
512
|
+
}
|
|
513
|
+
flipBootstrapPointer(cwd, targetDir, writerIdentity);
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Flip the bootstrap pointer back to the previous_contextDir (parallel to
|
|
517
|
+
* `git switch -`). Shared engine behind `/context switch -` and the
|
|
518
|
+
* `context-switch` Pi tool with `to_previous=true`.
|
|
519
|
+
*
|
|
520
|
+
* Reads the existing pointer's `previous_contextDir`; if absent (the pointer
|
|
521
|
+
* was never switched), throws a structured error naming the precondition.
|
|
522
|
+
*/
|
|
523
|
+
export function switchToPrevious(cwd, writerIdentity) {
|
|
524
|
+
const bootstrapPath = path.join(cwd, ".pi-context.json");
|
|
525
|
+
if (!fs.existsSync(bootstrapPath)) {
|
|
526
|
+
throw new BootstrapNotFoundError(cwd, bootstrapPath);
|
|
527
|
+
}
|
|
528
|
+
const raw = fs.readFileSync(bootstrapPath, "utf-8");
|
|
529
|
+
const pointer = JSON.parse(raw);
|
|
530
|
+
const previous = pointer.previous_contextDir;
|
|
531
|
+
const current = pointer.contextDir;
|
|
532
|
+
if (typeof previous !== "string" || previous.length === 0) {
|
|
533
|
+
throw new Error(`/context switch -: pointer has no previous_contextDir to flip back to (substrate has never been switched). The /context switch - form requires a prior /context switch invocation to populate the previous_contextDir field.`);
|
|
534
|
+
}
|
|
535
|
+
if (typeof current !== "string") {
|
|
536
|
+
throw new Error(`/context switch -: existing pointer at ${bootstrapPath} lacks a string contextDir; refuses to flip an unreadable pointer`);
|
|
537
|
+
}
|
|
538
|
+
flipBootstrapPointer(cwd, previous, writerIdentity);
|
|
539
|
+
return { from: current, to: previous };
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Enumerate top-level dirs under `cwd` that contain a `config.json` — i.e.,
|
|
543
|
+
* dirs that could be the target of `/context switch`. Returns an array of
|
|
544
|
+
* `{name, isActive}` entries; `isActive` flips true for the dir matching the
|
|
545
|
+
* current bootstrap pointer's contextDir. Shared engine behind `/context list`
|
|
546
|
+
* and the `context-list` Pi tool.
|
|
547
|
+
*/
|
|
548
|
+
export function listSubstrates(cwd) {
|
|
549
|
+
let activeContextDir = null;
|
|
550
|
+
try {
|
|
551
|
+
const bootstrapPath = path.join(cwd, ".pi-context.json");
|
|
552
|
+
if (fs.existsSync(bootstrapPath)) {
|
|
553
|
+
const raw = fs.readFileSync(bootstrapPath, "utf-8");
|
|
554
|
+
const pointer = JSON.parse(raw);
|
|
555
|
+
if (typeof pointer.contextDir === "string")
|
|
556
|
+
activeContextDir = pointer.contextDir;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// Malformed pointer: list still works, no dir flagged active.
|
|
561
|
+
activeContextDir = null;
|
|
562
|
+
}
|
|
563
|
+
const out = [];
|
|
564
|
+
let entries;
|
|
565
|
+
try {
|
|
566
|
+
entries = fs.readdirSync(cwd);
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
for (const name of entries.sort()) {
|
|
572
|
+
const fullPath = path.join(cwd, name);
|
|
573
|
+
let stat;
|
|
574
|
+
try {
|
|
575
|
+
stat = fs.statSync(fullPath);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (!stat.isDirectory())
|
|
581
|
+
continue;
|
|
582
|
+
// archive/ is itself a directory but holds archived substrates; skip
|
|
583
|
+
// the wrapper dir itself (the items inside are not directly switchable).
|
|
584
|
+
if (name === "archive")
|
|
585
|
+
continue;
|
|
586
|
+
if (!fs.existsSync(path.join(fullPath, "config.json")))
|
|
587
|
+
continue;
|
|
588
|
+
out.push({ name, isActive: activeContextDir === name });
|
|
589
|
+
}
|
|
590
|
+
return out;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Move a substrate dir to `archive/<name>/`. Shared engine behind
|
|
594
|
+
* `/context archive <dir>` and the `context-archive` Pi tool.
|
|
595
|
+
*
|
|
596
|
+
* Safety preconditions:
|
|
597
|
+
* 1. Target dir must NOT be the active substrate (the dir the bootstrap
|
|
598
|
+
* pointer currently names). Archiving the active substrate would leave
|
|
599
|
+
* the resolver pointing at a non-existent path.
|
|
600
|
+
* 2. Target dir must exist + must have a config.json (refuses to archive a
|
|
601
|
+
* non-substrate dir).
|
|
602
|
+
* 3. `archive/<name>/` must not already exist (refuses to clobber a prior
|
|
603
|
+
* archive of the same name).
|
|
604
|
+
*
|
|
605
|
+
* Creates `archive/` if absent. Uses `fs.renameSync` for atomicity on the
|
|
606
|
+
* same filesystem.
|
|
607
|
+
*/
|
|
608
|
+
export function archiveSubstrate(cwd, targetDir) {
|
|
609
|
+
const bootstrapPath = path.join(cwd, ".pi-context.json");
|
|
610
|
+
if (fs.existsSync(bootstrapPath)) {
|
|
611
|
+
try {
|
|
612
|
+
const raw = fs.readFileSync(bootstrapPath, "utf-8");
|
|
613
|
+
const pointer = JSON.parse(raw);
|
|
614
|
+
if (pointer.contextDir === targetDir) {
|
|
615
|
+
throw new Error(`/context archive: refuses to archive '${targetDir}' — it is the ACTIVE substrate (the bootstrap pointer names it). Switch to a different substrate first with '/context switch <other-dir>' or '/context switch -c <new-dir>' before archiving '${targetDir}'.`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
// Propagate the structural-refuse error verbatim; tolerate other
|
|
620
|
+
// read-errors (a malformed pointer should not block archival of an
|
|
621
|
+
// unrelated dir).
|
|
622
|
+
if (err instanceof Error && err.message.startsWith("/context archive: refuses"))
|
|
623
|
+
throw err;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const sourcePath = path.join(cwd, targetDir);
|
|
627
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isDirectory()) {
|
|
628
|
+
throw new Error(`/context archive: target dir '${targetDir}' does not exist at ${sourcePath}`);
|
|
629
|
+
}
|
|
630
|
+
if (!fs.existsSync(path.join(sourcePath, "config.json"))) {
|
|
631
|
+
throw new Error(`/context archive: target dir '${targetDir}' has no config.json — refuses to archive a non-substrate dir (use rm or git mv directly for non-substrate cleanup)`);
|
|
632
|
+
}
|
|
633
|
+
const archiveRoot = path.join(cwd, "archive");
|
|
634
|
+
if (!fs.existsSync(archiveRoot))
|
|
635
|
+
fs.mkdirSync(archiveRoot, { recursive: true });
|
|
636
|
+
const destPath = path.join(archiveRoot, targetDir);
|
|
637
|
+
if (fs.existsSync(destPath)) {
|
|
638
|
+
throw new Error(`/context archive: archive/${targetDir} already exists at ${destPath} — refuses to clobber a prior archive of the same name. Rename or remove the prior archive first.`);
|
|
639
|
+
}
|
|
640
|
+
fs.renameSync(sourcePath, destPath);
|
|
641
|
+
return { from: path.relative(cwd, sourcePath), to: path.relative(cwd, destPath) };
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* /context switch — handler for the slash command surface. Parses args for
|
|
645
|
+
* the three subforms: `-c <new-dir>` (bootstrap new + flip), `-` (flip to
|
|
646
|
+
* previous_contextDir), bare `<existing-dir>` (flip to existing substrate).
|
|
647
|
+
*/
|
|
648
|
+
function handleSwitch(args, ctx) {
|
|
649
|
+
const trimmed = args.trim();
|
|
650
|
+
if (trimmed.length === 0) {
|
|
651
|
+
ctx.ui.notify("Usage: /context switch <existing-dir> | /context switch -c <new-dir> | /context switch -", "error");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
const tokens = trimmed.split(/\s+/);
|
|
655
|
+
const writerIdentity = resolveSlashCommandWriter(ctx);
|
|
656
|
+
try {
|
|
657
|
+
if (tokens[0] === "-c") {
|
|
658
|
+
const target = tokens[1];
|
|
659
|
+
if (!target) {
|
|
660
|
+
ctx.ui.notify("Usage: /context switch -c <new-dir>", "error");
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const { created } = switchAndCreate(ctx.cwd, target, writerIdentity);
|
|
664
|
+
const createdLine = created.length > 0 ? ` (created ${created.length} dirs: ${created.join(", ")})` : "";
|
|
665
|
+
ctx.ui.notify(`Switched bootstrap pointer to new substrate '${target}'${createdLine}. Run /context accept-all + /context install to populate.`, "info");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (tokens[0] === "-") {
|
|
669
|
+
const { from, to } = switchToPrevious(ctx.cwd, writerIdentity);
|
|
670
|
+
ctx.ui.notify(`Switched bootstrap pointer from '${from}' back to '${to}' (previous_contextDir).`, "info");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
// Bare dir name — flip to an existing substrate.
|
|
674
|
+
switchToExisting(ctx.cwd, tokens[0], writerIdentity);
|
|
675
|
+
ctx.ui.notify(`Switched bootstrap pointer to existing substrate '${tokens[0]}'.`, "info");
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
679
|
+
ctx.ui.notify(msg, "error");
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* /context list — enumerate substrate dirs in the cwd (top-level dirs
|
|
684
|
+
* containing a config.json). Marks the active one (the dir the bootstrap
|
|
685
|
+
* pointer names).
|
|
686
|
+
*/
|
|
687
|
+
function handleList(_args, ctx) {
|
|
688
|
+
const subs = listSubstrates(ctx.cwd);
|
|
689
|
+
if (subs.length === 0) {
|
|
690
|
+
ctx.ui.notify("No substrate dirs found under cwd (no top-level dir contains config.json). Run /context init <dir> + /context accept-all to bootstrap one.", "info");
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const lines = subs.map((s) => (s.isActive ? `* ${s.name} (active)` : ` ${s.name}`));
|
|
694
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* /context archive — move a substrate dir to archive/<dir>/. Refuses to
|
|
698
|
+
* archive the active substrate; refuses to clobber a prior archive of the
|
|
699
|
+
* same name.
|
|
700
|
+
*/
|
|
701
|
+
function handleArchive(args, ctx) {
|
|
702
|
+
const targetDir = args.trim().split(/\s+/)[0];
|
|
703
|
+
if (!targetDir) {
|
|
704
|
+
ctx.ui.notify("Usage: /context archive <dir>", "error");
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
try {
|
|
708
|
+
const { from, to } = archiveSubstrate(ctx.cwd, targetDir);
|
|
709
|
+
ctx.ui.notify(`Archived substrate '${from}' to '${to}'.`, "info");
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
713
|
+
ctx.ui.notify(msg, "error");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
339
716
|
// ── Extension factory ───────────────────────────────────────────────────────
|
|
340
717
|
const extension = (pi) => {
|
|
341
718
|
// ── Update check on session start (non-blocking) ───────────────────
|
|
342
719
|
pi.on("session_start", async (_event, ctx) => {
|
|
343
720
|
checkForUpdates((msg, level) => ctx.ui.notify(msg, level)).catch(() => { });
|
|
344
721
|
});
|
|
345
|
-
// ──
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
try {
|
|
363
|
-
params.item = JSON.parse(params.item);
|
|
364
|
-
}
|
|
365
|
-
catch {
|
|
366
|
-
throw new Error(`item parameter must be a JSON object, got unparseable string`);
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
// Auto-id allocation (FGAP-084 dual-surface twin of file-block-item --auto-id)
|
|
370
|
-
if (params.autoId && params.item && typeof params.item === "object" && !params.item.id) {
|
|
371
|
-
params.item.id = nextId(ctx.cwd, params.block);
|
|
372
|
-
}
|
|
373
|
-
// Duplicate check if item has an id field
|
|
374
|
-
if (params.item && typeof params.item === "object" && "id" in params.item) {
|
|
375
|
-
try {
|
|
376
|
-
const data = readBlock(ctx.cwd, params.block);
|
|
377
|
-
const arr = data[params.arrayKey];
|
|
378
|
-
if (Array.isArray(arr) && arr.some((i) => i.id === params.item.id)) {
|
|
379
|
-
throw new Error(`Item '${params.item.id}' already exists in ${params.block}.${params.arrayKey}`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
catch (e) {
|
|
383
|
-
/* Re-throw duplicate errors; swallow block-not-found */
|
|
384
|
-
if (e instanceof Error && e.message.includes("already exists"))
|
|
385
|
-
throw e;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
appendToBlock(ctx.cwd, params.block, params.arrayKey, params.item);
|
|
389
|
-
const id = params.item?.id ? ` '${params.item.id}'` : "";
|
|
390
|
-
return {
|
|
391
|
-
details: undefined,
|
|
392
|
-
content: [{ type: "text", text: `Appended item${id} to ${params.block}.${params.arrayKey}` }],
|
|
393
|
-
};
|
|
394
|
-
},
|
|
395
|
-
});
|
|
396
|
-
// ── Tool: update-block-item ───────────────────────────────────────────
|
|
397
|
-
pi.registerTool({
|
|
398
|
-
name: "update-block-item",
|
|
399
|
-
label: "Update Block Item",
|
|
400
|
-
description: "Update fields on an item in a project block array. Finds by predicate field match.",
|
|
401
|
-
promptSnippet: "Update items in project blocks — change status, add details, mark resolved",
|
|
402
|
-
parameters: Type.Object({
|
|
403
|
-
block: Type.String({ description: "Block name (e.g., 'issues', 'decisions')" }),
|
|
404
|
-
arrayKey: Type.String({ description: "Array key in the block" }),
|
|
405
|
-
match: Type.Record(Type.String(), Type.Unknown(), { description: "Fields to match (e.g., { id: 'issue-123' })" }),
|
|
406
|
-
updates: Type.Record(Type.String(), Type.Unknown(), {
|
|
407
|
-
description: "Fields to update (e.g., { status: 'resolved' })",
|
|
408
|
-
}),
|
|
409
|
-
}),
|
|
410
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
411
|
-
if (Object.keys(params.updates).length === 0) {
|
|
412
|
-
throw new Error("No fields to update — updates parameter is empty");
|
|
413
|
-
}
|
|
414
|
-
const matchEntries = Object.entries(params.match);
|
|
415
|
-
updateItemInBlock(ctx.cwd, params.block, params.arrayKey, (item) => matchEntries.every(([k, v]) => item[k] === v), params.updates);
|
|
416
|
-
const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
417
|
-
return {
|
|
418
|
-
details: undefined,
|
|
419
|
-
content: [
|
|
420
|
-
{
|
|
421
|
-
type: "text",
|
|
422
|
-
text: `Updated item (${matchDesc}) in ${params.block}.${params.arrayKey}: ${Object.keys(params.updates).join(", ")}`,
|
|
423
|
-
},
|
|
424
|
-
],
|
|
425
|
-
};
|
|
426
|
-
},
|
|
427
|
-
});
|
|
428
|
-
// ── Tool: append-relation ─────────────────────────────────────────────
|
|
429
|
-
pi.registerTool({
|
|
430
|
-
name: "append-relation",
|
|
431
|
-
label: "Append Relation",
|
|
432
|
-
description: "Append a closure-table relation (edge: parent, child, relation_type, optional ordinal) to relations.json. " +
|
|
433
|
-
"Shape is AJV-validated; an exact-duplicate edge (same parent+child+relation_type) is a no-op. Reference " +
|
|
434
|
-
"integrity (endpoints resolve, relation_type registered, no cycle) is NOT checked here — run context-validate " +
|
|
435
|
-
"after. Creates relations.json if absent.",
|
|
436
|
-
promptSnippet: "Create a relation/edge between two items (parent→child under a relation_type)",
|
|
437
|
-
parameters: Type.Object({
|
|
438
|
-
parent: Type.String({ description: "Canonical id (or lens bin name) of the parent endpoint" }),
|
|
439
|
-
child: Type.String({ description: "Canonical id of the child endpoint" }),
|
|
440
|
-
relation_type: Type.String({
|
|
441
|
-
description: "Registered relation_type canonical_id / hierarchy edge type / lens id",
|
|
442
|
-
}),
|
|
443
|
-
ordinal: Type.Optional(Type.Integer({ description: "Optional sibling-ordering within (parent, relation_type)" })),
|
|
444
|
-
}),
|
|
445
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
446
|
-
const edge = {
|
|
447
|
-
parent: params.parent,
|
|
448
|
-
child: params.child,
|
|
449
|
-
relation_type: params.relation_type,
|
|
450
|
-
...(params.ordinal !== undefined ? { ordinal: params.ordinal } : {}),
|
|
451
|
-
};
|
|
452
|
-
const { appended } = appendRelation(ctx.cwd, edge);
|
|
453
|
-
const ordinalNote = params.ordinal !== undefined ? ` (ordinal ${params.ordinal})` : "";
|
|
454
|
-
const text = appended
|
|
455
|
-
? `Appended relation ${edge.parent} -[${edge.relation_type}]-> ${edge.child}${ordinalNote}`
|
|
456
|
-
: `Relation ${edge.parent} -[${edge.relation_type}]-> ${edge.child} already exists — no-op`;
|
|
457
|
-
return {
|
|
458
|
-
details: undefined,
|
|
459
|
-
content: [{ type: "text", text }],
|
|
460
|
-
};
|
|
461
|
-
},
|
|
462
|
-
});
|
|
463
|
-
// ── Tool: append-block-nested-item ────────────────────────────────────
|
|
464
|
-
pi.registerTool({
|
|
465
|
-
name: "append-block-nested-item",
|
|
466
|
-
label: "Append Block Nested Item",
|
|
467
|
-
description: "Append an item to a nested array on a parent-array item in a project block. Schema validation is automatic.",
|
|
468
|
-
promptSnippet: "Append items to nested arrays inside parent items (e.g., findings inside a review)",
|
|
469
|
-
parameters: Type.Object({
|
|
470
|
-
block: Type.String({ description: "Block name (e.g., 'spec-reviews')" }),
|
|
471
|
-
arrayKey: Type.String({ description: "Parent array key (e.g., 'reviews')" }),
|
|
472
|
-
match: Type.Record(Type.String(), Type.Unknown(), {
|
|
473
|
-
description: "Fields to match the parent item (e.g., { id: 'REVIEW-001' })",
|
|
474
|
-
}),
|
|
475
|
-
nestedKey: Type.String({ description: "Nested array key on the matched parent (e.g., 'findings')" }),
|
|
476
|
-
item: Type.Unknown({ description: "Item object to append to the nested array — must conform to schema" }),
|
|
477
|
-
}),
|
|
478
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
479
|
-
if (typeof params.item === "string") {
|
|
480
|
-
try {
|
|
481
|
-
params.item = JSON.parse(params.item);
|
|
482
|
-
}
|
|
483
|
-
catch {
|
|
484
|
-
throw new Error(`item parameter must be a JSON object, got unparseable string`);
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
const matchEntries = Object.entries(params.match);
|
|
488
|
-
const predicate = (i) => matchEntries.every(([k, v]) => i[k] === v);
|
|
489
|
-
appendToNestedArray(ctx.cwd, params.block, params.arrayKey, predicate, params.nestedKey, params.item);
|
|
490
|
-
const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
491
|
-
const id = params.item?.id ? ` '${params.item.id}'` : "";
|
|
492
|
-
return {
|
|
493
|
-
details: undefined,
|
|
494
|
-
content: [
|
|
495
|
-
{
|
|
496
|
-
type: "text",
|
|
497
|
-
text: `Appended item${id} to ${params.block}.${params.arrayKey}[${matchDesc}].${params.nestedKey}`,
|
|
498
|
-
},
|
|
499
|
-
],
|
|
500
|
-
};
|
|
501
|
-
},
|
|
502
|
-
});
|
|
503
|
-
// ── Tool: update-block-nested-item ────────────────────────────────────
|
|
504
|
-
pi.registerTool({
|
|
505
|
-
name: "update-block-nested-item",
|
|
506
|
-
label: "Update Block Nested Item",
|
|
507
|
-
description: "Update fields on a nested-array item inside a parent-array item in a project block. Finds parent and nested by predicate field match. Throws on parent or nested miss (mirrors update-block-item semantics).",
|
|
508
|
-
promptSnippet: "Update items inside nested arrays — change finding state, mark resolved",
|
|
509
|
-
parameters: Type.Object({
|
|
510
|
-
block: Type.String({ description: "Block name (e.g., 'spec-reviews')" }),
|
|
511
|
-
arrayKey: Type.String({ description: "Parent array key (e.g., 'reviews')" }),
|
|
512
|
-
match: Type.Record(Type.String(), Type.Unknown(), {
|
|
513
|
-
description: "Fields to match the parent item (e.g., { id: 'REVIEW-001' })",
|
|
514
|
-
}),
|
|
515
|
-
nestedKey: Type.String({ description: "Nested array key on the matched parent (e.g., 'findings')" }),
|
|
516
|
-
nestedMatch: Type.Record(Type.String(), Type.Unknown(), {
|
|
517
|
-
description: "Fields to match the nested item (e.g., { id: 'F-001' })",
|
|
518
|
-
}),
|
|
519
|
-
updates: Type.Record(Type.String(), Type.Unknown(), {
|
|
520
|
-
description: "Fields to update on the nested item (e.g., { state: 'resolved' })",
|
|
521
|
-
}),
|
|
522
|
-
}),
|
|
523
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
524
|
-
if (Object.keys(params.updates).length === 0) {
|
|
525
|
-
throw new Error("No fields to update — updates parameter is empty");
|
|
526
|
-
}
|
|
527
|
-
const parentEntries = Object.entries(params.match);
|
|
528
|
-
const nestedEntries = Object.entries(params.nestedMatch);
|
|
529
|
-
const parentPred = (i) => parentEntries.every(([k, v]) => i[k] === v);
|
|
530
|
-
const nestedPred = (i) => nestedEntries.every(([k, v]) => i[k] === v);
|
|
531
|
-
updateNestedArrayItem(ctx.cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred, params.updates);
|
|
532
|
-
const parentDesc = parentEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
533
|
-
const nestedDesc = nestedEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
534
|
-
return {
|
|
535
|
-
details: undefined,
|
|
536
|
-
content: [
|
|
537
|
-
{
|
|
538
|
-
type: "text",
|
|
539
|
-
text: `Updated nested item (${nestedDesc}) in ${params.block}.${params.arrayKey}[${parentDesc}].${params.nestedKey}: ${Object.keys(params.updates).join(", ")}`,
|
|
540
|
-
},
|
|
541
|
-
],
|
|
542
|
-
};
|
|
543
|
-
},
|
|
544
|
-
});
|
|
545
|
-
// ── Tool: remove-block-item ───────────────────────────────────────────
|
|
546
|
-
pi.registerTool({
|
|
547
|
-
name: "remove-block-item",
|
|
548
|
-
label: "Remove Block Item",
|
|
549
|
-
description: "Remove items matching a predicate from a top-level array in a project block. Idempotent — returns { removed: 0 } on no match without throwing. Schema validation runs after removal.",
|
|
550
|
-
promptSnippet: "Remove items from project blocks — prune retracted issues, dedupe entries",
|
|
551
|
-
parameters: Type.Object({
|
|
552
|
-
block: Type.String({ description: "Block name (e.g., 'issues')" }),
|
|
553
|
-
arrayKey: Type.String({ description: "Top-level array key (e.g., 'issues')" }),
|
|
554
|
-
match: Type.Record(Type.String(), Type.Unknown(), { description: "Fields to match (e.g., { id: 'issue-123' })" }),
|
|
555
|
-
}),
|
|
556
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
557
|
-
const matchEntries = Object.entries(params.match);
|
|
558
|
-
const predicate = (i) => matchEntries.every(([k, v]) => i[k] === v);
|
|
559
|
-
const result = removeFromBlock(ctx.cwd, params.block, params.arrayKey, predicate);
|
|
560
|
-
const matchDesc = matchEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
561
|
-
return {
|
|
562
|
-
details: undefined,
|
|
563
|
-
content: [
|
|
564
|
-
{
|
|
565
|
-
type: "text",
|
|
566
|
-
text: `Removed ${result.removed} item(s) matching (${matchDesc}) from ${params.block}.${params.arrayKey}`,
|
|
567
|
-
},
|
|
568
|
-
],
|
|
569
|
-
};
|
|
570
|
-
},
|
|
571
|
-
});
|
|
572
|
-
// ── Tool: remove-block-nested-item ────────────────────────────────────
|
|
573
|
-
pi.registerTool({
|
|
574
|
-
name: "remove-block-nested-item",
|
|
575
|
-
label: "Remove Block Nested Item",
|
|
576
|
-
description: "Remove items matching a predicate from a nested array on a parent-array item in a project block. Throws on parent miss; returns { removed: 0 } on nested miss without throwing.",
|
|
577
|
-
promptSnippet: "Remove nested items — drop rejected findings, retract nested references",
|
|
578
|
-
parameters: Type.Object({
|
|
579
|
-
block: Type.String({ description: "Block name (e.g., 'spec-reviews')" }),
|
|
580
|
-
arrayKey: Type.String({ description: "Parent array key (e.g., 'reviews')" }),
|
|
581
|
-
match: Type.Record(Type.String(), Type.Unknown(), {
|
|
582
|
-
description: "Fields to match the parent item (e.g., { id: 'REVIEW-001' })",
|
|
583
|
-
}),
|
|
584
|
-
nestedKey: Type.String({ description: "Nested array key on the matched parent (e.g., 'findings')" }),
|
|
585
|
-
nestedMatch: Type.Record(Type.String(), Type.Unknown(), {
|
|
586
|
-
description: "Fields to match the nested items to remove (e.g., { id: 'F-001' })",
|
|
587
|
-
}),
|
|
588
|
-
}),
|
|
589
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
590
|
-
const parentEntries = Object.entries(params.match);
|
|
591
|
-
const nestedEntries = Object.entries(params.nestedMatch);
|
|
592
|
-
const parentPred = (i) => parentEntries.every(([k, v]) => i[k] === v);
|
|
593
|
-
const nestedPred = (i) => nestedEntries.every(([k, v]) => i[k] === v);
|
|
594
|
-
const result = removeFromNestedArray(ctx.cwd, params.block, params.arrayKey, parentPred, params.nestedKey, nestedPred);
|
|
595
|
-
const parentDesc = parentEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
596
|
-
const nestedDesc = nestedEntries.map(([k, v]) => `${k}=${v}`).join(", ");
|
|
597
|
-
return {
|
|
598
|
-
details: undefined,
|
|
599
|
-
content: [
|
|
600
|
-
{
|
|
601
|
-
type: "text",
|
|
602
|
-
text: `Removed ${result.removed} nested item(s) matching (${nestedDesc}) from ${params.block}.${params.arrayKey}[${parentDesc}].${params.nestedKey}`,
|
|
603
|
-
},
|
|
604
|
-
],
|
|
605
|
-
};
|
|
606
|
-
},
|
|
607
|
-
});
|
|
608
|
-
// ── Tool: read-block-dir ──────────────────────────────────────────────
|
|
609
|
-
pi.registerTool({
|
|
610
|
-
name: "read-block-dir",
|
|
611
|
-
label: "Read Block Dir",
|
|
612
|
-
description: "Enumerate and parse all .json files in a .project/<subdir>/ directory, returned as a sorted array. Missing directories return [].",
|
|
613
|
-
promptSnippet: "Enumerate project block subdirectories (phases, schemas, etc.) as parsed JSON",
|
|
614
|
-
parameters: Type.Object({
|
|
615
|
-
subdir: Type.String({ description: "Subdirectory under .project/ (e.g., 'phases', 'schemas')" }),
|
|
616
|
-
}),
|
|
617
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
618
|
-
const result = readBlockDir(ctx.cwd, params.subdir);
|
|
619
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
620
|
-
const truncated = truncateHead(jsonStr);
|
|
621
|
-
let text = truncated.content;
|
|
622
|
-
if (truncated.truncated) {
|
|
623
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit. Full content: .project/${params.subdir}/]`;
|
|
624
|
-
}
|
|
625
|
-
return {
|
|
626
|
-
details: undefined,
|
|
627
|
-
content: [{ type: "text", text }],
|
|
628
|
-
};
|
|
629
|
-
},
|
|
630
|
-
});
|
|
631
|
-
// ── Tool: read-block ────────────────────────────────────────────────────
|
|
632
|
-
pi.registerTool({
|
|
633
|
-
name: "read-block",
|
|
634
|
-
label: "Read Block",
|
|
635
|
-
description: "Read a project block file as structured JSON.",
|
|
636
|
-
promptSnippet: "Read a project block as structured JSON",
|
|
637
|
-
parameters: Type.Object({
|
|
638
|
-
block: Type.String({ description: "Block name (e.g., 'issues', 'tasks', 'requirements')" }),
|
|
639
|
-
}),
|
|
640
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
641
|
-
const result = readBlock(ctx.cwd, params.block);
|
|
642
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
643
|
-
const truncated = truncateHead(jsonStr);
|
|
644
|
-
let text = truncated.content;
|
|
645
|
-
if (truncated.truncated) {
|
|
646
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit. Full content: .project/${params.block}.json]`;
|
|
647
|
-
}
|
|
648
|
-
return {
|
|
649
|
-
details: undefined,
|
|
650
|
-
content: [{ type: "text", text }],
|
|
651
|
-
};
|
|
652
|
-
},
|
|
653
|
-
});
|
|
654
|
-
// ── Tool: write-block ───────────────────────────────────────────────────
|
|
655
|
-
pi.registerTool({
|
|
656
|
-
name: "write-block",
|
|
657
|
-
label: "Write Block",
|
|
658
|
-
description: "Write or replace an entire project block with schema validation.",
|
|
659
|
-
promptSnippet: "Write or replace a project block with schema validation",
|
|
660
|
-
parameters: Type.Object({
|
|
661
|
-
block: Type.String({ description: "Block name (e.g., 'project', 'architecture')" }),
|
|
662
|
-
data: Type.Unknown({ description: "Complete block data — must conform to block schema" }),
|
|
663
|
-
}),
|
|
664
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
665
|
-
const data = typeof params.data === "string" ? JSON.parse(params.data) : params.data;
|
|
666
|
-
writeBlock(ctx.cwd, params.block, data);
|
|
667
|
-
return {
|
|
668
|
-
details: undefined,
|
|
669
|
-
content: [{ type: "text", text: `Wrote block '${params.block}' successfully` }],
|
|
670
|
-
};
|
|
671
|
-
},
|
|
672
|
-
});
|
|
673
|
-
// ── Tool: context-status ────────────────────────────────────────────────
|
|
674
|
-
pi.registerTool({
|
|
675
|
-
name: "context-status",
|
|
676
|
-
label: "Context Status",
|
|
677
|
-
description: "Get derived context state — source metrics, block summaries, planning lifecycle status.",
|
|
678
|
-
promptSnippet: "Get context state — source metrics, block summaries, planning lifecycle status",
|
|
679
|
-
parameters: Type.Object({}),
|
|
680
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
681
|
-
const result = contextState(ctx.cwd);
|
|
682
|
-
return {
|
|
683
|
-
details: undefined,
|
|
684
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
685
|
-
};
|
|
686
|
-
},
|
|
687
|
-
});
|
|
688
|
-
// ── Tool: context-validate ──────────────────────────────────────────────
|
|
689
|
-
pi.registerTool({
|
|
690
|
-
name: "context-validate",
|
|
691
|
-
label: "Context Validate",
|
|
692
|
-
description: "Validate cross-block referential integrity — check that IDs referenced across blocks exist.",
|
|
693
|
-
promptSnippet: "Validate cross-block referential integrity",
|
|
694
|
-
parameters: Type.Object({}),
|
|
695
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
696
|
-
const result = validateContext(ctx.cwd);
|
|
697
|
-
return {
|
|
698
|
-
details: undefined,
|
|
699
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
700
|
-
};
|
|
701
|
-
},
|
|
702
|
-
});
|
|
703
|
-
// ── Tool: read-config ───────────────────────────────────────────────────
|
|
704
|
-
pi.registerTool({
|
|
705
|
-
name: "read-config",
|
|
706
|
-
label: "Read Config",
|
|
707
|
-
description: "Read the substrate config.json as structured JSON — vocabulary, lenses, relation_types, status_buckets, display_strings, layers, block_kinds, installed_schemas, installed_blocks.",
|
|
708
|
-
promptSnippet: "Read project config — vocabulary, lenses, relation_types, status_buckets",
|
|
709
|
-
parameters: Type.Object({}),
|
|
710
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
711
|
-
const config = loadConfig(ctx.cwd);
|
|
712
|
-
const root = tryResolveContextDir(ctx.cwd);
|
|
713
|
-
const configPath = root === null ? null : path.join(root, "config.json");
|
|
714
|
-
const result = { config, configPath };
|
|
715
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
716
|
-
const truncated = truncateHead(jsonStr);
|
|
717
|
-
let text = truncated.content;
|
|
718
|
-
if (truncated.truncated) {
|
|
719
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit. Full content: ${configPath}]`;
|
|
720
|
-
}
|
|
721
|
-
return {
|
|
722
|
-
details: undefined,
|
|
723
|
-
content: [{ type: "text", text }],
|
|
724
|
-
};
|
|
725
|
-
},
|
|
726
|
-
});
|
|
727
|
-
// ── Tool: list-tools ──────────────────────────────────────────────────────
|
|
728
|
-
pi.registerTool({
|
|
729
|
-
name: "list-tools",
|
|
730
|
-
label: "List Tools",
|
|
731
|
-
description: "List every tool bound into the current Pi session — name, description, parameter JSON-schema, and source extension — plus which tools are currently active. Self-introspection of the agent's own tool surface (all loaded extensions + builtins).",
|
|
732
|
-
promptSnippet: "Discover available tools — names, params, descriptions, active set",
|
|
733
|
-
parameters: Type.Object({}),
|
|
734
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
|
735
|
-
// Closes over the factory `pi` (the introspection surface lives on
|
|
736
|
-
// ExtensionAPI, not ExtensionContext) — `_ctx` is unused.
|
|
737
|
-
const all = pi.getAllTools();
|
|
738
|
-
const active = pi.getActiveTools();
|
|
739
|
-
const result = { tools: all, active, total: all.length, activeCount: active.length };
|
|
740
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
741
|
-
const truncated = truncateHead(jsonStr);
|
|
742
|
-
let text = truncated.content;
|
|
743
|
-
if (truncated.truncated) {
|
|
744
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
745
|
-
}
|
|
746
|
-
return {
|
|
747
|
-
details: undefined,
|
|
748
|
-
content: [{ type: "text", text }],
|
|
749
|
-
};
|
|
750
|
-
},
|
|
751
|
-
});
|
|
752
|
-
// ── Tool: read-samples-catalog ────────────────────────────────────────────
|
|
753
|
-
pi.registerTool({
|
|
754
|
-
name: "read-samples-catalog",
|
|
755
|
-
label: "Read Samples Catalog",
|
|
756
|
-
description: "Enumerate installable sample block kinds (DEC-0037 packaged view): per kind — title, description, item shape, applicable relation_types (as source/target), invariants, lenses — plus top-level relation_type/lens/invariant/layer/status_bucket registries. Package-intrinsic: reads the extension's bundled samples catalog, independent of any project. Optional `kind` returns one packaged kind.",
|
|
757
|
-
promptSnippet: "Discover installable sample block kinds — title, shape, relation_types, invariants, lenses",
|
|
758
|
-
parameters: Type.Object({
|
|
759
|
-
kind: Type.Optional(Type.String({ description: "Filter to one block_kind canonical_id (e.g. 'tasks')" })),
|
|
760
|
-
}),
|
|
761
|
-
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
762
|
-
// Package-intrinsic: the catalog reads the extension's bundled samples
|
|
763
|
-
// directory, not the project substrate — `_ctx` (and its cwd) is unused.
|
|
764
|
-
const catalog = samplesCatalog(params.kind ? { kind: params.kind } : undefined);
|
|
765
|
-
const jsonStr = JSON.stringify(catalog, null, 2);
|
|
766
|
-
const truncated = truncateHead(jsonStr);
|
|
767
|
-
let text = truncated.content;
|
|
768
|
-
if (truncated.truncated) {
|
|
769
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
770
|
-
}
|
|
771
|
-
return {
|
|
772
|
-
details: undefined,
|
|
773
|
-
content: [{ type: "text", text }],
|
|
774
|
-
};
|
|
775
|
-
},
|
|
776
|
-
});
|
|
777
|
-
// ── Tool: context-current-state ───────────────────────────────────────────
|
|
778
|
-
pi.registerTool({
|
|
779
|
-
name: "context-current-state",
|
|
780
|
-
label: "Context Current State",
|
|
781
|
-
description: "Derive 'where are we + what's next' purely from .project substrate — focus, in-flight tasks, ranked atomic-next actions (open framework-gaps then unblocked planned tasks), and blocked tasks. No writes; nothing hand-stored.",
|
|
782
|
-
promptSnippet: "Derive current project state — focus, in-flight, next actions, blocked",
|
|
783
|
-
parameters: Type.Object({}),
|
|
784
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
785
|
-
const state = currentState(ctx.cwd);
|
|
786
|
-
return {
|
|
787
|
-
details: undefined,
|
|
788
|
-
content: [{ type: "text", text: JSON.stringify(state, null, 2) }],
|
|
789
|
-
};
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
// ── Tool: context-bootstrap-state ─────────────────────────────────────────
|
|
793
|
-
pi.registerTool({
|
|
794
|
-
name: "context-bootstrap-state",
|
|
795
|
-
label: "Context Bootstrap State",
|
|
796
|
-
description: "Derive the substrate bootstrap state for the cwd, purely from the filesystem (DEC-0040): 'no-pointer' | 'no-config' | 'not-installed' | 'ready', plus the resolved contextDir and any declared-but-unmaterialized installed assets. Unlike every other tool, this NEVER throws on an un-bootstrapped substrate — it returns 'no-pointer' so you can detect a fresh substrate and tell the human to run /context start (bootstrap is human-only). No writes.",
|
|
797
|
-
promptSnippet: "Derive substrate bootstrap state — no-pointer | no-config | not-installed | ready (never throws pre-bootstrap)",
|
|
798
|
-
parameters: Type.Object({}),
|
|
799
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
800
|
-
const status = deriveBootstrapState(ctx.cwd);
|
|
801
|
-
return {
|
|
802
|
-
details: undefined,
|
|
803
|
-
content: [{ type: "text", text: JSON.stringify(status, null, 2) }],
|
|
804
|
-
};
|
|
805
|
-
},
|
|
806
|
-
});
|
|
807
|
-
// ── Tool: rename-canonical-id ─────────────────────────────────────────────
|
|
808
|
-
pi.registerTool({
|
|
809
|
-
name: "rename-canonical-id",
|
|
810
|
-
label: "Rename Canonical Id",
|
|
811
|
-
description: "Rename a canonical_id (kind: item | relation_type | lens | layer) from oldId to newId across all substrate surfaces that carry it as DATA — item home block + relations.json edges, or the relevant config registries. Out-of-substrate occurrences (analysis MDs, git history) are REPORTED, never rewritten. block_kind renames are unsupported (filesystem cascade). Use dryRun to preview the would-change counts without writing.",
|
|
812
|
-
promptSnippet: "Rename a canonical_id (item/relation_type/lens/layer) across substrate; dryRun to preview",
|
|
813
|
-
parameters: Type.Object({
|
|
814
|
-
kind: Type.String({ description: "One of: item | relation_type | lens | layer" }),
|
|
815
|
-
oldId: Type.String({ description: "Current canonical_id to rename from" }),
|
|
816
|
-
newId: Type.String({ description: "New canonical_id to rename to" }),
|
|
817
|
-
dryRun: Type.Optional(Type.Boolean({ description: "Compute would-change counts without writing" })),
|
|
818
|
-
}),
|
|
819
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
820
|
-
const report = renameCanonicalId(ctx.cwd, params.kind, params.oldId, params.newId, { dryRun: params.dryRun });
|
|
821
|
-
return {
|
|
822
|
-
details: undefined,
|
|
823
|
-
content: [{ type: "text", text: JSON.stringify(report, null, 2) }],
|
|
824
|
-
};
|
|
825
|
-
},
|
|
826
|
-
});
|
|
827
|
-
// ── Tool: amend-config ────────────────────────────────────────────────────
|
|
828
|
-
pi.registerTool({
|
|
829
|
-
name: "amend-config",
|
|
830
|
-
label: "Amend Config",
|
|
831
|
-
description: "Scoped add / replace / remove of ONE entry in ONE config.json registry (block_kinds, relation_types, lenses, " +
|
|
832
|
-
"layers, invariants, status_buckets, display_strings, naming, installed_schemas, installed_blocks, hierarchy). " +
|
|
833
|
-
"The whole resulting config is AJV-validated (SHAPE) and op-correctness is enforced (add ⇒ key absent, " +
|
|
834
|
-
"replace/remove ⇒ key present). Cross-registry referential integrity (removing a still-referenced " +
|
|
835
|
-
"relation_type / lens / layer / block_kind) is NOT checked here — run context-validate after. dryRun previews " +
|
|
836
|
-
"without writing.",
|
|
837
|
-
promptSnippet: "Add/replace/remove one entry in a config.json registry (vocabulary, lenses, invariants, status_buckets)",
|
|
838
|
-
parameters: Type.Object({
|
|
839
|
-
registry: Type.String({
|
|
840
|
-
description: "One of: block_kinds | relation_types | lenses | layers | invariants | status_buckets | display_strings | naming | installed_schemas | installed_blocks | hierarchy",
|
|
841
|
-
}),
|
|
842
|
-
operation: Type.String({ description: "add | replace | remove" }),
|
|
843
|
-
key: Type.String({
|
|
844
|
-
description: "Entry key: id for keyed-array (block_kinds/relation_types/lenses/layers/invariants), map key for " +
|
|
845
|
-
"map (status_buckets/display_strings/naming), the string value for string-array " +
|
|
846
|
-
"(installed_schemas/installed_blocks), or a JSON {parent_block, child_block, relation_type} for hierarchy",
|
|
847
|
-
}),
|
|
848
|
-
entry: Type.Optional(Type.Unknown({
|
|
849
|
-
description: "Entry payload: object for keyed-array/hierarchy, string for map value; omit for remove. For keyed-array its id field must equal key; for string-array (when given) it must equal key",
|
|
850
|
-
})),
|
|
851
|
-
dryRun: Type.Optional(Type.Boolean({ description: "Preview the op without writing config.json" })),
|
|
852
|
-
}),
|
|
853
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
854
|
-
// Type.Unknown() params may arrive as JSON strings. Parse if possible; on
|
|
855
|
-
// failure KEEP the raw string (valid for map-value registries whose value
|
|
856
|
-
// is a bare string, e.g. naming/display_strings/status_buckets).
|
|
857
|
-
let entry = params.entry;
|
|
858
|
-
if (typeof entry === "string") {
|
|
859
|
-
try {
|
|
860
|
-
entry = JSON.parse(entry);
|
|
861
|
-
}
|
|
862
|
-
catch {
|
|
863
|
-
/* keep raw string — valid for map-value registries */
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
const result = amendConfigEntry(ctx.cwd, params.registry, params.operation, params.key, entry, undefined, {
|
|
867
|
-
dryRun: params.dryRun,
|
|
868
|
-
});
|
|
869
|
-
const verb = result.modified ? (params.dryRun ? `would ${result.operation}` : `${result.operation}d`) : "no-op";
|
|
870
|
-
return {
|
|
871
|
-
details: undefined,
|
|
872
|
-
content: [{ type: "text", text: `amend-config: ${verb} ${result.registry}[${result.key}]` }],
|
|
873
|
-
};
|
|
874
|
-
},
|
|
875
|
-
});
|
|
876
|
-
// ── Tool: read-schema ───────────────────────────────────────────────────
|
|
877
|
-
pi.registerTool({
|
|
878
|
-
name: "read-schema",
|
|
879
|
-
label: "Read Schema",
|
|
880
|
-
description: "Read a substrate schema by name as parsed JSON. Returns null when the schema file is absent.",
|
|
881
|
-
promptSnippet: "Read a block schema as structured JSON",
|
|
882
|
-
parameters: Type.Object({
|
|
883
|
-
schemaName: Type.String({
|
|
884
|
-
description: "Schema name without extension (e.g., 'tasks', 'decisions', 'issues')",
|
|
885
|
-
}),
|
|
886
|
-
}),
|
|
887
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
888
|
-
const schema = readSchema(ctx.cwd, params.schemaName);
|
|
889
|
-
const schemaPathStr = schemaPath(ctx.cwd, params.schemaName);
|
|
890
|
-
const result = { schema, schemaPath: schemaPathStr };
|
|
891
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
892
|
-
const truncated = truncateHead(jsonStr);
|
|
893
|
-
let text = truncated.content;
|
|
894
|
-
if (truncated.truncated) {
|
|
895
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit. Full content: ${schemaPathStr}]`;
|
|
896
|
-
}
|
|
897
|
-
return {
|
|
898
|
-
details: undefined,
|
|
899
|
-
content: [{ type: "text", text }],
|
|
900
|
-
};
|
|
901
|
-
},
|
|
902
|
-
});
|
|
903
|
-
// ── Tool: write-schema ──────────────────────────────────────────────────
|
|
904
|
-
pi.registerTool({
|
|
905
|
-
name: "write-schema",
|
|
906
|
-
label: "Write Schema",
|
|
907
|
-
description: "Create or replace a substrate block-kind JSON Schema. operation 'create' requires the schema absent; " +
|
|
908
|
-
"'replace' requires it present. The body is AJV draft-07 meta-validated before an atomic write. CAVEAT: a " +
|
|
909
|
-
"'replace' that changes the schema's version does NOT migrate existing block items — read-time " +
|
|
910
|
-
"validateBlockWithMigration throws a version mismatch until a code-level MigrationFn is registered (no tool " +
|
|
911
|
-
"surface for that). Registering the block_kind that points at this schema is a separate step (amend-config " +
|
|
912
|
-
"block_kinds).",
|
|
913
|
-
promptSnippet: "Create or replace a block-kind JSON Schema (meta-validated, atomic)",
|
|
914
|
-
parameters: Type.Object({
|
|
915
|
-
operation: Type.String({ description: "create | replace" }),
|
|
916
|
-
schemaName: Type.String({ description: "Schema name without extension (e.g., 'tasks')" }),
|
|
917
|
-
schema: Type.Unknown({
|
|
918
|
-
description: "The whole JSON Schema object (draft-07). Accepts a JSON string.",
|
|
919
|
-
}),
|
|
920
|
-
dryRun: Type.Optional(Type.Boolean({ description: "Meta-validate without writing" })),
|
|
921
|
-
}),
|
|
922
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
923
|
-
// Type.Unknown() params may arrive as JSON strings. Parse if possible; on
|
|
924
|
-
// failure KEEP the raw value (meta-validation rejects a non-object body).
|
|
925
|
-
let schema = params.schema;
|
|
926
|
-
if (typeof schema === "string") {
|
|
927
|
-
try {
|
|
928
|
-
schema = JSON.parse(schema);
|
|
929
|
-
}
|
|
930
|
-
catch {
|
|
931
|
-
/* keep raw string — meta-validation will reject a non-object */
|
|
932
|
-
}
|
|
933
|
-
}
|
|
934
|
-
const result = writeSchemaChecked(ctx.cwd, params.schemaName, schema, params.operation, undefined, { dryRun: params.dryRun });
|
|
935
|
-
const verb = result.written ? `${result.operation}d` : `would ${result.operation}`;
|
|
936
|
-
return {
|
|
937
|
-
details: undefined,
|
|
938
|
-
content: [
|
|
939
|
-
{ type: "text", text: `write-schema: ${verb} schema '${params.schemaName}' at ${result.schemaPath}` },
|
|
940
|
-
],
|
|
941
|
-
};
|
|
942
|
-
},
|
|
943
|
-
});
|
|
944
|
-
// ── Tool: context-init ──────────────────────────────────────────────────
|
|
945
|
-
pi.registerTool({
|
|
946
|
-
name: "context-init",
|
|
947
|
-
label: "Context Init",
|
|
948
|
-
description: "Initialize the substrate dir (bootstrap pointer + dirs only; run accept-all + install to populate).",
|
|
949
|
-
promptSnippet: "Initialize the substrate dir (bootstrap pointer + dirs only; run accept-all + install to populate)",
|
|
950
|
-
parameters: Type.Object({
|
|
951
|
-
contextDir: Type.String({
|
|
952
|
-
description: "Substrate dir name (e.g. .project). Required per DEC-0015 — no default.",
|
|
953
|
-
}),
|
|
954
|
-
}),
|
|
955
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
956
|
-
const result = initProject(ctx.cwd, params.contextDir);
|
|
957
|
-
return {
|
|
958
|
-
details: undefined,
|
|
959
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
960
|
-
};
|
|
961
|
-
},
|
|
962
|
-
});
|
|
963
|
-
// ── Tool: context-accept-all ──────────────────────────────────────────────
|
|
964
|
-
pi.registerTool({
|
|
965
|
-
name: "context-accept-all",
|
|
966
|
-
label: "Accept-All Conception",
|
|
967
|
-
description: "Adopt the canonical packaged conception (samples/conception.json) as this substrate's config.json (accept-all). Writes config only — run install after. Idempotent: never overwrites an existing config.",
|
|
968
|
-
promptSnippet: "Adopt the canonical conception as config (accept-all)",
|
|
969
|
-
parameters: Type.Object({}),
|
|
970
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
971
|
-
let result;
|
|
972
|
-
try {
|
|
973
|
-
result = adoptConception(ctx.cwd);
|
|
974
|
-
}
|
|
975
|
-
catch (err) {
|
|
976
|
-
if (err instanceof BootstrapNotFoundError) {
|
|
977
|
-
return {
|
|
978
|
-
details: undefined,
|
|
979
|
-
content: [{ type: "text", text: "substrate not initialized — run context-init first" }],
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
throw err;
|
|
983
|
-
}
|
|
984
|
-
return {
|
|
985
|
-
details: undefined,
|
|
986
|
-
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
987
|
-
};
|
|
988
|
-
},
|
|
989
|
-
});
|
|
990
|
-
// ── Tool: filter-block-items ──────────────────────────────────────────
|
|
991
|
-
pi.registerTool({
|
|
992
|
-
name: "filter-block-items",
|
|
993
|
-
label: "Filter Block Items",
|
|
994
|
-
description: "Filter the array items of a block by a single-field predicate (eq / neq / in / matches). Discovers the single top-level array property in the block; items missing the predicate field are never matched. Wraps the canonical readBlock + caller-side filter into one queryable surface; never mutates the block.",
|
|
995
|
-
promptSnippet: "Filter a block's items by a predicate — eq / neq / in / matches against a single field",
|
|
996
|
-
parameters: Type.Object({
|
|
997
|
-
block: Type.String({
|
|
998
|
-
description: "Block name (e.g., 'tasks', 'decisions', 'framework-gaps', 'context-contracts')",
|
|
999
|
-
}),
|
|
1000
|
-
field: Type.String({ description: "Item field to test (e.g., 'status', 'priority', 'id')" }),
|
|
1001
|
-
op: Type.Union([Type.Literal("eq"), Type.Literal("neq"), Type.Literal("in"), Type.Literal("matches")], {
|
|
1002
|
-
description: "Comparison operator: eq (===), neq (!==), in (value is array, item[field] in it), matches (regexp test on string)",
|
|
1003
|
-
}),
|
|
1004
|
-
value: Type.Unknown({
|
|
1005
|
-
description: "Comparison value — scalar for eq/neq, array for in, regexp pattern string for matches",
|
|
1006
|
-
}),
|
|
1007
|
-
}),
|
|
1008
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1009
|
-
const result = filterBlockItems(ctx.cwd, params.block, {
|
|
1010
|
-
field: params.field,
|
|
1011
|
-
op: params.op,
|
|
1012
|
-
value: params.value,
|
|
1013
|
-
});
|
|
1014
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
1015
|
-
const truncated = truncateHead(jsonStr);
|
|
1016
|
-
let text = truncated.content;
|
|
1017
|
-
if (truncated.truncated) {
|
|
1018
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1019
|
-
}
|
|
1020
|
-
return {
|
|
1021
|
-
details: undefined,
|
|
1022
|
-
content: [{ type: "text", text }],
|
|
1023
|
-
};
|
|
1024
|
-
},
|
|
1025
|
-
});
|
|
1026
|
-
// ── Tool: resolve-item-by-id ──────────────────────────────────────────
|
|
1027
|
-
pi.registerTool({
|
|
1028
|
-
name: "resolve-item-by-id",
|
|
1029
|
-
label: "Resolve Item By Id",
|
|
1030
|
-
description: "Look up the block, array key, and item payload for a given ID across all .project/ blocks. Returns null when no item matches. Mirrors the resolveItemById SDK function and shares its prefix-vs-block invariant — IDs whose prefix maps to a known block but live elsewhere throw at index-build time.",
|
|
1031
|
-
promptSnippet: "Resolve a kind-prefixed ID (DEC-/FEAT-/FGAP-/issue-/REQ-/TASK-/etc.) to its owning block and item",
|
|
1032
|
-
parameters: Type.Object({
|
|
1033
|
-
id: Type.String({ description: "Kind-prefixed ID, e.g., DEC-0001 / FEAT-001 / FGAP-003 / issue-064" }),
|
|
1034
|
-
}),
|
|
1035
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1036
|
-
const result = resolveItemById(ctx.cwd, params.id);
|
|
1037
|
-
return {
|
|
1038
|
-
details: undefined,
|
|
1039
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1040
|
-
};
|
|
1041
|
-
},
|
|
1042
|
-
});
|
|
1043
|
-
// ── Tool: read-block-item ─────────────────────────────────────────────
|
|
1044
|
-
pi.registerTool({
|
|
1045
|
-
name: "read-block-item",
|
|
1046
|
-
label: "Read Block Item",
|
|
1047
|
-
description: "Read a single item from a named block by its id — returns the item or null. Block-scoped (unlike resolve-item-by-id, which searches all blocks by kind-prefixed id). Avoids fetching a whole large block to get one item.",
|
|
1048
|
-
promptSnippet: "Read one item from a block by id (block-scoped; null if absent)",
|
|
1049
|
-
parameters: Type.Object({
|
|
1050
|
-
block: Type.String({ description: "Block name (e.g., 'tasks', 'decisions', 'framework-gaps')" }),
|
|
1051
|
-
id: Type.String({ description: "Item id within the block (e.g., 'TASK-001')" }),
|
|
1052
|
-
}),
|
|
1053
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1054
|
-
const result = readBlockItem(ctx.cwd, params.block, params.id);
|
|
1055
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
1056
|
-
const truncated = truncateHead(jsonStr);
|
|
1057
|
-
let text = truncated.content;
|
|
1058
|
-
if (truncated.truncated) {
|
|
1059
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1060
|
-
}
|
|
1061
|
-
return {
|
|
1062
|
-
details: undefined,
|
|
1063
|
-
content: [{ type: "text", text }],
|
|
1064
|
-
};
|
|
1065
|
-
},
|
|
1066
|
-
});
|
|
1067
|
-
// ── Tool: read-block-page ─────────────────────────────────────────────
|
|
1068
|
-
pi.registerTool({
|
|
1069
|
-
name: "read-block-page",
|
|
1070
|
-
label: "Read Block Page",
|
|
1071
|
-
description: "Paginate a block's items: returns { items, total, hasMore }. offset default 0, limit default 50. Use for blocks too large to fetch whole (past the 50KB read-block cap). total is the full item count; hasMore signals another page.",
|
|
1072
|
-
promptSnippet: "Paginate a block's items — offset + limit; returns {items,total,hasMore}",
|
|
1073
|
-
parameters: Type.Object({
|
|
1074
|
-
block: Type.String({ description: "Block name (e.g., 'framework-gaps', 'decisions', 'issues')" }),
|
|
1075
|
-
offset: Type.Optional(Type.Integer({ minimum: 0, description: "Start index (default 0)" })),
|
|
1076
|
-
limit: Type.Optional(Type.Integer({ minimum: 1, description: "Max items to return (default 50)" })),
|
|
1077
|
-
}),
|
|
1078
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1079
|
-
const result = readBlockPage(ctx.cwd, params.block, { offset: params.offset, limit: params.limit });
|
|
1080
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
1081
|
-
const truncated = truncateHead(jsonStr);
|
|
1082
|
-
let text = truncated.content;
|
|
1083
|
-
if (truncated.truncated) {
|
|
1084
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1085
|
-
}
|
|
1086
|
-
return {
|
|
1087
|
-
details: undefined,
|
|
1088
|
-
content: [{ type: "text", text }],
|
|
1089
|
-
};
|
|
1090
|
-
},
|
|
1091
|
-
});
|
|
1092
|
-
// ── Tool: join-blocks ─────────────────────────────────────────────────
|
|
1093
|
-
pi.registerTool({
|
|
1094
|
-
name: "join-blocks",
|
|
1095
|
-
label: "Join Blocks",
|
|
1096
|
-
description: "Join two blocks in one call (FGAP-043). EDGE mode: pass `relationType` — pairs left items with right-block items connected by that relations.json edge (`leftEndpoint` parent|child, default parent). FIELD mode: pass `leftField`+`rightField` — pairs where left[leftField] === right[rightField]. Optional left pre-filter via where{Field,Op,Value}. Returns [{left, right:[]}] (right always an array; one-to-many). Use instead of N+1 read-block + resolve calls.",
|
|
1097
|
-
promptSnippet: "Join two blocks in one call — by relation edge or shared field; returns {left,right[]} pairs",
|
|
1098
|
-
parameters: Type.Object({
|
|
1099
|
-
leftBlock: Type.String({ description: "Left block name (e.g., 'tasks')" }),
|
|
1100
|
-
rightBlock: Type.String({ description: "Right block name (e.g., 'verification')" }),
|
|
1101
|
-
relationType: Type.Optional(Type.String({ description: "Edge mode: relations.json relation_type" })),
|
|
1102
|
-
leftField: Type.Optional(Type.String({ description: "Field mode: left item field" })),
|
|
1103
|
-
rightField: Type.Optional(Type.String({ description: "Field mode: right item field" })),
|
|
1104
|
-
leftEndpoint: Type.Optional(Type.Union([Type.Literal("parent"), Type.Literal("child")], {
|
|
1105
|
-
description: "Edge mode: is the left item the edge parent (default) or child",
|
|
1106
|
-
})),
|
|
1107
|
-
whereField: Type.Optional(Type.String({ description: "Optional left pre-filter field" })),
|
|
1108
|
-
whereOp: Type.Optional(Type.Union([Type.Literal("eq"), Type.Literal("neq"), Type.Literal("in"), Type.Literal("matches")])),
|
|
1109
|
-
whereValue: Type.Optional(Type.Unknown({ description: "Optional left pre-filter value" })),
|
|
1110
|
-
}),
|
|
1111
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1112
|
-
const leftPredicate = params.whereField !== undefined
|
|
1113
|
-
? { field: params.whereField, op: params.whereOp ?? "eq", value: params.whereValue }
|
|
1114
|
-
: undefined;
|
|
1115
|
-
const result = joinBlocks(ctx.cwd, {
|
|
1116
|
-
leftBlock: params.leftBlock,
|
|
1117
|
-
rightBlock: params.rightBlock,
|
|
1118
|
-
relationType: params.relationType,
|
|
1119
|
-
leftField: params.leftField,
|
|
1120
|
-
rightField: params.rightField,
|
|
1121
|
-
leftEndpoint: params.leftEndpoint,
|
|
1122
|
-
leftPredicate,
|
|
1123
|
-
});
|
|
1124
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
1125
|
-
const truncated = truncateHead(jsonStr);
|
|
1126
|
-
let text = truncated.content;
|
|
1127
|
-
if (truncated.truncated) {
|
|
1128
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1129
|
-
}
|
|
1130
|
-
return {
|
|
1131
|
-
details: undefined,
|
|
1132
|
-
content: [{ type: "text", text }],
|
|
1133
|
-
};
|
|
1134
|
-
},
|
|
1135
|
-
});
|
|
1136
|
-
// ── Tool: resolve-items-by-id (bulk) ──────────────────────────────────
|
|
1137
|
-
pi.registerTool({
|
|
1138
|
-
name: "resolve-items-by-id",
|
|
1139
|
-
label: "Resolve Items By Id (Bulk)",
|
|
1140
|
-
description: "Bulk variant of resolve-item-by-id — resolve N kind-prefixed ids against a single buildIdIndex traversal. Returns an object mapping each input id to its ItemLocation (block / arrayKey / item) or null when not found. Coexists with the singular resolve-item-by-id tool; bulk collapses the N×singular-call pattern for callers resolving multiple ids in one render pass.",
|
|
1141
|
-
promptSnippet: "Resolve a batch of kind-prefixed ids (DEC-/FGAP-/TASK-/issue-/REQ-/...) in one call",
|
|
1142
|
-
parameters: Type.Object({
|
|
1143
|
-
ids: Type.Array(Type.String(), {
|
|
1144
|
-
description: "Array of kind-prefixed ids (DEC-/FGAP-/TASK-/issue-/REQ-/...) to resolve in one call",
|
|
1145
|
-
}),
|
|
1146
|
-
}),
|
|
1147
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1148
|
-
const resultMap = resolveItemsByIds(ctx.cwd, params.ids);
|
|
1149
|
-
const obj = {};
|
|
1150
|
-
for (const [id, loc] of resultMap)
|
|
1151
|
-
obj[id] = loc;
|
|
1152
|
-
const jsonStr = JSON.stringify(obj, null, 2);
|
|
1153
|
-
const truncated = truncateHead(jsonStr);
|
|
1154
|
-
let text = truncated.content;
|
|
1155
|
-
if (truncated.truncated) {
|
|
1156
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1157
|
-
}
|
|
1158
|
-
return {
|
|
1159
|
-
details: undefined,
|
|
1160
|
-
content: [{ type: "text", text }],
|
|
1161
|
-
};
|
|
1162
|
-
},
|
|
1163
|
-
});
|
|
1164
|
-
// ── Tool: complete-task ────────────────────────────────────────────────
|
|
1165
|
-
pi.registerTool({
|
|
1166
|
-
name: "complete-task",
|
|
1167
|
-
label: "Complete Task",
|
|
1168
|
-
description: "Complete a task with verification gate — requires a passing verification entry targeting the task.",
|
|
1169
|
-
promptSnippet: "Complete a task — gates on passing verification before updating status",
|
|
1170
|
-
parameters: Type.Object({
|
|
1171
|
-
taskId: Type.String({ description: "Task ID to complete" }),
|
|
1172
|
-
verificationId: Type.String({
|
|
1173
|
-
description: "Verification entry ID (must target this task with status 'passed')",
|
|
1174
|
-
}),
|
|
1175
|
-
}),
|
|
1176
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1177
|
-
const result = completeTask(ctx.cwd, params.taskId, params.verificationId);
|
|
1178
|
-
return {
|
|
1179
|
-
details: undefined,
|
|
1180
|
-
content: [
|
|
1181
|
-
{
|
|
1182
|
-
type: "text",
|
|
1183
|
-
text: `Task '${result.taskId}' completed (was '${result.previousStatus}'). Verification: ${result.verificationId} (${result.verificationStatus})`,
|
|
1184
|
-
},
|
|
1185
|
-
],
|
|
1186
|
-
};
|
|
1187
|
-
},
|
|
1188
|
-
});
|
|
1189
|
-
// ── Tool: context-validate-relations ──────────────────────────────────
|
|
1190
|
-
pi.registerTool({
|
|
1191
|
-
name: "context-validate-relations",
|
|
1192
|
-
label: "Context Validate Relations",
|
|
1193
|
-
description: "Validate substrate relations.json edges against config-declared lenses + hierarchy + relation_types and the cross-block id index. Returns SubstrateValidationResult with status (clean/warnings/invalid) and per-issue diagnostics.",
|
|
1194
|
-
promptSnippet: "Validate substrate relations against config + items",
|
|
1195
|
-
parameters: Type.Object({}),
|
|
1196
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1197
|
-
const result = validateContextRelations(ctx.cwd);
|
|
1198
|
-
return {
|
|
1199
|
-
details: undefined,
|
|
1200
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1201
|
-
};
|
|
1202
|
-
},
|
|
1203
|
-
});
|
|
1204
|
-
// ── Tool: context-edges-for-lens ──────────────────────────────────────
|
|
1205
|
-
pi.registerTool({
|
|
1206
|
-
name: "context-edges-for-lens",
|
|
1207
|
-
label: "Context Edges For Lens",
|
|
1208
|
-
description: "Materialize the Edge[] for a named lens — synthetic edges from derived_from_field for auto-derived lenses; authored edges filtered by relation_type for hand-curated lenses; unioned items from composition members for kind=composition lenses.",
|
|
1209
|
-
promptSnippet: "Materialize edges for a named lens (auto-derived or hand-curated)",
|
|
1210
|
-
parameters: Type.Object({
|
|
1211
|
-
lensId: Type.String({ description: "Lens id from config.lenses[].id" }),
|
|
1212
|
-
}),
|
|
1213
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1214
|
-
const result = edgesForLensByName(ctx.cwd, params.lensId);
|
|
1215
|
-
return {
|
|
1216
|
-
details: undefined,
|
|
1217
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1218
|
-
};
|
|
1219
|
-
},
|
|
1220
|
-
});
|
|
1221
|
-
// ── Tool: context-walk-descendants ────────────────────────────────────
|
|
1222
|
-
pi.registerTool({
|
|
1223
|
-
name: "context-walk-descendants",
|
|
1224
|
-
label: "Context Walk Descendants",
|
|
1225
|
-
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).",
|
|
1226
|
-
promptSnippet: "Walk closure-table descendants under a relation_type",
|
|
1227
|
-
parameters: Type.Object({
|
|
1228
|
-
parentId: Type.String({ description: "Parent id (canonical id or lens bin name)" }),
|
|
1229
|
-
relationType: Type.String({ description: "Relation type from config.relation_types[].canonical_id" }),
|
|
1230
|
-
}),
|
|
1231
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1232
|
-
const result = walkLensDescendants(ctx.cwd, params.parentId, params.relationType);
|
|
1233
|
-
return {
|
|
1234
|
-
details: undefined,
|
|
1235
|
-
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
|
|
1236
|
-
};
|
|
1237
|
-
},
|
|
1238
|
-
});
|
|
1239
|
-
// ── Tool: walk-ancestors ─────────────────────────────────────────────
|
|
1240
|
-
// Reverse-direction counterpart to context-walk-descendants. Coexists
|
|
1241
|
-
// with the descendants tool — this tool is the parent-direction
|
|
1242
|
-
// traversal; FGAP-029 partial closure (TASK-036 / sub-phase 2.3).
|
|
1243
|
-
pi.registerTool({
|
|
1244
|
-
name: "walk-ancestors",
|
|
1245
|
-
label: "Walk Ancestors",
|
|
1246
|
-
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).",
|
|
1247
|
-
promptSnippet: "Walk closure-table ancestors under a relation_type",
|
|
1248
|
-
parameters: Type.Object({
|
|
1249
|
-
itemId: Type.String({ description: "Child item id whose ancestors are sought" }),
|
|
1250
|
-
relationType: Type.String({ description: "Relation type from config.relation_types[].canonical_id" }),
|
|
1251
|
-
}),
|
|
1252
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1253
|
-
const result = walkAncestorsByLens(ctx.cwd, params.itemId, params.relationType);
|
|
1254
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
1255
|
-
const truncated = truncateHead(jsonStr);
|
|
1256
|
-
let text = truncated.content;
|
|
1257
|
-
if (truncated.truncated) {
|
|
1258
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1259
|
-
}
|
|
1260
|
-
return {
|
|
1261
|
-
details: undefined,
|
|
1262
|
-
content: [{ type: "text", text }],
|
|
1263
|
-
};
|
|
1264
|
-
},
|
|
1265
|
-
});
|
|
1266
|
-
// ── Tool: find-references ────────────────────────────────────────────
|
|
1267
|
-
// Edge-level inspection of closure-table references incident on an item.
|
|
1268
|
-
// Returns Edge[] (NOT string[]) — distinguishing semantic vs the id-chain
|
|
1269
|
-
// walk-ancestors / context-walk-descendants tools. Coexists with both:
|
|
1270
|
-
// walk-* surfaces serve id-chain traversal; find-references serves
|
|
1271
|
-
// relation-typed edge inspection. TASK-037 / Phase 2 sub-phase 2.4 —
|
|
1272
|
-
// final Phase 2 atomic unit.
|
|
1273
|
-
pi.registerTool({
|
|
1274
|
-
name: "find-references",
|
|
1275
|
-
label: "Find References",
|
|
1276
|
-
description: "Find all closure-table edges incident on an item id (inbound, outbound, or both). Returns Edge[] preserving relation_type + ordinal per record — edge-level view, not the id-chain projection that walk-ancestors / context-walk-descendants emit.",
|
|
1277
|
-
promptSnippet: "Find closure-table edges incident on an item id",
|
|
1278
|
-
parameters: Type.Object({
|
|
1279
|
-
itemId: Type.String({ description: "Item id whose incident edges are sought" }),
|
|
1280
|
-
direction: Type.Optional(Type.Union([Type.Literal("inbound"), Type.Literal("outbound"), Type.Literal("both")], {
|
|
1281
|
-
description: "inbound: edges where child === itemId; outbound: edges where parent === itemId; both: union (default).",
|
|
1282
|
-
})),
|
|
1283
|
-
}),
|
|
1284
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1285
|
-
const result = findReferencesInRepo(ctx.cwd, params.itemId, params.direction);
|
|
1286
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
1287
|
-
const truncated = truncateHead(jsonStr);
|
|
1288
|
-
let text = truncated.content;
|
|
1289
|
-
if (truncated.truncated) {
|
|
1290
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1291
|
-
}
|
|
1292
|
-
return {
|
|
1293
|
-
details: undefined,
|
|
1294
|
-
content: [{ type: "text", text }],
|
|
1295
|
-
};
|
|
1296
|
-
},
|
|
1297
|
-
});
|
|
1298
|
-
// ── Tool: gather-execution-context ───────────────────────────────────
|
|
1299
|
-
// Work-unit-driven context bundling per DEC-0017: read unit + read its
|
|
1300
|
-
// context-contract (by unit_kind) + walk each declared relation_type
|
|
1301
|
-
// bidirectionally per direction semantic + resolve reached ids to full
|
|
1302
|
-
// item payloads via the bulk resolver. Returns ContextBundle as one
|
|
1303
|
-
// structured payload, removing the N+1-read pattern that orchestrators
|
|
1304
|
-
// had to hand-roll before this primitive. Closes FGAP-031.
|
|
1305
|
-
// TASK-039 / Phase 3 sub-phase 3.2.
|
|
1306
|
-
pi.registerTool({
|
|
1307
|
-
name: "gather-execution-context",
|
|
1308
|
-
label: "Gather Execution Context",
|
|
1309
|
-
description: "Compose a ContextBundle for a work-unit by reading its context-contract (by unit_kind) and walking declared relation_types bidirectionally per direction semantic. Returns unit + perRelationType buckets of resolved items + traversal_depth + scoped_at. Per DEC-0017 substrate primitive serving harness-confined dispatch.",
|
|
1310
|
-
promptSnippet: "Compose ContextBundle for unit + context-contract-declared bundle_relation_types",
|
|
1311
|
-
parameters: Type.Object({
|
|
1312
|
-
unitId: Type.String({ description: "Work-unit id (e.g. TASK-NNN / DEC-NNNN / FGAP-NNN)" }),
|
|
1313
|
-
kind: Type.String({
|
|
1314
|
-
description: "Unit-kind type tag (e.g. 'task', 'decision', 'verification') matching a context-contract entry's unit_kind",
|
|
1315
|
-
}),
|
|
1316
|
-
maxDepth: Type.Optional(Type.Integer({
|
|
1317
|
-
minimum: 1,
|
|
1318
|
-
description: "Override per-relation-type max_depth via Math.min against each spec.max_depth",
|
|
1319
|
-
})),
|
|
1320
|
-
}),
|
|
1321
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1322
|
-
const result = gatherExecutionContext(ctx.cwd, params);
|
|
1323
|
-
const jsonStr = JSON.stringify(result, null, 2);
|
|
1324
|
-
const truncated = truncateHead(jsonStr);
|
|
1325
|
-
let text = truncated.content;
|
|
1326
|
-
if (truncated.truncated) {
|
|
1327
|
-
text += `\n\n[Truncated: ${truncated.totalBytes} bytes exceeds 50KB limit.]`;
|
|
1328
|
-
}
|
|
1329
|
-
return {
|
|
1330
|
-
details: undefined,
|
|
1331
|
-
content: [{ type: "text", text }],
|
|
1332
|
-
};
|
|
1333
|
-
},
|
|
1334
|
-
});
|
|
1335
|
-
// ── Roadmap tools (Step 7 / pi-context PM-lens) ─────────────────────────
|
|
1336
|
-
// Strip non-serializable fields (suggestionTemplate fn, grouped Map) from
|
|
1337
|
-
// the embedded LoadedLensView records before tool serialization. Mirrors
|
|
1338
|
-
// the Map → object precedent used elsewhere for tool boundary shapes.
|
|
1339
|
-
const serializeRoadmapView = (view) => ({
|
|
1340
|
-
roadmap: view.roadmap,
|
|
1341
|
-
phases: view.phases.map((pv) => ({
|
|
1342
|
-
phase: pv.phase,
|
|
1343
|
-
lensView: "error" in pv.lensView
|
|
1344
|
-
? pv.lensView
|
|
1345
|
-
: {
|
|
1346
|
-
lens: pv.lensView.lens,
|
|
1347
|
-
items: pv.lensView.items,
|
|
1348
|
-
edges: pv.lensView.edges,
|
|
1349
|
-
grouped: Object.fromEntries(pv.lensView.grouped),
|
|
1350
|
-
uncategorized: pv.lensView.uncategorized,
|
|
1351
|
-
},
|
|
1352
|
-
status: pv.status,
|
|
1353
|
-
...(pv.milestone ? { milestone: pv.milestone } : {}),
|
|
1354
|
-
...(pv.milestoneSatisfied !== undefined ? { milestoneSatisfied: pv.milestoneSatisfied } : {}),
|
|
1355
|
-
})),
|
|
1356
|
-
phaseOrder: view.phaseOrder,
|
|
1357
|
-
cycles: view.cycles,
|
|
1358
|
-
edges: view.edges,
|
|
1359
|
-
});
|
|
1360
|
-
pi.registerTool({
|
|
1361
|
-
name: "context-roadmap-load",
|
|
1362
|
-
label: "Context: load roadmap",
|
|
1363
|
-
description: "Load a roadmap by id and return the materialized RoadmapView (phases, lens-views, status rollup, milestone resolution, scoped phase_depends_on edges, topo-ordered phaseOrder + cycles). Per DEC-0012 phase ordering lives in relations.json with relation_type='phase_depends_on'.",
|
|
1364
|
-
promptSnippet: "Load a roadmap by id",
|
|
1365
|
-
parameters: Type.Object({
|
|
1366
|
-
roadmapId: Type.String({ description: "ROADMAP-NNN id from <config.root>/roadmap.json" }),
|
|
1367
|
-
}),
|
|
1368
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1369
|
-
const view = loadRoadmap(ctx.cwd, params.roadmapId);
|
|
1370
|
-
if ("error" in view) {
|
|
1371
|
-
return {
|
|
1372
|
-
details: undefined,
|
|
1373
|
-
content: [{ type: "text", text: JSON.stringify(view, null, 2) }],
|
|
1374
|
-
};
|
|
1375
|
-
}
|
|
1376
|
-
return {
|
|
1377
|
-
details: undefined,
|
|
1378
|
-
content: [{ type: "text", text: JSON.stringify(serializeRoadmapView(view), null, 2) }],
|
|
1379
|
-
};
|
|
1380
|
-
},
|
|
1381
|
-
});
|
|
1382
|
-
pi.registerTool({
|
|
1383
|
-
name: "context-roadmap-render",
|
|
1384
|
-
label: "Context: render roadmap",
|
|
1385
|
-
description: "Render a roadmap by id as pure-textual markdown — phase order list, per-phase adjacency lines (sourced from view.edges, alphabetically sorted), status rollup counts, milestone resolution, exit criteria. NO mermaid / graph syntax: per-phase **Depends on:** lines come strictly from authored phase_depends_on edges scoped to in-roadmap phases.",
|
|
1386
|
-
promptSnippet: "Render a roadmap as markdown",
|
|
1387
|
-
parameters: Type.Object({
|
|
1388
|
-
roadmapId: Type.String({ description: "ROADMAP-NNN id from <config.root>/roadmap.json" }),
|
|
1389
|
-
}),
|
|
1390
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1391
|
-
const view = loadRoadmap(ctx.cwd, params.roadmapId);
|
|
1392
|
-
if ("error" in view) {
|
|
1393
|
-
return {
|
|
1394
|
-
details: undefined,
|
|
1395
|
-
content: [{ type: "text", text: JSON.stringify(view, null, 2) }],
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
const naming = loadContext(ctx.cwd).config?.naming;
|
|
1399
|
-
return {
|
|
1400
|
-
details: undefined,
|
|
1401
|
-
content: [{ type: "text", text: renderRoadmap(view, naming) }],
|
|
1402
|
-
};
|
|
1403
|
-
},
|
|
1404
|
-
});
|
|
1405
|
-
pi.registerTool({
|
|
1406
|
-
name: "context-roadmap-validate",
|
|
1407
|
-
label: "Context: validate roadmap(s)",
|
|
1408
|
-
description: "Validate every roadmap × phase × milestone in <config.root>/roadmap.json. Codes: roadmap_lens_missing, roadmap_phase_dep_missing, roadmap_phase_cycle, roadmap_composition_cycle, roadmap_milestone_evidence_block_missing, roadmap_milestone_query_invalid, roadmap_status_unknown_value. Display strings flow through config.display_strings (pi-context divergence). Optional roadmapId filter restricts issue list to a single roadmap.",
|
|
1409
|
-
promptSnippet: "Validate roadmaps",
|
|
1410
|
-
parameters: Type.Object({
|
|
1411
|
-
roadmapId: Type.Optional(Type.String({ description: "Filter to issues matching this roadmap_id (omit for full-project validation)" })),
|
|
1412
|
-
}),
|
|
1413
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1414
|
-
const result = validateRoadmaps(ctx.cwd);
|
|
1415
|
-
const filtered = params.roadmapId
|
|
1416
|
-
? result.issues.filter((i) => !i.roadmap_id || i.roadmap_id === params.roadmapId)
|
|
1417
|
-
: result.issues;
|
|
1418
|
-
return {
|
|
1419
|
-
details: undefined,
|
|
1420
|
-
content: [{ type: "text", text: JSON.stringify({ status: result.status, issues: filtered }, null, 2) }],
|
|
1421
|
-
};
|
|
1422
|
-
},
|
|
1423
|
-
});
|
|
1424
|
-
pi.registerTool({
|
|
1425
|
-
name: "context-roadmap-list",
|
|
1426
|
-
label: "Context: list roadmaps",
|
|
1427
|
-
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).",
|
|
1428
|
-
promptSnippet: "List roadmaps",
|
|
1429
|
-
parameters: Type.Object({}),
|
|
1430
|
-
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1431
|
-
return {
|
|
1432
|
-
details: undefined,
|
|
1433
|
-
content: [{ type: "text", text: JSON.stringify(listRoadmaps(ctx.cwd), null, 2) }],
|
|
1434
|
-
};
|
|
1435
|
-
},
|
|
1436
|
-
});
|
|
722
|
+
// ── Eager framework guidance (FGAP-090) ────────────────────────────
|
|
723
|
+
// Append (never replace) the orientation block to the assembled system
|
|
724
|
+
// prompt so the in-pi agent receives a topic→tool-call map up front.
|
|
725
|
+
// The runtime chains extensions' systemPrompt outputs; returning the
|
|
726
|
+
// augmented prompt preserves pi-core's prompt + other extensions, and
|
|
727
|
+
// returning nothing would reset to base.
|
|
728
|
+
pi.on("before_agent_start", (event) => ({
|
|
729
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildOrientationBlock()}`,
|
|
730
|
+
}));
|
|
731
|
+
// Surface the packaged pi-context skill directory to the runtime.
|
|
732
|
+
pi.on("resources_discover", () => ({ skillPaths: [skillsDir()] }));
|
|
733
|
+
// ── Register substrate tools from the op-registry ────────────────────
|
|
734
|
+
// The 45 substrate tool definitions live in ops-registry.ts as
|
|
735
|
+
// OpDefinitions; registerAll iterates them and registers each as a pi tool
|
|
736
|
+
// under a uniform execute wrapper. Behavior-identical to the prior inline
|
|
737
|
+
// per-tool registrations that previously occupied this region.
|
|
738
|
+
registerAll(pi);
|
|
1437
739
|
const CONTEXT_SUBCOMMANDS = {
|
|
1438
740
|
init: {
|
|
1439
741
|
description: "Initialize the substrate dir (bootstrap pointer + dirs only; run accept-all + install to populate)",
|
|
1440
742
|
handler: (args, ctx) => handleInit(args, ctx),
|
|
1441
743
|
},
|
|
744
|
+
switch: {
|
|
745
|
+
description: "Flip the bootstrap pointer (parallel to git switch). Forms: '<existing-dir>' (flip to existing substrate) | '-c <new-dir>' (bootstrap new + flip) | '-' (flip back to previous_contextDir)",
|
|
746
|
+
handler: (args, ctx) => handleSwitch(args, ctx),
|
|
747
|
+
},
|
|
748
|
+
list: {
|
|
749
|
+
description: "Enumerate top-level dirs containing config.json (switchable substrates); marks the active one",
|
|
750
|
+
handler: (args, ctx) => handleList(args, ctx),
|
|
751
|
+
},
|
|
752
|
+
archive: {
|
|
753
|
+
description: "Move a non-active substrate dir to archive/<dir>/ (refuses to archive the active substrate or clobber an existing archive)",
|
|
754
|
+
handler: (args, ctx) => handleArchive(args, ctx),
|
|
755
|
+
},
|
|
1442
756
|
install: {
|
|
1443
|
-
description: "Copy schemas and starter blocks declared in
|
|
757
|
+
description: "Copy schemas and starter blocks declared in the substrate dir's config.json from the package samples catalog",
|
|
1444
758
|
handler: (args, ctx) => {
|
|
1445
759
|
const overwrite = /(^|\s)--update(\s|$)/.test(args);
|
|
1446
760
|
const result = installContext(ctx.cwd, { overwrite });
|
|
@@ -1462,7 +776,7 @@ const extension = (pi) => {
|
|
|
1462
776
|
lines.push(`Not found in samples catalog (${result.notFound.length}): ${result.notFound.join(", ")}`);
|
|
1463
777
|
}
|
|
1464
778
|
if (lines.length === 0) {
|
|
1465
|
-
lines.push("Nothing declared in installed_schemas / installed_blocks — edit
|
|
779
|
+
lines.push("Nothing declared in installed_schemas / installed_blocks — edit the substrate dir's config.json to add entries.");
|
|
1466
780
|
}
|
|
1467
781
|
const level = result.notFound.length > 0 ? "warning" : "info";
|
|
1468
782
|
ctx.ui.notify(lines.join("\n"), level);
|
|
@@ -1521,7 +835,7 @@ const extension = (pi) => {
|
|
|
1521
835
|
handler: (_args, ctx) => {
|
|
1522
836
|
const list = listRoadmaps(ctx.cwd);
|
|
1523
837
|
if (list.length === 0) {
|
|
1524
|
-
ctx.ui.notify("No roadmaps found. Install the roadmap block via
|
|
838
|
+
ctx.ui.notify("No roadmaps found. Install the roadmap block via the substrate dir's config.json installed_blocks, then author roadmap.json.", "info");
|
|
1525
839
|
return;
|
|
1526
840
|
}
|
|
1527
841
|
const lines = list.map((r) => `${r.id} [${r.status ?? "(unspecified)"}] ${r.title} (${r.phaseCount} phase${r.phaseCount === 1 ? "" : "s"})`);
|
|
@@ -1648,8 +962,9 @@ const extension = (pi) => {
|
|
|
1648
962
|
});
|
|
1649
963
|
};
|
|
1650
964
|
export default extension;
|
|
965
|
+
export { contextRegistryPath, invalidateRegistry, loadRegistry, REGISTRY_FILE_VERSION, registerSubstrate, resolveAlias, resolveSubstrateDir, writeRegistry, } from "./context-registry.js";
|
|
1651
966
|
// Re-export for consumers
|
|
1652
|
-
export { blockStructure, buildIdIndex, CONTEXT_BLOCK_TYPES, completeTask, findAppendableBlocks, resolveItemById, schemaInfo, schemaVocabulary, } from "./context-sdk.js";
|
|
967
|
+
export { blockStructure, buildIdIndex, CONTEXT_BLOCK_TYPES, completeTask, findAppendableBlocks, resolveItemById, resolveRef, schemaInfo, schemaVocabulary, } from "./context-sdk.js";
|
|
1653
968
|
export { renameCanonicalId } from "./rename-canonical-id.js";
|
|
1654
969
|
export { listRoadmaps, loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmaps, } from "./roadmap-plan.js";
|
|
1655
970
|
//# sourceMappingURL=index.js.map
|