@davidorex/pi-context 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +69 -15
  2. package/README.md +71 -18
  3. package/dist/block-api.d.ts +165 -18
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +800 -57
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/citation-rot-scanner.d.ts +80 -0
  8. package/dist/citation-rot-scanner.d.ts.map +1 -0
  9. package/dist/citation-rot-scanner.js +421 -0
  10. package/dist/citation-rot-scanner.js.map +1 -0
  11. package/dist/content-hash.d.ts +25 -0
  12. package/dist/content-hash.d.ts.map +1 -0
  13. package/dist/content-hash.js +62 -0
  14. package/dist/content-hash.js.map +1 -0
  15. package/dist/context-dir.d.ts +126 -1
  16. package/dist/context-dir.d.ts.map +1 -1
  17. package/dist/context-dir.js +208 -4
  18. package/dist/context-dir.js.map +1 -1
  19. package/dist/context-registry.d.ts +122 -0
  20. package/dist/context-registry.d.ts.map +1 -0
  21. package/dist/context-registry.js +189 -0
  22. package/dist/context-registry.js.map +1 -0
  23. package/dist/context-sdk.d.ts +179 -12
  24. package/dist/context-sdk.d.ts.map +1 -1
  25. package/dist/context-sdk.js +536 -101
  26. package/dist/context-sdk.js.map +1 -1
  27. package/dist/context.d.ts +222 -4
  28. package/dist/context.d.ts.map +1 -1
  29. package/dist/context.js +244 -56
  30. package/dist/context.js.map +1 -1
  31. package/dist/git-env.d.ts +2 -0
  32. package/dist/git-env.d.ts.map +1 -0
  33. package/dist/git-env.js +29 -0
  34. package/dist/git-env.js.map +1 -0
  35. package/dist/index.d.ts +124 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +440 -1125
  38. package/dist/index.js.map +1 -1
  39. package/dist/lens-view.d.ts.map +1 -1
  40. package/dist/lens-view.js +5 -3
  41. package/dist/lens-view.js.map +1 -1
  42. package/dist/migration-registry-loader.d.ts +121 -0
  43. package/dist/migration-registry-loader.d.ts.map +1 -0
  44. package/dist/migration-registry-loader.js +309 -0
  45. package/dist/migration-registry-loader.js.map +1 -0
  46. package/dist/migrations-store.d.ts +141 -0
  47. package/dist/migrations-store.d.ts.map +1 -0
  48. package/dist/migrations-store.js +206 -0
  49. package/dist/migrations-store.js.map +1 -0
  50. package/dist/object-store.d.ts +26 -0
  51. package/dist/object-store.d.ts.map +1 -0
  52. package/dist/object-store.js +104 -0
  53. package/dist/object-store.js.map +1 -0
  54. package/dist/ops-registry.d.ts +44 -0
  55. package/dist/ops-registry.d.ts.map +1 -0
  56. package/dist/ops-registry.js +1223 -0
  57. package/dist/ops-registry.js.map +1 -0
  58. package/dist/orientation.d.ts +16 -0
  59. package/dist/orientation.d.ts.map +1 -0
  60. package/dist/orientation.js +49 -0
  61. package/dist/orientation.js.map +1 -0
  62. package/dist/promote-item.d.ts +28 -0
  63. package/dist/promote-item.d.ts.map +1 -0
  64. package/dist/promote-item.js +257 -0
  65. package/dist/promote-item.js.map +1 -0
  66. package/dist/read-element.d.ts +129 -0
  67. package/dist/read-element.d.ts.map +1 -0
  68. package/dist/read-element.js +296 -0
  69. package/dist/read-element.js.map +1 -0
  70. package/dist/rename-canonical-id.d.ts.map +1 -1
  71. package/dist/rename-canonical-id.js +22 -13
  72. package/dist/rename-canonical-id.js.map +1 -1
  73. package/dist/roadmap-plan.d.ts.map +1 -1
  74. package/dist/roadmap-plan.js +23 -13
  75. package/dist/roadmap-plan.js.map +1 -1
  76. package/dist/schema-validator.d.ts +1 -0
  77. package/dist/schema-validator.d.ts.map +1 -1
  78. package/dist/schema-validator.js +7 -3
  79. package/dist/schema-validator.js.map +1 -1
  80. package/dist/schema-write.d.ts +78 -4
  81. package/dist/schema-write.d.ts.map +1 -1
  82. package/dist/schema-write.js +297 -34
  83. package/dist/schema-write.js.map +1 -1
  84. package/dist/status-vocab.d.ts +1 -0
  85. package/dist/status-vocab.d.ts.map +1 -1
  86. package/dist/status-vocab.js +4 -0
  87. package/dist/status-vocab.js.map +1 -1
  88. package/dist/truncate.d.ts +34 -0
  89. package/dist/truncate.d.ts.map +1 -0
  90. package/dist/truncate.js +85 -0
  91. package/dist/truncate.js.map +1 -0
  92. package/dist/write-schema-migration-tool.d.ts +46 -0
  93. package/dist/write-schema-migration-tool.d.ts.map +1 -0
  94. package/dist/write-schema-migration-tool.js +115 -0
  95. package/dist/write-schema-migration-tool.js.map +1 -0
  96. package/package.json +35 -2
  97. package/samples/blocks/work-orders.json +3 -0
  98. package/samples/conception.json +22 -3
  99. package/samples/migrations.json +133 -0
  100. package/samples/schemas/context-contracts.schema.json +19 -4
  101. package/samples/schemas/conventions.schema.json +68 -12
  102. package/samples/schemas/decisions.schema.json +125 -28
  103. package/samples/schemas/features.schema.json +140 -26
  104. package/samples/schemas/framework-gaps.schema.json +171 -26
  105. package/samples/schemas/issues.schema.json +86 -13
  106. package/samples/schemas/layer-plans.schema.json +195 -35
  107. package/samples/schemas/phase.schema.json +18 -3
  108. package/samples/schemas/rationale.schema.json +37 -7
  109. package/samples/schemas/requirements.schema.json +77 -10
  110. package/samples/schemas/research.schema.json +169 -36
  111. package/samples/schemas/spec-reviews.schema.json +88 -18
  112. package/samples/schemas/story.schema.json +18 -3
  113. package/samples/schemas/tasks.schema.json +66 -10
  114. package/samples/schemas/verification.schema.json +70 -12
  115. package/samples/schemas/work-orders.schema.json +195 -0
  116. package/schemas/bootstrap.schema.json +18 -6
  117. package/schemas/config.schema.json +58 -26
  118. package/schemas/context-registry.schema.json +46 -0
  119. package/schemas/migrations.schema.json +123 -0
  120. package/schemas/relations.schema.json +41 -10
  121. package/skill-narrative.md +42 -21
  122. package/skills/pi-context/SKILL.md +159 -65
  123. package/skills/pi-context/references/bundled-resources.md +7 -2
@@ -6,14 +6,18 @@
6
6
  import { execSync } from "node:child_process";
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
- import { readBlock, updateItemInBlock } from "./block-api.js";
10
- import { findUnmaterializedAssets, loadConfig, loadRelations, validateRelations, } from "./context.js";
9
+ import { readBlock, readBlockForDir, updateItemInBlock } from "./block-api.js";
10
+ import { appendRelation, endpointKey, findUnmaterializedAssets, loadConfig, loadRelations, validateRelations, } from "./context.js";
11
11
  import { resolveContextDir, SCHEMAS_DIR, schemaPath, schemasDir, tryResolveContextDir } from "./context-dir.js";
