@davidorex/pi-context 0.30.0 → 0.32.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 (90) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +27 -11
  3. package/dist/block-api.d.ts +13 -0
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +28 -3
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/content-hash.d.ts +13 -0
  8. package/dist/content-hash.d.ts.map +1 -1
  9. package/dist/content-hash.js +16 -0
  10. package/dist/content-hash.js.map +1 -1
  11. package/dist/context-dir.d.ts +12 -0
  12. package/dist/context-dir.d.ts.map +1 -1
  13. package/dist/context-dir.js +14 -0
  14. package/dist/context-dir.js.map +1 -1
  15. package/dist/context-sdk.d.ts +71 -33
  16. package/dist/context-sdk.d.ts.map +1 -1
  17. package/dist/context-sdk.js +547 -149
  18. package/dist/context-sdk.js.map +1 -1
  19. package/dist/context.d.ts +213 -2
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +119 -5
  22. package/dist/context.js.map +1 -1
  23. package/dist/index.d.ts +595 -9
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2225 -55
  26. package/dist/index.js.map +1 -1
  27. package/dist/lens-view.d.ts +0 -5
  28. package/dist/lens-view.d.ts.map +1 -1
  29. package/dist/lens-view.js +43 -1
  30. package/dist/lens-view.js.map +1 -1
  31. package/dist/migration-registry-loader.d.ts +36 -12
  32. package/dist/migration-registry-loader.d.ts.map +1 -1
  33. package/dist/migration-registry-loader.js +79 -17
  34. package/dist/migration-registry-loader.js.map +1 -1
  35. package/dist/migrations-store.d.ts +45 -18
  36. package/dist/migrations-store.d.ts.map +1 -1
  37. package/dist/migrations-store.js +56 -22
  38. package/dist/migrations-store.js.map +1 -1
  39. package/dist/ops-registry.d.ts +16 -0
  40. package/dist/ops-registry.d.ts.map +1 -1
  41. package/dist/ops-registry.js +352 -117
  42. package/dist/ops-registry.js.map +1 -1
  43. package/dist/pending-blocked-store.d.ts +83 -0
  44. package/dist/pending-blocked-store.d.ts.map +1 -0
  45. package/dist/pending-blocked-store.js +93 -0
  46. package/dist/pending-blocked-store.js.map +1 -0
  47. package/dist/promote-item.d.ts.map +1 -1
  48. package/dist/promote-item.js +41 -12
  49. package/dist/promote-item.js.map +1 -1
  50. package/dist/roadmap-plan.d.ts +121 -99
  51. package/dist/roadmap-plan.d.ts.map +1 -1
  52. package/dist/roadmap-plan.js +281 -345
  53. package/dist/roadmap-plan.js.map +1 -1
  54. package/dist/schema-merge.d.ts +26 -0
  55. package/dist/schema-merge.d.ts.map +1 -0
  56. package/dist/schema-merge.js +176 -0
  57. package/dist/schema-merge.js.map +1 -0
  58. package/dist/status-vocab.d.ts +12 -2
  59. package/dist/status-vocab.d.ts.map +1 -1
  60. package/dist/status-vocab.js +14 -1
  61. package/dist/status-vocab.js.map +1 -1
  62. package/package.json +2 -1
  63. package/samples/blocks/milestone.json +3 -0
  64. package/samples/blocks/session-notes.json +1 -0
  65. package/samples/conception.json +358 -15
  66. package/samples/migrations.json +8 -0
  67. package/samples/schemas/context-contracts.schema.json +4 -0
  68. package/samples/schemas/conventions.schema.json +4 -0
  69. package/samples/schemas/decisions.schema.json +4 -0
  70. package/samples/schemas/features.schema.json +4 -0
  71. package/samples/schemas/framework-gaps.schema.json +5 -1
  72. package/samples/schemas/issues.schema.json +30 -2
  73. package/samples/schemas/layer-plans.schema.json +6 -2
  74. package/samples/schemas/milestone.schema.json +79 -0
  75. package/samples/schemas/phase.schema.json +4 -0
  76. package/samples/schemas/rationale.schema.json +4 -0
  77. package/samples/schemas/requirements.schema.json +4 -0
  78. package/samples/schemas/research.schema.json +5 -1
  79. package/samples/schemas/session-notes.schema.json +89 -0
  80. package/samples/schemas/spec-reviews.schema.json +4 -0
  81. package/samples/schemas/story.schema.json +8 -0
  82. package/samples/schemas/tasks.schema.json +4 -0
  83. package/samples/schemas/verification.schema.json +4 -0
  84. package/samples/schemas/work-orders.schema.json +6 -2
  85. package/schemas/config.schema.json +101 -3
  86. package/schemas/migrations.schema.json +25 -0
  87. package/schemas/pending-blocked.schema.json +190 -0
  88. package/skill-narrative.md +14 -10
  89. package/skills/pi-context/SKILL.md +127 -49
  90. package/skills/pi-context/references/bundled-resources.md +7 -2
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Roadmap + plan substrate primitives — pure functions over typed blocks.
2
+ * Roadmap substrate primitives — the roadmap as a DERIVED view over the
3
+ * milestone block + authored relations (FGAP-042). There is no roadmap block,
4
+ * no roadmap.json, and no ROADMAP- id: the roadmap IS the milestone-block
5
+ * items ordered by the `milestone_precedes_milestone` closure-table edges,
6
+ * with per-milestone membership resolved through `phase_positioned_in_milestone`
7
+ * and per-phase membership through `task_positioned_in_phase`.
3
8
  *
4
- * Module landing pattern follows lens-view.ts: pure functions take cwd +
5
- * identifiers, return structured results or { error } records. Subcommand
6
- * handlers and tool execute() shells in index.ts are thin wrappers that
7
- * route results to ctx.ui.notify, pi.sendMessage, or AgentToolResult.
9
+ * Module landing pattern follows lens-view.ts: pure functions take cwd,
10
+ * return structured results or { error } records. Subcommand handlers and
11
+ * tool execute() shells in index.ts are thin wrappers that route results to
12
+ * ctx.ui.notify, pi.sendMessage, or AgentToolResult.
8
13
  *
