@davidorex/pi-context 0.31.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 (71) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +12 -11
  3. package/dist/block-api.d.ts.map +1 -1
  4. package/dist/block-api.js +27 -2
  5. package/dist/block-api.js.map +1 -1
  6. package/dist/context-sdk.d.ts +71 -33
  7. package/dist/context-sdk.d.ts.map +1 -1
  8. package/dist/context-sdk.js +547 -149
  9. package/dist/context-sdk.js.map +1 -1
  10. package/dist/context.d.ts +153 -2
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +75 -5
  13. package/dist/context.js.map +1 -1
  14. package/dist/index.d.ts +71 -19
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +405 -94
  17. package/dist/index.js.map +1 -1
  18. package/dist/lens-view.d.ts +0 -5
  19. package/dist/lens-view.d.ts.map +1 -1
  20. package/dist/lens-view.js +43 -1
  21. package/dist/lens-view.js.map +1 -1
  22. package/dist/migration-registry-loader.d.ts +20 -12
  23. package/dist/migration-registry-loader.d.ts.map +1 -1
  24. package/dist/migration-registry-loader.js +46 -17
  25. package/dist/migration-registry-loader.js.map +1 -1
  26. package/dist/migrations-store.d.ts +45 -18
  27. package/dist/migrations-store.d.ts.map +1 -1
  28. package/dist/migrations-store.js +56 -22
  29. package/dist/migrations-store.js.map +1 -1
  30. package/dist/ops-registry.d.ts.map +1 -1
  31. package/dist/ops-registry.js +91 -118
  32. package/dist/ops-registry.js.map +1 -1
  33. package/dist/promote-item.d.ts.map +1 -1
  34. package/dist/promote-item.js +41 -12
  35. package/dist/promote-item.js.map +1 -1
  36. package/dist/roadmap-plan.d.ts +121 -99
  37. package/dist/roadmap-plan.d.ts.map +1 -1
  38. package/dist/roadmap-plan.js +281 -345
  39. package/dist/roadmap-plan.js.map +1 -1
  40. package/dist/status-vocab.d.ts +12 -2
  41. package/dist/status-vocab.d.ts.map +1 -1
  42. package/dist/status-vocab.js +14 -1
  43. package/dist/status-vocab.js.map +1 -1
  44. package/package.json +1 -1
  45. package/samples/blocks/milestone.json +3 -0
  46. package/samples/blocks/session-notes.json +1 -0
  47. package/samples/conception.json +308 -15
  48. package/samples/migrations.json +8 -0
  49. package/samples/schemas/context-contracts.schema.json +4 -0
  50. package/samples/schemas/conventions.schema.json +4 -0
  51. package/samples/schemas/decisions.schema.json +4 -0
  52. package/samples/schemas/features.schema.json +4 -0
  53. package/samples/schemas/framework-gaps.schema.json +4 -0
  54. package/samples/schemas/issues.schema.json +28 -0
  55. package/samples/schemas/layer-plans.schema.json +4 -0
  56. package/samples/schemas/milestone.schema.json +79 -0
  57. package/samples/schemas/phase.schema.json +4 -0
  58. package/samples/schemas/rationale.schema.json +4 -0
  59. package/samples/schemas/requirements.schema.json +4 -0
  60. package/samples/schemas/research.schema.json +4 -0
  61. package/samples/schemas/session-notes.schema.json +89 -0
  62. package/samples/schemas/spec-reviews.schema.json +4 -0
  63. package/samples/schemas/story.schema.json +8 -0
  64. package/samples/schemas/tasks.schema.json +4 -0
  65. package/samples/schemas/verification.schema.json +4 -0
  66. package/samples/schemas/work-orders.schema.json +4 -0
  67. package/schemas/config.schema.json +77 -3
  68. package/schemas/migrations.schema.json +25 -0
  69. package/skill-narrative.md +7 -5
  70. package/skills/pi-context/SKILL.md +44 -49
  71. package/skills/pi-context/references/bundled-resources.md +5 -1
@@ -8,19 +8,20 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { readBlock, readBlockForDir, updateItemInBlock } from "./block-api.js";
11
- import { appendRelation, appendRelations, endpointIdentity, endpointKey, findUnmaterializedAssets, isSkeletonConfig, loadConfig, loadRelations, removeRelation, validateRelations, writeRelations, } from "./context.js";
11
+ import { appendRelation, appendRelations, counterEndpoint, endpointIdentity, endpointKey, findUnmaterializedAssets, isSkeletonConfig, loadConfig, loadConfigForDir, loadRelations, primaryEndpoint, removeRelation, validateRelations, writeRelations, } from "./context.js";
12
12
  import { resolveContextDir, SCHEMAS_DIR, schemaPath, schemasDir, tryResolveContextDir } from "./context-dir.js";
13
13
  import { loadRegistry, resolveAlias, resolveSubstrateDir } from "./context-registry.js";
14
14
  import { cleanGitEnv } from "./git-env.js";
15
15
  import { getLensValidators } from "./lens-validator.js";
16
16
  import { findReferencesInRepo } from "./lens-view.js";
17
+ import { getProjectMigrationRegistryForDir } from "./migration-registry-loader.js";
17
18
  import { addressInto, discoverArrayKey, pageArray } from "./read-element.js";
18
- import { validateFromFile } from "./schema-validator.js";
19
+ import { ValidationError, validateBlockWithMigrationForDir, validateFromFile } from "./schema-validator.js";
19
20
  import { findNestedIdBearingArrays } from "./schema-write.js";
20
- import { resolveStatusVocabulary } from "./status-vocab.js";
21
+ import { resolveStateDerivation, resolveStatusVocabulary } from "./status-vocab.js";
21
22
  import { topoSort } from "./topo.js";
22
23
  // Re-export substrate SDK so consumers can keep importing through context-sdk.
