@davidorex/pi-context 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +195 -0
  3. package/dist/block-api.d.ts +328 -0
  4. package/dist/block-api.d.ts.map +1 -0
  5. package/dist/block-api.js +1167 -0
  6. package/dist/block-api.js.map +1 -0
  7. package/dist/block-validation.d.ts +33 -0
  8. package/dist/block-validation.d.ts.map +1 -0
  9. package/dist/block-validation.js +213 -0
  10. package/dist/block-validation.js.map +1 -0
  11. package/dist/context-dir.d.ts +93 -0
  12. package/dist/context-dir.d.ts.map +1 -0
  13. package/dist/context-dir.js +224 -0
  14. package/dist/context-dir.js.map +1 -0
  15. package/dist/context-sdk.d.ts +428 -0
  16. package/dist/context-sdk.d.ts.map +1 -0
  17. package/dist/context-sdk.js +1327 -0
  18. package/dist/context-sdk.js.map +1 -0
  19. package/dist/context.d.ts +497 -0
  20. package/dist/context.d.ts.map +1 -0
  21. package/dist/context.js +1060 -0
  22. package/dist/context.js.map +1 -0
  23. package/dist/dispatch-context.d.ts +112 -0
  24. package/dist/dispatch-context.d.ts.map +1 -0
  25. package/dist/dispatch-context.js +119 -0
  26. package/dist/dispatch-context.js.map +1 -0
  27. package/dist/execution-context.d.ts +138 -0
  28. package/dist/execution-context.d.ts.map +1 -0
  29. package/dist/execution-context.js +151 -0
  30. package/dist/execution-context.js.map +1 -0
  31. package/dist/index.d.ts +34 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +1655 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/lens-validator.d.ts +50 -0
  36. package/dist/lens-validator.d.ts.map +1 -0
  37. package/dist/lens-validator.js +46 -0
  38. package/dist/lens-validator.js.map +1 -0
  39. package/dist/lens-view.d.ts +75 -0
  40. package/dist/lens-view.d.ts.map +1 -0
  41. package/dist/lens-view.js +253 -0
  42. package/dist/lens-view.js.map +1 -0
  43. package/dist/rename-canonical-id.d.ts +64 -0
  44. package/dist/rename-canonical-id.d.ts.map +1 -0
  45. package/dist/rename-canonical-id.js +378 -0
  46. package/dist/rename-canonical-id.js.map +1 -0
  47. package/dist/roadmap-plan.d.ts +186 -0
  48. package/dist/roadmap-plan.d.ts.map +1 -0
  49. package/dist/roadmap-plan.js +534 -0
  50. package/dist/roadmap-plan.js.map +1 -0
  51. package/dist/samples-catalog.d.ts +48 -0
  52. package/dist/samples-catalog.d.ts.map +1 -0
  53. package/dist/samples-catalog.js +128 -0
  54. package/dist/samples-catalog.js.map +1 -0
  55. package/dist/schema-migrations.d.ts +82 -0
  56. package/dist/schema-migrations.d.ts.map +1 -0
  57. package/dist/schema-migrations.js +106 -0
  58. package/dist/schema-migrations.js.map +1 -0
  59. package/dist/schema-validator.d.ts +76 -0
  60. package/dist/schema-validator.d.ts.map +1 -0
  61. package/dist/schema-validator.js +219 -0
  62. package/dist/schema-validator.js.map +1 -0
  63. package/dist/schema-write.d.ts +122 -0
  64. package/dist/schema-write.d.ts.map +1 -0
  65. package/dist/schema-write.js +210 -0
  66. package/dist/schema-write.js.map +1 -0
  67. package/dist/status-vocab.d.ts +60 -0
  68. package/dist/status-vocab.d.ts.map +1 -0
  69. package/dist/status-vocab.js +109 -0
  70. package/dist/status-vocab.js.map +1 -0
  71. package/dist/test-helpers.d.ts +12 -0
  72. package/dist/test-helpers.d.ts.map +1 -0
  73. package/dist/test-helpers.js +28 -0
  74. package/dist/test-helpers.js.map +1 -0
  75. package/dist/topo.d.ts +19 -0
  76. package/dist/topo.d.ts.map +1 -0
  77. package/dist/topo.js +99 -0
  78. package/dist/topo.js.map +1 -0
  79. package/dist/update-check.d.ts +6 -0
  80. package/dist/update-check.d.ts.map +1 -0
  81. package/dist/update-check.js +82 -0
  82. package/dist/update-check.js.map +1 -0
  83. package/package.json +115 -0
  84. package/samples/blocks/context-contracts.json +3 -0
  85. package/samples/blocks/conventions.json +3 -0
  86. package/samples/blocks/decisions.json +3 -0
  87. package/samples/blocks/features.json +3 -0
  88. package/samples/blocks/framework-gaps.json +3 -0
  89. package/samples/blocks/issues.json +3 -0
  90. package/samples/blocks/layer-plans.json +3 -0
  91. package/samples/blocks/phase.json +3 -0
  92. package/samples/blocks/rationale.json +3 -0
  93. package/samples/blocks/requirements.json +3 -0
  94. package/samples/blocks/research.json +3 -0
  95. package/samples/blocks/spec-reviews.json +3 -0
  96. package/samples/blocks/story.json +3 -0
  97. package/samples/blocks/tasks.json +3 -0
  98. package/samples/blocks/verification.json +3 -0
  99. package/samples/conception.json +467 -0
  100. package/samples/schemas/context-contracts.schema.json +89 -0
  101. package/samples/schemas/conventions.schema.json +34 -0
  102. package/samples/schemas/decisions.schema.json +87 -0
  103. package/samples/schemas/features.schema.json +53 -0
  104. package/samples/schemas/framework-gaps.schema.json +64 -0
  105. package/samples/schemas/issues.schema.json +39 -0
  106. package/samples/schemas/layer-plans.schema.json +92 -0
  107. package/samples/schemas/phase.schema.json +118 -0
  108. package/samples/schemas/rationale.schema.json +24 -0
  109. package/samples/schemas/requirements.schema.json +34 -0
  110. package/samples/schemas/research.schema.json +134 -0
  111. package/samples/schemas/spec-reviews.schema.json +62 -0
  112. package/samples/schemas/story.schema.json +73 -0
  113. package/samples/schemas/tasks.schema.json +34 -0
  114. package/samples/schemas/verification.schema.json +37 -0
  115. package/schemas/bootstrap.schema.json +24 -0
  116. package/schemas/config.schema.json +193 -0
  117. package/schemas/layer.schema.json +9 -0
  118. package/schemas/priority.schema.json +9 -0
  119. package/schemas/relations.schema.json +31 -0
  120. package/schemas/severity.schema.json +9 -0
  121. package/schemas/source.schema.json +9 -0
  122. package/schemas/status.schema.json +9 -0
  123. package/schemas/verification-method.schema.json +9 -0
  124. package/skill-narrative.md +130 -0
  125. package/skills/pi-context/SKILL.md +684 -0
  126. package/skills/pi-context/references/bundled-resources.md +47 -0