9
14
  * Status vocabulary normalization: STATUS_VOCABULARY_DEFAULTS maps each
10
15
  * schema's native status enum (issues uses open|resolved|deferred;
@@ -16,23 +21,17 @@
16
21
  * `config.status_buckets` over the defaults — users override per-project
17
22
  * by editing config.json (FGAP-013 closed).
18
23
  *
19
- * Diagnostic display strings: validateRoadmaps() emits issues whose
24
+ * Diagnostic display strings: validateRoadmap() emits issues whose
20
25
  * `code` field is an opaque slug; the human-readable message is
21
26
  * resolved via `diagMessage(cwd, code, fallback)` which checks
22
27
  * `config.display_strings[code]` and falls back to embedded English.
23
28
  * This is the pi-context divergence: main hardcodes English; pi-context
24
29
  * routes the message through config so per-project / per-locale
25
30
  * overrides are config-driven.
26
- *
27
- * Topological sort + cycle detection (topoSort) is shared utility used by
28
- * loadRoadmap (over phase_depends_on edges) and loadPlan (over
29
- * plan_item_depends_on edges) per DEC-0012's edges-only authoring contract.
30
31
  */
31
- import { readBlock } from "./block-api.js";
32
- import { endpointKey, loadContext } from "./context.js";
33
- import { availableBlocks } from "./context-sdk.js";
32
+ import { counterEndpoint, endpointKey, loadContext, primaryEndpoint, } from "./context.js";
33
+ import { buildIdIndex, currentState } from "./context-sdk.js";
34
34
  import { registerLensValidator } from "./lens-validator.js";
35
- import { loadLensView } from "./lens-view.js";
36
35
  import { resolveStatusVocabulary, STATUS_VOCABULARY_DEFAULTS } from "./status-vocab.js";
37
36
  import { topoSort } from "./topo.js";
38
37
  export { resolveStatusVocabulary, STATUS_VOCABULARY_DEFAULTS };