23
- export { displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
24
+ export { counterEndpoint, displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, primaryEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
24
25
  export function availableBlocks(cwd) {
25
26
  const workflowDir = tryResolveContextDir(cwd);
26
27
  if (workflowDir === null)
@@ -527,14 +528,51 @@ export function contextState(cwd) {
527
528
  * `.project` substrate. No writes; tolerant of absent optional blocks (every
528
529
  * branch defaults to empty rather than throwing).
529
530
  *
530
- * Edge-direction contract for blocked/ready derivation (verified against
531
- * roadmap-plan.ts:471 — the topoSort-deps mapping for phase_depends_on uses the
532
- * identical convention): a `task_depends_on_task` edge `{parent: D, child: T}`
531
+ * Edge-direction contract for blocked/ready derivation (the same convention as
532
+ * roadmap-plan's topoSort-preds mapping for milestone_precedes_milestone
533
+ * preds are parents of edges whose child is the node): a
534
+ * `task_depends_on_task` edge `{parent: D, child: T}`
533
535
  * means task T DEPENDS ON task D, so D must reach status "completed" before T is
534
536
  * unblocked. (relation name source_verb_target = task_depends_on_task ⇒ child is
535
537
  * the source/dependent, parent is the target/prerequisite; config display_name
536
538
  * "depends on task".)
539
+ *
540
+ * Readiness ALSO honors `task_gated_by_item` gates (FGAP-061 NOW slice): a
541
+ * `task_gated_by_item` edge `{parent: T, child: G}` means task T is GATED BY
542
+ * item G (the gate target, of any kind — gap/decision/feature/task/…), so G must
543
+ * reach the "complete" bucket before T's gate releases. A planned task with any
544
+ * gate target NOT in the "complete" bucket is reported in `blocked` (the target
545
+ * id present in `blockedBy`, unioned with unsatisfied dep-parents) and excluded
546
+ * from `nextActions`; a gate target reaching "complete" releases the gate. Gate
547
+ * satisfaction is `bucket(target) === "complete"` via the same status-vocabulary
548
+ * the status-consistency invariant engine uses — kind-general, no per-kind
549
+ * special-casing (gap→closed, decision→enacted, feature→complete, task→completed
550
+ * all bucket to "complete"). A dangling gate target (id resolves to no item) is
551
+ * treated as satisfied/non-blocking, mirroring the dangling-dep guard.
552
+ *
553
+ * Scope is strictly the literal relation_type `task_gated_by_item`:
554
+ * `decision_gated_by_item` and other non-task `*_gated_by_item` edges are inert
555
+ * here (currentState buckets only tasks). A gate target in a terminal-abandoned
556
+ * status (wontfix/superseded/cancelled — buckets to "unknown", NOT "complete")
557
+ * keeps the gated task blocked under the `=== "complete"` rule; promoting such
558
+ * states to gate-releasing, and config-driven generalization to all gate
559
+ * relation kinds, is the FEAT-004 refinement boundary, out of scope here.
560
+ */
561
+ /**
562
+ * Render a `next_ranked` entry's `reason_template`, substituting `{token}`
563
+ * occurrences from `tokens` (every value stringified). Tokens absent from the
564
+ * map are left literal. An undefined template yields the empty string — the
565
+ * deriver carries no kind-coupled reason default; the stock registry declares
566
+ * the templates that reproduce the canonical reason strings.
537
567
  */
568
+ function renderReasonTemplate(template, tokens) {
569
+ if (template === undefined)
570
+ return "";
571
+ return template.replace(/\{(\w+)\}/g, (match, key) => {
572
+ const v = tokens[key];
573
+ return v !== undefined ? v : match;
574
+ });
575
+ }
538
576
  export function currentState(cwd) {
539
577
  // Tolerate any substrate-read failure (no .project, malformed config, etc.)
540
578
  // by collapsing to the empty state — this is a pure read surface.
@@ -559,12 +597,22 @@ export function currentState(cwd) {
559
597
  // a raw item.status string to its StatusBucket, defaulting to "unknown".
560
598
  const vocab = resolveStatusVocabulary(cwd);
561
599
  const bucket = (item) => vocab[String(item.status)] ?? "unknown";
562
- // ── inFlight: tasks-block items bucketing to in_progress ───────────────────
600
+ // Resolve the config-declared derivation registry (TASK-020 / FGAP-017). When
601
+ // it is ABSENT, every coupling below is unconfigured, so the function returns
602
+ // the truthful "state-derivation not configured" signal — a state distinct
603
+ // from a configured-but-empty substrate (which derives normally to
604
+ // `focus: "no active focus."` + empty arrays). All 16 couplings below read
605
+ // `sd.*`; no kind / relation / rank / status / head-size literal remains.
606
+ const sd = resolveStateDerivation(cwd);
607
+ if (sd === null) {
608
+ return { focus: "state-derivation not configured", inFlight: [], nextActions: [], blocked: [], milestones: [] };
609
+ }
610
+ // ── inFlight: items of an `in_flight.kinds` block bucketing to in_flight.bucket ─
563
611
  const inFlight = [];
564
612
  for (const loc of index.byRefname.values()) {
565
- if (loc.block !== "tasks")
613
+ if (!sd.in_flight.kinds.includes(loc.block))
566
614
  continue;
567
- if (bucket(loc.item) !== "in_progress")
615
+ if (bucket(loc.item) !== sd.in_flight.bucket)
568
616
  continue;
569
617
  inFlight.push({
570
618
  id: loc.id,
@@ -572,108 +620,207 @@ export function currentState(cwd) {
572
620
  description: typeof loc.item.description === "string" ? loc.item.description : "",
573
621
  });
574
622
  }
575
- // Task dependency adjacency from task_depends_on_task edges: for task T,
576
- // depParents(T) = parents of edges {parent, child:T}. T is unblocked iff
577
- // every dep parent that resolves to a known item is completed (deps pointing
578
- // at unknown ids are treated as satisfied — a dangling edge is a relations
579
- // integrity concern surfaced by validateRelations, not a blocker here).
580
- const isCompleted = (taskId) => {
581
- const loc = index.byRefname.get(taskId);
623
+ // A target is complete when it resolves to a known item whose status buckets to
624
+ // "complete" the SHARED status-vocab completeness notion (as TASK-065 left it,
625
+ // the same check the status-consistency invariant engine uses), kind-general and
626
+ // not a per-derivation literal.
627
+ const isCompleted = (itemId) => {
628
+ const loc = index.byRefname.get(itemId);
582
629
  return loc !== undefined && bucket(loc.item) === "complete";
583
630
  };
584
- const depParentsOf = (taskId) => edges
585
- .filter((e) => e.relation_type === "task_depends_on_task" && endpointKey(e.child) === taskId)
631
+ // Blocking-relation adjacency, driven by `sd.blocked_by.relation_types`. The
632
+ // two stock relations use OPPOSITE endpoint directions, preserved exactly:
633
+ // • task_depends_on_task — DEPENDENCY direction: parents of edges whose CHILD
634
+ // is the item (the prerequisites of the item).
635
+ // • task_gated_by_item — GATE direction: children of edges whose PARENT is
636
+ // the item (the gate targets the item waits on).
637
+ // A relation present in the set with no known direction rule defaults to the
638
+ // DEPENDENCY direction (parent-of-edge-with-child=item). Which relations
639
+ // participate is gated on membership in `sd.blocked_by.relation_types`, so an
640
+ // empty/absent relation is simply not consulted.
641
+ // Partition the configured blocking relations into GATE-direction vs
642
+ // DEPENDENCY-direction by each relation's declared `role_direction` (FGAP-113),
643
+ // replacing the former single `task_gated_by_item` string literal:
644
+ // • role_direction === "as_child" → GATE direction: the relation's PRIMARY
645
+ // role (the gate) sits at edge.child and the waiting item at edge.parent, so
646
+ // the item's gates are the CHILDREN of edges whose PARENT is the item.
647
+ // • otherwise (as_parent OR unset) → DEPENDENCY direction: the item's
648
+ // prerequisites are the PARENTS of edges whose CHILD is the item.
649
+ // For the stock set {task_depends_on_task (as_parent), task_gated_by_item
650
+ // (as_child)} this partition is identical to the pre-change literal split, so
651
+ // blocked / nextActions / blockedBy stay byte-identical; any *_gated_by_item
652
+ // sibling later added to blocked_by routes to the gate direction by
653
+ // construction (no literal to extend). A blocked_by relation the config does
654
+ // NOT register with a role_direction reads as the DEPENDENCY default.
655
+ const blockedByRels = new Set(sd.blocked_by.relation_types);
656
+ const roleDirection = new Map();
657
+ for (const rt of loadConfig(cwd)?.relation_types ?? []) {
658
+ if (rt.role_direction !== undefined)
659
+ roleDirection.set(rt.canonical_id, rt.role_direction);
660
+ }
661
+ const gateDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) === "as_child"));
662
+ const depDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) !== "as_child"));
663
+ const dependencyPredsOf = (itemId) => edges
664
+ .filter((e) => depDirRels.has(e.relation_type) && endpointKey(e.child) === itemId)
586
665
  .map((e) => endpointKey(e.parent));
587
- const incompleteDeps = (taskId) => depParentsOf(taskId).filter((dep) => index.byRefname.has(dep) && !isCompleted(dep));
588
- // Collect all to-do (ready/queued) tasks once — drives both blocked + ready
589
- // derivations. "todo" bucket = planned/queued work (raw status "planned"
590
- // buckets to todo under STATUS_VOCABULARY_DEFAULTS).
666
+ const gatePredsOf = (itemId) => edges
667
+ .filter((e) => gateDirRels.has(e.relation_type) && endpointKey(e.parent) === itemId)
668
+ .map((e) => endpointKey(e.child));
669
+ // All preds (deps gates) in discovery order — used by the topo ordering below.
670
+ const allPredsOf = (itemId) => [...dependencyPredsOf(itemId), ...gatePredsOf(itemId)];
671
+ const incompletePreds = (itemId) => dependencyPredsOf(itemId).filter((dep) => index.byRefname.has(dep) && !isCompleted(dep));
672
+ const unsatisfiedGates = (itemId) => gatePredsOf(itemId).filter((target) => index.byRefname.has(target) && !isCompleted(target));
673
+ // The "planned tasks" set is the next_ranked entry that has NO rank_field (the
674
+ // stock tasks entry, topo-ordered). It drives both blocked + ready derivations.
675
+ const topoEntry = sd.next_ranked.find((e) => e.rank_field === undefined);
591
676
  const plannedTasks = [];
592
- for (const loc of index.byRefname.values()) {
593
- if (loc.block === "tasks" && bucket(loc.item) === "todo")
594
- plannedTasks.push({ id: loc.id, loc });
677
+ if (topoEntry !== undefined) {
678
+ for (const loc of index.byRefname.values()) {
679
+ if (loc.block === topoEntry.kind && bucket(loc.item) === topoEntry.bucket) {
680
+ plannedTasks.push({ id: loc.id, loc });
681
+ }
682
+ }
595
683
  }
596
- // ── blocked: planned tasks with at least one incomplete dep parent ─────────
684
+ // blockedBy(T) = UNION of T's unsatisfied dependency-direction preds and
685
+ // unsatisfied gate-direction targets, de-duplicated while preserving discovery
686
+ // order (deps first, then gates). With no gate relation configured / no gate
687
+ // edges present this collapses to the dependency-only set.
688
+ const blockersOf = (taskId) => {
689
+ const result = [];
690
+ const seen = new Set();
691
+ for (const blocker of [...incompletePreds(taskId), ...unsatisfiedGates(taskId)]) {
692
+ if (seen.has(blocker))
693
+ continue;
694
+ seen.add(blocker);
695
+ result.push(blocker);
696
+ }
697
+ return result;
698
+ };
699
+ // ── blocked: planned tasks with at least one unsatisfied dep or gate ─────────
597
700
  const blocked = [];
598
701
  const blockedIds = new Set();
599
702
  for (const { id, loc } of plannedTasks) {
600
- const blockedBy = incompleteDeps(id);
703
+ const blockedBy = blockersOf(id);
601
704
  if (blockedBy.length > 0) {
602
705
  blocked.push({ id, block: loc.block, blockedBy });
603
706
  blockedIds.add(id);
604
707
  }
605
708
  }
606
709
  // ── nextActions (atomic-next, ranked) ──────────────────────────────────────
710
+ // Iterate `sd.next_ranked` IN ARRAY ORDER — array order IS the cross-kind push
711
+ // order (stock: topo-ordered tasks, then priority-ranked issues, then
712
+ // priority-ranked gaps).
607
713
  const nextActions = [];
608
- // 1. Open framework-gaps (gaps bucketing to todo — raw status "identified"
609
- // buckets to todo under STATUS_VOCABULARY_DEFAULTS), ranked
610
- // P0 > P1 > P2 > P3 (missing priority sorts last) then by id.
611
- const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
612
- const openGaps = [];
613
- for (const loc of index.byRefname.values()) {
614
- if (loc.block !== "framework-gaps")
615
- continue;
616
- if (bucket(loc.item) !== "todo")
617
- continue;
618
- openGaps.push({ id: loc.id, priority: typeof loc.item.priority === "string" ? loc.item.priority : undefined });
619
- }
620
- openGaps.sort((a, b) => {
621
- const ra = a.priority !== undefined ? (priorityRank[a.priority] ?? 99) : 99;
622
- const rb = b.priority !== undefined ? (priorityRank[b.priority] ?? 99) : 99;
623
- if (ra !== rb)
624
- return ra - rb;
625
- return a.id.localeCompare(b.id);
626
- });
627
- for (const g of openGaps) {
628
- nextActions.push({
629
- id: g.id,
630
- kind: "framework-gap",
631
- ...(g.priority !== undefined ? { priority: g.priority } : {}),
632
- reason: `open gap (priority ${g.priority ?? "unset"})`,
633
- });
714
+ for (const entry of sd.next_ranked) {
715
+ if (entry.rank_field !== undefined) {
716
+ // Field-ranked entry (stock: framework-gaps by `priority`). Select items of
717
+ // `entry.kind` at `entry.bucket`, rank by index in `entry.rank_order` (value
718
+ // not listed → large sentinel 99) then by id.
719
+ const rankField = entry.rank_field;
720
+ const rankIndex = {};
721
+ (entry.rank_order ?? []).forEach((v, i) => {
722
+ rankIndex[v] = i;
723
+ });
724
+ const selected = [];
725
+ for (const loc of index.byRefname.values()) {
726
+ if (loc.block !== entry.kind)
727
+ continue;
728
+ if (bucket(loc.item) !== entry.bucket)
729
+ continue;
730
+ const raw = loc.item[rankField];
731
+ selected.push({ id: loc.id, value: typeof raw === "string" ? raw : undefined });
732
+ }
733
+ selected.sort((a, b) => {
734
+ const ra = a.value !== undefined ? (rankIndex[a.value] ?? 99) : 99;
735
+ const rb = b.value !== undefined ? (rankIndex[b.value] ?? 99) : 99;
736
+ if (ra !== rb)
737
+ return ra - rb;
738
+ return a.id.localeCompare(b.id);
739
+ });
740
+ for (const s of selected) {
741
+ nextActions.push({
742
+ id: s.id,
743
+ kind: entry.label,
744
+ ...(s.value !== undefined ? { [rankField]: s.value } : {}),
745
+ reason: renderReasonTemplate(entry.reason_template, { rank_value: s.value ?? "unset", id: s.id }),
746
+ });
747
+ }
748
+ }
749
+ else {
750
+ // Topo-ordered entry (stock: tasks). Ready = planned items NOT in `blocked`,
751
+ // ordered via topoSort over the planned nodes with preds = dependency preds
752
+ // ∪ gate targets. topoSort only counts edges between graph nodes, so preds
753
+ // outside the planned set (completed / non-task) don't gate the ordering.
754
+ const { order } = topoSort(plannedTasks, (t) => t.id, (t) => allPredsOf(t.id));
755
+ for (const id of order) {
756
+ if (blockedIds.has(id))
757
+ continue;
758
+ nextActions.push({ id, kind: entry.label, reason: renderReasonTemplate(entry.reason_template, { id }) });
759
+ }
760
+ }
634
761
  }
635
- // 2. Ready tasks: planned tasks NOT in `blocked`, ordered via topoSort over
636
- // the planned-task nodes with deps = their task_depends_on_task parents.
637
- // topoSort only counts edges between nodes present in the graph, so deps
638
- // pointing outside the planned set (e.g. already-completed prerequisites)
639
- // don't gate the ordering we then filter the resulting order to the
640
- // ready (unblocked + planned) subset.
641
- const { order } = topoSort(plannedTasks, (t) => t.id, (t) => depParentsOf(t.id));
642
- for (const id of order) {
643
- if (blockedIds.has(id))
644
- continue;
645
- nextActions.push({ id, kind: "task", reason: "unblocked planned task" });
762
+ // Cap nextActions at the config-declared scannable head derivation can
763
+ // surface a long backlog; the head is the actionable slice for "what's next".
764
+ const cappedNextActions = nextActions.slice(0, sd.head_size);
765
+ // ── milestones: config-declared membership rollups ──────────────────────────
766
+ // For each `sd.rollups` entry, orientation is read from the membership
767
+ // relation's declared `role_direction` (FGAP-113): the CONTAINER (the rollup
768
+ // item itself) sits at the PRIMARY endpoint, its MEMBERS at the COUNTER
769
+ // endpoint. `phase_positioned_in_milestone` is `as_child` (container=milestone
770
+ // at edge.child, member=phase at edge.parent), so `primaryEndpoint`===child /
771
+ // `counterEndpoint`===parent reproduces the prior filter-child / map-parent
772
+ // selection exactly. A membership relation the config does not register with a
773
+ // `role_direction` defaults to `as_child` (the pre-FGAP-113 container=child
774
+ // convention). The rollup emits `complete_status` when ≥1 member exists and
775
+ // every member id resolves to a known item bucketing to complete; else
776
+ // `incomplete_status` (covering no-members + any-incomplete). Every comparison
777
+ // routes through bucket() — no raw status literal.
778
+ const milestones = [];
779
+ for (const entry of sd.rollups) {
780
+ const dir = roleDirection.get(entry.membership_relation) ?? "as_child";
781
+ for (const loc of index.byRefname.values()) {
782
+ if (loc.block !== entry.kind)
783
+ continue;
784
+ const memberIds = edges
785
+ .filter((e) => e.relation_type === entry.membership_relation && endpointKey(primaryEndpoint(e, dir)) === loc.id)
786
+ .map((e) => endpointKey(counterEndpoint(e, dir)));
787
+ const phaseCount = memberIds.length;
788
+ const allComplete = memberIds.every((memberId) => isCompleted(memberId));
789
+ const reached = phaseCount >= 1 && allComplete;
790
+ milestones.push({
791
+ id: loc.id,
792
+ status: reached ? entry.complete_status : entry.incomplete_status,
793
+ phaseCount,
794
+ });
795
+ }
646
796
  }
647
- // Cap nextActions at a scannable head (first 15) — derivation can surface a
648
- // long backlog; the head is the actionable slice for "what's next".
649
- const NEXT_ACTIONS_CAP = 15;
650
- const cappedNextActions = nextActions.slice(0, NEXT_ACTIONS_CAP);
797
+ milestones.sort((a, b) => a.id.localeCompare(b.id));
651
798
  // ── focus: single derived string ───────────────────────────────────────────
652
799
  let focus;
653
800
  if (inFlight.length > 0) {
654
801
  focus = `in-flight: ${inFlight.map((t) => t.id).join(", ")}`;
655
802
  }
656
803
  else {
657
- // Fall back to a phase bucketing to in_progress (phase.json phases[]
658
- // array-block).
659
- let inProgressPhase = null;
804
+ // Fall back to the first item of `focus_fallback.kind` bucketing to
805
+ // `focus_fallback.bucket` (stock: an in-progress phase).
806
+ let fallbackItem = null;
660
807
  for (const loc of index.byRefname.values()) {
661
- if (loc.block !== "phase")
808
+ if (loc.block !== sd.focus_fallback.kind)
662
809
  continue;
663
- if (bucket(loc.item) !== "in_progress")
810
+ if (bucket(loc.item) !== sd.focus_fallback.bucket)
664
811
  continue;
665
- inProgressPhase = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
812
+ fallbackItem = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
666
813
  break;
667
814
  }
668
- if (inProgressPhase !== null) {
669
- const label = inProgressPhase.name ? `${inProgressPhase.id} (${inProgressPhase.name})` : inProgressPhase.id;
670
- focus = `phase: ${label}`;
815
+ if (fallbackItem !== null) {
816
+ const label = fallbackItem.name ? `${fallbackItem.id} (${fallbackItem.name})` : fallbackItem.id;
817
+ focus = `${sd.focus_fallback.kind}: ${label}`;
671
818
  }
672
819
  else {
673
820
  focus = "no active focus.";
674
821
  }
675
822
  }
676
- return { focus, inFlight, nextActions: cappedNextActions, blocked };
823
+ return { focus, inFlight, nextActions: cappedNextActions, blocked, milestones };
677
824
  }
678
825
  // discoverArrayKey lives in read-element.ts (the lowest pure layer) and is
679
826
  // imported above — ONE copy of the single-top-level-array heuristic shared by
@@ -1034,22 +1181,31 @@ export function resolveItemsByIds(cwd, ids) {
1034
1181
  }
1035
1182
  // ── Relation porcelain (selector → structured EdgeEndpoint → raw append) ─────
1036
1183
  /**
1037
- * Load + JSON-parse a foreign substrate dir's config.json WITHOUT pointer
1038
- * resolution or AJV validation best-effort, returns null on absence/parse
1039
- * failure. Used only to feed `expectedBlockForId`'s prefix invariant when
1040
- * indexing a foreign substrate in the porcelain; the foreign substrate's own
1041
- * write path already AJV-validated its config, so a re-validate here would only
1042
- * add a failure mode to a read.
1184
+ * Load a foreign substrate dir's config.json PREFERRING the migration-aware
1185
+ * loader (`loadConfigForDir`: migrate-then-validate) so this reader sees the
1186
+ * SAME config shape `loadConfig` sees once a shape-changing config migration
1187
+ * exists (no split-brain read of the same file). Best-effort in two tiers:
1188
+ * when the migration-aware load fails (unresolvable version, invalid), fall
1189
+ * back to the prior raw parse — the consumer is `expectedBlockForId`'s prefix
1190
+ * invariant, which is better fed an unvalidated `block_kinds` than none (a
1191
+ * null config would silently disable the invariant and let a mis-filed
1192
+ * foreign id resolve instead of degrading). Absent or unparsable → null,
1193
+ * exactly the prior contract.
1043
1194
  */
1044
1195
  function loadConfigForDirBestEffort(substrateDir) {
1045
1196
  const p = path.join(substrateDir, "config.json");
1046
1197
  if (!fs.existsSync(p))
1047
1198
  return null;
1048
1199
  try {
1049
- return JSON.parse(fs.readFileSync(p, "utf-8"));
1200
+ return loadConfigForDir(substrateDir);
1050
1201
  }
1051
1202
  catch {
1052
- return null;
1203
+ try {
1204
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
1205
+ }
1206
+ catch {
1207
+ return null;
1208
+ }
1053
1209
  }
1054
1210
  }
1055
1211
  /**
@@ -1134,24 +1290,180 @@ function edgeIdentityKey(edge) {
1134
1290
  return `${endpointIdentity(edge.parent)} ${endpointIdentity(edge.child)} ${edge.relation_type}`;
1135
1291
  }
1136
1292
  /**
1137
- * Friendly-selector relation append (Cycle 5 porcelain). Resolves `parent` /
1138
- * `child` STRING selectors to structured `EdgeEndpoint`s via
1139
- * `resolveRelationSelector`, then delegates to the raw `appendRelation` plumbing
1140
- * (atomic, AJV-validated, exact-duplicate no-op same deferred-integrity
1141
- * semantics). Keeps the string param surface its callers (the append-relation
1142
- * Pi tool + the orchestrator CLI) already expose.
1293
+ * Shared edge-registry validator (TASK-062). The single source of edge
1294
+ * registration + endpoint-kind semantics, invoked BOTH at write time (the
1295
+ * `appendRelationByRef` / `appendRelationsByRef` porcelain, so a bad edge throws
1296
+ * before the raw write) AND post-hoc in {@link validateContext} (so write-time
1297
+ * and validate-time verdicts are guaranteed identical).
1298
+ *
1299
+ * Returns an ARRAY of human-readable error messages — empty when the edge is
1300
+ * acceptable. Each message is byte-identical to the wording `validateContext`
1301
+ * historically emitted inline, so the validate-time issue stream is unchanged;
1302
+ * the write path joins the array into the thrown Error message.
1303
+ *
1304
+ * Checks (registration + source/target-kind ONLY — NO `category` check; the
1305
+ * relation_type `category` attribute has no per-edge referent anywhere in code):
1306
+ * - (a) `edge.relation_type` MUST be registered in `config.relation_types[]`
1307
+ * (matched by `canonical_id`). Unregistered → the registration message;
1308
+ * when unregistered the kind check is short-circuited (no rt to read).
1309
+ * - (b) PRESENCE-GATED source/target-kind membership: a relation_type with
1310
+ * NEITHER `source_kinds` NOR `target_kinds` is unchecked (mirrors the
1311
+ * `if (!rt.source_kinds && !rt.target_kinds) continue;` gate). When a set
1312
+ * is present, the resolved endpoint block (via `resolve(...).loc.block`)
1313
+ * MUST be in it, honoring the `"*"` wildcard. A lens_bin / dangling /
1314
+ * unregistered endpoint carries no `loc` and is skipped for the kind
1315
+ * check (endpoint-resolution failures are validateContext's own surface,
1316
+ * not this helper's).
1317
+ *
1318
+ * `resolve` is the caller-supplied endpoint resolver (the same pass-bound
1319
+ * `resolveRef` closure validateContext builds; the write path builds a fresh one
1320
+ * over a freshly-built active index).
1321
+ */
1322
+ export function validateEdgeAgainstRegistry(edge, config, resolve) {
1323
+ const errors = [];
1324
+ const parentKey = endpointKey(edge.parent);
1325
+ const childKey = endpointKey(edge.child);
1326
+ const rt = (config.relation_types ?? []).find((r) => r.canonical_id === edge.relation_type);
1327
+ if (!rt) {
1328
+ errors.push(`Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`);
1329
+ // Short-circuit: with no registered relation_type there is no source/target
1330
+ // metadata to gate on (mirrors validateContext's `if (!rt) continue;`).
1331
+ return errors;
1332
+ }
1333
+ // Presence gate — neither set declared → endpoint kinds unchecked.
1334
+ if (!rt.source_kinds && !rt.target_kinds)
1335
+ return errors;
1336
+ const parentLoc = resolve(edge.parent).loc;
1337
+ const childLoc = resolve(edge.child).loc;
1338
+ if (parentLoc && rt.source_kinds && !(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
1339
+ errors.push(`Edge ${parentKey} -> ${childKey}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`);
1340
+ }
1341
+ if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
1342
+ errors.push(`Edge ${parentKey} -> ${childKey}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`);
1343
+ }
1344
+ return errors;
1345
+ }
1346
+ /**
1347
+ * Build the per-call edge-validation resolver for the WRITE-TIME porcelain — a
1348
+ * `resolveRef` closure bound to a freshly-built active index + a fresh foreign
1349
+ * cache for this write, plus the loaded config. Mirrors the pass-bound resolver
1350
+ * validateContext constructs, so the two paths resolve endpoints identically.
1351
+ * Returns `null` when no config is present (a pre-bootstrap substrate has no
1352
+ * relation_types registry to validate against → write-time check is a no-op,
1353
+ * matching validateContext's `if (config)` gate).
1354
+ */
1355
+ function buildWriteTimeEdgeValidator(cwd) {
1356
+ const config = loadConfig(cwd);
1357
+ if (!config)
1358
+ return null;
1359
+ const activeIndex = buildIdIndex(cwd);
1360
+ const foreignCache = new Map();
1361
+ const resolve = (ref) => resolveRef(cwd, ref, { activeIndex, foreignCache });
1362
+ return { config, resolve };
1363
+ }
1364
+ /**
1365
+ * Single-edge write-time gate (TASK-062): build the validator for `cwd` and
1366
+ * THROW if `edge` fails the shared registry check. A no-op when no config is
1367
+ * present (pre-bootstrap substrate). The thrown message names the offending
1368
+ * endpoint kind / relation_type / expected kinds (the helper's wording).
1369
+ */
1370
+ function assertEdgeValidForWrite(cwd, edge) {
1371
+ const validator = buildWriteTimeEdgeValidator(cwd);
1372
+ if (!validator)
1373
+ return;
1374
+ const edgeErrors = validateEdgeAgainstRegistry(edge, validator.config, validator.resolve);
1375
+ if (edgeErrors.length > 0) {
1376
+ throw new Error(`Edge rejected at write time (invalid relation_type / endpoint kind): ${edgeErrors.join("; ")}`);
1377
+ }
1378
+ }
1379
+ /**
1380
+ * Whether a role-bearing relation is orientation-AMBIGUOUS: its `source_kinds`
1381
+ * and `target_kinds` overlap (a shared kind, or either side unconstrained / the
1382
+ * `"*"` wildcard), so a bare `{parent, child}` append cannot be reliably oriented
1383
+ * from the endpoint kinds alone. Disjoint-kind relations are self-orienting — the
1384
+ * `validateEdgeAgainstRegistry` source/target-kind gate already rejects an
1385
+ * inversion — so a bare append of them stays allowed.
1386
+ */
1387
+ function relationKindsOverlap(rt) {
1388
+ const s = rt?.source_kinds;
1389
+ const t = rt?.target_kinds;
1390
+ if (!s || !t)
1391
+ return true; // an unconstrained endpoint is universal → overlaps everything
1392
+ if (s.includes("*") || t.includes("*"))
1393
+ return true;
1394
+ return s.some((k) => t.includes(k));
1395
+ }
1396
+ /**
1397
+ * Resolve a {@link RelationAppendInput} (raw `{parent,child}` OR role-typed
1398
+ * `{primary,counter}`) to the canonical `{parent, child}` STRING selectors the raw
1399
+ * plumbing consumes, applying the FGAP-113 write-orientation rules:
1400
+ * - the raw and role-typed pairs are mutually exclusive; exactly one complete
1401
+ * pair must be supplied.
1402
+ * - the role-typed form maps `primary`/`counter` → `parent`/`child` via the
1403
+ * relation's declared `role_direction`; it throws when the relation declares no
1404
+ * `role_direction` (there is no primary/counter role to map).
1405
+ * - a bare `{parent,child}` append of a role-BEARING relation that is
1406
+ * orientation-ambiguous (same-kind / wildcard endpoints) is REJECTED, directing
1407
+ * the author to `--primary`/`--counter`. The porcelain never guesses or swaps.
1408
+ * - a bare append of a role-less relation, or of a role-bearing DISJOINT-kind
1409
+ * relation (self-orienting via the kind gate), passes through unchanged.
1410
+ */
1411
+ function orientAppendInput(config, rel) {
1412
+ const hasRole = rel.primary !== undefined || rel.counter !== undefined;
1413
+ const hasRaw = rel.parent !== undefined || rel.child !== undefined;
1414
+ if (hasRole && hasRaw) {
1415
+ throw new Error(`Relation append for '${rel.relation_type}': --primary/--counter and --parent/--child are mutually exclusive; supply exactly one orientation pair.`);
1416
+ }
1417
+ const rt = (config?.relation_types ?? []).find((r) => r.canonical_id === rel.relation_type);
1418
+ const roleDir = rt?.role_direction;
1419
+ const ordinalPart = rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {};
1420
+ if (hasRole) {
1421
+ if (rel.primary === undefined || rel.counter === undefined) {
1422
+ throw new Error(`Relation append for '${rel.relation_type}': the role-typed form needs BOTH --primary and --counter.`);
1423
+ }
1424
+ if (roleDir === undefined) {
1425
+ throw new Error(`Relation '${rel.relation_type}' declares no role_direction, so it has no primary/counter role to map — author it with --parent/--child.`);
1426
+ }
1427
+ const parent = roleDir === "as_parent" ? rel.primary : rel.counter;
1428
+ const child = roleDir === "as_parent" ? rel.counter : rel.primary;
1429
+ return { parent, child, relation_type: rel.relation_type, ...ordinalPart };
1430
+ }
1431
+ if (rel.parent === undefined || rel.child === undefined) {
1432
+ throw new Error(`Relation append for '${rel.relation_type}': supply either --parent and --child, or --primary and --counter.`);
1433
+ }
1434
+ if (roleDir !== undefined && relationKindsOverlap(rt)) {
1435
+ throw new Error(`Relation '${rel.relation_type}' carries a declared role_direction and is orientation-ambiguous (its ` +
1436
+ `source and target kinds overlap), so a bare --parent/--child append cannot be reliably oriented. Re-issue ` +
1437
+ `with --primary/--counter (primary = the endpoint holding the relation's semantic role, stored at edge.${roleDir === "as_parent" ? "parent" : "child"}).`);
1438
+ }
1439
+ return { parent: rel.parent, child: rel.child, relation_type: rel.relation_type, ...ordinalPart };
1440
+ }
1441
+ /**
1442
+ * Friendly-selector relation append (Cycle 5 porcelain). Accepts EITHER raw
1443
+ * `{parent, child}` selectors OR the role-typed `{primary, counter}` form
1444
+ * (FGAP-113), resolves the (possibly role-mapped) STRING selectors to structured
1445
+ * `EdgeEndpoint`s via `resolveRelationSelector`, then delegates to the raw
1446
+ * `appendRelation` plumbing (atomic, AJV-validated, exact-duplicate no-op — same
1447
+ * deferred-integrity semantics). Keeps the string param surface its callers (the
1448
+ * append-relation Pi tool + the orchestrator CLI) already expose.
1143
1449
  *
1144
1450
  * Returns `{ appended, edge }` where `edge` is the RESOLVED structured edge
1145
1451
  * actually written (so callers can report / dry-run-validate the structured
1146
1452
  * form).
1147
1453
  */
1148
1454
  export function appendRelationByRef(cwd, rel, ctx, opts) {
1455
+ const oriented = orientAppendInput(loadConfig(cwd), rel);
1149
1456
  const edge = {
1150
- parent: resolveRelationSelector(cwd, rel.parent),
1151
- child: resolveRelationSelector(cwd, rel.child),
1152
- relation_type: rel.relation_type,
1153
- ...(rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {}),
1457
+ parent: resolveRelationSelector(cwd, oriented.parent),
1458
+ child: resolveRelationSelector(cwd, oriented.child),
1459
+ relation_type: oriented.relation_type,
1460
+ ...(oriented.ordinal !== undefined ? { ordinal: oriented.ordinal } : {}),
1154
1461
  };
1462
+ // Write-time edge-registry gate (TASK-062): reject an unregistered
1463
+ // relation_type or a source/target-kind-violating endpoint BEFORE any write
1464
+ // (dryRun included — preview must surface the same rejection). The shared
1465
+ // helper guarantees this verdict is identical to validateContext's.
1466
+ assertEdgeValidForWrite(cwd, edge);
1155
1467
  if (opts?.dryRun) {
1156
1468
  // Preview parity: run the SAME validation the write path applies (the
1157
1469
  // prospective Edge[] against the whole relations schema — what
@@ -1262,12 +1574,33 @@ export function replaceRelationByRef(cwd, rels, ctx, opts) {
1262
1574
  * `validateContext`).
1263
1575
  */
1264
1576
  export function appendRelationsByRef(cwd, edges, ctx, opts) {
1265
- const resolved = edges.map((rel) => ({
1266
- parent: resolveRelationSelector(cwd, rel.parent),
1267
- child: resolveRelationSelector(cwd, rel.child),
1268
- relation_type: rel.relation_type,
1269
- ...(rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {}),
1270
- }));
1577
+ // Orient every input once against the loaded config (FGAP-113): a role-typed
1578
+ // {primary,counter} edge maps to parent/child via role_direction; a bare
1579
+ // {parent,child} append of an orientation-ambiguous role-bearing relation is
1580
+ // rejected here (before any write), directing the author to --primary/--counter.
1581
+ const config = loadConfig(cwd);
1582
+ const resolved = edges.map((rel) => {
1583
+ const oriented = orientAppendInput(config, rel);
1584
+ return {
1585
+ parent: resolveRelationSelector(cwd, oriented.parent),
1586
+ child: resolveRelationSelector(cwd, oriented.child),
1587
+ relation_type: oriented.relation_type,
1588
+ ...(oriented.ordinal !== undefined ? { ordinal: oriented.ordinal } : {}),
1589
+ };
1590
+ });
1591
+ // Write-time edge-registry gate (TASK-062): every resolved edge in the batch
1592
+ // is checked BEFORE any write (dryRun included). Build the validator once for
1593
+ // the batch (config + active index built once) and reject if any edge fails —
1594
+ // an all-or-nothing batch (no partial write past a bad edge).
1595
+ const validator = buildWriteTimeEdgeValidator(cwd);
1596
+ if (validator) {
1597
+ for (const edge of resolved) {
1598
+ const edgeErrors = validateEdgeAgainstRegistry(edge, validator.config, validator.resolve);
1599
+ if (edgeErrors.length > 0) {
1600
+ throw new Error(`Edge rejected at write time (invalid relation_type / endpoint kind): ${edgeErrors.join("; ")}`);
1601
+ }
1602
+ }
1603
+ }
1271
1604
  if (opts?.dryRun) {
1272
1605
  // Preview parity: replay the bulk dedup the raw appendRelations applies —
1273
1606
  // skip an edge whose identity is already on disk OR earlier in THIS batch
@@ -1553,7 +1886,6 @@ export function validateContext(cwd) {
1553
1886
  // false-pass a zero-edge substrate. The edge-integrity loop below is a
1554
1887
  // no-op on empty relations, so it needs no separate guard.
1555
1888
  if (config) {
1556
- const registeredRelTypes = new Set((config.relation_types ?? []).map((rt) => rt.canonical_id));
1557
1889
  // Per-pass foreign-index cache (Constraint 3): N foreign edges into the same
1558
1890
  // registered substrate build that substrate's index ONCE within this pass.
1559
1891
  const foreignCache = new Map();
@@ -1607,60 +1939,48 @@ export function validateContext(cwd) {
1607
1939
  code: "edge_endpoint_dangling",
1608
1940
  });
1609
1941
  }
1610
- if (!registeredRelTypes.has(edge.relation_type)) {
1611
- issues.push({
1612
- severity: "error",
1613
- message: `Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`,
1614
- block: "relations",
1615
- field: `${parentKey}->${childKey}`,
1616
- });
1617
- }
1618
1942
  }
1619
- // ── Edge endpoint-kind check (FGAP-086, DEC-0037) ─────────────────────
1620
- // Presence-gated: a relation_type with neither source_kinds nor
1621
- // target_kinds is unchecked, so the frozen .project substrate (whose
1622
- // relation_types carry no endpoint metadata) is not retroactively failed.
1623
- // When metadata is present, an edge endpoint whose resolved block is not in
1624
- // the declared kind set (and the set is not the "*" wildcard) is an error.
1625
- // loc.block is the data-file basename; the source/target_kinds name
1626
- // block_kind canonical_ids the loc.block==canonical_id assumption is
1627
- // inherited from the invariant loop below (~:1140), where inv.block (a
1628
- // canonical_id) is matched directly against loc.block.
1943
+ // ── Edge registration + endpoint-kind check (FGAP-086, DEC-0037; TASK-062
1944
+ // factored into the shared validateEdgeAgainstRegistry helper so the
1945
+ // write-time porcelain and this validate-time loop reach an IDENTICAL
1946
+ // verdict). The helper performs (a) relation_type-registration and
1947
+ // (b) PRESENCE-GATED source/target-kind membership ("*" wildcard honored);
1948
+ // a relation_type with neither kind set is unchecked (the frozen .project
1949
+ // substrate, whose relation_types carry no endpoint metadata, is not
1950
+ // retroactively failed). Its returned messages are byte-identical to the
1951
+ // wording this loop historically emitted inline (registration message +
1952
+ // source/target kind messages), each mapped to the same issue shape
1953
+ // (severity error, block "relations", field parent->child, no code).
1954
+ // Order discipline (TASK-062): context-validate prints issues[], so the
1955
+ // emission order is a UX surface and must match the pre-refactor two-pass
1956
+ // shape — ALL relation_type-registration issues across every edge first,
1957
+ // THEN ALL source/target-kind issues across every edge (class-grouped, not
1958
+ // interleaved per edge). The shared helper returns both classes per edge;
1959
+ // we collect once then partition by message text (registration messages
1960
+ // carry "is not registered"; kind messages carry "source kind"/"target
1961
+ // kind"). Issue set/count/wording/severity and the registration
1962
+ // short-circuit are unchanged — this is order-only.
1963
+ const registrationIssues = [];
1964
+ const kindIssues = [];
1629
1965
  for (const edge of relations) {
1630
- const rt = config.relation_types?.find((r) => r.canonical_id === edge.relation_type);
1631
- if (!rt)
1632
- continue; // unregistered relation_type already reported above
1633
- if (!rt.source_kinds && !rt.target_kinds)
1634
- continue; // metadata absent → unchecked
1635
1966
  const parentKey = endpointKey(edge.parent);
1636
1967
  const childKey = endpointKey(edge.child);
1637
- // Use the resolved location for item endpoints (active OR foreign), so a
1638
- // foreign item's block is kind-checked against source/target_kinds too. A
1639
- // lens_bin endpoint (endpointKind:"lens_bin") carries no loc and is skipped
1640
- // — it never routes through item-block resolution.
1641
- const parentRes = resolve(edge.parent);
1642
- const childRes = resolve(edge.child);
1643
- const parentLoc = parentRes.loc;
1644
- const childLoc = childRes.loc;
1645
- if (parentLoc &&
1646
- rt.source_kinds &&
1647
- !(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
1648
- issues.push({
1968
+ for (const message of validateEdgeAgainstRegistry(edge, config, resolve)) {
1969
+ const issue = {
1649
1970
  severity: "error",
1650
- message: `Edge ${parentKey} -> ${childKey}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1971
+ message,
1651
1972
  block: "relations",
1652
1973
  field: `${parentKey}->${childKey}`,
1653
- });
1654
- }
1655
- if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
1656
- issues.push({
1657
- severity: "error",
1658
- message: `Edge ${parentKey} -> ${childKey}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1659
- block: "relations",
1660
- field: `${parentKey}->${childKey}`,
1661
- });
1974
+ };
1975
+ if (message.includes("is not registered")) {
1976
+ registrationIssues.push(issue);
1977
+ }
1978
+ else {
1979
+ kindIssues.push(issue);
1980
+ }
1662
1981
  }
1663
1982
  }
1983
+ issues.push(...registrationIssues, ...kindIssues);
1664
1984
  // Cycle detection — delegate to validateRelations. It performs its own
1665
1985
  // lens/hierarchy/relation_type resolution and emits several edge codes;
1666
1986
  // only its cycle diagnostics are merged here (the parent/child/relation_type
@@ -1864,6 +2184,59 @@ export function validateContext(cwd) {
1864
2184
  });
1865
2185
  }
1866
2186
  }
2187
+ // ── Substrate-wide block schema-validity sweep ────────────────────────────
2188
+ // The composed verdict must cover schema validity, not only edge/invariant
2189
+ // integrity: invalidity introduced WITHOUT a write to the block (a schema
2190
+ // resync, a hand edit, catalog drift) previously sat behind a clean verdict
2191
+ // until the next write to that block failed. For every block that has BOTH a
2192
+ // schema in <substrateDir>/schemas/ AND a block file, validate the whole
2193
+ // block against its installed schema — migration-aware when the envelope is
2194
+ // stamped (validateBlockWithMigrationForDir walks a lagging version through
2195
+ // the registered chain), raw otherwise (the same helper: absent version ⇒
2196
+ // straight validate). Every failure surfaces as an ERROR naming the block,
2197
+ // the failing item id (resolved from the AJV instancePath), and the
2198
+ // field/keyword. Guarded per block so one unreadable file cannot mask the
2199
+ // rest of the sweep; pointer-less projects degrade gracefully (no sweep —
2200
+ // there is no substrate to validate), matching validateContext's class rule.
2201
+ {
2202
+ const substrateDir = tryResolveContextDir(cwd);
2203
+ const schemasDir = substrateDir ? path.join(substrateDir, "schemas") : null;
2204
+ if (substrateDir && schemasDir && fs.existsSync(schemasDir)) {
2205
+ const registry = getProjectMigrationRegistryForDir(substrateDir);
2206
+ for (const schemaFile of fs.readdirSync(schemasDir).filter((f) => f.endsWith(".schema.json"))) {
2207
+ const blockName = schemaFile.slice(0, -".schema.json".length);
2208
+ const blockFile = path.join(substrateDir, `${blockName}.json`);
2209
+ if (!fs.existsSync(blockFile))
2210
+ continue;
2211
+ try {
2212
+ const blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
2213
+ validateBlockWithMigrationForDir(substrateDir, blockName, blockData, registry);
2214
+ }
2215
+ catch (err) {
2216
+ if (err instanceof ValidationError) {
2217
+ for (const e of err.errors) {
2218
+ const itemId = blockItemIdForInstancePath(cwd, blockName, e.instancePath);
2219
+ issues.push({
2220
+ severity: "error",
2221
+ message: `Block '${blockName}' fails its installed schema at ${e.instancePath || "(envelope)"}${itemId ? ` (item '${itemId}')` : ""}: ${e.keyword} ${e.message ?? ""}`.trimEnd(),
2222
+ block: blockName,
2223
+ ...(itemId !== undefined ? { field: itemId } : {}),
2224
+ code: "block_schema_invalid",
2225
+ });
2226
+ }
2227
+ }
2228
+ else {
2229
+ issues.push({
2230
+ severity: "error",
2231
+ message: `Block '${blockName}' failed schema-validity sweep: ${err instanceof Error ? err.message : String(err)}`,
2232
+ block: blockName,
2233
+ code: "block_schema_invalid",
2234
+ });
2235
+ }
2236
+ }
2237
+ }
2238
+ }
2239
+ }
1867
2240
  const errorCount = issues.filter((i) => i.severity === "error").length;
1868
2241
  const warningCount = issues.filter((i) => i.severity === "warning").length;
1869
2242
  return {
@@ -1871,6 +2244,31 @@ export function validateContext(cwd) {
1871
2244
  issues,
1872
2245
  };
1873
2246
  }
2247
+ /**
2248
+ * Resolve the failing block item's `id` from an AJV `instancePath` of the form
2249
+ * `/<arrayKey>/<index>[/...]` by re-reading the block file. Returns undefined
2250
+ * for envelope-level paths or when the item carries no string id.
2251
+ */
2252
+ function blockItemIdForInstancePath(cwd, blockName, instancePath) {
2253
+ const m = /^\/([^/]+)\/(\d+)/.exec(instancePath);
2254
+ if (!m)
2255
+ return undefined;
2256
+ try {
2257
+ const substrateDir = resolveContextDir(cwd);
2258
+ const data = JSON.parse(fs.readFileSync(path.join(substrateDir, `${blockName}.json`), "utf-8"));
2259
+ const arr = data[m[1]];
2260
+ if (!Array.isArray(arr))
2261
+ return undefined;
2262
+ const item = arr[Number(m[2])];
2263
+ if (!item || typeof item !== "object")
2264
+ return undefined;
2265
+ const id = item.id;
2266
+ return typeof id === "string" ? id : undefined;
2267
+ }
2268
+ catch {
2269
+ return undefined;
2270
+ }
2271
+ }
1874
2272
  /**
1875
2273
  * Gate task completion on verification. Reads the verification block to confirm
1876
2274
  * a passing verification entry exists, asserts a `verification_verifies_item`