@@ -0,0 +1,1327 @@
1
+ /**
2
+ * Project SDK — queryable surface for project block state, discovery,
3
+ * and derived metrics. Computes everything dynamically from filesystem
4
+ * and git — no cache, no stale data.
5
+ */
6
+ import { execSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { readBlock, updateItemInBlock } from "./block-api.js";
10
+ import { findUnmaterializedAssets, loadConfig, loadRelations, validateRelations, } from "./context.js";
11
+ import { resolveContextDir, SCHEMAS_DIR, schemaPath, schemasDir, tryResolveContextDir } from "./context-dir.js";
12
+ import { getLensValidators } from "./lens-validator.js";
13
+ import { resolveStatusVocabulary } from "./status-vocab.js";
14
+ import { topoSort } from "./topo.js";
15
+ // Re-export substrate SDK so consumers can keep importing through context-sdk.
16
+ export { displayName, edgesForLens, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
17
+ export function availableBlocks(cwd) {
18
+ const workflowDir = tryResolveContextDir(cwd);
19
+ if (workflowDir === null)
20
+ return [];
21
+ const schemasDirPath = schemasDir(cwd);
22
+ if (!fs.existsSync(workflowDir))
23
+ return [];
24
+ const blocks = [];
25
+ for (const file of fs.readdirSync(workflowDir)) {
26
+ if (!file.endsWith(".json"))
27
+ continue;
28
+ const name = file.replace(".json", "");
29
+ const hasSchema = fs.existsSync(path.join(schemasDirPath, `${name}.schema.json`));
30
+ blocks.push({ name, hasSchema });
31
+ }
32
+ return blocks.sort((a, b) => a.name.localeCompare(b.name));
33
+ }
34
+ /**
35
+ * Discover schemas in the substrate dir's `schemas/` subdirectory (resolved
36
+ * via `schemasDir(cwd)` per DEC-0015). Returns sorted list of
37
+ * absolute paths to .schema.json files.
38
+ */
39
+ export function availableSchemas(cwd) {
40
+ const root = tryResolveContextDir(cwd);
41
+ if (root === null)
42
+ return [];
43
+ const dir = path.join(root, SCHEMAS_DIR);
44
+ if (!fs.existsSync(dir))
45
+ return [];
46
+ const schemas = [];
47
+ for (const file of fs.readdirSync(dir)) {
48
+ if (file.endsWith(".schema.json")) {
49
+ schemas.push(path.join(dir, file));
50
+ }
51
+ }
52
+ return schemas.sort();
53
+ }
54
+ /**
55
+ * Discover blocks with array properties by scanning the substrate dir's
56
+ * `schemas/` subdirectory (resolved via `schemasDir(cwd)` per
57
+ * DEC-0015) for schemas whose root type has at least one array property.
58
+ * Returns block name, first array key, and schema path for each.
59
+ */
60
+ export function findAppendableBlocks(cwd) {
61
+ const root = tryResolveContextDir(cwd);
62
+ if (root === null)
63
+ return [];
64
+ const schemasDirPath = path.join(root, SCHEMAS_DIR);
65
+ if (!fs.existsSync(schemasDirPath))
66
+ return [];
67
+ const results = [];
68
+ for (const file of fs.readdirSync(schemasDirPath)) {
69
+ if (!file.endsWith(".schema.json"))
70
+ continue;
71
+ const blockName = file.replace(".schema.json", "");
72
+ try {
73
+ const schema = JSON.parse(fs.readFileSync(path.join(schemasDirPath, file), "utf-8"));
74
+ if (schema.properties) {
75
+ for (const [key, prop] of Object.entries(schema.properties)) {
76
+ if (prop.type === "array") {
77
+ results.push({ block: blockName, arrayKey: key, schemaPath: path.join(schemasDirPath, file) });
78
+ break; // first array property
79
+ }
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ /* skip malformed schemas */
85
+ }
86
+ }
87
+ return results;
88
+ }
89
+ // ── Vocabulary (derived from schemas) ─────────────────────────────────────────
90
+ /** Default planning lifecycle block types shipped with /context init. */
91
+ export const CONTEXT_BLOCK_TYPES = [
92
+ "project",
93
+ "domain",
94
+ "requirements",
95
+ "architecture",
96
+ "tasks",
97
+ "decisions",
98
+ "issues",
99
+ "rationale",
100
+ "verification",
101
+ "handoff",
102
+ "conformance-reference",
103
+ "audit",
104
+ ];
105
+ /**
106
+ * Read and parse a schema, extracting property metadata.
107
+ * Returns null if the schema file doesn't exist or is unparseable.
108
+ */
109
+ export function schemaInfo(cwd, schemaName) {
110
+ return schemaInfoFromPath(schemaPath(cwd, schemaName), schemaName);
111
+ }
112
+ /**
113
+ * Extract schema property metadata from an ABSOLUTE schema file path.
114
+ * Identical extraction to `schemaInfo` but addressed by path rather than
115
+ * (cwd, name) — lets package-intrinsic consumers (e.g. samples-catalog) read
116
+ * the extension's bundled samples/schemas/*.schema.json without a project
117
+ * substrate. Returns null if the schema file doesn't exist or is unparseable.
118
+ */
119
+ export function schemaInfoFromPath(absSchemaPath, schemaName) {
120
+ try {
121
+ const raw = JSON.parse(fs.readFileSync(absSchemaPath, "utf-8"));
122
+ const title = String(raw.title ?? schemaName);
123
+ const requiredSet = new Set(Array.isArray(raw.required) ? raw.required : []);
124
+ const properties = [];
125
+ const arrayKeys = [];
126
+ const itemProperties = {};
127
+ if (raw.properties && typeof raw.properties === "object") {
128
+ for (const [name, propRaw] of Object.entries(raw.properties)) {
129
+ const propType = extractType(propRaw);
130
+ const prop = {
131
+ name,
132
+ type: propType,
133
+ required: requiredSet.has(name),
134
+ description: propRaw.description ? String(propRaw.description) : undefined,
135
+ enum: Array.isArray(propRaw.enum) ? propRaw.enum : undefined,
136
+ };
137
+ properties.push(prop);
138
+ if (propType === "array") {
139
+ arrayKeys.push(name);
140
+ // Extract item properties (one level deep)
141
+ const items = propRaw.items;
142
+ if (items?.properties && typeof items.properties === "object") {
143
+ const itemRequiredSet = new Set(Array.isArray(items.required) ? items.required : []);
144
+ const itemProps = [];
145
+ for (const [iName, iPropRaw] of Object.entries(items.properties)) {
146
+ itemProps.push({
147
+ name: iName,
148
+ type: extractType(iPropRaw),
149
+ required: itemRequiredSet.has(iName),
150
+ description: iPropRaw.description ? String(iPropRaw.description) : undefined,
151
+ enum: Array.isArray(iPropRaw.enum) ? iPropRaw.enum : undefined,
152
+ });
153
+ }
154
+ itemProperties[name] = itemProps;
155
+ }
156
+ }
157
+ }
158
+ }
159
+ return {
160
+ name: schemaName,
161
+ title,
162
+ properties,
163
+ arrayKeys,
164
+ itemProperties: Object.keys(itemProperties).length > 0 ? itemProperties : undefined,
165
+ };
166
+ }
167
+ catch {
168
+ return null;
169
+ }
170
+ }
171
+ /** Extract type string from a JSON Schema property. */
172
+ function extractType(prop) {
173
+ if (Array.isArray(prop.type))
174
+ return prop.type.join("|");
175
+ if (typeof prop.type === "string")
176
+ return prop.type;
177
+ return "unknown";
178
+ }
179
+ /**
180
+ * All schemas with their property metadata.
181
+ * Scans the substrate dir's `schemas/` subdirectory (resolved via
182
+ * `schemasDir(cwd)` per DEC-0015) and parses each schema.
183
+ */
184
+ export function schemaVocabulary(cwd) {
185
+ const root = tryResolveContextDir(cwd);
186
+ if (root === null)
187
+ return [];
188
+ const schemasDirPath = path.join(root, SCHEMAS_DIR);
189
+ if (!fs.existsSync(schemasDirPath))
190
+ return [];
191
+ const results = [];
192
+ for (const file of fs.readdirSync(schemasDirPath).sort()) {
193
+ if (!file.endsWith(".schema.json"))
194
+ continue;
195
+ const name = file.replace(".schema.json", "");
196
+ const info = schemaInfo(cwd, name);
197
+ if (info)
198
+ results.push(info);
199
+ }
200
+ return results;
201
+ }
202
+ /**
203
+ * What blocks exist and their structure — combines availableBlocks
204
+ * and block summaries into a single queryable function.
205
+ */
206
+ export function blockStructure(cwd) {
207
+ const blockDir = tryResolveContextDir(cwd);
208
+ if (blockDir === null)
209
+ return [];
210
+ const blocks = availableBlocks(cwd);
211
+ return blocks.map((b) => {
212
+ const arrays = [];
213
+ try {
214
+ const data = readBlock(cwd, b.name);
215
+ for (const [key, val] of Object.entries(data)) {
216
+ if (Array.isArray(val)) {
217
+ arrays.push({ key, itemCount: val.length });
218
+ }
219
+ }
220
+ }
221
+ catch {
222
+ /* block unreadable */
223
+ }
224
+ return {
225
+ name: b.name,
226
+ exists: fs.existsSync(path.join(blockDir, `${b.name}.json`)),
227
+ hasSchema: b.hasSchema,
228
+ arrays,
229
+ };
230
+ });
231
+ }
232
+ /**
233
+ * Derive the bootstrap progression for `cwd` from the filesystem, in order:
234
+ * no-pointer — no `.pi-context.json` (checked directly so this NEVER throws
235
+ * pre-bootstrap — it is the unset-substrate detection read the
236
+ * harness-confined LLM uses to redirect the human to `/context start`)
237
+ * no-config — pointer present, no `config.json`
238
+ * not-installed — config present, some declared installed_* asset is absent
239
+ * ready — config present, all declared assets materialized (or none declared)
240
+ *
241
+ * Does NOT swallow corruption: a malformed `config.json` propagates
242
+ * `loadConfig`'s ValidationError — the four states are the NORMAL progression;
243
+ * corruption is a separate error condition, not a bootstrap stop.
244
+ */
245
+ export function deriveBootstrapState(cwd) {
246
+ const empty = { schemas: [], blocks: [] };
247
+ if (!fs.existsSync(path.join(cwd, ".pi-context.json"))) {
248
+ return { state: "no-pointer", contextDir: null, missing: empty };
249
+ }
250
+ const contextDir = resolveContextDir(cwd);
251
+ const config = loadConfig(cwd);
252
+ if (config === null) {
253
+ return { state: "no-config", contextDir, missing: empty };
254
+ }
255
+ const missing = findUnmaterializedAssets(cwd, config);
256
+ const installed = missing.schemas.length === 0 && missing.blocks.length === 0;
257
+ return { state: installed ? "ready" : "not-installed", contextDir, missing };
258
+ }
259
+ /**
260
+ * Derive project state from authoritative sources at query time.
261
+ * No cache, no stale data — computed fresh on every call.
262
+ */
263
+ export function contextState(cwd) {
264
+ // Git state
265
+ let lastCommit = "unknown";
266
+ let lastCommitMessage = "";
267
+ try {
268
+ lastCommit = execSync("git log -1 --format=%h", { cwd, encoding: "utf-8" }).trim();
269
+ lastCommitMessage = execSync("git log -1 --format=%s", { cwd, encoding: "utf-8" }).trim();
270
+ }
271
+ catch {
272
+ /* not a git repo or no commits */
273
+ }
274
+ // Recent commits
275
+ let recentCommits = [];
276
+ try {
277
+ const log = execSync("git log --oneline -5", { cwd, encoding: "utf-8" }).trim();
278
+ if (log)
279
+ recentCommits = log.split("\n");
280
+ }
281
+ catch {
282
+ /* not a git repo */
283
+ }
284
+ // Resolve src dirs — workspace-aware: if cwd has a package.json with
285
+ // "workspaces" globs, collect src/ from each matched package directory;
286
+ // otherwise fall back to the single cwd/src/ directory.
287
+ const srcDirs = [];
288
+ try {
289
+ const rootPkg = path.join(cwd, "package.json");
290
+ if (fs.existsSync(rootPkg)) {
291
+ const pkg = JSON.parse(fs.readFileSync(rootPkg, "utf-8"));
292
+ if (Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) {
293
+ for (const pattern of pkg.workspaces) {
294
+ // Support trailing /* glob (e.g. "packages/*")
295
+ const base = pattern.replace(/\/?\*$/, "");
296
+ const baseDir = path.join(cwd, base);
297
+ if (fs.existsSync(baseDir) && fs.statSync(baseDir).isDirectory()) {
298
+ for (const entry of fs.readdirSync(baseDir)) {
299
+ const pkgSrc = path.join(baseDir, entry, "src");
300
+ if (fs.existsSync(pkgSrc) && fs.statSync(pkgSrc).isDirectory()) {
301
+ srcDirs.push(pkgSrc);
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
307
+ }
308
+ }
309
+ catch {
310
+ /* failed to read/parse package.json — fall through */
311
+ }
312
+ // Fallback: if no workspace dirs found, use cwd/src as before
313
+ if (srcDirs.length === 0) {
314
+ const single = path.join(cwd, "src");
315
+ if (fs.existsSync(single))
316
+ srcDirs.push(single);
317
+ }
318
+ // Source file count and line count (non-test .ts files, recursive)
319
+ let sourceFiles = 0;
320
+ let sourceLines = 0;
321
+ function walkTsFiles(dir, cb) {
322
+ let entries;
323
+ try {
324
+ entries = fs.readdirSync(dir, { withFileTypes: true });
325
+ }
326
+ catch {
327
+ return;
328
+ }
329
+ for (const entry of entries) {
330
+ const fullPath = path.join(dir, entry.name);
331
+ if (entry.isDirectory()) {
332
+ walkTsFiles(fullPath, cb);
333
+ }
334
+ else if (entry.isFile()) {
335
+ cb(fullPath);
336
+ }
337
+ }
338
+ }
339
+ for (const srcDir of srcDirs) {
340
+ walkTsFiles(srcDir, (filePath) => {
341
+ const base = path.basename(filePath);
342
+ if (!base.endsWith(".ts") || base.endsWith(".test.ts"))
343
+ return;
344
+ sourceFiles++;
345
+ try {
346
+ const content = fs.readFileSync(filePath, "utf-8");
347
+ sourceLines += content.split("\n").length;
348
+ }
349
+ catch {
350
+ /* unreadable file */
351
+ }
352
+ });
353
+ }
354
+ // Test count derived from static scan of it()/it.only()/test()/test.only() declarations in test files
355
+ let testCount = 0;
356
+ for (const srcDir of srcDirs) {
357
+ walkTsFiles(srcDir, (filePath) => {
358
+ if (!filePath.endsWith(".test.ts"))
359
+ return;
360
+ try {
361
+ const content = fs.readFileSync(filePath, "utf-8");
362
+ const matches = content.match(/^\s*(?:it|test)(?:\.only)?\s*\(/gm);
363
+ if (matches)
364
+ testCount += matches.length;
365
+ }
366
+ catch {
367
+ /* unreadable file */
368
+ }
369
+ });
370
+ }
371
+ // Block summaries — scan all blocks, report item counts and status distribution
372
+ const blockSummaries = {};
373
+ const blockDir = tryResolveContextDir(cwd);
374
+ try {
375
+ if (blockDir !== null && fs.existsSync(blockDir)) {
376
+ for (const file of fs.readdirSync(blockDir)) {
377
+ if (!file.endsWith(".json"))
378
+ continue;
379
+ const blockName = file.replace(".json", "");
380
+ try {
381
+ const data = readBlock(cwd, blockName);
382
+ const arrays = {};
383
+ for (const [key, val] of Object.entries(data)) {
384
+ if (!Array.isArray(val))
385
+ continue;
386
+ const items = val;
387
+ const arrSummary = { total: items.length };
388
+ // Aggregate by status if items have a status field
389
+ if (items.length > 0 && typeof items[0] === "object" && items[0] !== null && "status" in items[0]) {
390
+ const byStatus = {};
391
+ for (const item of items) {
392
+ const s = String(item.status ?? "unknown");
393
+ byStatus[s] = (byStatus[s] ?? 0) + 1;
394
+ }
395
+ arrSummary.byStatus = byStatus;
396
+ }
397
+ arrays[key] = arrSummary;
398
+ }
399
+ if (Object.keys(arrays).length > 0) {
400
+ blockSummaries[blockName] = { arrays };
401
+ }
402
+ }
403
+ catch {
404
+ /* skip unreadable blocks */
405
+ }
406
+ }
407
+ }
408
+ }
409
+ catch {
410
+ /* no block dir */
411
+ }
412
+ // Phases from the <substrateDir>/phase.json array-block (DEC-0028).
413
+ // Singular file basename matches phase.schema.json + the verification.json
414
+ // precedent (singular file + singular schema + plural array_key "phases").
415
+ // `total` is phases[].length; `current` counts completed phases — a
416
+ // monotonic progress measure that does not assume contiguous numbering.
417
+ // Absent phase.json yields total=0/current=0 (graceful).
418
+ let phaseTotal = 0;
419
+ let phaseCurrent = 0;
420
+ try {
421
+ const phaseData = readBlock(cwd, "phase");
422
+ if (Array.isArray(phaseData.phases)) {
423
+ phaseTotal = phaseData.phases.length;
424
+ phaseCurrent = phaseData.phases.filter((p) => p.status === "completed").length;
425
+ }
426
+ }
427
+ catch {
428
+ /* no phase.json */
429
+ }
430
+ // Planning lifecycle derived state
431
+ const state = {
432
+ testCount,
433
+ sourceFiles,
434
+ sourceLines,
435
+ lastCommit,
436
+ lastCommitMessage,
437
+ recentCommits,
438
+ blockSummaries,
439
+ phases: { total: phaseTotal, current: phaseCurrent },
440
+ blocks: availableBlocks(cwd).length,
441
+ schemas: availableSchemas(cwd).length,
442
+ };
443
+ // Requirements summary
444
+ try {
445
+ const reqData = readBlock(cwd, "requirements");
446
+ if (Array.isArray(reqData.requirements)) {
447
+ const items = reqData.requirements;
448
+ const byStatus = {};
449
+ const byPriority = {};
450
+ for (const item of items) {
451
+ const s = String(item.status ?? "unknown");
452
+ byStatus[s] = (byStatus[s] ?? 0) + 1;
453
+ const p = String(item.priority ?? "unknown");
454
+ byPriority[p] = (byPriority[p] ?? 0) + 1;
455
+ }
456
+ state.requirements = { total: items.length, byStatus, byPriority };
457
+ }
458
+ }
459
+ catch {
460
+ /* block doesn't exist */
461
+ }
462
+ // Tasks summary
463
+ try {
464
+ const taskData = readBlock(cwd, "tasks");
465
+ if (Array.isArray(taskData.tasks)) {
466
+ const items = taskData.tasks;
467
+ const byStatus = {};
468
+ for (const item of items) {
469
+ const s = String(item.status ?? "unknown");
470
+ byStatus[s] = (byStatus[s] ?? 0) + 1;
471
+ }
472
+ state.tasks = { total: items.length, byStatus };
473
+ }
474
+ }
475
+ catch {
476
+ /* block doesn't exist */
477
+ }
478
+ // Domain summary
479
+ try {
480
+ const domainData = readBlock(cwd, "domain");
481
+ if (Array.isArray(domainData.entries)) {
482
+ state.domain = { total: domainData.entries.length };
483
+ }
484
+ }
485
+ catch {
486
+ /* block doesn't exist */
487
+ }
488
+ // Verification summary
489
+ try {
490
+ const verData = readBlock(cwd, "verification");
491
+ if (Array.isArray(verData.verifications)) {
492
+ const items = verData.verifications;
493
+ let passed = 0;
494
+ let failed = 0;
495
+ for (const item of items) {
496
+ if (item.status === "passed")
497
+ passed++;
498
+ else if (item.status === "failed")
499
+ failed++;
500
+ }
501
+ state.verifications = { total: items.length, passed, failed };
502
+ }
503
+ }
504
+ catch {
505
+ /* block doesn't exist */
506
+ }
507
+ // Handoff presence
508
+ const handoffRoot = tryResolveContextDir(cwd);
509
+ state.hasHandoff = handoffRoot !== null && fs.existsSync(path.join(handoffRoot, "handoff.json"));
510
+ return state;
511
+ }
512
+ /**
513
+ * Derive {@link CurrentState} ("where are we + what's next") purely from
514
+ * `.project` substrate. No writes; tolerant of absent optional blocks (every
515
+ * branch defaults to empty rather than throwing).
516
+ *
517
+ * Edge-direction contract for blocked/ready derivation (verified against
518
+ * roadmap-plan.ts:471 — the topoSort-deps mapping for phase_depends_on uses the
519
+ * identical convention): a `task_depends_on_task` edge `{parent: D, child: T}`
520
+ * means task T DEPENDS ON task D, so D must reach status "completed" before T is
521
+ * unblocked. (relation name source_verb_target = task_depends_on_task ⇒ child is
522
+ * the source/dependent, parent is the target/prerequisite; config display_name
523
+ * "depends on task".)
524
+ */
525
+ export function currentState(cwd) {
526
+ // Tolerate any substrate-read failure (no .project, malformed config, etc.)
527
+ // by collapsing to the empty state — this is a pure read surface.
528
+ let idIndex;
529
+ try {
530
+ idIndex = buildIdIndex(cwd);
531
+ }
532
+ catch {
533
+ idIndex = new Map();
534
+ }
535
+ let edges;
536
+ try {
537
+ edges = loadRelations(cwd);
538
+ }
539
+ catch {
540
+ edges = [];
541
+ }
542
+ // Resolve the active status-vocabulary ONCE (defaults shadowed by
543
+ // config.status_buckets). Every status comparison below routes through the
544
+ // resulting bucket — no raw status literal ("in-progress"/"completed"/etc.)
545
+ // is compared in source (DEC-0025 vocabulary-neutrality). bucket(item) maps
546
+ // a raw item.status string to its StatusBucket, defaulting to "unknown".
547
+ const vocab = resolveStatusVocabulary(cwd);
548
+ const bucket = (item) => vocab[String(item.status)] ?? "unknown";
549
+ // ── inFlight: tasks-block items bucketing to in_progress ───────────────────
550
+ const inFlight = [];
551
+ for (const [id, loc] of idIndex) {
552
+ if (loc.block !== "tasks")
553
+ continue;
554
+ if (bucket(loc.item) !== "in_progress")
555
+ continue;
556
+ inFlight.push({
557
+ id,
558
+ block: loc.block,
559
+ description: typeof loc.item.description === "string" ? loc.item.description : "",
560
+ });
561
+ }
562
+ // Task dependency adjacency from task_depends_on_task edges: for task T,
563
+ // depParents(T) = parents of edges {parent, child:T}. T is unblocked iff
564
+ // every dep parent that resolves to a known item is completed (deps pointing
565
+ // at unknown ids are treated as satisfied — a dangling edge is a relations
566
+ // integrity concern surfaced by validateRelations, not a blocker here).
567
+ const isCompleted = (taskId) => {
568
+ const loc = idIndex.get(taskId);
569
+ return loc !== undefined && bucket(loc.item) === "complete";
570
+ };
571
+ const depParentsOf = (taskId) => edges.filter((e) => e.relation_type === "task_depends_on_task" && e.child === taskId).map((e) => e.parent);
572
+ const incompleteDeps = (taskId) => depParentsOf(taskId).filter((dep) => idIndex.has(dep) && !isCompleted(dep));
573
+ // Collect all to-do (ready/queued) tasks once — drives both blocked + ready
574
+ // derivations. "todo" bucket = planned/queued work (raw status "planned"
575
+ // buckets to todo under STATUS_VOCABULARY_DEFAULTS).
576
+ const plannedTasks = [];
577
+ for (const [id, loc] of idIndex) {
578
+ if (loc.block === "tasks" && bucket(loc.item) === "todo")
579
+ plannedTasks.push({ id, loc });
580
+ }
581
+ // ── blocked: planned tasks with at least one incomplete dep parent ─────────
582
+ const blocked = [];
583
+ const blockedIds = new Set();
584
+ for (const { id, loc } of plannedTasks) {
585
+ const blockedBy = incompleteDeps(id);
586
+ if (blockedBy.length > 0) {
587
+ blocked.push({ id, block: loc.block, blockedBy });
588
+ blockedIds.add(id);
589
+ }
590
+ }
591
+ // ── nextActions (atomic-next, ranked) ──────────────────────────────────────
592
+ const nextActions = [];
593
+ // 1. Open framework-gaps (gaps bucketing to todo — raw status "identified"
594
+ // buckets to todo under STATUS_VOCABULARY_DEFAULTS), ranked
595
+ // P0 > P1 > P2 > P3 (missing priority sorts last) then by id.
596
+ const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
597
+ const openGaps = [];
598
+ for (const [id, loc] of idIndex) {
599
+ if (loc.block !== "framework-gaps")
600
+ continue;
601
+ if (bucket(loc.item) !== "todo")
602
+ continue;
603
+ openGaps.push({ id, priority: typeof loc.item.priority === "string" ? loc.item.priority : undefined });
604
+ }
605
+ openGaps.sort((a, b) => {
606
+ const ra = a.priority !== undefined ? (priorityRank[a.priority] ?? 99) : 99;
607
+ const rb = b.priority !== undefined ? (priorityRank[b.priority] ?? 99) : 99;
608
+ if (ra !== rb)
609
+ return ra - rb;
610
+ return a.id.localeCompare(b.id);
611
+ });
612
+ for (const g of openGaps) {
613
+ nextActions.push({
614
+ id: g.id,
615
+ kind: "framework-gap",
616
+ ...(g.priority !== undefined ? { priority: g.priority } : {}),
617
+ reason: `open gap (priority ${g.priority ?? "unset"})`,
618
+ });
619
+ }
620
+ // 2. Ready tasks: planned tasks NOT in `blocked`, ordered via topoSort over
621
+ // the planned-task nodes with deps = their task_depends_on_task parents.
622
+ // topoSort only counts edges between nodes present in the graph, so deps
623
+ // pointing outside the planned set (e.g. already-completed prerequisites)
624
+ // don't gate the ordering — we then filter the resulting order to the
625
+ // ready (unblocked + planned) subset.
626
+ const { order } = topoSort(plannedTasks, (t) => t.id, (t) => depParentsOf(t.id));
627
+ for (const id of order) {
628
+ if (blockedIds.has(id))
629
+ continue;
630
+ nextActions.push({ id, kind: "task", reason: "unblocked planned task" });
631
+ }
632
+ // Cap nextActions at a scannable head (first 15) — derivation can surface a
633
+ // long backlog; the head is the actionable slice for "what's next".
634
+ const NEXT_ACTIONS_CAP = 15;
635
+ const cappedNextActions = nextActions.slice(0, NEXT_ACTIONS_CAP);
636
+ // ── focus: single derived string ───────────────────────────────────────────
637
+ let focus;
638
+ if (inFlight.length > 0) {
639
+ focus = `in-flight: ${inFlight.map((t) => t.id).join(", ")}`;
640
+ }
641
+ else {
642
+ // Fall back to a phase bucketing to in_progress (phase.json phases[]
643
+ // array-block).
644
+ let inProgressPhase = null;
645
+ for (const [id, loc] of idIndex) {
646
+ if (loc.block !== "phase")
647
+ continue;
648
+ if (bucket(loc.item) !== "in_progress")
649
+ continue;
650
+ inProgressPhase = { id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
651
+ break;
652
+ }
653
+ if (inProgressPhase !== null) {
654
+ const label = inProgressPhase.name ? `${inProgressPhase.id} (${inProgressPhase.name})` : inProgressPhase.id;
655
+ focus = `phase: ${label}`;
656
+ }
657
+ else {
658
+ focus = "no active focus.";
659
+ }
660
+ }
661
+ return { focus, inFlight, nextActions: cappedNextActions, blocked };
662
+ }
663
+ /**
664
+ * Discover the single top-level array key in a block payload. Returns null
665
+ * when the block has zero array properties; throws when ambiguous (two or
666
+ * more array properties), since callers cannot proceed without an explicit
667
+ * disambiguation policy. Mirrors the heuristic in
668
+ * scripts/orchestrator/inject-context-items.ts:85-95 — both consumers share
669
+ * the same single-array-key assumption used across .project/ block writes.
670
+ */
671
+ function discoverArrayKey(blockData) {
672
+ const arrayKeys = Object.entries(blockData).filter(([, v]) => Array.isArray(v));
673
+ if (arrayKeys.length === 0)
674
+ return null;
675
+ if (arrayKeys.length === 1)
676
+ return arrayKeys[0][0];
677
+ throw new Error(`filterBlockItems: block has multiple top-level array properties (${arrayKeys
678
+ .map(([k]) => k)
679
+ .join(", ")}); array_key per block is not declared in any registry — single-array assumption violated`);
680
+ }
681
+ /**
682
+ * Filter the array items of a block by a predicate. Reads the block via the
683
+ * canonical block-api `readBlock`, discovers the single top-level array key,
684
+ * and returns a new array of items satisfying the predicate. The source
685
+ * block is never mutated.
686
+ *
687
+ * Behavior contract:
688
+ * - Block must exist; underlying `readBlock` throw propagates.
689
+ * - Block must have exactly one top-level array property (single-array
690
+ * assumption — same as inject-context-items.ts).
691
+ * - Items missing the predicate field never match (see FilterPredicate
692
+ * docstring for rationale).
693
+ * - `op: "in"` requires `value` to be an array; otherwise no items match.
694
+ * - `op: "matches"` constructs `new RegExp(String(value))`; a malformed
695
+ * regex pattern throws synchronously from the RegExp constructor.
696
+ *
697
+ * Closes part of the FGAP-026 phase 2 query-surface gap (TASK-034).
698
+ */
699
+ export function filterBlockItems(cwd, blockName, predicate) {
700
+ const data = readBlock(cwd, blockName);
701
+ const arrayKey = discoverArrayKey(data);
702
+ if (arrayKey === null)
703
+ return [];
704
+ const items = data[arrayKey];
705
+ const { field, op, value } = predicate;
706
+ const re = op === "matches" ? new RegExp(String(value)) : null;
707
+ return items.filter((raw) => {
708
+ if (!raw || typeof raw !== "object")
709
+ return false;
710
+ const item = raw;
711
+ const fv = item[field];
712
+ if (fv === undefined)
713
+ return false;
714
+ switch (op) {
715
+ case "eq":
716
+ return fv === value;
717
+ case "neq":
718
+ return fv !== value;
719
+ case "in":
720
+ return Array.isArray(value) && value.includes(fv);
721
+ case "matches":
722
+ return typeof fv === "string" && re.test(fv);
723
+ default:
724
+ return false;
725
+ }
726
+ });
727
+ }
728
+ /**
729
+ * Read a single item from ONE named block by its id, or null if absent.
730
+ * Block-scoped (no cross-substrate idIndex, no prefix-vs-block invariant — that is resolveItemById).
731
+ * Reuses filterBlockItems(id eq) so it inherits readBlock + discoverArrayKey + edge semantics
732
+ * (missing block / multiple top-level arrays THROW; no-array block or id-not-found → null). FGAP-045.
733
+ */
734
+ export function readBlockItem(cwd, blockName, id) {
735
+ const matches = filterBlockItems(cwd, blockName, { field: "id", op: "eq", value: id });
736
+ return matches.length > 0 ? matches[0] : null;
737
+ }
738
+ /**
739
+ * Paginate a block's items. Reuses readBlock + discoverArrayKey. Returns the FULL count as `total`
740
+ * (not the page length) and `hasMore = offset + limit < total`. No-array block → {items:[],total:0,
741
+ * hasMore:false}; offset ≥ total → empty items with correct total. Missing block / multiple top-level
742
+ * arrays propagate the throw (consistent with filterBlockItems). FGAP-045.
743
+ */
744
+ export function readBlockPage(cwd, blockName, opts = {}) {
745
+ const offset = opts.offset ?? 0;
746
+ const limit = opts.limit ?? 50;
747
+ const data = readBlock(cwd, blockName);
748
+ const arrayKey = discoverArrayKey(data);
749
+ const arr = arrayKey ? data[arrayKey] : [];
750
+ const total = arr.length;
751
+ const items = arr.slice(offset, offset + limit);
752
+ return { items, total, hasMore: offset + limit < total };
753
+ }
754
+ /**
755
+ * Read a block and return its single top-level array of items, or [] when the
756
+ * block has no array property. Mirrors the read+discover prologue shared by
757
+ * filterBlockItems / readBlockPage; missing block / multiple top-level arrays
758
+ * propagate the throw via readBlock + discoverArrayKey. Module-private helper
759
+ * for joinBlocks' no-predicate left path + field-mode right read.
760
+ */
761
+ function readBlockArray(cwd, blockName) {
762
+ const data = readBlock(cwd, blockName);
763
+ const arrayKey = discoverArrayKey(data);
764
+ if (arrayKey === null)
765
+ return [];
766
+ return data[arrayKey].filter((raw) => !!raw && typeof raw === "object");
767
+ }
768
+ /**
769
+ * Cross-block join (FGAP-043, HYBRID). Mode = exactly one of relationType (edge) XOR
770
+ * leftField+rightField (field). Returns one JoinResult per left item (after leftPredicate),
771
+ * right always an array. Edge mode is DEC-0013-native (relations.json); field mode joins on a
772
+ * shared field value (legacy inline-FK + arbitrary shared fields). Reuses filterBlockItems /
773
+ * readBlock / discoverArrayKey / loadRelations / buildIdIndex.
774
+ */
775
+ export function joinBlocks(cwd, spec) {
776
+ const isEdge = spec.relationType !== undefined;
777
+ const isField = spec.leftField !== undefined || spec.rightField !== undefined;
778
+ if (isEdge && isField)
779
+ throw new Error("joinBlocks: specify EITHER relationType (edge mode) OR leftField+rightField (field mode), not both");
780
+ if (!isEdge && !isField)
781
+ throw new Error("joinBlocks: specify relationType (edge mode) or leftField+rightField (field mode)");
782
+ if (isField && (spec.leftField === undefined || spec.rightField === undefined))
783
+ throw new Error("joinBlocks: field mode requires both leftField and rightField");
784
+ const leftItems = spec.leftPredicate
785
+ ? filterBlockItems(cwd, spec.leftBlock, spec.leftPredicate)
786
+ : readBlockArray(cwd, spec.leftBlock);
787
+ if (isField) {
788
+ const rightItems = readBlockArray(cwd, spec.rightBlock);
789
+ const rf = spec.rightField;
790
+ const lf = spec.leftField;
791
+ const index = new Map();
792
+ for (const r of rightItems) {
793
+ const key = r[rf];
794
+ const bucket = index.get(key);
795
+ if (bucket)
796
+ bucket.push(r);
797
+ else
798
+ index.set(key, [r]);
799
+ }
800
+ return leftItems.map((left) => ({ left, right: left[lf] !== undefined ? (index.get(left[lf]) ?? []) : [] }));
801
+ }
802
+ // EDGE mode
803
+ const relationType = spec.relationType;
804
+ const leftEndpoint = spec.leftEndpoint ?? "parent";
805
+ const edges = loadRelations(cwd).filter((e) => e.relation_type === relationType);
806
+ const idIndex = buildIdIndex(cwd);
807
+ return leftItems.map((left) => {
808
+ const leftId = left.id;
809
+ const right = [];
810
+ if (typeof leftId === "string") {
811
+ for (const e of edges) {
812
+ const here = leftEndpoint === "parent" ? e.parent : e.child;
813
+ if (here !== leftId)
814
+ continue;
815
+ const otherId = leftEndpoint === "parent" ? e.child : e.parent;
816
+ const loc = idIndex.get(otherId);
817
+ if (loc && loc.block === spec.rightBlock)
818
+ right.push(loc.item);
819
+ }
820
+ }
821
+ return { left, right };
822
+ });
823
+ }
824
+ /**
825
+ * Look up the block expected to host an ID based on its prefix, driven by
826
+ * the config registry.
827
+ *
828
+ * Resolution: scan `cfg.block_kinds[]` for the longest-matching `prefix`
829
+ * and return its `canonical_id`. Returns null when:
830
+ * - `cfg` is null (no config / pre-bootstrap project — graceful fallback)
831
+ * - no `block_kinds[].prefix` matches the id (bare phase IDs, legacy
832
+ * unprefixed IDs, future prefixes not yet registered)
833
+ *
834
+ * Registry is config-driven from line 1 — prefix conflicts surface at
835
+ * config-registration time rather than fixture-write time. Closes issue-089
836
+ * class (PLAN- vs PLAN-NNN collision) structurally.
837
+ *
838
+ * Longest-prefix wins so that compatible registrations like `R-` and
839
+ * `REVIEW-` resolve unambiguously.
840
+ */
841
+ export function expectedBlockForId(id, cfg) {
842
+ if (!cfg)
843
+ return null;
844
+ let best = null;
845
+ for (const bk of cfg.block_kinds) {
846
+ // An empty prefix matches every id (startsWith("")) — it must never act as
847
+ // a catch-all claiming unprefixed ids (FGAP-062). Empty prefix is a LEGITIMATE
848
+ // signal for slug-id blocks (e.g. conventions, FGAP-051) that don't use
849
+ // prefix+number ids; such blocks are simply excluded from prefix-based
850
+ // resolution (their items index under their own block file with no
851
+ // prefix-vs-block enforcement).
852
+ if (!bk.prefix)
853
+ continue;
854
+ if (id.startsWith(bk.prefix)) {
855
+ if (!best || bk.prefix.length > best.prefix.length) {
856
+ best = { prefix: bk.prefix, canonical: bk.canonical_id };
857
+ }
858
+ }
859
+ }
860
+ return best ? best.canonical : null;
861
+ }
862
+ /**
863
+ * Build a map from item-ID to its location across every block in `.project/`.
864
+ *
865
+ * Scan strategy:
866
+ * - `.project/*.json` — every array property whose items are objects with
867
+ * a string `id` field becomes an indexed entry. Phases participate as an
868
+ * ordinary array-block since DEC-0028 (PHASE-NNN ids in `phase.json` under
869
+ * the plural `phases` array key); there is no dedicated file-per-phase branch.
870
+ *
871
+ * Prefix invariant: when an item ID starts with one of the prefixes registered
872
+ * in `config.block_kinds[]`, the block it was found in must match that
873
+ * registry's `canonical_id`. Mismatches throw immediately — schema patterns
874
+ * make this state unreachable through validated writes, so encountering one
875
+ * indicates either a direct-fs corruption or an unmapped prefix collision that
876
+ * needs explicit resolution. When no config exists (pre-bootstrap project),
877
+ * the prefix invariant is silently skipped — every encountered id is indexed
878
+ * without enforcement.
879
+ *
880
+ * Collisions on identical IDs across different blocks: first writer wins
881
+ * (no overwrite) — duplicate entries are intentionally ignored to keep
882
+ * the resolver deterministic without allocating warning channels here.
883
+ */
884
+ export function buildIdIndex(cwd) {
885
+ const index = new Map();
886
+ const blockDir = tryResolveContextDir(cwd);
887
+ if (blockDir === null)
888
+ return index;
889
+ const cfg = loadConfig(cwd);
890
+ // Phases are an ordinary array-block since DEC-0028: each phase carries a
891
+ // PHASE-NNN top-level `id` and lives in `phase.json` under the plural
892
+ // `phases` array key (singular file basename matches phase.schema.json +
893
+ // the verification.json precedent). The generic block-file scan below
894
+ // indexes them by id like any other block. When `PHASE-` is registered in
895
+ // config.block_kinds (canonical_id "phase"), expectedBlockForId resolves
896
+ // PHASE-NNN ids to block "phase" — matching the file they are found in, so
897
+ // the prefix-vs-block invariant passes without a dedicated branch.
898
+ // Top-level block files — scan every array property for items with `id`.
899
+ if (!fs.existsSync(blockDir))
900
+ return index;
901
+ for (const file of fs.readdirSync(blockDir)) {
902
+ if (!file.endsWith(".json"))
903
+ continue;
904
+ const blockName = file.replace(".json", "");
905
+ let data;
906
+ try {
907
+ data = readBlock(cwd, blockName);
908
+ }
909
+ catch {
910
+ continue; // unreadable / malformed block — skip
911
+ }
912
+ for (const [arrayKey, val] of Object.entries(data)) {
913
+ if (!Array.isArray(val))
914
+ continue;
915
+ for (const raw of val) {
916
+ if (!raw || typeof raw !== "object")
917
+ continue;
918
+ const item = raw;
919
+ const idVal = item.id;
920
+ if (typeof idVal !== "string" || idVal.length === 0)
921
+ continue;
922
+ const expected = expectedBlockForId(idVal, cfg);
923
+ if (expected !== null && expected !== blockName) {
924
+ throw new Error(`buildIdIndex: ID '${idVal}' found in block '${blockName}' but its prefix maps to block '${expected}'. ` +
925
+ `Prefix-vs-block-kind invariant violated — this indicates a direct-fs write that bypassed schema validation, or a prefix collision needing explicit resolution.`);
926
+ }
927
+ if (!index.has(idVal)) {
928
+ index.set(idVal, { block: blockName, arrayKey, item });
929
+ }
930
+ }
931
+ }
932
+ }
933
+ return index;
934
+ }
935
+ /**
936
+ * One-off lookup — builds the full index then performs a single get.
937
+ * Callers performing multiple lookups in one render pass should call
938
+ * `buildIdIndex` once and reuse the returned map.
939
+ */
940
+ export function resolveItemById(cwd, id) {
941
+ return buildIdIndex(cwd).get(id) ?? null;
942
+ }
943
+ /**
944
+ * Bulk variant of `resolveItemById` — resolve N ids against a single
945
+ * `buildIdIndex` traversal. Complements the singular form (which remains
946
+ * available for one-off renderer-driven lookups) by collapsing N independent
947
+ * `buildIdIndex` rebuilds into one. Coexists with the singular surface;
948
+ * neither supersedes the other.
949
+ *
950
+ * Semantics:
951
+ * - Returns a `Map<string, ItemLocation | null>` whose entries are keyed
952
+ * by the INPUT ids exactly as supplied (no normalization, no dedup
953
+ * beyond Map's intrinsic key-uniqueness). Duplicate input ids therefore
954
+ * collapse to one map entry — caller-side responsibility if multiplicity
955
+ * matters; the canonical bulk-lookup contract is "set of ids → set of
956
+ * resolutions" rather than "list → list".
957
+ * - For each input id: present in the index → its `ItemLocation`; absent
958
+ * → null entry. Every input id has an entry in the returned map (no
959
+ * silent drops); this is the property that distinguishes the bulk
960
+ * surface from a partial-result `getMany`.
961
+ * - Empty input (`ids: []`) returns an empty Map (no index build cost
962
+ * beyond the unavoidable directory existence check inside buildIdIndex).
963
+ * - Insertion order matches the first-encounter order of `ids` (standard
964
+ * ES Map semantics on `.set`).
965
+ *
966
+ * Behavior contract:
967
+ * - Single `buildIdIndex(cwd)` invocation regardless of `ids.length`
968
+ * (closes the N×singular-call pattern that motivated the bulk surface).
969
+ * - Prefix-vs-block invariant violations inside the index build propagate
970
+ * out as-is — same surface contract as `resolveItemById`.
971
+ *
972
+ * Closes part of the FGAP-026 phase 2 query-surface gap (TASK-035).
973
+ */
974
+ export function resolveItemsByIds(cwd, ids) {
975
+ const out = new Map();
976
+ if (ids.length === 0)
977
+ return out;
978
+ const index = buildIdIndex(cwd);
979
+ for (const id of ids) {
980
+ if (out.has(id))
981
+ continue; // duplicate input — Map dedup semantics
982
+ out.set(id, index.get(id) ?? null);
983
+ }
984
+ return out;
985
+ }
986
+ /**
987
+ * Field-equality predicate for config-declared invariants. Mirrors the
988
+ * composition-lens `where` semantics (context.ts:773-778): the item
989
+ * qualifies only when EVERY (field, value) pair matches item[field] === value.
990
+ * Absent predicate → every item qualifies.
991
+ */
992
+ function matchesWhere(item, where) {
993
+ if (!where)
994
+ return true;
995
+ for (const [k, v] of Object.entries(where))
996
+ if (item[k] !== v)
997
+ return false;
998
+ return true;
999
+ }
1000
+ /**
1001
+ * Validate cross-block referential integrity against the EDGE model
1002
+ * (DEC-0013: `relations.json` closure-table edges are THE reference surface).
1003
+ * Returns structured issues rather than throwing.
1004
+ *
1005
+ * Edge integrity replaces the pre-DEC-0036 per-block inline-FK reference scan:
1006
+ * each edge's `parent`/`child` must resolve to a known item id (via the unified
1007
+ * `buildIdIndex`), and each edge's `relation_type` must be registered in
1008
+ * `config.relation_types[]` (DEC-0030 tripartite canonical_ids). Cycle
1009
+ * detection is delegated to `validateRelations` (its `edge_cycle_detected`
1010
+ * diagnostics are merged in).
1011
+ *
1012
+ * Substrate invariants beyond edge integrity are enforced generically from
1013
+ * `config.invariants[]` per the `requires-edge` class — no invariant vocabulary
1014
+ * (block kind / status / relation_type / direction) lives in source (DEC-0025).
1015
+ * The canonical pi-context conception declares two invariants as config DATA:
1016
+ * `completed-task-has-verification` and `decision-cites-forcing-artifact`; a
1017
+ * project ships only the invariants its own conception requires.
1018
+ */
1019
+ export function validateContext(cwd) {
1020
+ const issues = [];
1021
+ // Build the unified ID index once — the resolution surface for every edge
1022
+ // endpoint and for the relocated invariants below.
1023
+ // Note: buildIdIndex enforces the prefix-vs-block invariant and may throw
1024
+ // on corrupted state; that surfaces as a hard failure to validateContext
1025
+ // callers (intended — corrupted IDs are not recoverable cross-ref issues).
1026
+ const idIndex = buildIdIndex(cwd);
1027
+ // ── Edge integrity (DEC-0013 closure-table reference surface) ─────────────
1028
+ // Load config + relations; both absent in a pre-bootstrap project, in which
1029
+ // case edge checks (and the relocated invariants, which depend on edges)
1030
+ // are skipped gracefully — there is no edge model to validate yet.
1031
+ const config = loadConfig(cwd);
1032
+ const relations = config ? loadRelations(cwd) : [];
1033
+ // `config` present → run edge-integrity + the relocated invariants. The
1034
+ // invariants detect MISSING edges (completed task without a verification
1035
+ // edge; decision without a forcing-artifact edge), so they must run even
1036
+ // when relations is empty — gating them on relations.length>0 would
1037
+ // false-pass a zero-edge substrate. The edge-integrity loop below is a
1038
+ // no-op on empty relations, so it needs no separate guard.
1039
+ if (config) {
1040
+ const registeredRelTypes = new Set((config.relation_types ?? []).map((rt) => rt.canonical_id));
1041
+ for (const edge of relations) {
1042
+ if (!idIndex.has(edge.parent)) {
1043
+ issues.push({
1044
+ severity: "error",
1045
+ message: `Edge parent '${edge.parent}' (relation_type '${edge.relation_type}') does not resolve to any item`,
1046
+ block: "relations",
1047
+ field: `${edge.parent}->${edge.child}`,
1048
+ });
1049
+ }
1050
+ if (!idIndex.has(edge.child)) {
1051
+ issues.push({
1052
+ severity: "error",
1053
+ message: `Edge child '${edge.child}' (relation_type '${edge.relation_type}') does not resolve to any item`,
1054
+ block: "relations",
1055
+ field: `${edge.parent}->${edge.child}`,
1056
+ });
1057
+ }
1058
+ if (!registeredRelTypes.has(edge.relation_type)) {
1059
+ issues.push({
1060
+ severity: "error",
1061
+ message: `Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`,
1062
+ block: "relations",
1063
+ field: `${edge.parent}->${edge.child}`,
1064
+ });
1065
+ }
1066
+ }
1067
+ // ── Edge endpoint-kind check (FGAP-086, DEC-0037) ─────────────────────
1068
+ // Presence-gated: a relation_type with neither source_kinds nor
1069
+ // target_kinds is unchecked, so the frozen .project substrate (whose
1070
+ // relation_types carry no endpoint metadata) is not retroactively failed.
1071
+ // When metadata is present, an edge endpoint whose resolved block is not in
1072
+ // the declared kind set (and the set is not the "*" wildcard) is an error.
1073
+ // loc.block is the data-file basename; the source/target_kinds name
1074
+ // block_kind canonical_ids — the loc.block==canonical_id assumption is
1075
+ // inherited from the invariant loop below (~:1140), where inv.block (a
1076
+ // canonical_id) is matched directly against loc.block.
1077
+ for (const edge of relations) {
1078
+ const rt = config.relation_types?.find((r) => r.canonical_id === edge.relation_type);
1079
+ if (!rt)
1080
+ continue; // unregistered relation_type already reported above
1081
+ if (!rt.source_kinds && !rt.target_kinds)
1082
+ continue; // metadata absent → unchecked
1083
+ const parentLoc = idIndex.get(edge.parent);
1084
+ const childLoc = idIndex.get(edge.child);
1085
+ if (parentLoc &&
1086
+ rt.source_kinds &&
1087
+ !(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
1088
+ issues.push({
1089
+ severity: "error",
1090
+ message: `Edge ${edge.parent} -> ${edge.child}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1091
+ block: "relations",
1092
+ field: `${edge.parent}->${edge.child}`,
1093
+ });
1094
+ }
1095
+ if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
1096
+ issues.push({
1097
+ severity: "error",
1098
+ message: `Edge ${edge.parent} -> ${edge.child}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1099
+ block: "relations",
1100
+ field: `${edge.parent}->${edge.child}`,
1101
+ });
1102
+ }
1103
+ }
1104
+ // Cycle detection — delegate to validateRelations. It performs its own
1105
+ // lens/hierarchy/relation_type resolution and emits several edge codes;
1106
+ // only its cycle diagnostics are merged here (the parent/child/relation_type
1107
+ // resolution above is the authoritative reference-integrity surface, so
1108
+ // merging validateRelations' resolution codes too would double-report).
1109
+ const itemsByBlock = {};
1110
+ for (const [id, loc] of idIndex) {
1111
+ (itemsByBlock[loc.block] ??= []).push({ id, ...loc.item });
1112
+ }
1113
+ try {
1114
+ const relResult = validateRelations(config, relations, itemsByBlock);
1115
+ for (const ri of relResult.issues) {
1116
+ if (ri.code !== "edge_cycle_detected")
1117
+ continue;
1118
+ issues.push({
1119
+ severity: "error",
1120
+ message: ri.message,
1121
+ block: "relations",
1122
+ code: ri.code,
1123
+ });
1124
+ }
1125
+ }
1126
+ catch {
1127
+ /* validateRelations is best-effort for cycle detection; a throw here
1128
+ must not mask the authoritative edge-integrity issues collected above. */
1129
+ }
1130
+ // ── Config-declared invariants (DEC-0025: vocabulary-neutral) ─────────
1131
+ // Replaces the two previously-hardcoded invariants (completed-task-has-
1132
+ // verification, decision-cites-forcing-artifact). Every block / status /
1133
+ // relation_type / direction literal comes from config.invariants[] DATA;
1134
+ // this loop contains no vocabulary literal. Each requires-edge invariant:
1135
+ // items in `block` matching `where` must occupy `direction`'s endpoint on
1136
+ // ≥1 edge whose relation_type ∈ relation_types — else a diagnostic.
1137
+ for (const inv of config.invariants ?? []) {
1138
+ if (inv.class !== "requires-edge")
1139
+ continue; // forward-compat: skip unknown classes
1140
+ const relTypeSet = new Set(inv.relation_types);
1141
+ const satisfied = new Set();
1142
+ for (const edge of relations) {
1143
+ if (!relTypeSet.has(edge.relation_type))
1144
+ continue;
1145
+ satisfied.add(inv.direction === "as_parent" ? edge.parent : edge.child);
1146
+ }
1147
+ for (const [id, loc] of idIndex) {
1148
+ if (loc.block !== inv.block)
1149
+ continue;
1150
+ if (!matchesWhere(loc.item, inv.where))
1151
+ continue;
1152
+ if (satisfied.has(id))
1153
+ continue;
1154
+ issues.push({
1155
+ severity: inv.severity ?? "error",
1156
+ message: (inv.message ?? `Item '{id}' in block '{block}' violates invariant '${inv.id}'`)
1157
+ .replaceAll("{id}", id)
1158
+ .replaceAll("{block}", inv.block),
1159
+ block: inv.block,
1160
+ field: `${id}.${inv.id}`,
1161
+ code: inv.id,
1162
+ });
1163
+ }
1164
+ }
1165
+ // ── status-consistency invariants (DEC-0040 / FGAP-073) ──────────────
1166
+ // Cross-block status drift: for each item in inv.block (optionally gated
1167
+ // by when_bucket on the item's own status bucket), inspect edges whose
1168
+ // relation_type ∈ inv.relation_types and whose inv.direction endpoint is
1169
+ // the item; the OTHER endpoint is the target. Violation when the target's
1170
+ // status bucket differs from require_target_bucket, or equals
1171
+ // forbid_target_bucket. Vocabulary-free — every literal comes from `inv`
1172
+ // or the config-resolved status vocabulary; no block/status/relation
1173
+ // string is hardcoded. vocab resolved once, outside the loop.
1174
+ const vocab = resolveStatusVocabulary(cwd);
1175
+ const bucketOf = (item) => vocab[String(item.status)] ?? "unknown";
1176
+ for (const inv of config.invariants ?? []) {
1177
+ if (inv.class !== "status-consistency")
1178
+ continue;
1179
+ const relSet = new Set(inv.relation_types);
1180
+ for (const [id, loc] of idIndex) {
1181
+ if (loc.block !== inv.block)
1182
+ continue;
1183
+ if (inv.when_bucket && bucketOf(loc.item) !== inv.when_bucket)
1184
+ continue;
1185
+ for (const edge of relations) {
1186
+ if (!relSet.has(edge.relation_type))
1187
+ continue;
1188
+ const selfIsParent = inv.direction === "as_parent";
1189
+ if ((selfIsParent ? edge.parent : edge.child) !== id)
1190
+ continue;
1191
+ const otherId = selfIsParent ? edge.child : edge.parent;
1192
+ const otherLoc = idIndex.get(otherId);
1193
+ if (!otherLoc)
1194
+ continue; // dangling endpoint handled by edge-integrity above
1195
+ const otherBucket = bucketOf(otherLoc.item);
1196
+ const violateRequire = inv.require_target_bucket !== undefined && otherBucket !== inv.require_target_bucket;
1197
+ const violateForbid = inv.forbid_target_bucket !== undefined && otherBucket === inv.forbid_target_bucket;
1198
+ if (violateRequire || violateForbid) {
1199
+ issues.push({
1200
+ severity: inv.severity ?? "error",
1201
+ message: (inv.message ?? `Item '{id}' (block '{block}') status-consistency '${inv.id}'`)
1202
+ .replaceAll("{id}", id)
1203
+ .replaceAll("{block}", inv.block),
1204
+ block: inv.block,
1205
+ field: `${id}.${inv.id}`,
1206
+ code: inv.id,
1207
+ });
1208
+ }
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1213
+ // Cross-block status-vocabulary check (FGAP-025): an item status value absent
1214
+ // from the declared vocabulary silently buckets to "unknown" in currentState /
1215
+ // status-consistency invariants — surface it. Vocabulary-neutral: reads the
1216
+ // config-driven vocab ("status" is the established item status field, same read
1217
+ // as currentState / bucketOf). A value mapped to the "unknown" BUCKET is still a
1218
+ // key (recognized) and is NOT flagged; only a value with NO key is. Warning-only.
1219
+ {
1220
+ const statusVocab = resolveStatusVocabulary(cwd);
1221
+ for (const [sid, sloc] of idIndex) {
1222
+ const sval = sloc.item.status;
1223
+ if (sval === undefined || sval === null)
1224
+ continue;
1225
+ if (!(String(sval) in statusVocab)) {
1226
+ issues.push({
1227
+ severity: "warning",
1228
+ message: `Item '${sid}' (block '${sloc.block}') status '${String(sval)}' is not in the declared status vocabulary — it silently buckets to 'unknown'.`,
1229
+ block: sloc.block,
1230
+ field: "status",
1231
+ code: "status_unknown_value",
1232
+ });
1233
+ }
1234
+ }
1235
+ }
1236
+ // Lens-validator dispatch (Step 7 / pi-context Divergence 3): iterate every
1237
+ // validator registered via registerLensValidator and merge its issues into
1238
+ // the project-validation result. Validators are guarded individually so a
1239
+ // throwing validator surfaces as a warning issue rather than a hard fail —
1240
+ // keeps the whole-project validate command robust against per-lens bugs.
1241
+ for (const v of getLensValidators()) {
1242
+ try {
1243
+ const result = v.validate(cwd);
1244
+ for (const li of result.issues) {
1245
+ issues.push({
1246
+ severity: li.severity,
1247
+ message: li.message,
1248
+ block: li.block,
1249
+ ...(li.field !== undefined ? { field: li.field } : {}),
1250
+ code: li.code,
1251
+ });
1252
+ }
1253
+ }
1254
+ catch (err) {
1255
+ issues.push({
1256
+ severity: "warning",
1257
+ message: `Lens validator '${v.name}' threw: ${err instanceof Error ? err.message : String(err)}`,
1258
+ block: "lens-validator",
1259
+ code: `lens_validator_failed:${v.name}`,
1260
+ });
1261
+ }
1262
+ }
1263
+ const errorCount = issues.filter((i) => i.severity === "error").length;
1264
+ const warningCount = issues.filter((i) => i.severity === "warning").length;
1265
+ return {
1266
+ status: errorCount > 0 ? "invalid" : warningCount > 0 ? "warnings" : "clean",
1267
+ issues,
1268
+ };
1269
+ }
1270
+ /**
1271
+ * Gate task completion on verification. Reads the verification block to confirm
1272
+ * a passing verification entry exists targeting this task, then atomically
1273
+ * updates the task status to "completed" with the verification cross-reference.
1274
+ */
1275
+ export function completeTask(cwd, taskId, verificationId) {
1276
+ // 1. Read and validate verification entry
1277
+ let verData;
1278
+ try {
1279
+ verData = readBlock(cwd, "verification");
1280
+ }
1281
+ catch {
1282
+ throw new Error(`Verification block not found — cannot complete task '${taskId}' without verification`);
1283
+ }
1284
+ const verifications = Array.isArray(verData.verifications) ? verData.verifications : [];
1285
+ const verification = verifications.find((v) => v.id === verificationId);
1286
+ if (!verification) {
1287
+ throw new Error(`Verification '${verificationId}' not found in verification block`);
1288
+ }
1289
+ if (verification.target !== taskId || verification.target_type !== "task") {
1290
+ throw new Error(`Verification '${verificationId}' targets '${verification.target}' (${verification.target_type}), not task '${taskId}'`);
1291
+ }
1292
+ if (verification.status !== "passed") {
1293
+ throw new Error(`Verification '${verificationId}' status is '${verification.status}', not 'passed' — cannot complete task`);
1294
+ }
1295
+ // 2. Read and validate task entry
1296
+ let taskData;
1297
+ try {
1298
+ taskData = readBlock(cwd, "tasks");
1299
+ }
1300
+ catch {
1301
+ throw new Error(`Tasks block not found — cannot complete task '${taskId}'`);
1302
+ }
1303
+ const tasks = Array.isArray(taskData.tasks) ? taskData.tasks : [];
1304
+ const task = tasks.find((t) => t.id === taskId);
1305
+ if (!task) {
1306
+ throw new Error(`Task '${taskId}' not found in tasks block`);
1307
+ }
1308
+ const currentStatus = String(task.status);
1309
+ if (currentStatus === "completed") {
1310
+ throw new Error(`Task '${taskId}' is already completed`);
1311
+ }
1312
+ if (currentStatus === "cancelled") {
1313
+ throw new Error(`Task '${taskId}' is already cancelled`);
1314
+ }
1315
+ // 3. Update task status with verification cross-reference
1316
+ updateItemInBlock(cwd, "tasks", "tasks", (t) => t.id === taskId, {
1317
+ status: "completed",
1318
+ verification: verificationId,
1319
+ });
1320
+ return {
1321
+ taskId,
1322
+ verificationId,
1323
+ verificationStatus: String(verification.status),
1324
+ previousStatus: currentStatus,
1325
+ };
1326
+ }
1327
+ //# sourceMappingURL=context-sdk.js.map