@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.
- package/CHANGELOG.md +23 -0
- package/README.md +195 -0
- package/dist/block-api.d.ts +328 -0
- package/dist/block-api.d.ts.map +1 -0
- package/dist/block-api.js +1167 -0
- package/dist/block-api.js.map +1 -0
- package/dist/block-validation.d.ts +33 -0
- package/dist/block-validation.d.ts.map +1 -0
- package/dist/block-validation.js +213 -0
- package/dist/block-validation.js.map +1 -0
- package/dist/context-dir.d.ts +93 -0
- package/dist/context-dir.d.ts.map +1 -0
- package/dist/context-dir.js +224 -0
- package/dist/context-dir.js.map +1 -0
- package/dist/context-sdk.d.ts +428 -0
- package/dist/context-sdk.d.ts.map +1 -0
- package/dist/context-sdk.js +1327 -0
- package/dist/context-sdk.js.map +1 -0
- package/dist/context.d.ts +497 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +1060 -0
- package/dist/context.js.map +1 -0
- package/dist/dispatch-context.d.ts +112 -0
- package/dist/dispatch-context.d.ts.map +1 -0
- package/dist/dispatch-context.js +119 -0
- package/dist/dispatch-context.js.map +1 -0
- package/dist/execution-context.d.ts +138 -0
- package/dist/execution-context.d.ts.map +1 -0
- package/dist/execution-context.js +151 -0
- package/dist/execution-context.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1655 -0
- package/dist/index.js.map +1 -0
- package/dist/lens-validator.d.ts +50 -0
- package/dist/lens-validator.d.ts.map +1 -0
- package/dist/lens-validator.js +46 -0
- package/dist/lens-validator.js.map +1 -0
- package/dist/lens-view.d.ts +75 -0
- package/dist/lens-view.d.ts.map +1 -0
- package/dist/lens-view.js +253 -0
- package/dist/lens-view.js.map +1 -0
- package/dist/rename-canonical-id.d.ts +64 -0
- package/dist/rename-canonical-id.d.ts.map +1 -0
- package/dist/rename-canonical-id.js +378 -0
- package/dist/rename-canonical-id.js.map +1 -0
- package/dist/roadmap-plan.d.ts +186 -0
- package/dist/roadmap-plan.d.ts.map +1 -0
- package/dist/roadmap-plan.js +534 -0
- package/dist/roadmap-plan.js.map +1 -0
- package/dist/samples-catalog.d.ts +48 -0
- package/dist/samples-catalog.d.ts.map +1 -0
- package/dist/samples-catalog.js +128 -0
- package/dist/samples-catalog.js.map +1 -0
- package/dist/schema-migrations.d.ts +82 -0
- package/dist/schema-migrations.d.ts.map +1 -0
- package/dist/schema-migrations.js +106 -0
- package/dist/schema-migrations.js.map +1 -0
- package/dist/schema-validator.d.ts +76 -0
- package/dist/schema-validator.d.ts.map +1 -0
- package/dist/schema-validator.js +219 -0
- package/dist/schema-validator.js.map +1 -0
- package/dist/schema-write.d.ts +122 -0
- package/dist/schema-write.d.ts.map +1 -0
- package/dist/schema-write.js +210 -0
- package/dist/schema-write.js.map +1 -0
- package/dist/status-vocab.d.ts +60 -0
- package/dist/status-vocab.d.ts.map +1 -0
- package/dist/status-vocab.js +109 -0
- package/dist/status-vocab.js.map +1 -0
- package/dist/test-helpers.d.ts +12 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +28 -0
- package/dist/test-helpers.js.map +1 -0
- package/dist/topo.d.ts +19 -0
- package/dist/topo.d.ts.map +1 -0
- package/dist/topo.js +99 -0
- package/dist/topo.js.map +1 -0
- package/dist/update-check.d.ts +6 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +82 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +115 -0
- package/samples/blocks/context-contracts.json +3 -0
- package/samples/blocks/conventions.json +3 -0
- package/samples/blocks/decisions.json +3 -0
- package/samples/blocks/features.json +3 -0
- package/samples/blocks/framework-gaps.json +3 -0
- package/samples/blocks/issues.json +3 -0
- package/samples/blocks/layer-plans.json +3 -0
- package/samples/blocks/phase.json +3 -0
- package/samples/blocks/rationale.json +3 -0
- package/samples/blocks/requirements.json +3 -0
- package/samples/blocks/research.json +3 -0
- package/samples/blocks/spec-reviews.json +3 -0
- package/samples/blocks/story.json +3 -0
- package/samples/blocks/tasks.json +3 -0
- package/samples/blocks/verification.json +3 -0
- package/samples/conception.json +467 -0
- package/samples/schemas/context-contracts.schema.json +89 -0
- package/samples/schemas/conventions.schema.json +34 -0
- package/samples/schemas/decisions.schema.json +87 -0
- package/samples/schemas/features.schema.json +53 -0
- package/samples/schemas/framework-gaps.schema.json +64 -0
- package/samples/schemas/issues.schema.json +39 -0
- package/samples/schemas/layer-plans.schema.json +92 -0
- package/samples/schemas/phase.schema.json +118 -0
- package/samples/schemas/rationale.schema.json +24 -0
- package/samples/schemas/requirements.schema.json +34 -0
- package/samples/schemas/research.schema.json +134 -0
- package/samples/schemas/spec-reviews.schema.json +62 -0
- package/samples/schemas/story.schema.json +73 -0
- package/samples/schemas/tasks.schema.json +34 -0
- package/samples/schemas/verification.schema.json +37 -0
- package/schemas/bootstrap.schema.json +24 -0
- package/schemas/config.schema.json +193 -0
- package/schemas/layer.schema.json +9 -0
- package/schemas/priority.schema.json +9 -0
- package/schemas/relations.schema.json +31 -0
- package/schemas/severity.schema.json +9 -0
- package/schemas/source.schema.json +9 -0
- package/schemas/status.schema.json +9 -0
- package/schemas/verification-method.schema.json +9 -0
- package/skill-narrative.md +130 -0
- package/skills/pi-context/SKILL.md +684 -0
- 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
|