@@ -109,8 +108,8 @@ function diagMessage(cwd, code, fallback) {
109
108
  * cycles — array of cycle paths, each as a string[] starting and
110
109
  * ending with the same id. Empty when graph is acyclic.
111
110
  *
112
- * Caller chooses whether cycles are fatal: validateRoadmaps surfaces
113
- * roadmap_phase_cycle; loadRoadmap returns the cycles[] alongside a
111
+ * Caller chooses whether cycles are fatal: validateRoadmap surfaces
112
+ * roadmap_milestone_cycle; loadRoadmap returns the cycles[] alongside a
114
113
  * partial order so renderRoadmap can show what it could and what
115
114
  * couldn't be ordered.
116
115
  */
@@ -118,326 +117,293 @@ function diagMessage(cwd, code, fallback) {
118
117
  // consume it without a roadmap-plan <-> context-sdk import cycle. Re-exported
119
118
  // here to preserve the existing import surface (barrel + roadmap-plan.test).
120
119
  export { topoSort };
121
- const ROADMAP_STATUS_ENUM = new Set(["draft", "active", "paused", "complete", "archived"]);
120
+ // ── Derived roadmap over milestone_precedes_milestone (FGAP-042) ────────────
121
+ //
122
+ // Roadmap order is the precedes-DAG over milestone-block items: authoring the
123
+ // order = appending milestone_precedes_milestone edges to relations.json. The
124
+ // renderer is pure-textual: per-milestone adjacency lines are sourced from
125
+ // MilestoneRoadmapView.edges, not inferred from `order` consecutive pairs (a
126
+ // fabrication pattern in branching DAGs).
127
+ // The roadmap selects its three domain relations by canonical_id
128
+ // (milestone_precedes_milestone for the precedes DAG, phase_positioned_in_milestone
129
+ // + task_positioned_in_phase for the membership tiers), but the ORIENTATION of
130
+ // each edge — which endpoint is the predecessor / container — is read from the
131
+ // relation's declared `role_direction` (FGAP-113) via primaryEndpoint /
132
+ // counterEndpoint, not hardcoded parent/child picks. The former module constants
133
+ // are inlined at their (few) use sites so no relation-name literal is duplicated
134
+ // as an orientation carrier.
122
135
  /**
123
- * Defensive read of roadmap.json. Returns the parsed array (may be empty)
124
- * or null when the block is absent / unreadable. Validator and listRoadmaps
125
- * treat absence as a non-defect (opt-in block).
136
+ * The declared orientation of a roadmap relation, or the roadmap's `fallback`
137
+ * when the config does not register a `role_direction` for it (FGAP-113). The
138
+ * fallbacks preserve the pre-metadata hardcoded orientation exactly:
139
+ * `milestone_precedes_milestone` → `as_parent` (predecessor at `edge.parent`);
140
+ * the two `*_positioned_in_*` membership relations → `as_child` (container at
141
+ * `edge.child`, member at `edge.parent`).
126
142
  */
127
- function readRoadmaps(cwd) {
128
- try {
129
- const data = readBlock(cwd, "roadmap");
130
- return Array.isArray(data.roadmaps) ? data.roadmaps : [];
131
- }
132
- catch {
133
- return null;
134
- }
143
+ function roadmapRoleDirection(config, relationType, fallback) {
144
+ return config?.relation_types?.find((r) => r.canonical_id === relationType)?.role_direction ?? fallback;
135
145
  }
146
+ // Error codes flip status to invalid; warning codes to warnings; info codes
147
+ // (roadmap_milestone_isolated) NEVER affect status and are excluded from the
148
+ // lens-validator merge.
149
+ const ROADMAP_ERROR_CODES = new Set([
150
+ "roadmap_precedes_endpoint_missing",
151
+ "roadmap_milestone_cycle",
152
+ "roadmap_milestone_missing",
153
+ ]);
154
+ const ROADMAP_WARNING_CODES = new Set(["roadmap_status_unknown_value"]);
136
155
  /**
137
- * Determine whether a milestone is satisfied: when both evidence_block and
138
- * evidence_query are present, read the block defensively and return true
139
- * iff at least one item has every evidence_query key matching by ===.
140
- * Missing block / read error / no query → false (validator surfaces a
141
- * separate diagnostic where appropriate).
142
- */
143
- function evaluateMilestone(cwd, m) {
144
- if (!m.evidence_block || !m.evidence_query)
145
- return false;
146
- let items;
147
- try {
148
- const data = readBlock(cwd, m.evidence_block);
149
- const arrayKey = Object.keys(data).find((k) => Array.isArray(data[k]));
150
- if (!arrayKey)
151
- return false;
152
- items = data[arrayKey];
153
- }
154
- catch {
155
- return false;
156
- }
157
- const query = m.evidence_query;
158
- for (const item of items) {
159
- let match = true;
160
- for (const [k, v] of Object.entries(query)) {
161
- if (item[k] !== v) {
162
- match = false;
163
- break;
164
- }
165
- }
166
- if (match)
167
- return true;
168
- }
169
- return false;
170
- }
171
- /**
172
- * Load a single roadmap by id. Returns the materialized RoadmapView or a
173
- * structured { error } when config / block / id is missing.
156
+ * Load the derived roadmap. Returns the materialized MilestoneRoadmapView or
157
+ * a structured { error } when no config exists. Zero milestone-block items is
158
+ * a VALID empty view (not an error) the roadmap is opt-in by authoring
159
+ * milestones + edges.
174
160
  *
175
161
  * Algorithm:
176
- * 1. Load context; bail when no .project/config.json.
177
- * 2. Defensively read roadmap.json; bail when absent.
178
- * 3. Lookup the roadmap by id; bail when unknown.
179
- * 4. Build PhaseView[] in authored input order (loadLensView per phase,
180
- * rollupPhaseStatus over the resulting items, milestone resolution
181
- * where declared).
182
- * 5. Compute the in-roadmap edge subset (phase_depends_on edges whose
183
- * both endpoints are in the roadmap) and topo-sort to derive
184
- * phaseOrder + cycles.
162
+ * 1. Load context; bail when no <substrate-dir>/config.json.
163
+ * 2. Index the substrate; milestones = index items whose block is
164
+ * "milestone" (stable block scan order).
165
+ * 3. Scope the milestone_precedes_milestone edges to those whose BOTH
166
+ * endpoints are milestone ids; topoSort (preds = parents of a
167
+ * milestone's incoming precedes edges) derives order + cycles.
168
+ * 4. Per milestone: status + phaseCount from currentState(cwd).milestones
169
+ * (the config-declared derived rollup; a milestone absent from that list
170
+ * reports the rollup pattern's incomplete default "planned" with
171
+ * phaseCount 0); phases = parents of phase_positioned_in_milestone edges
172
+ * (child = the milestone), each with tasks = parents of
173
+ * task_positioned_in_phase edges (child = the phase) and a
174
+ * rollupPhaseStatus over the resolved task items; the milestone-level
175
+ * rollup aggregates all member phases' task items in one call.
185
176
  *
186
177
  * The returned `edges` field is the authoritative scoped subset that the
187
- * renderer consumes for per-phase adjacency lines.
178
+ * renderer consumes for per-milestone adjacency lines.
188
179
  */
189
- export function loadRoadmap(cwd, roadmapId) {
180
+ export function loadRoadmap(cwd) {
190
181
  const ctx = loadContext(cwd);
191
182
  if (!ctx.config) {
192
183
  return {
193
184
  error: "No <substrate-dir>/config.json — run /context init <substrate-dir> first, then declare lenses + install assets.",
194
185
  };
195
186
  }
196
- const roadmaps = readRoadmaps(cwd);
197
- if (roadmaps === null) {
198
- return {
199
- error: "no roadmap.json install via <substrate-dir>/config.json's installed_blocks then author roadmap.json",
200
- };
201
- }
202
- const roadmap = roadmaps.find((r) => r.id === roadmapId);
203
- if (!roadmap) {
204
- const known = roadmaps.map((r) => r.id).join(", ") || "(none)";
205
- return { error: `Roadmap '${roadmapId}' not found. Known: ${known}` };
187
+ const index = buildIdIndex(cwd);
188
+ const milestoneItems = index.items.filter((loc) => loc.block === "milestone");
189
+ if (milestoneItems.length === 0) {
190
+ return { milestones: [], order: [], cycles: [], edges: [] };
206
191
  }
192
+ const milestoneIds = new Set(milestoneItems.map((loc) => loc.id));
193
+ // Precedes DAG: predecessor at the PRIMARY endpoint (as_parent fallback), so a
194
+ // milestone's preds are the PRIMARY endpoints of edges where the milestone is
195
+ // the COUNTER (successor) endpoint. The endpoint-membership filter below is
196
+ // orientation-independent (both endpoints must be milestones either way).
197
+ const precedesRel = "milestone_precedes_milestone";
198
+ const precedesDir = roadmapRoleDirection(ctx.config, precedesRel, "as_parent");
199
+ const edges = ctx.relations.filter((e) => e.relation_type === precedesRel &&
200
+ milestoneIds.has(endpointKey(e.parent)) &&
201
+ milestoneIds.has(endpointKey(e.child)));
202
+ const { order, cycles } = topoSort(milestoneItems, (loc) => loc.id, (loc) => edges
203
+ .filter((e) => endpointKey(counterEndpoint(e, precedesDir)) === loc.id)
204
+ .map((e) => endpointKey(primaryEndpoint(e, precedesDir))));
205
+ const phaseMembershipRel = "phase_positioned_in_milestone";
206
+ const phaseDir = roadmapRoleDirection(ctx.config, phaseMembershipRel, "as_child");
207
+ const taskMembershipRel = "task_positioned_in_phase";
208
+ const taskDir = roadmapRoleDirection(ctx.config, taskMembershipRel, "as_child");
207
209
  const vocabulary = resolveStatusVocabulary(cwd);
208
- const phases = roadmap.phases.map((phase) => {
209
- const lensView = loadLensView(cwd, phase.lens);
210
- const items = "error" in lensView ? [] : lensView.items;
211
- const status = rollupPhaseStatus(items, vocabulary);
212
- const milestone = phase.milestone ? roadmap.milestones?.find((m) => m.id === phase.milestone) : undefined;
213
- const milestoneSatisfied = milestone ? evaluateMilestone(cwd, milestone) : undefined;
214
- return { phase, lensView, status, milestone, milestoneSatisfied };
210
+ // One currentState call: the config-declared milestone rollup supplies each
211
+ // milestone's derived status (reached/planned) + phaseCount.
212
+ const derivedMilestones = new Map(currentState(cwd).milestones.map((m) => [m.id, m]));
213
+ const milestones = milestoneItems.map((loc) => {
214
+ const derived = derivedMilestones.get(loc.id);
215
+ // Member phases: the milestone is the CONTAINER (primary); its members are
216
+ // the COUNTER endpoints of phase_positioned_in_milestone edges pointing at it.
217
+ const memberPhaseIds = ctx.relations
218
+ .filter((e) => e.relation_type === phaseMembershipRel && endpointKey(primaryEndpoint(e, phaseDir)) === loc.id)
219
+ .map((e) => endpointKey(counterEndpoint(e, phaseDir)));
220
+ const allTaskItems = [];
221
+ const phases = memberPhaseIds.map((phaseId) => {
222
+ const phaseLoc = index.byRefname.get(phaseId);
223
+ const taskIds = ctx.relations
224
+ .filter((e) => e.relation_type === taskMembershipRel && endpointKey(primaryEndpoint(e, taskDir)) === phaseId)
225
+ .map((e) => endpointKey(counterEndpoint(e, taskDir)));
226
+ const taskItems = taskIds.map((taskId) => {
227
+ const taskLoc = index.byRefname.get(taskId);
228
+ return taskLoc ? { ...taskLoc.item, id: taskId } : { id: taskId };
229
+ });
230
+ allTaskItems.push(...taskItems);
231
+ const tasks = taskItems.map((item) => ({
232
+ id: item.id,
233
+ ...(typeof item.status === "string" ? { status: item.status } : {}),
234
+ ...(typeof item.title === "string" ? { title: item.title } : {}),
235
+ }));
236
+ return {
237
+ id: phaseId,
238
+ ...(typeof phaseLoc?.item.name === "string" ? { name: phaseLoc.item.name } : {}),
239
+ ...(typeof phaseLoc?.item.status === "string" ? { status: phaseLoc.item.status } : {}),
240
+ tasks,
241
+ rollup: rollupPhaseStatus(taskItems, vocabulary),
242
+ };
243
+ });
244
+ return {
245
+ id: loc.id,
246
+ ...(typeof loc.item.name === "string" ? { name: loc.item.name } : {}),
247
+ status: derived?.status ?? "planned",
248
+ phaseCount: derived?.phaseCount ?? 0,
249
+ phases,
250
+ rollup: rollupPhaseStatus(allTaskItems, vocabulary),
251
+ };
215
252
  });
216
- const inRoadmap = new Set(roadmap.phases.map((p) => p.id));
217
- const edges = ctx.relations.filter((e) => e.relation_type === "phase_depends_on" &&
218
- inRoadmap.has(endpointKey(e.parent)) &&
219
- inRoadmap.has(endpointKey(e.child)));
220
- const { order, cycles } = topoSort(roadmap.phases, (p) => p.id, (p) => edges.filter((e) => endpointKey(e.child) === p.id).map((e) => endpointKey(e.parent)));
221
- return { roadmap, phases, phaseOrder: order, cycles, edges };
253
+ return { milestones, order, cycles, edges };
222
254
  }
223
255
  /**
224
- * Discovery returns one summary entry per roadmap. Empty array when
225
- * roadmap.json is absent (listing is discovery; absence is the truthful
226
- * answer, not an error).
227
- */
228
- export function listRoadmaps(cwd) {
229
- const roadmaps = readRoadmaps(cwd);
230
- if (!roadmaps)
231
- return [];
232
- return roadmaps.map((r) => ({
233
- id: r.id,
234
- title: r.title,
235
- ...(r.status ? { status: r.status } : {}),
236
- phaseCount: r.phases.length,
237
- }));
238
- }
239
- /**
240
- * Validate every roadmap × phase × milestone. Codes are opaque slugs;
241
- * display strings resolve via diagMessage (config.display_strings →
242
- * embedded English fallback). The pi-context lens-validator dispatch
243
- * (registered at module bottom) maps these to ContextValidationIssue-
244
- * compatible records and merges them into validateContext's output.
256
+ * Validate the derived roadmap. Codes are opaque slugs; display strings
257
+ * resolve via diagMessage (config.display_strings embedded English
258
+ * fallback). The pi-context lens-validator dispatch (registered at module
259
+ * bottom) contributes the error-code issues into validateContext's output.
260
+ *
261
+ * Codes:
262
+ * roadmap_precedes_endpoint_missing (error) — a milestone_precedes_milestone
263
+ * edge endpoint that is not a milestone-block item (absent OR wrong kind).
264
+ * roadmap_milestone_cycle (error) a cycle in the precedes graph.
265
+ * roadmap_milestone_missing (error) — a phase_positioned_in_milestone edge
266
+ * whose child is not a known milestone.
267
+ * roadmap_status_unknown_value (warning) a member phase whose task rollup
268
+ * buckets unknown with total > 0 (items lack a recognised status value).
269
+ * roadmap_milestone_isolated (info) — a milestone with zero precedes edges
270
+ * while at least one precedes edge exists elsewhere. Info NEVER flips
271
+ * status: status = invalid iff any error-code issue; warnings iff any
272
+ * warning-code issue; else clean.
245
273
  *
246
- * Roadmap.json absent → { status: "clean", issues: [] } (opt-in block;
247
- * absence is not a defect).
274
+ * No config → { status: "clean", issues: [] } (nothing to validate).
248
275
  */
249
- export function validateRoadmaps(cwd) {
276
+ export function validateRoadmap(cwd) {
250
277
  const issues = [];
251
278
  const ctx = loadContext(cwd);
252
279
  if (!ctx.config)
253
280
  return { status: "clean", issues: [] };
254
- const roadmaps = readRoadmaps(cwd);
255
- if (!roadmaps || roadmaps.length === 0)
256
- return { status: "clean", issues: [] };
257
- const lensIds = new Set((ctx.config.lenses ?? []).map((l) => l.id));
258
- const blockNames = new Set(availableBlocks(cwd).map((b) => b.name));
259
- const vocabulary = resolveStatusVocabulary(cwd);
260
- const errorCodes = new Set([
261
- "roadmap_lens_missing",
262
- "roadmap_phase_dep_missing",
263
- "roadmap_phase_cycle",
264
- "roadmap_composition_cycle",
265
- ]);
266
- for (const roadmap of roadmaps) {
267
- // roadmap.status — must be in known enum (warning when not).
268
- if (roadmap.status !== undefined && !ROADMAP_STATUS_ENUM.has(roadmap.status)) {
269
- issues.push({
270
- code: "roadmap_status_unknown_value",
271
- message: diagMessage(cwd, "roadmap_status_unknown_value", `Roadmap '${roadmap.id}' has unknown status '${roadmap.status}'. Known: ${Array.from(ROADMAP_STATUS_ENUM).join(", ")}`),
272
- roadmap_id: roadmap.id,
273
- });
274
- }
275
- const inRoadmap = new Set(roadmap.phases.map((p) => p.id));
276
- const milestoneIds = new Set((roadmap.milestones ?? []).map((m) => m.id));
277
- // Per-phase checks: lens existence + composition cycle + status rollup
278
- // for unknown-status warning + milestone validation (independent of
279
- // lens resolution — milestone diagnostics surface even when the lens
280
- // itself fails to load).
281
- for (const phase of roadmap.phases) {
282
- let lensResolved = false;
283
- if (!lensIds.has(phase.lens)) {
281
+ const index = buildIdIndex(cwd);
282
+ const milestoneIds = new Set(index.items.filter((loc) => loc.block === "milestone").map((loc) => loc.id));
283
+ // Precedes-edge endpoint integrity BOTH endpoints must be milestone-block
284
+ // items (an id resolving to another block is "wrong kind", equally missing).
285
+ // This is an orientation-independent both-endpoints check, so it needs no
286
+ // role_direction; it selects the relation by canonical_id only.
287
+ for (const e of ctx.relations) {
288
+ if (e.relation_type !== "milestone_precedes_milestone")
289
+ continue;
290
+ for (const endpoint of [e.parent, e.child]) {
291
+ const key = endpointKey(endpoint);
292
+ if (!milestoneIds.has(key)) {
284
293
  issues.push({
285
- code: "roadmap_lens_missing",
286
- message: diagMessage(cwd, "roadmap_lens_missing", `Phase '${phase.id}' in roadmap '${roadmap.id}' references unknown lens '${phase.lens}'.`),
287
- roadmap_id: roadmap.id,
288
- phase_id: phase.id,
294
+ code: "roadmap_precedes_endpoint_missing",
295
+ message: diagMessage(cwd, "roadmap_precedes_endpoint_missing", `milestone_precedes_milestone edge {parent:${endpointKey(e.parent)}, child:${endpointKey(e.child)}} references '${key}' which is not a milestone-block item.`),
296
+ milestone_id: key,
289
297
  });
290
298
  }
291
- else {
292
- const lensView = loadLensView(cwd, phase.lens);
293
- if ("error" in lensView) {
294
- if (lensView.error.includes("composition_cycle_detected")) {
295
- issues.push({
296
- code: "roadmap_composition_cycle",
297
- message: diagMessage(cwd, "roadmap_composition_cycle", `Phase '${phase.id}' in roadmap '${roadmap.id}' resolves to composition lens with cycle: ${lensView.error}`),
298
- roadmap_id: roadmap.id,
299
- phase_id: phase.id,
300
- });
301
- }
302
- }
303
- else {
304
- lensResolved = true;
305
- const status = rollupPhaseStatus(lensView.items, vocabulary);
306
- if (status.bucket === "unknown" && status.total > 0) {
307
- issues.push({
308
- code: "roadmap_status_unknown_value",
309
- message: diagMessage(cwd, "roadmap_status_unknown_value", `Phase '${phase.id}' in roadmap '${roadmap.id}' rolls up to bucket 'unknown' across ${status.total} item(s) — items lack a recognised status enum value.`),
310
- roadmap_id: roadmap.id,
311
- phase_id: phase.id,
312
- });
313
- }
314
- }
315
- }
316
- // Milestone evidence_block + evidence_query checks — independent
317
- // of lens resolution outcome.
318
- if (phase.milestone) {
319
- const milestone = roadmap.milestones?.find((m) => m.id === phase.milestone);
320
- if (milestone) {
321
- if (milestone.evidence_block && !blockNames.has(milestone.evidence_block)) {
322
- issues.push({
323
- code: "roadmap_milestone_evidence_block_missing",
324
- message: diagMessage(cwd, "roadmap_milestone_evidence_block_missing", `Milestone '${milestone.id}' in roadmap '${roadmap.id}' references evidence_block '${milestone.evidence_block}' which is not loaded.`),
325
- roadmap_id: roadmap.id,
326
- phase_id: phase.id,
327
- });
328
- }
329
- if (milestone.evidence_query !== undefined) {
330
- const q = milestone.evidence_query;
331
- const ok = q !== null &&
332
- typeof q === "object" &&
333
- !Array.isArray(q) &&
334
- Object.values(q).every((v) => v === null || ["string", "number", "boolean"].includes(typeof v));
335
- if (!ok) {
336
- issues.push({
337
- code: "roadmap_milestone_query_invalid",
338
- message: diagMessage(cwd, "roadmap_milestone_query_invalid", `Milestone '${milestone.id}' in roadmap '${roadmap.id}' has invalid evidence_query — expected plain object with primitive values only.`),
339
- roadmap_id: roadmap.id,
340
- phase_id: phase.id,
341
- });
342
- }
343
- }
344
- }
345
- }
346
- void lensResolved;
347
299
  }
348
- // Dangling phase_depends_on edges — both endpoints must be in this roadmap
349
- // when at least one endpoint is. (A phase_depends_on edge whose neither
350
- // endpoint matches any roadmap phase is silently skipped it may belong
351
- // to a different roadmap.)
352
- for (const e of ctx.relations) {
353
- if (e.relation_type !== "phase_depends_on")
354
- continue;
355
- const pKey = endpointKey(e.parent);
356
- const cKey = endpointKey(e.child);
357
- const parentIn = inRoadmap.has(pKey);
358
- const childIn = inRoadmap.has(cKey);
359
- if (parentIn !== childIn) {
360
- const dangling = parentIn ? cKey : pKey;
361
- issues.push({
362
- code: "roadmap_phase_dep_missing",
363
- message: diagMessage(cwd, "roadmap_phase_dep_missing", `phase_depends_on edge {parent:${pKey}, child:${cKey}} in roadmap '${roadmap.id}' references phase '${dangling}' that is not declared in this roadmap.`),
364
- roadmap_id: roadmap.id,
365
- phase_id: dangling,
366
- });
367
- }
300
+ }
301
+ // Phase-membership integrity a phase_positioned_in_milestone edge's CONTAINER
302
+ // (the milestone, at the relation's PRIMARY endpoint) must be a known milestone.
303
+ const phaseMembershipRel = "phase_positioned_in_milestone";
304
+ const phaseDir = roadmapRoleDirection(ctx.config, phaseMembershipRel, "as_child");
305
+ for (const e of ctx.relations) {
306
+ if (e.relation_type !== phaseMembershipRel)
307
+ continue;
308
+ const milestoneKey = endpointKey(primaryEndpoint(e, phaseDir));
309
+ if (!milestoneIds.has(milestoneKey)) {
310
+ issues.push({
311
+ code: "roadmap_milestone_missing",
312
+ message: diagMessage(cwd, "roadmap_milestone_missing", `phase_positioned_in_milestone edge {parent:${endpointKey(e.parent)}, child:${endpointKey(e.child)}} references milestone '${milestoneKey}' that is not declared in the milestone block.`),
313
+ milestone_id: milestoneKey,
314
+ phase_id: endpointKey(counterEndpoint(e, phaseDir)),
315
+ });
368
316
  }
369
- // Phase cycles — re-run topoSort over the in-roadmap edge subset.
370
- const scopedEdges = ctx.relations.filter((e) => e.relation_type === "phase_depends_on" &&
371
- inRoadmap.has(endpointKey(e.parent)) &&
372
- inRoadmap.has(endpointKey(e.child)));
373
- const { cycles } = topoSort(roadmap.phases, (p) => p.id, (p) => scopedEdges.filter((e) => endpointKey(e.child) === p.id).map((e) => endpointKey(e.parent)));
374
- for (const cycle of cycles) {
317
+ }
318
+ // Derived-view checks (cycles + per-phase rollup + isolation) read the same
319
+ // derivation the load path produces.
320
+ const view = loadRoadmap(cwd);
321
+ if (!("error" in view)) {
322
+ for (const cycle of view.cycles) {
375
323
  issues.push({
376
- code: "roadmap_phase_cycle",
377
- message: diagMessage(cwd, "roadmap_phase_cycle", `Phase cycle in roadmap '${roadmap.id}': ${cycle.join(" → ")}`),
378
- roadmap_id: roadmap.id,
324
+ code: "roadmap_milestone_cycle",
325
+ message: diagMessage(cwd, "roadmap_milestone_cycle", `Milestone cycle in milestone_precedes_milestone edges: ${cycle.join(" → ")}`),
379
326
  cycle,
380
327
  });
381
328
  }
382
- // milestoneIds is referenced for symmetry with future expansion; mark
383
- // usage to satisfy noUnusedLocals without inserting noise downstream.
384
- void milestoneIds;
329
+ for (const m of view.milestones) {
330
+ for (const p of m.phases) {
331
+ if (p.rollup.bucket === "unknown" && p.rollup.total > 0) {
332
+ issues.push({
333
+ code: "roadmap_status_unknown_value",
334
+ message: diagMessage(cwd, "roadmap_status_unknown_value", `Phase '${p.id}' in milestone '${m.id}' rolls up to bucket 'unknown' across ${p.rollup.total} item(s) — items lack a recognised status enum value.`),
335
+ milestone_id: m.id,
336
+ phase_id: p.id,
337
+ });
338
+ }
339
+ }
340
+ }
341
+ if (view.edges.length > 0) {
342
+ const connected = new Set();
343
+ for (const e of view.edges) {
344
+ connected.add(endpointKey(e.parent));
345
+ connected.add(endpointKey(e.child));
346
+ }
347
+ for (const m of view.milestones) {
348
+ if (!connected.has(m.id)) {
349
+ issues.push({
350
+ code: "roadmap_milestone_isolated",
351
+ message: diagMessage(cwd, "roadmap_milestone_isolated", `Milestone '${m.id}' participates in no milestone_precedes_milestone edge while other milestones are ordered.`),
352
+ milestone_id: m.id,
353
+ });
354
+ }
355
+ }
356
+ }
385
357
  }
358
+ // Info codes are excluded from status: invalid iff any error-code issue;
359
+ // warnings iff any warning-code issue; else clean.
386
360
  let status = "clean";
387
- for (const i of issues) {
388
- if (errorCodes.has(i.code)) {
389
- status = "invalid";
390
- break;
391
- }
361
+ if (issues.some((i) => ROADMAP_ERROR_CODES.has(i.code)))
362
+ status = "invalid";
363
+ else if (issues.some((i) => ROADMAP_WARNING_CODES.has(i.code)))
392
364
  status = "warnings";
393
- }
394
365
  return { status, issues };
395
366
  }
396
367
  /**
397
- * Render a loaded RoadmapView as pure-textual markdown — NO mermaid, NO
398
- * diagrams, NO graph syntax. Per-phase **Depends on:** adjacency lines
399
- * are sourced strictly from view.edges (sorted alphabetically; "—" when
400
- * the phase has no incoming phase_depends_on edges). Cycle-participating
401
- * phases are surfaced under a separate "**Unordered (cycle-participating):**"
402
- * heading and a "**Cycles detected:**" line. The renderer never infers
403
- * adjacency from phaseOrder consecutive pairs (that pattern fabricates
404
- * sibling edges in branching DAGs).
405
- *
406
- * naming consulted only for lens-target display labels; phase / roadmap
407
- * ids render as-is.
368
+ * Render a loaded MilestoneRoadmapView as pure-textual markdown — NO mermaid,
369
+ * NO diagrams, NO graph syntax. Per-milestone **Preceded by:** adjacency lines
370
+ * are sourced strictly from view.edges (sorted alphabetically; "—" when the
371
+ * milestone has no incoming precedes edges). Cycle-participating milestones
372
+ * are surfaced under a separate "**Unordered (cycle participants):**" heading
373
+ * and a "**Cycles detected:**" line. The renderer never infers adjacency from
374
+ * `order` consecutive pairs (that pattern fabricates sibling edges in
375
+ * branching DAGs).
408
376
  */
409
- export function renderRoadmap(view, naming) {
377
+ export function renderRoadmap(view) {
410
378
  const lines = [];
411
- const r = view.roadmap;
412
- lines.push(`# Roadmap: ${r.title} (${r.id})`);
379
+ const byId = new Map(view.milestones.map((m) => [m.id, m]));
380
+ lines.push("# Roadmap (derived)");
381
+ lines.push("");
382
+ lines.push(`**Milestones:** ${view.milestones.length} | **Ordered:** ${view.order.length} | **Cycles:** ${view.cycles.length}`);
413
383
  lines.push("");
414
- lines.push(`**Status:** ${r.status ?? "(unspecified)"}`);
415
- lines.push(`**Phases:** ${r.phases.length} | **Ordered:** ${view.phaseOrder.length} | **Cycles:** ${view.cycles.length}`);
384
+ lines.push("## Milestone order");
416
385
  lines.push("");
417
- if (r.description) {
418
- lines.push(r.description);
386
+ if (view.milestones.length === 0) {
387
+ lines.push("(no milestones)");
419
388
  lines.push("");
420
389
  }
421
- lines.push("## Phase order");
422
- lines.push("");
423
- const phaseById = new Map(r.phases.map((p) => [p.id, p]));
424
- if (view.phaseOrder.length === 0) {
390
+ else if (view.order.length === 0) {
425
391
  lines.push("(no acyclic ordering possible — see cycles below)");
426
392
  lines.push("");
427
393
  }
428
394
  else {
429
- view.phaseOrder.forEach((id, idx) => {
430
- const p = phaseById.get(id);
431
- lines.push(`${idx + 1}. ${id} — ${p?.name ?? "(unknown)"}`);
395
+ view.order.forEach((id, idx) => {
396
+ const m = byId.get(id);
397
+ lines.push(`${idx + 1}. ${id} — ${m?.name ?? "(unnamed)"} [${m?.status ?? "planned"}]`);
432
398
  });
433
399
  lines.push("");
434
400
  }
435
- const orderedSet = new Set(view.phaseOrder);
436
- const cycleOnly = r.phases.filter((p) => !orderedSet.has(p.id));
401
+ const orderedSet = new Set(view.order);
402
+ const cycleOnly = view.milestones.filter((m) => !orderedSet.has(m.id));
437
403
  if (cycleOnly.length > 0) {
438
- lines.push("**Unordered (cycle-participating):**");
439
- for (const p of cycleOnly) {
440
- lines.push(`- ${p.id} — ${p.name}`);
404
+ lines.push("**Unordered (cycle participants):**");
405
+ for (const m of cycleOnly) {
406
+ lines.push(`- ${m.id} — ${m.name ?? "(unnamed)"}`);
441
407
  }
442
408
  lines.push("");
443
409
  }
@@ -446,98 +412,68 @@ export function renderRoadmap(view, naming) {
446
412
  lines.push(`**Cycles detected:** ${rendered}`);
447
413
  lines.push("");
448
414
  }
449
- lines.push("## Phases");
415
+ if (view.milestones.length === 0) {
416
+ return lines.join("\n").replace(/\n+$/, "\n");
417
+ }
418
+ lines.push("## Milestones");
450
419
  lines.push("");
451
- // Render in phaseOrder first, then any cycle-only phases (deduped, input order).
452
- const phaseViewById = new Map(view.phases.map((pv) => [pv.phase.id, pv]));
453
- const renderOrder = [];
454
- for (const id of view.phaseOrder)
455
- renderOrder.push(id);
456
- for (const p of cycleOnly)
457
- renderOrder.push(p.id);
420
+ // Render in topo order first, then any cycle-only milestones (input order).
421
+ const renderOrder = [...view.order, ...cycleOnly.map((m) => m.id)];
458
422
  for (const id of renderOrder) {
459
- const pv = phaseViewById.get(id);
460
- if (!pv)
423
+ const m = byId.get(id);
424
+ if (!m)
461
425
  continue;
462
- const p = pv.phase;
463
- lines.push(`### ${p.name} (${p.id}) [${pv.status.bucket}]`);
426
+ lines.push(`### ${m.name ?? "(unnamed)"} (${m.id}) [${m.status}]`);
464
427
  lines.push("");
465
- const lensTargetLabel = (() => {
466
- if (!("error" in pv.lensView)) {
467
- const lens = pv.lensView.lens;
468
- if (lens.kind === "composition") {
469
- return `${(lens.targets ?? []).map((t) => naming?.[t] ?? t).join(", ")} (composition)`;
470
- }
471
- if (lens.target)
472
- return naming?.[lens.target] ?? lens.target;
473
- }
474
- return p.lens;
475
- })();
476
- lines.push(`**Lens:** ${p.lens}${lensTargetLabel !== p.lens ? ` (${lensTargetLabel})` : ""}`);
477
428
  const incoming = view.edges
478
- .filter((e) => endpointKey(e.child) === p.id)
429
+ .filter((e) => endpointKey(e.child) === m.id)
479
430
  .map((e) => endpointKey(e.parent))
480
431
  .sort();
481
- lines.push(`**Depends on:** ${incoming.length > 0 ? incoming.join(", ") : "—"}`);
482
- const c = pv.status.counts;
483
- lines.push(`**Counts:** complete=${c.complete} in_progress=${c.in_progress} blocked=${c.blocked} todo=${c.todo} unknown=${c.unknown} (total=${pv.status.total})`);
432
+ lines.push(`**Preceded by:** ${incoming.length > 0 ? incoming.join(", ") : "—"}`);
433
+ const c = m.rollup.counts;
434
+ lines.push(`**Rollup:** complete=${c.complete} in_progress=${c.in_progress} blocked=${c.blocked} todo=${c.todo} unknown=${c.unknown} (total=${m.rollup.total})`);
484
435
  lines.push("");
485
- if ("error" in pv.lensView) {
486
- lines.push(`**Lens error:** ${pv.lensView.error}`);
436
+ for (const p of m.phases) {
437
+ lines.push(`#### ${p.name ?? "(unnamed)"} (${p.id}) [${p.status ?? "(unspecified)"}]`);
487
438
  lines.push("");
488
- }
489
- else {
490
- const items = pv.lensView.items;
491
- if (items.length > 0) {
492
- lines.push(`| Item | Status | Title |`);
493
- lines.push(`|----------|----------|----------|`);
494
- for (const item of items) {
495
- const status = typeof item.status === "string" ? item.status : "";
496
- const titleStr = typeof item.title === "string" ? item.title : "";
497
- lines.push(`| ${item.id} | ${status} | ${titleStr} |`);
439
+ if (p.tasks.length === 0) {
440
+ lines.push("(no tasks)");
441
+ lines.push("");
442
+ }
443
+ else {
444
+ lines.push("| Task | Status |");
445
+ lines.push("|------|--------|");
446
+ for (const t of p.tasks) {
447
+ lines.push(`| ${t.title ?? t.id} | ${t.status ?? ""} |`);
498
448
  }
499
449
  lines.push("");
500
450
  }
501
451
  }
502
- if (pv.milestone) {
503
- const verdict = pv.milestoneSatisfied ? "satisfied" : "not yet satisfied";
504
- lines.push(`**Milestone:** ${pv.milestone.id} — ${pv.milestone.name} — ${verdict}`);
505
- lines.push("");
506
- }
507
- if (p.exit_criteria && p.exit_criteria.length > 0) {
508
- lines.push("**Exit criteria:**");
509
- for (const crit of p.exit_criteria)
510
- lines.push(`- ${crit}`);
511
- lines.push("");
512
- }
513
452
  }
514
453
  return lines.join("\n").replace(/\n+$/, "\n");
515
454
  }
516
- // ── Lens-validator dispatch registration (Divergence 3) ─────────────────────
455
+ // ── Lens-validator dispatch registration ─────────────────────────────────────
517
456
  //
518
457
  // Module-init side-effect: validateContext in context-sdk.ts iterates all
519
458
  // registered lens-validators and merges their issues into the project-
520
459
  // validation result. roadmap-plan registers itself here so import of this
521
460
  // module from index.ts wires the dispatch automatically — no hardcoded
522
- // import in context-sdk.ts.
461
+ // import in context-sdk.ts. Only error-code issues contribute to the merge:
462
+ // warnings and info stay on the context-roadmap-validate surface.
523
463
  registerLensValidator({
524
464
  name: "roadmap",
525
465
  validate: (cwd) => {
526
- const result = validateRoadmaps(cwd);
527
- const errorCodes = new Set([
528
- "roadmap_lens_missing",
529
- "roadmap_phase_dep_missing",
530
- "roadmap_phase_cycle",
531
- "roadmap_composition_cycle",
532
- ]);
533
- const issues = result.issues.map((ri) => ({
466
+ const result = validateRoadmap(cwd);
467
+ const issues = result.issues
468
+ .filter((ri) => ROADMAP_ERROR_CODES.has(ri.code))
469
+ .map((ri) => ({
534
470
  code: ri.code,
535
- severity: errorCodes.has(ri.code) ? "error" : "warning",
471
+ severity: "error",
536
472
  message: ri.message,
537
- block: ri.roadmap_id ? `roadmap.${ri.roadmap_id}` : "roadmap",
473
+ block: "milestone",
538
474
  ...(ri.phase_id ? { field: `phase.${ri.phase_id}` } : {}),
539
475
  }));
540
- const status = issues.length === 0 ? "clean" : issues.some((i) => i.severity === "error") ? "invalid" : "warnings";
476
+ const status = issues.length === 0 ? "clean" : "invalid";
541
477
  return { status, issues };
542
478
  },
543
479
  });