12
+ import { loadRegistry, resolveAlias, resolveSubstrateDir } from "./context-registry.js";
13
+ import { cleanGitEnv } from "./git-env.js";
12
14
  import { getLensValidators } from "./lens-validator.js";
15
+ import { addressInto, discoverArrayKey, pageArray } from "./read-element.js";
16
+ import { findNestedIdBearingArrays } from "./schema-write.js";
13
17
  import { resolveStatusVocabulary } from "./status-vocab.js";
14
18
  import { topoSort } from "./topo.js";
15
19
  // Re-export substrate SDK so consumers can keep importing through context-sdk.
16
- export { displayName, edgesForLens, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
20
+ export { displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
17
21
  export function availableBlocks(cwd) {
18
22
  const workflowDir = tryResolveContextDir(cwd);
19
23
  if (workflowDir === null)
@@ -265,8 +269,8 @@ export function contextState(cwd) {
265
269
  let lastCommit = "unknown";
266
270
  let lastCommitMessage = "";
267
271
  try {
268
- lastCommit = execSync("git log -1 --format=%h", { cwd, encoding: "utf-8" }).trim();
269
- lastCommitMessage = execSync("git log -1 --format=%s", { cwd, encoding: "utf-8" }).trim();
272
+ lastCommit = execSync("git log -1 --format=%h", { cwd, encoding: "utf-8", env: cleanGitEnv() }).trim();
273
+ lastCommitMessage = execSync("git log -1 --format=%s", { cwd, encoding: "utf-8", env: cleanGitEnv() }).trim();
270
274
  }
271
275
  catch {
272
276
  /* not a git repo or no commits */
@@ -274,7 +278,7 @@ export function contextState(cwd) {
274
278
  // Recent commits
275
279
  let recentCommits = [];
276
280
  try {
277
- const log = execSync("git log --oneline -5", { cwd, encoding: "utf-8" }).trim();
281
+ const log = execSync("git log --oneline -5", { cwd, encoding: "utf-8", env: cleanGitEnv() }).trim();
278
282
  if (log)
279
283
  recentCommits = log.split("\n");
280
284
  }
@@ -525,12 +529,12 @@ export function contextState(cwd) {
525
529
  export function currentState(cwd) {
526
530
  // Tolerate any substrate-read failure (no .project, malformed config, etc.)
527
531
  // by collapsing to the empty state — this is a pure read surface.
528
- let idIndex;
532
+ let index;
529
533
  try {
530
- idIndex = buildIdIndex(cwd);
534
+ index = buildIdIndex(cwd);
531
535
  }
532
536
  catch {
533
- idIndex = new Map();
537
+ index = { dir: cwd, byRefname: new Map(), byOid: new Map(), items: [] };
534
538
  }
535
539
  let edges;
536
540
  try {
@@ -548,13 +552,13 @@ export function currentState(cwd) {
548
552
  const bucket = (item) => vocab[String(item.status)] ?? "unknown";
549
553
  // ── inFlight: tasks-block items bucketing to in_progress ───────────────────
550
554
  const inFlight = [];
551
- for (const [id, loc] of idIndex) {
555
+ for (const loc of index.byRefname.values()) {
552
556
  if (loc.block !== "tasks")
553
557
  continue;
554
558
  if (bucket(loc.item) !== "in_progress")
555
559
  continue;
556
560
  inFlight.push({
557
- id,
561
+ id: loc.id,
558
562
  block: loc.block,
559
563
  description: typeof loc.item.description === "string" ? loc.item.description : "",
560
564
  });
@@ -565,18 +569,20 @@ export function currentState(cwd) {
565
569
  // at unknown ids are treated as satisfied — a dangling edge is a relations
566
570
  // integrity concern surfaced by validateRelations, not a blocker here).
567
571
  const isCompleted = (taskId) => {
568
- const loc = idIndex.get(taskId);
572
+ const loc = index.byRefname.get(taskId);
569
573
  return loc !== undefined && bucket(loc.item) === "complete";
570
574
  };
571
- const depParentsOf = (taskId) => edges.filter((e) => e.relation_type === "task_depends_on_task" && e.child === taskId).map((e) => e.parent);
572
- const incompleteDeps = (taskId) => depParentsOf(taskId).filter((dep) => idIndex.has(dep) && !isCompleted(dep));
575
+ const depParentsOf = (taskId) => edges
576
+ .filter((e) => e.relation_type === "task_depends_on_task" && endpointKey(e.child) === taskId)
577
+ .map((e) => endpointKey(e.parent));
578
+ const incompleteDeps = (taskId) => depParentsOf(taskId).filter((dep) => index.byRefname.has(dep) && !isCompleted(dep));
573
579
  // Collect all to-do (ready/queued) tasks once — drives both blocked + ready
574
580
  // derivations. "todo" bucket = planned/queued work (raw status "planned"
575
581
  // buckets to todo under STATUS_VOCABULARY_DEFAULTS).
576
582
  const plannedTasks = [];
577
- for (const [id, loc] of idIndex) {
583
+ for (const loc of index.byRefname.values()) {
578
584
  if (loc.block === "tasks" && bucket(loc.item) === "todo")
579
- plannedTasks.push({ id, loc });
585
+ plannedTasks.push({ id: loc.id, loc });
580
586
  }
581
587
  // ── blocked: planned tasks with at least one incomplete dep parent ─────────
582
588
  const blocked = [];
@@ -595,12 +601,12 @@ export function currentState(cwd) {
595
601
  // P0 > P1 > P2 > P3 (missing priority sorts last) then by id.
596
602
  const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
597
603
  const openGaps = [];
598
- for (const [id, loc] of idIndex) {
604
+ for (const loc of index.byRefname.values()) {
599
605
  if (loc.block !== "framework-gaps")
600
606
  continue;
601
607
  if (bucket(loc.item) !== "todo")
602
608
  continue;
603
- openGaps.push({ id, priority: typeof loc.item.priority === "string" ? loc.item.priority : undefined });
609
+ openGaps.push({ id: loc.id, priority: typeof loc.item.priority === "string" ? loc.item.priority : undefined });
604
610
  }
605
611
  openGaps.sort((a, b) => {
606
612
  const ra = a.priority !== undefined ? (priorityRank[a.priority] ?? 99) : 99;
@@ -642,12 +648,12 @@ export function currentState(cwd) {
642
648
  // Fall back to a phase bucketing to in_progress (phase.json phases[]
643
649
  // array-block).
644
650
  let inProgressPhase = null;
645
- for (const [id, loc] of idIndex) {
651
+ for (const loc of index.byRefname.values()) {
646
652
  if (loc.block !== "phase")
647
653
  continue;
648
654
  if (bucket(loc.item) !== "in_progress")
649
655
  continue;
650
- inProgressPhase = { id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
656
+ inProgressPhase = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
651
657
  break;
652
658
  }
653
659
  if (inProgressPhase !== null) {
@@ -660,24 +666,10 @@ export function currentState(cwd) {
660
666
  }
661
667
  return { focus, inFlight, nextActions: cappedNextActions, blocked };
662
668
  }
663
- /**
664
- * Discover the single top-level array key in a block payload. Returns null
665
- * when the block has zero array properties; throws when ambiguous (two or
666
- * more array properties), since callers cannot proceed without an explicit
667
- * disambiguation policy. Mirrors the heuristic in
668
- * scripts/orchestrator/inject-context-items.ts:85-95 — both consumers share
669
- * the same single-array-key assumption used across .project/ block writes.
670
- */
671
- function discoverArrayKey(blockData) {
672
- const arrayKeys = Object.entries(blockData).filter(([, v]) => Array.isArray(v));
673
- if (arrayKeys.length === 0)
674
- return null;
675
- if (arrayKeys.length === 1)
676
- return arrayKeys[0][0];
677
- throw new Error(`filterBlockItems: block has multiple top-level array properties (${arrayKeys
678
- .map(([k]) => k)
679
- .join(", ")}); array_key per block is not declared in any registry — single-array assumption violated`);
680
- }
669
+ // discoverArrayKey lives in read-element.ts (the lowest pure layer) and is
670
+ // imported above — ONE copy of the single-top-level-array heuristic shared by
671
+ // filterBlockItems / readBlockPage / serializeForRead. Mirrors the assumption
672
+ // in scripts/orchestrator/inject-context-items.ts used across .project/ writes.
681
673
  /**
682
674
  * Filter the array items of a block by a predicate. Reads the block via the
683
675
  * canonical block-api `readBlock`, discovers the single top-level array key,
@@ -728,28 +720,34 @@ export function filterBlockItems(cwd, blockName, predicate) {
728
720
  /**
729
721
  * Read a single item from ONE named block by its id, or null if absent.
730
722
  * Block-scoped (no cross-substrate idIndex, no prefix-vs-block invariant — that is resolveItemById).
731
- * Reuses filterBlockItems(id eq) so it inherits readBlock + discoverArrayKey + edge semantics
732
- * (missing block / multiple top-level arrays THROW; no-array block or id-not-found null). FGAP-045.
723
+ * Reads the block then routes id-resolution through the shared addressInto primitive
724
+ * (id matches `.id` or `.canonical_id`), so block-item lookup uses the same element
725
+ * addressing as every other read surface. Missing block / multiple top-level arrays
726
+ * THROW (readBlock + discoverArrayKey via addressInto); no-array block or id-not-found → null. FGAP-045.
733
727
  */
734
728
  export function readBlockItem(cwd, blockName, id) {
735
- const matches = filterBlockItems(cwd, blockName, { field: "id", op: "eq", value: id });
736
- return matches.length > 0 ? matches[0] : null;
729
+ const data = readBlock(cwd, blockName);
730
+ // A block with multiple top-level arrays must throw (single-array assumption);
731
+ // addressInto tolerates ambiguity, so probe discoverArrayKey directly first to
732
+ // preserve the documented throw, then address into the resolved array.
733
+ discoverArrayKey(data);
734
+ const hit = addressInto(data, { id });
735
+ return hit.found ? hit.value : null;
737
736
  }
738
737
  /**
739
- * Paginate a block's items. Reuses readBlock + discoverArrayKey. Returns the FULL count as `total`
740
- * (not the page length) and `hasMore = offset + limit < total`. No-array block → {items:[],total:0,
741
- * hasMore:false}; offset total empty items with correct total. Missing block / multiple top-level
742
- * arrays propagate the throw (consistent with filterBlockItems). FGAP-045.
738
+ * Paginate a block's items. Reads the block, discovers its single top-level array,
739
+ * then routes the slice/total/hasMore math through the shared `pageArray` primitive
740
+ * (ONE pagination implementation serializeForRead uses the same). Returns the FULL
741
+ * count as `total` (not the page length) and `hasMore = offset + limit < total`.
742
+ * No-array block → {items:[],total:0,hasMore:false}; offset ≥ total → empty items
743
+ * with correct total. Missing block / multiple top-level arrays propagate the throw
744
+ * (consistent with filterBlockItems). FGAP-045.
743
745
  */
744
746
  export function readBlockPage(cwd, blockName, opts = {}) {
745
- const offset = opts.offset ?? 0;
746
- const limit = opts.limit ?? 50;
747
747
  const data = readBlock(cwd, blockName);
748
748
  const arrayKey = discoverArrayKey(data);
749
749
  const arr = arrayKey ? data[arrayKey] : [];
750
- const total = arr.length;
751
- const items = arr.slice(offset, offset + limit);
752
- return { items, total, hasMore: offset + limit < total };
750
+ return pageArray(arr, opts);
753
751
  }
754
752
  /**
755
753
  * Read a block and return its single top-level array of items, or [] when the
@@ -768,9 +766,11 @@ function readBlockArray(cwd, blockName) {
768
766
  /**
769
767
  * Cross-block join (FGAP-043, HYBRID). Mode = exactly one of relationType (edge) XOR
770
768
  * leftField+rightField (field). Returns one JoinResult per left item (after leftPredicate),
771
- * right always an array. Edge mode is DEC-0013-native (relations.json); field mode joins on a
772
- * shared field value (legacy inline-FK + arbitrary shared fields). Reuses filterBlockItems /
773
- * readBlock / discoverArrayKey / loadRelations / buildIdIndex.
769
+ * right always an array. Edge mode is DEC-0013-native (relations.json); field mode is a
770
+ * DEPRECATED backward-compat path that joins on a shared field value (formerly inline-FK,
771
+ * now arbitrary shared fields). NO validation uses field mode — cross-block validation is
772
+ * edge-only since DEC-0036; field mode here is a query convenience, not a reference surface.
773
+ * Reuses filterBlockItems / readBlock / discoverArrayKey / loadRelations / buildIdIndex.
774
774
  */
775
775
  export function joinBlocks(cwd, spec) {
776
776
  const isEdge = spec.relationType !== undefined;
@@ -803,17 +803,17 @@ export function joinBlocks(cwd, spec) {
803
803
  const relationType = spec.relationType;
804
804
  const leftEndpoint = spec.leftEndpoint ?? "parent";
805
805
  const edges = loadRelations(cwd).filter((e) => e.relation_type === relationType);
806
- const idIndex = buildIdIndex(cwd);
806
+ const index = buildIdIndex(cwd);
807
807
  return leftItems.map((left) => {
808
808
  const leftId = left.id;
809
809
  const right = [];
810
810
  if (typeof leftId === "string") {
811
811
  for (const e of edges) {
812
- const here = leftEndpoint === "parent" ? e.parent : e.child;
812
+ const here = leftEndpoint === "parent" ? endpointKey(e.parent) : endpointKey(e.child);
813
813
  if (here !== leftId)
814
814
  continue;
815
- const otherId = leftEndpoint === "parent" ? e.child : e.parent;
816
- const loc = idIndex.get(otherId);
815
+ const otherId = leftEndpoint === "parent" ? endpointKey(e.child) : endpointKey(e.parent);
816
+ const loc = index.byRefname.get(otherId);
817
817
  if (loc && loc.block === spec.rightBlock)
818
818
  right.push(loc.item);
819
819
  }
@@ -882,29 +882,54 @@ export function expectedBlockForId(id, cfg) {
882
882
  * the resolver deterministic without allocating warning channels here.
883
883
  */
884
884
  export function buildIdIndex(cwd) {
885
- const index = new Map();
886
885
  const blockDir = tryResolveContextDir(cwd);
887
- if (blockDir === null)
886
+ if (blockDir === null) {
887
+ // No active pointer — return an empty SubstrateIndex (dir set to cwd so
888
+ // the surface is still well-formed; substrate_id undefined). Mirrors the
889
+ // prior empty-Map return.
890
+ return { dir: cwd, byRefname: new Map(), byOid: new Map(), items: [] };
891
+ }
892
+ return buildIdIndexForDir(blockDir, cwd, loadConfig(cwd));
893
+ }
894
+ /**
895
+ * Build the item-id → location index for an ARBITRARY substrate directory
896
+ * (the dir-targeted twin of `buildIdIndex`, which resolves the active pointer
897
+ * dir). Used by the relation porcelain to index a FOREIGN substrate (resolved
898
+ * via the registry from a `<alias>:` selector) as well as the active substrate.
899
+ *
900
+ * `substrateDir` is the absolute substrate directory to scan; `cfg` is that
901
+ * dir's config (drives the prefix-vs-block invariant via `expectedBlockForId`),
902
+ * passed by the caller so this function performs no pointer resolution. Reads
903
+ * each block file via `readBlockForDir` so the version-aware validation hook
904
+ * fires identically to the active-dir path. Same first-writer-wins collision
905
+ * semantics + prefix-invariant throw as `buildIdIndex`.
906
+ */
907
+ export function buildIdIndexForDir(substrateDir, _cwd, cfg) {
908
+ // `_cwd` is part of the locked F1 signature (the active-dir caller threads its
909
+ // cwd; the foreign-substrate caller threads the foreign dir) so F2 can resolve
910
+ // registry-relative concerns from it. F1's body reads config explicitly via
911
+ // `cfg`, so `_cwd` is currently unused — retained for the forward-compatible
912
+ // surface rather than dropped and re-added next cycle.
913
+ const byRefname = new Map();
914
+ const byOid = new Map();
915
+ const items = [];
916
+ const index = {
917
+ substrate_id: cfg?.substrate_id,
918
+ dir: substrateDir,
919
+ byRefname,
920
+ byOid,
921
+ items,
922
+ };
923
+ if (!fs.existsSync(substrateDir))
888
924
  return index;
889
- const cfg = loadConfig(cwd);
890
- // Phases are an ordinary array-block since DEC-0028: each phase carries a
891
- // PHASE-NNN top-level `id` and lives in `phase.json` under the plural
892
- // `phases` array key (singular file basename matches phase.schema.json +
893
- // the verification.json precedent). The generic block-file scan below
894
- // indexes them by id like any other block. When `PHASE-` is registered in
895
- // config.block_kinds (canonical_id "phase"), expectedBlockForId resolves
896
- // PHASE-NNN ids to block "phase" — matching the file they are found in, so
897
- // the prefix-vs-block invariant passes without a dedicated branch.
898
925
  // Top-level block files — scan every array property for items with `id`.
899
- if (!fs.existsSync(blockDir))
900
- return index;
901
- for (const file of fs.readdirSync(blockDir)) {
926
+ for (const file of fs.readdirSync(substrateDir)) {
902
927
  if (!file.endsWith(".json"))
903
928
  continue;
904
929
  const blockName = file.replace(".json", "");
905
930
  let data;
906
931
  try {
907
- data = readBlock(cwd, blockName);
932
+ data = readBlockForDir(substrateDir, blockName);
908
933
  }
909
934
  catch {
910
935
  continue; // unreadable / malformed block — skip
@@ -924,8 +949,23 @@ export function buildIdIndex(cwd) {
924
949
  throw new Error(`buildIdIndex: ID '${idVal}' found in block '${blockName}' but its prefix maps to block '${expected}'. ` +
925
950
  `Prefix-vs-block-kind invariant violated — this indicates a direct-fs write that bypassed schema validation, or a prefix collision needing explicit resolution.`);
926
951
  }
927
- if (!index.has(idVal)) {
928
- index.set(idVal, { block: blockName, arrayKey, item });
952
+ // `items` carries ONE entry per id-bearing item in scan order (the
953
+ // iteration surface). `byRefname` is first-writer-wins on refname
954
+ // collision (one entry per distinct refname) — exactly the prior
955
+ // single-Map semantics. The locator that lands in `items` is the
956
+ // same object reference stored under the maps when this is the first
957
+ // writer for its refname, so iteration and lookup share identity.
958
+ const loc = { id: idVal, block: blockName, arrayKey, item };
959
+ items.push(loc);
960
+ if (!byRefname.has(idVal)) {
961
+ byRefname.set(idVal, loc);
962
+ }
963
+ // `byOid` — populated for items carrying a string `oid` (DORMANT this
964
+ // cycle: no F1 consumer reads it). First-writer-wins on oid collision,
965
+ // mirroring `byRefname`.
966
+ const oidVal = item.oid;
967
+ if (typeof oidVal === "string" && oidVal.length > 0 && !byOid.has(oidVal)) {
968
+ byOid.set(oidVal, loc);
929
969
  }
930
970
  }
931
971
  }
@@ -938,7 +978,7 @@ export function buildIdIndex(cwd) {
938
978
  * `buildIdIndex` once and reuse the returned map.
939
979
  */
940
980
  export function resolveItemById(cwd, id) {
941
- return buildIdIndex(cwd).get(id) ?? null;
981
+ return buildIdIndex(cwd).byRefname.get(id) ?? null;
942
982
  }
943
983
  /**
944
984
  * Bulk variant of `resolveItemById` — resolve N ids against a single
@@ -979,10 +1019,275 @@ export function resolveItemsByIds(cwd, ids) {
979
1019
  for (const id of ids) {
980
1020
  if (out.has(id))
981
1021
  continue; // duplicate input — Map dedup semantics
982
- out.set(id, index.get(id) ?? null);
1022
+ out.set(id, index.byRefname.get(id) ?? null);
983
1023
  }
984
1024
  return out;
985
1025
  }
1026
+ // ── Relation porcelain (selector → structured EdgeEndpoint → raw append) ─────
1027
+ /**
1028
+ * Load + JSON-parse a foreign substrate dir's config.json WITHOUT pointer
1029
+ * resolution or AJV validation — best-effort, returns null on absence/parse
1030
+ * failure. Used only to feed `expectedBlockForId`'s prefix invariant when
1031
+ * indexing a foreign substrate in the porcelain; the foreign substrate's own
1032
+ * write path already AJV-validated its config, so a re-validate here would only
1033
+ * add a failure mode to a read.
1034
+ */
1035
+ function loadConfigForDirBestEffort(substrateDir) {
1036
+ const p = path.join(substrateDir, "config.json");
1037
+ if (!fs.existsSync(p))
1038
+ return null;
1039
+ try {
1040
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
1041
+ }
1042
+ catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+ /**
1047
+ * Is `selector` a declared lens bin in the active config? Scans every
1048
+ * `config.lenses[].bins[]`. Disambiguates a bare selector that names a bin
1049
+ * (→ `{kind:"lens_bin"}`) from one that names an item refname (→ `{kind:"item"}`).
1050
+ */
1051
+ function selectorIsLensBin(cwd, selector) {
1052
+ const cfg = loadConfig(cwd);
1053
+ if (!cfg)
1054
+ return false;
1055
+ for (const lens of cfg.lenses ?? []) {
1056
+ if (lens.bins.includes(selector))
1057
+ return true;
1058
+ }
1059
+ return false;
1060
+ }
1061
+ /**
1062
+ * Resolve one friendly relation selector to a structured `EdgeEndpoint`:
1063
+ * - `<alias>:<refname>` (alias is a registered substrate alias) → FOREIGN item
1064
+ * `{kind:"item", substrate_id, oid, refname}` (oid from the foreign index;
1065
+ * when the foreign refname does not resolve, oid is left as the refname so the
1066
+ * endpoint round-trips — Cycle 8 resolves foreign endpoints, this cycle only
1067
+ * forms them; an unresolved foreign endpoint validates as a sentinel).
1068
+ * - a selector matching a declared lens bin → `{kind:"lens_bin", bin}`.
1069
+ * - a bare `refname` → SAME-substrate item `{kind:"item", oid, refname}` (oid
1070
+ * from the active index; falls back to refname when unresolved so an
1071
+ * edge to a not-yet-filed item is still expressible).
1072
+ *
1073
+ * NOTE: the `<alias>:` branch is tried first so an alias-prefixed selector is
1074
+ * never misread as a bare refname containing a colon.
1075
+ */
1076
+ export function resolveRelationSelector(cwd, selector) {
1077
+ // `<alias>:<refname>` — only when the prefix is a REGISTERED alias (a bare
1078
+ // refname that happens to contain a colon is not an alias selector).
1079
+ const colon = selector.indexOf(":");
1080
+ if (colon > 0) {
1081
+ const alias = selector.slice(0, colon);
1082
+ const refname = selector.slice(colon + 1);
1083
+ const substrate_id = resolveAlias(cwd, alias);
1084
+ if (substrate_id !== null) {
1085
+ const dir = resolveSubstrateDir(cwd, substrate_id);
1086
+ let oid = refname;
1087
+ if (dir !== null) {
1088
+ const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
1089
+ const foreignIndex = buildIdIndexForDir(abs, abs, loadConfigForDirBestEffort(abs));
1090
+ const loc = foreignIndex.byRefname.get(refname);
1091
+ if (loc && typeof loc.item.oid === "string")
1092
+ oid = loc.item.oid;
1093
+ }
1094
+ return { kind: "item", substrate_id, oid, refname };
1095
+ }
1096
+ }
1097
+ // A declared lens bin → lens_bin endpoint (never an item).
1098
+ if (selectorIsLensBin(cwd, selector)) {
1099
+ return { kind: "lens_bin", bin: selector };
1100
+ }
1101
+ // Bare refname → same-substrate item. oid from the active index; falls back
1102
+ // to the refname itself when the item is not yet filed.
1103
+ const index = buildIdIndex(cwd);
1104
+ const loc = index.byRefname.get(selector);
1105
+ const oid = loc && typeof loc.item.oid === "string" ? loc.item.oid : selector;
1106
+ return { kind: "item", oid, refname: selector };
1107
+ }
1108
+ /**
1109
+ * Friendly-selector relation append (Cycle 5 porcelain). Resolves `parent` /
1110
+ * `child` STRING selectors to structured `EdgeEndpoint`s via
1111
+ * `resolveRelationSelector`, then delegates to the raw `appendRelation` plumbing
1112
+ * (atomic, AJV-validated, exact-duplicate no-op — same deferred-integrity
1113
+ * semantics). Keeps the string param surface its callers (the append-relation
1114
+ * Pi tool + the orchestrator CLI) already expose.
1115
+ *
1116
+ * Returns `{ appended, edge }` where `edge` is the RESOLVED structured edge
1117
+ * actually written (so callers can report / dry-run-validate the structured
1118
+ * form).
1119
+ */
1120
+ export function appendRelationByRef(cwd, rel, ctx) {
1121
+ const edge = {
1122
+ parent: resolveRelationSelector(cwd, rel.parent),
1123
+ child: resolveRelationSelector(cwd, rel.child),
1124
+ relation_type: rel.relation_type,
1125
+ ...(rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {}),
1126
+ };
1127
+ const { appended } = appendRelation(cwd, edge, ctx);
1128
+ return { appended, edge };
1129
+ }
1130
+ /**
1131
+ * Build (or fetch from the per-pass cache) the {@link SubstrateIndex} for a
1132
+ * REGISTERED foreign substrate. Resolves the substrate dir from the registry,
1133
+ * absolutizes it against `cwd`, and builds the index once per substrate_id within
1134
+ * a validation pass (the `foreignCache` is keyed by substrate_id). A build that
1135
+ * THROWS (malformed foreign block / prefix-invariant violation) is caught and
1136
+ * returns null — the caller resolves the ref `dangling` rather than crashing the
1137
+ * whole validation pass on one bad foreign substrate.
1138
+ *
1139
+ * Returns null when the substrate_id is not registered (→ caller `unregistered`)
1140
+ * or when the foreign-index build throws (→ caller `dangling`). A registered
1141
+ * substrate whose dir is missing on disk builds an empty index (not null) via
1142
+ * `buildIdIndexForDir`'s existsSync guard, so its endpoints resolve `dangling`.
1143
+ */
1144
+ function foreignIndexFor(cwd, substrate_id, foreignCache) {
1145
+ const cached = foreignCache.get(substrate_id);
1146
+ if (cached)
1147
+ return cached;
1148
+ const dir = resolveSubstrateDir(cwd, substrate_id);
1149
+ if (dir === null)
1150
+ return null; // not registered → unregistered (caller)
1151
+ const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
1152
+ try {
1153
+ const index = buildIdIndexForDir(abs, abs, loadConfigForDirBestEffort(abs));
1154
+ foreignCache.set(substrate_id, index);
1155
+ return index;
1156
+ }
1157
+ catch {
1158
+ // Malformed foreign block / prefix-invariant throw — degrade to dangling.
1159
+ // Do NOT cache the failure (a transient read could differ); the per-pass
1160
+ // cost of a re-throw is bounded by the edge count into this substrate.
1161
+ return null;
1162
+ }
1163
+ }
1164
+ /**
1165
+ * Classify a single edge endpoint (legacy string OR structured) into one of the
1166
+ * four {@link ResolveStatus} values — the load-bearing F2 resolver wired into
1167
+ * `validateContext`'s edge loop + the `validateRelations` `resolve?` hook.
1168
+ *
1169
+ * Algorithm (the locked Cycle-8 design):
1170
+ * 1. A structured `{kind:"lens_bin"}` endpoint → `{status:"active",
1171
+ * endpointKind:"lens_bin"}` with NO item lookup (a lens_bin never routes
1172
+ * through item resolution — the corruption-risk surface, Constraint 4).
1173
+ * 2. An item endpoint WITH A LOCATOR — a structured `substrate_id`, OR a STRING
1174
+ * of the form `<alias>:<refname>` whose `<alias>` prefix is a REGISTERED
1175
+ * alias — resolves against the named FOREIGN substrate: substrate_id/alias
1176
+ * NOT in the registry → `unregistered`; registered → build (cached) the
1177
+ * foreign index → look up by `oid` (structured locator carrying an oid) else
1178
+ * by `refname` in `byOid`/`byRefname` → found `foreign` / absent `dangling`.
1179
+ * A foreign-index build that throws → `dangling` (never a crash).
1180
+ * 3. An item endpoint with NO locator — a bare oid or a bare refname (a string
1181
+ * with no `:` alias-prefix) — resolves against the ACTIVE index ONLY → found
1182
+ * `active` / absent `dangling`.
1183
+ *
1184
+ * The alias-string parse is ATTEMPTED FIRST on any string containing a `:`
1185
+ * (mirroring `resolveRelationSelector`): the `<x>` in `<x>:<y>` is treated as an
1186
+ * alias candidate, so such a string is an aliased-item locator (step 2), NOT a
1187
+ * bare refname (step 3). If `<x>` is NOT a registered alias → `unregistered`. So
1188
+ * today's `project:FGAP-153` (the `project` alias is not registered until Phase H)
1189
+ * → `unregistered`. The real 30 are therefore `unregistered` pre-H and flip to
1190
+ * `foreign` once Phase H registers the `project` alias (the count/total stay
1191
+ * unchanged at reclassification — see the note in `validateContext`).
1192
+ *
1193
+ * `opts.activeIndex` lets the caller pass a pre-built active index (built once per
1194
+ * validation pass); `opts.foreignCache` memoizes foreign indices per substrate_id
1195
+ * within the pass (so N edges into one foreign substrate build its index ONCE).
1196
+ */
1197
+ export function resolveRef(cwd, ref, opts) {
1198
+ const foreignCache = opts?.foreignCache ?? new Map();
1199
+ const activeIndex = opts?.activeIndex ?? buildIdIndex(cwd);
1200
+ // (1) lens_bin endpoint — no item lookup.
1201
+ if (typeof ref !== "string" && ref.kind === "lens_bin") {
1202
+ return { status: "active", endpointKind: "lens_bin" };
1203
+ }
1204
+ // Derive the locator + lookup keys.
1205
+ // - structured item: substrate_id (locator), oid, refname.
1206
+ // - string: attempt `<alias>:<refname>` parse — a `:` whose prefix is a
1207
+ // REGISTERED alias yields a foreign locator; otherwise no locator (step 3
1208
+ // looks up the whole string in the active index).
1209
+ let substrate_id;
1210
+ let oid;
1211
+ let refname;
1212
+ // A string carrying a `:` is treated as a `<alias>:<refname>` LOCATOR
1213
+ // candidate (the alias parse is ATTEMPTED): the prefix is an alias that
1214
+ // either resolves (→ foreign locator) or does NOT (→ `unregistered` — the
1215
+ // pre-Phase-H state of the 30 `project:` strings, whose `project` alias is
1216
+ // not yet registered). A registered alias yields a foreign `substrate_id`
1217
+ // locator + the post-colon refname; an UNregistered alias is flagged with the
1218
+ // `aliasUnregistered` sentinel so step (2)/(3) below routes it to
1219
+ // `unregistered` rather than the active index. A string with NO `:` is a bare
1220
+ // active refname (step 3).
1221
+ let aliasUnregistered = false;
1222
+ if (typeof ref === "string") {
1223
+ const colon = ref.indexOf(":");
1224
+ if (colon > 0) {
1225
+ const alias = ref.slice(0, colon);
1226
+ const aliasSubId = resolveAlias(cwd, alias);
1227
+ if (aliasSubId !== null) {
1228
+ // Registered alias → foreign locator; refname is the post-colon part.
1229
+ substrate_id = aliasSubId;
1230
+ refname = ref.slice(colon + 1);
1231
+ }
1232
+ else {
1233
+ // `:`-prefix is an alias CANDIDATE but the alias is NOT registered →
1234
+ // `unregistered` (NOT active-dangling). The whole string is retained as
1235
+ // the refname for diagnostics; no active-index lookup is performed.
1236
+ aliasUnregistered = true;
1237
+ refname = ref;
1238
+ }
1239
+ }
1240
+ else {
1241
+ // No colon — bare active refname.
1242
+ refname = ref;
1243
+ }
1244
+ }
1245
+ else {
1246
+ // Structured item endpoint.
1247
+ substrate_id = ref.substrate_id;
1248
+ oid = ref.oid;
1249
+ refname = ref.refname;
1250
+ }
1251
+ // A `<alias>:<refname>` string whose alias is NOT registered → unregistered
1252
+ // (locked decision 1: the alias parse is attempted; a missing alias is the
1253
+ // pre-Phase-H state of the 30, NOT an active-substrate dangling lookup).
1254
+ if (aliasUnregistered) {
1255
+ return { status: "unregistered", endpointKind: "item", refname };
1256
+ }
1257
+ // (2) item endpoint WITH a foreign locator (structured substrate_id OR an
1258
+ // alias that resolved to one).
1259
+ if (substrate_id !== undefined) {
1260
+ const index = foreignIndexFor(cwd, substrate_id, foreignCache);
1261
+ if (index === null) {
1262
+ // substrate_id not registered → unregistered (a build-throw also returns
1263
+ // null, but foreignIndexFor only returns null on UNREGISTERED when the
1264
+ // registry lacks the id; the throw path returns null too — disambiguate
1265
+ // by re-checking the registry to keep the two outcomes distinct).
1266
+ const dir = resolveSubstrateDir(cwd, substrate_id);
1267
+ return dir === null
1268
+ ? { status: "unregistered", endpointKind: "item", substrate_id, oid, refname }
1269
+ : { status: "dangling", endpointKind: "item", substrate_id, oid, refname };
1270
+ }
1271
+ // Look up by oid first (structured locator carrying an oid), else by refname.
1272
+ let loc;
1273
+ if (typeof oid === "string" && oid.length > 0)
1274
+ loc = index.byOid.get(oid);
1275
+ if (!loc && typeof refname === "string" && refname.length > 0)
1276
+ loc = index.byRefname.get(refname);
1277
+ return loc
1278
+ ? { status: "foreign", endpointKind: "item", substrate_id, oid, refname, loc }
1279
+ : { status: "dangling", endpointKind: "item", substrate_id, oid, refname };
1280
+ }
1281
+ // (3) item endpoint with NO locator → ACTIVE index only.
1282
+ let loc;
1283
+ if (typeof oid === "string" && oid.length > 0)
1284
+ loc = activeIndex.byOid.get(oid);
1285
+ if (!loc && typeof refname === "string" && refname.length > 0)
1286
+ loc = activeIndex.byRefname.get(refname);
1287
+ return loc
1288
+ ? { status: "active", endpointKind: "item", oid, refname, loc }
1289
+ : { status: "dangling", endpointKind: "item", oid, refname };
1290
+ }
986
1291
  /**
987
1292
  * Field-equality predicate for config-declared invariants. Mirrors the
988
1293
  * composition-lens `where` semantics (context.ts:773-778): the item
@@ -1023,13 +1328,55 @@ export function validateContext(cwd) {
1023
1328
  // Note: buildIdIndex enforces the prefix-vs-block invariant and may throw
1024
1329
  // on corrupted state; that surfaces as a hard failure to validateContext
1025
1330
  // callers (intended — corrupted IDs are not recoverable cross-ref issues).
1026
- const idIndex = buildIdIndex(cwd);
1331
+ const index = buildIdIndex(cwd);
1027
1332
  // ── Edge integrity (DEC-0013 closure-table reference surface) ─────────────
1028
1333
  // Load config + relations; both absent in a pre-bootstrap project, in which
1029
1334
  // case edge checks (and the relocated invariants, which depend on edges)
1030
1335
  // are skipped gracefully — there is no edge model to validate yet.
1031
1336
  const config = loadConfig(cwd);
1032
1337
  const relations = config ? loadRelations(cwd) : [];
1338
+ // ── SoT-drift invariant (content-addressed substrate identity, Cycle 4) ───
1339
+ // When the active config declares a `substrate_id`, the project-root
1340
+ // registry (.pi-context-registry.json) MUST carry an entry for that id whose
1341
+ // `dir` resolves to the active substrate dir. A missing entry or a dir
1342
+ // mismatch means the registry has drifted from the active substrate's sole
1343
+ // SoT (config.substrate_id) and is an ERROR. When `config.substrate_id` is
1344
+ // ABSENT (a pre-identity / pre-Phase-H substrate), the check SKIPS — read
1345
+ // the field directly off the config rather than via substrateIdFor (which
1346
+ // THROWS on absence) so an un-migrated substrate still validates cleanly.
1347
+ if (config) {
1348
+ const substrateId = config.substrate_id;
1349
+ if (typeof substrateId === "string" && substrateId.length > 0) {
1350
+ const registry = loadRegistry(cwd);
1351
+ const entry = registry?.substrates?.[substrateId];
1352
+ if (!entry) {
1353
+ issues.push({
1354
+ severity: "error",
1355
+ message: `config.substrate_id '${substrateId}' is not registered in the project-root .pi-context-registry.json — register the active substrate (registerSubstrate) so foreign-locator resolution can find it`,
1356
+ block: "config",
1357
+ field: "substrate_id",
1358
+ code: "substrate_id_unregistered",
1359
+ });
1360
+ }
1361
+ else {
1362
+ const registeredAbs = path.resolve(cwd, entry.dir);
1363
+ // resolveContextDir returns path.join(cwd, contextDir), which is
1364
+ // RELATIVE when cwd is relative (e.g. '.'). Absolutize it so the
1365
+ // comparison is absolute-vs-absolute and a relative cwd can't
1366
+ // produce a false-positive drift error.
1367
+ const activeAbs = path.resolve(resolveContextDir(cwd));
1368
+ if (registeredAbs !== activeAbs) {
1369
+ issues.push({
1370
+ severity: "error",
1371
+ message: `config.substrate_id '${substrateId}' registry entry dir '${entry.dir}' (resolved ${registeredAbs}) does not match the active substrate dir ${activeAbs} — the registry has drifted from the active substrate's SoT`,
1372
+ block: "config",
1373
+ field: "substrate_id",
1374
+ code: "substrate_id_registry_mismatch",
1375
+ });
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1033
1380
  // `config` present → run edge-integrity + the relocated invariants. The
1034
1381
  // invariants detect MISSING edges (completed task without a verification
1035
1382
  // edge; decision without a forcing-artifact edge), so they must run even
@@ -1038,21 +1385,57 @@ export function validateContext(cwd) {
1038
1385
  // no-op on empty relations, so it needs no separate guard.
1039
1386
  if (config) {
1040
1387
  const registeredRelTypes = new Set((config.relation_types ?? []).map((rt) => rt.canonical_id));
1388
+ // Per-pass foreign-index cache (Constraint 3): N foreign edges into the same
1389
+ // registered substrate build that substrate's index ONCE within this pass.
1390
+ const foreignCache = new Map();
1391
+ // Resolver bound to this pass's cwd + active index + foreign cache; supplied
1392
+ // to validateRelations so its lens/hierarchy resolution can see foreign items.
1393
+ const resolve = (ref) => resolveRef(cwd, ref, { activeIndex: index, foreignCache });
1041
1394
  for (const edge of relations) {
1042
- if (!idIndex.has(edge.parent)) {
1395
+ // F2 severity split (DEC §F2): every endpoint is classified by resolveRef
1396
+ // into active | foreign | dangling | unregistered. active/foreign/lens_bin
1397
+ // → no issue; unregistered → ERROR (`edge_endpoint_unregistered`); dangling
1398
+ // → ERROR (`edge_endpoint_dangling`). The two new codes REPLACE the prior
1399
+ // inline "does not resolve" message — the intended reclassification of the
1400
+ // cross-substrate (`<alias>:`) strings (their alias is unregistered pre-H).
1401
+ const parentKey = endpointKey(edge.parent);
1402
+ const childKey = endpointKey(edge.child);
1403
+ const parentRes = resolve(edge.parent);
1404
+ const childRes = resolve(edge.child);
1405
+ if (parentRes.status === "unregistered") {
1043
1406
  issues.push({
1044
1407
  severity: "error",
1045
- message: `Edge parent '${edge.parent}' (relation_type '${edge.relation_type}') does not resolve to any item`,
1408
+ message: `Edge parent '${parentKey}' (relation_type '${edge.relation_type}') names an unregistered substrate alias/id`,
1046
1409
  block: "relations",
1047
- field: `${edge.parent}->${edge.child}`,
1410
+ field: `${parentKey}->${childKey}`,
1411
+ code: "edge_endpoint_unregistered",
1048
1412
  });
1049
1413
  }
1050
- if (!idIndex.has(edge.child)) {
1414
+ else if (parentRes.status === "dangling") {
1051
1415
  issues.push({
1052
1416
  severity: "error",
1053
- message: `Edge child '${edge.child}' (relation_type '${edge.relation_type}') does not resolve to any item`,
1417
+ message: `Edge parent '${parentKey}' (relation_type '${edge.relation_type}') does not resolve to any item`,
1054
1418
  block: "relations",
1055
- field: `${edge.parent}->${edge.child}`,
1419
+ field: `${parentKey}->${childKey}`,
1420
+ code: "edge_endpoint_dangling",
1421
+ });
1422
+ }
1423
+ if (childRes.status === "unregistered") {
1424
+ issues.push({
1425
+ severity: "error",
1426
+ message: `Edge child '${childKey}' (relation_type '${edge.relation_type}') names an unregistered substrate alias/id`,
1427
+ block: "relations",
1428
+ field: `${parentKey}->${childKey}`,
1429
+ code: "edge_endpoint_unregistered",
1430
+ });
1431
+ }
1432
+ else if (childRes.status === "dangling") {
1433
+ issues.push({
1434
+ severity: "error",
1435
+ message: `Edge child '${childKey}' (relation_type '${edge.relation_type}') does not resolve to any item`,
1436
+ block: "relations",
1437
+ field: `${parentKey}->${childKey}`,
1438
+ code: "edge_endpoint_dangling",
1056
1439
  });
1057
1440
  }
1058
1441
  if (!registeredRelTypes.has(edge.relation_type)) {
@@ -1060,7 +1443,7 @@ export function validateContext(cwd) {
1060
1443
  severity: "error",
1061
1444
  message: `Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`,
1062
1445
  block: "relations",
1063
- field: `${edge.parent}->${edge.child}`,
1446
+ field: `${parentKey}->${childKey}`,
1064
1447
  });
1065
1448
  }
1066
1449
  }
@@ -1080,24 +1463,32 @@ export function validateContext(cwd) {
1080
1463
  continue; // unregistered relation_type already reported above
1081
1464
  if (!rt.source_kinds && !rt.target_kinds)
1082
1465
  continue; // metadata absent → unchecked
1083
- const parentLoc = idIndex.get(edge.parent);
1084
- const childLoc = idIndex.get(edge.child);
1466
+ const parentKey = endpointKey(edge.parent);
1467
+ const childKey = endpointKey(edge.child);
1468
+ // Use the resolved location for item endpoints (active OR foreign), so a
1469
+ // foreign item's block is kind-checked against source/target_kinds too. A
1470
+ // lens_bin endpoint (endpointKind:"lens_bin") carries no loc and is skipped
1471
+ // — it never routes through item-block resolution.
1472
+ const parentRes = resolve(edge.parent);
1473
+ const childRes = resolve(edge.child);
1474
+ const parentLoc = parentRes.loc;
1475
+ const childLoc = childRes.loc;
1085
1476
  if (parentLoc &&
1086
1477
  rt.source_kinds &&
1087
1478
  !(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
1088
1479
  issues.push({
1089
1480
  severity: "error",
1090
- message: `Edge ${edge.parent} -> ${edge.child}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1481
+ message: `Edge ${parentKey} -> ${childKey}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1091
1482
  block: "relations",
1092
- field: `${edge.parent}->${edge.child}`,
1483
+ field: `${parentKey}->${childKey}`,
1093
1484
  });
1094
1485
  }
1095
1486
  if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
1096
1487
  issues.push({
1097
1488
  severity: "error",
1098
- message: `Edge ${edge.parent} -> ${edge.child}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1489
+ message: `Edge ${parentKey} -> ${childKey}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1099
1490
  block: "relations",
1100
- field: `${edge.parent}->${edge.child}`,
1491
+ field: `${parentKey}->${childKey}`,
1101
1492
  });
1102
1493
  }
1103
1494
  }
@@ -1107,11 +1498,11 @@ export function validateContext(cwd) {
1107
1498
  // resolution above is the authoritative reference-integrity surface, so
1108
1499
  // merging validateRelations' resolution codes too would double-report).
1109
1500
  const itemsByBlock = {};
1110
- for (const [id, loc] of idIndex) {
1111
- (itemsByBlock[loc.block] ??= []).push({ id, ...loc.item });
1501
+ for (const loc of index.byRefname.values()) {
1502
+ (itemsByBlock[loc.block] ??= []).push({ id: loc.id, ...loc.item });
1112
1503
  }
1113
1504
  try {
1114
- const relResult = validateRelations(config, relations, itemsByBlock);
1505
+ const relResult = validateRelations(config, relations, itemsByBlock, resolve);
1115
1506
  for (const ri of relResult.issues) {
1116
1507
  if (ri.code !== "edge_cycle_detected")
1117
1508
  continue;
@@ -1142,9 +1533,10 @@ export function validateContext(cwd) {
1142
1533
  for (const edge of relations) {
1143
1534
  if (!relTypeSet.has(edge.relation_type))
1144
1535
  continue;
1145
- satisfied.add(inv.direction === "as_parent" ? edge.parent : edge.child);
1536
+ satisfied.add(inv.direction === "as_parent" ? endpointKey(edge.parent) : endpointKey(edge.child));
1146
1537
  }
1147
- for (const [id, loc] of idIndex) {
1538
+ for (const loc of index.byRefname.values()) {
1539
+ const id = loc.id;
1148
1540
  if (loc.block !== inv.block)
1149
1541
  continue;
1150
1542
  if (!matchesWhere(loc.item, inv.where))
@@ -1177,7 +1569,8 @@ export function validateContext(cwd) {
1177
1569
  if (inv.class !== "status-consistency")
1178
1570
  continue;
1179
1571
  const relSet = new Set(inv.relation_types);
1180
- for (const [id, loc] of idIndex) {
1572
+ for (const loc of index.byRefname.values()) {
1573
+ const id = loc.id;
1181
1574
  if (loc.block !== inv.block)
1182
1575
  continue;
1183
1576
  if (inv.when_bucket && bucketOf(loc.item) !== inv.when_bucket)
@@ -1186,10 +1579,10 @@ export function validateContext(cwd) {
1186
1579
  if (!relSet.has(edge.relation_type))
1187
1580
  continue;
1188
1581
  const selfIsParent = inv.direction === "as_parent";
1189
- if ((selfIsParent ? edge.parent : edge.child) !== id)
1582
+ if ((selfIsParent ? endpointKey(edge.parent) : endpointKey(edge.child)) !== id)
1190
1583
  continue;
1191
- const otherId = selfIsParent ? edge.child : edge.parent;
1192
- const otherLoc = idIndex.get(otherId);
1584
+ const otherId = selfIsParent ? endpointKey(edge.child) : endpointKey(edge.parent);
1585
+ const otherLoc = index.byRefname.get(otherId);
1193
1586
  if (!otherLoc)
1194
1587
  continue; // dangling endpoint handled by edge-integrity above
1195
1588
  const otherBucket = bucketOf(otherLoc.item);
@@ -1218,7 +1611,8 @@ export function validateContext(cwd) {
1218
1611
  // key (recognized) and is NOT flagged; only a value with NO key is. Warning-only.
1219
1612
  {
1220
1613
  const statusVocab = resolveStatusVocabulary(cwd);
1221
- for (const [sid, sloc] of idIndex) {
1614
+ for (const sloc of index.byRefname.values()) {
1615
+ const sid = sloc.id;
1222
1616
  const sval = sloc.item.status;
1223
1617
  if (sval === undefined || sval === null)
1224
1618
  continue;
@@ -1233,6 +1627,47 @@ export function validateContext(cwd) {
1233
1627
  }
1234
1628
  }
1235
1629
  }
1630
+ // ── Nested id-bearing array warning (content-addressed substrate identity,
1631
+ // Cycle 9.2) ─────────────────────────────────────────────────────────────
1632
+ // Schema-level, independent of block DATA + config: enumerate the active
1633
+ // substrate's installed schemas and flag every array property at nesting
1634
+ // depth ≥ 1 whose item shape carries an `id` — a relationship-as-embedding
1635
+ // that should be promoted to a top-level entity + membership edge (Phase H).
1636
+ // Non-fatal (warning), so it raises the warning count only and never flips
1637
+ // status to "invalid" by itself. Runs whether or not config is present;
1638
+ // skips cleanly when the schemas dir is absent (pre-bootstrap substrate) and
1639
+ // is best-effort per-file (an unparseable / unreadable schema is skipped
1640
+ // rather than failing the whole project validate).
1641
+ {
1642
+ // Resolve the substrate dir via tryResolveContextDir (returns null when no
1643
+ // pointer exists) rather than schemasDir, whose resolveContextDir THROWS on a
1644
+ // pointer-less cwd — validateContext must degrade cleanly there, not throw.
1645
+ const ctxRoot = tryResolveContextDir(cwd);
1646
+ const schemasDirPath = ctxRoot === null ? null : path.join(ctxRoot, SCHEMAS_DIR);
1647
+ if (schemasDirPath !== null && fs.existsSync(schemasDirPath)) {
1648
+ for (const file of fs.readdirSync(schemasDirPath).sort()) {
1649
+ if (!file.endsWith(".schema.json"))
1650
+ continue;
1651
+ const schemaName = file.slice(0, -".schema.json".length);
1652
+ let parsed;
1653
+ try {
1654
+ parsed = JSON.parse(fs.readFileSync(path.join(schemasDirPath, file), "utf-8"));
1655
+ }
1656
+ catch {
1657
+ continue; // unreadable / non-JSON schema — not this pass's concern
1658
+ }
1659
+ for (const fieldPath of findNestedIdBearingArrays(parsed)) {
1660
+ issues.push({
1661
+ severity: "warning",
1662
+ message: `nested id-bearing array '${fieldPath}' — promote to a top-level entity + membership edge (Phase H)`,
1663
+ block: schemaName,
1664
+ field: fieldPath,
1665
+ code: "nested_id_bearing_array",
1666
+ });
1667
+ }
1668
+ }
1669
+ }
1670
+ }
1236
1671
  // Lens-validator dispatch (Step 7 / pi-context Divergence 3): iterate every
1237
1672
  // validator registered via registerLensValidator and merge its issues into
1238
1673
  // the project-validation result. Validators are guarded individually so a