@davidorex/pi-context 0.26.0 → 0.28.1

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 +71 -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
package/dist/context.js CHANGED
@@ -22,8 +22,64 @@ import fs from "node:fs";
22
22
  import path from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { appendManyToTypedFileIfAbsent, writeTypedFile } from "./block-api.js";
25
- import { assertSubstrateName, resolveContextDir, SCHEMAS_DIR, tryResolveContextDir } from "./context-dir.js";
25
+ import { assertSubstrateName, mintSubstrateId, resolveContextDir, SCHEMAS_DIR, tryResolveContextDir, } from "./context-dir.js";
26
+ import { registerSubstrate } from "./context-registry.js";
26
27
  import { ValidationError, validateFromFile } from "./schema-validator.js";
28
+ export function normalizeEndpoint(e) {
29
+ if (typeof e === "string") {
30
+ return { kind: "item", key: e, foreign: false };
31
+ }
32
+ if (e.kind === "lens_bin") {
33
+ return { kind: "lens_bin", key: e.bin, bin: e.bin };
34
+ }
35
+ return { kind: "item", key: e.refname ?? e.oid, foreign: !!e.substrate_id };
36
+ }
37
+ /**
38
+ * The consumer-layer node key for an endpoint — the string that joins against
39
+ * `idIndex` (keyed by item refname) or is compared to `lens.bins` labels. This
40
+ * is the normalize-to-string shim every walker / validator / grouping site reads
41
+ * in place of the bare `edge.parent`/`edge.child`. Returns byte-identical results
42
+ * for legacy string endpoints.
43
+ */
44
+ export function endpointKey(e) {
45
+ return normalizeEndpoint(e).key;
46
+ }
47
+ /**
48
+ * The bin label for a `{kind:"lens_bin"}` endpoint; `null` for any item endpoint
49
+ * (string or structured). Lens sites test this so a lens_bin endpoint can never
50
+ * reach an `idIndex.get` path (Constraint 4). A legacy STRING parent is `null`
51
+ * here too — it stays bin-or-item ambiguous and is resolved by
52
+ * `lens.bins.includes(endpointKey(e))` exactly as before this cycle.
53
+ */
54
+ export function endpointBin(e) {
55
+ const n = normalizeEndpoint(e);
56
+ return n.kind === "lens_bin" ? n.bin : null;
57
+ }
58
+ /**
59
+ * The DEDUP identity of an endpoint (the deliberate asymmetry vs the consumer
60
+ * node key): string → the string; structured item → `${substrate_id ?? ""}:${oid}`;
61
+ * lens_bin → `bin:${bin}`. This is what `identityKey` keys on for the
62
+ * append-if-absent no-op.
63
+ *
64
+ * The `${substrate_id ?? ""}:${oid}` colon-form is the EDGE DEDUP KEY ONLY — it is
65
+ * NOT the oid's own format. The oid is a bare 32-hex digest (see `mintOid`, which
66
+ * returns the digest with no substrate prefix and no colon). The `substrate_id`
67
+ * prefix is prepended here solely so two items that happen to share an oid across
68
+ * DIFFERENT substrates dedup as distinct edges; within one substrate the oid alone
69
+ * is content-addressed, so two items with the same oid but different refnames dedup
70
+ * together.
71
+ *
72
+ * A bare-refname STRING and a structured same-item with the SAME refname do NOT
73
+ * dedup-merge here (string key `"FGAP-1"` vs item key `":<oid>"`) — the documented,
74
+ * probed asymmetry.
75
+ */
76
+ export function endpointIdentity(e) {
77
+ if (typeof e === "string")
78
+ return e;
79
+ if (e.kind === "lens_bin")
80
+ return `bin:${e.bin}`;
81
+ return `${e.substrate_id ?? ""}:${e.oid}`;
82
+ }
27
83
  // ── Schema paths (bundled with the package) ──────────────────────────────────
28
84
  /**
29
85
  * Resolve the bundled config / relations schema files. Resolved relative to
@@ -43,12 +99,25 @@ function bundledSchemaPath(name) {
43
99
  function configPath(cwd) {
44
100
  return path.join(resolveContextDir(cwd), "config.json");
45
101
  }
102
+ /** `<substrateDir>/config.json` — the dir-targeted twin of `configPath`. Takes
103
+ * the ALREADY-RESOLVED substrate dir directly (no pointer resolution), mirroring
104
+ * the Cycle-1 `*ForDir` pattern (cf. `relationsPathForDir`). The Cycle-10
105
+ * canonicalizer targets a non-active work-dupe substrate via this path. */
106
+ function configPathForDir(substrateDir) {
107
+ return path.join(substrateDir, "config.json");
108
+ }
46
109
  /** `<resolveContextDir(cwd)>/relations.json` — same substrate-dir-relative
47
110
  * resolution as configPath; previous `.project/`-fixed exemption removed for
48
111
  * DEC-0015 compliance. */
49
112
  function relationsPath(cwd) {
50
113
  return path.join(resolveContextDir(cwd), "relations.json");
51
114
  }
115
+ /** `<substrateDir>/relations.json` — the dir-targeted twin of `relationsPath`.
116
+ * Takes the ALREADY-RESOLVED substrate dir directly (no pointer resolution),
117
+ * mirroring the Cycle-1 `*ForDir` pattern. */
118
+ function relationsPathForDir(substrateDir) {
119
+ return path.join(substrateDir, "relations.json");
120
+ }
52
121
  // ── Loaders ─────────────────────────────────────────────────────────────────
53
122
  /**
54
123
  * Resolve the substrate root for `cwd` — pointer-canonical (DEC-0045 / FGAP-079).
@@ -80,7 +149,19 @@ export function loadConfig(cwd) {
80
149
  const root = tryResolveContextDir(cwd);
81
150
  if (root === null)
82
151
  return null;
83
- const p = path.join(root, "config.json");
152
+ return loadConfigForDir(root);
153
+ }
154
+ /**
155
+ * Dir-targeted twin of {@link loadConfig} (Cycle-1 `*ForDir` pattern). Reads +
156
+ * AJV-validates `<substrateDir>/config.json` against the bundled config schema
157
+ * for the ALREADY-RESOLVED substrate dir — no `.pi-context.json` pointer
158
+ * resolution. Returns `null` when the file is absent (a normal pre-write state).
159
+ * `loadConfig` is a thin wrapper resolving the active pointer dir; behaviour is
160
+ * byte-identical when called via cwd. Used by the Cycle-10 canonicalizer to read
161
+ * a NON-active (work-dupe) substrate's config in place.
162
+ */
163
+ export function loadConfigForDir(substrateDir) {
164
+ const p = path.join(substrateDir, "config.json");
84
165
  if (!fs.existsSync(p))
85
166
  return null;
86
167
  let raw;
@@ -88,14 +169,14 @@ export function loadConfig(cwd) {
88
169
  raw = fs.readFileSync(p, "utf-8");
89
170
  }
90
171
  catch (err) {
91
- throw new Error(`loadConfig: failed to read ${p}: ${err instanceof Error ? err.message : String(err)}`);
172
+ throw new Error(`loadConfigForDir: failed to read ${p}: ${err instanceof Error ? err.message : String(err)}`);
92
173
  }
93
174
  let data;
94
175
  try {
95
176
  data = JSON.parse(raw);
96
177
  }
97
178
  catch (err) {
98
- throw new Error(`loadConfig: invalid JSON in ${p}: ${err instanceof Error ? err.message : String(err)}`);
179
+ throw new Error(`loadConfigForDir: invalid JSON in ${p}: ${err instanceof Error ? err.message : String(err)}`);
99
180
  }
100
181
  validateFromFile(bundledSchemaPath("config"), data, `config.json (${p})`);
101
182
  return data;
@@ -142,7 +223,19 @@ export function loadRelations(cwd) {
142
223
  const root = tryResolveContextDir(cwd);
143
224
  if (root === null)
144
225
  return [];
145
- const p = path.join(root, "relations.json");
226
+ return loadRelationsForDir(root);
227
+ }
228
+ /**
229
+ * Dir-targeted twin of {@link loadRelations} (Cycle-1 `*ForDir` pattern). Reads
230
+ * `<substrateDir>/relations.json` against the ALREADY-RESOLVED substrate dir —
231
+ * no `.pi-context.json` pointer resolution. Returns `[]` when the file is absent;
232
+ * AJV-validates the top-level `Edge[]` array against the bundled relations schema
233
+ * exactly as the cwd form does. `loadRelations` is a thin wrapper resolving the
234
+ * active pointer dir; behaviour is byte-identical when called via cwd. Used by
235
+ * the Phase-H migration to read a NON-active substrate's edges in place.
236
+ */
237
+ export function loadRelationsForDir(substrateDir) {
238
+ const p = relationsPathForDir(substrateDir);
146
239
  if (!fs.existsSync(p))
147
240
  return [];
148
241
  let raw;
@@ -177,7 +270,18 @@ export function loadRelations(cwd) {
177
270
  * top-level-array stamping semantics documented in block-api).
178
271
  */
179
272
  export function writeRelations(cwd, edges, ctx) {
180
- writeTypedFile(relationsPath(cwd), bundledSchemaPath("relations"), edges, ctx, "relations.json");
273
+ writeRelationsForDir(resolveContextDir(cwd), edges, ctx);
274
+ }
275
+ /**
276
+ * Dir-targeted twin of `writeRelations` (Cycle-1 `*ForDir` pattern). Operates
277
+ * on `<substrateDir>/relations.json` against the ALREADY-RESOLVED substrate dir
278
+ * — no `.pi-context.json` pointer resolution. `writeRelations` is a thin
279
+ * wrapper resolving the active dir; behaviour is byte-identical when called via
280
+ * cwd. AJV-shape validation comes from `writeTypedFile`; same attestation-parity
281
+ * no-op semantics as the cwd form.
282
+ */
283
+ export function writeRelationsForDir(substrateDir, edges, ctx) {
284
+ writeTypedFile(relationsPathForDir(substrateDir), bundledSchemaPath("relations"), edges, ctx, "relations.json");
181
285
  }
182
286
  /**
183
287
  * Composite identity of an edge for append-if-absent dedup: the
@@ -189,7 +293,7 @@ export function writeRelations(cwd, edges, ctx) {
189
293
  */
190
294
  const identityKey = (e) => {
191
295
  const r = e;
192
- return `${r.parent} ${r.child} ${r.relation_type}`;
296
+ return `${endpointIdentity(r.parent)} ${endpointIdentity(r.child)} ${r.relation_type}`;
193
297
  };
194
298
  /**
195
299
  * Append closure-table edges to `<resolveContextDir(cwd)>/relations.json`,
@@ -214,7 +318,19 @@ const identityKey = (e) => {
214
318
  * semantics documented in block-api).
215
319
  */
216
320
  export function appendRelations(cwd, edges, ctx) {
217
- return appendManyToTypedFileIfAbsent(relationsPath(cwd), bundledSchemaPath("relations"), null, edges, identityKey, ctx, "relations.json");
321
+ return appendRelationsForDir(resolveContextDir(cwd), edges, ctx);
322
+ }
323
+ /**
324
+ * Dir-targeted twin of `appendRelations` (Cycle-1 `*ForDir` pattern). Operates
325
+ * on `<substrateDir>/relations.json` against the ALREADY-RESOLVED substrate dir
326
+ * — no pointer resolution. `appendRelations` is a thin wrapper resolving the
327
+ * active dir; behaviour is byte-identical when called via cwd. Same deferred-
328
+ * guard semantics: only AJV-shape validation + the (parent, child,
329
+ * relation_type) exact-duplicate-no-op; relation_type registration / endpoint
330
+ * resolution / cycle checks remain deferred to `validateContext`.
331
+ */
332
+ export function appendRelationsForDir(substrateDir, edges, ctx) {
333
+ return appendManyToTypedFileIfAbsent(relationsPathForDir(substrateDir), bundledSchemaPath("relations"), null, edges, identityKey, ctx, "relations.json");
218
334
  }
219
335
  /**
220
336
  * Append a single closure-table edge. Convenience over `appendRelations`:
@@ -223,7 +339,17 @@ export function appendRelations(cwd, edges, ctx) {
223
339
  * no-op). Same deferred-guard semantics as `appendRelations`.
224
340
  */
225
341
  export function appendRelation(cwd, edge, ctx) {
226
- const r = appendRelations(cwd, [edge], ctx);
342
+ return appendRelationForDir(resolveContextDir(cwd), edge, ctx);
343
+ }
344
+ /**
345
+ * Dir-targeted twin of `appendRelation` (Cycle-1 `*ForDir` pattern). Operates
346
+ * on `<substrateDir>/relations.json` against the ALREADY-RESOLVED substrate dir.
347
+ * `appendRelation` is a thin wrapper resolving the active dir; behaviour is
348
+ * byte-identical when called via cwd. Same `{ appended }` semantics + deferred
349
+ * guards as `appendRelationsForDir`.
350
+ */
351
+ export function appendRelationForDir(substrateDir, edge, ctx) {
352
+ const r = appendRelationsForDir(substrateDir, [edge], ctx);
227
353
  return { appended: r.appended > 0 };
228
354
  }
229
355
  /**
@@ -232,7 +358,18 @@ export function appendRelation(cwd, edge, ctx) {
232
358
  * Same cycle-safety reasoning as `writeRelations`.
233
359
  */
234
360
  export function writeConfig(cwd, config, ctx) {
235
- writeTypedFile(configPath(cwd), bundledSchemaPath("config"), config, ctx, "config.json");
361
+ writeConfigForDir(resolveContextDir(cwd), config, ctx);
362
+ }
363
+ /**
364
+ * Dir-targeted twin of `writeConfig` (Cycle-1 `*ForDir` pattern). Atomic,
365
+ * AJV-validated write of `<substrateDir>/config.json` against the bundled config
366
+ * schema for the ALREADY-RESOLVED substrate dir — no pointer resolution.
367
+ * `writeConfig` is a thin wrapper resolving the active dir; behaviour is
368
+ * byte-identical when called via cwd. Same attestation-parity no-op semantics as
369
+ * the cwd form (the config schema declares no envelope author fields).
370
+ */
371
+ export function writeConfigForDir(substrateDir, config, ctx) {
372
+ writeTypedFile(configPathForDir(substrateDir), bundledSchemaPath("config"), config, ctx, "config.json");
236
373
  }
237
374
  /**
238
375
  * accept-all: adopt the package's canonical packaged conception
@@ -262,7 +399,21 @@ export function adoptConception(cwd) {
262
399
  const samplesRoot = path.resolve(here, "..", "samples");
263
400
  const conception = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
264
401
  conception.root = root; // SET root from the resolved substrate dir — the conception template ships none (DEC-0041)
402
+ // Mint + register the per-substrate content-addressed substrate_id (Cycle 4).
403
+ // config.json is the sole SoT for substrate_id and first exists here at
404
+ // adopt-time (init only scaffolds the pointer + dirs), so minting wires into
405
+ // this path rather than bare init. Idempotent: skip the mint if the
406
+ // conception already carries a valid substrate_id (it ships none, so this
407
+ // mints on a fresh accept-all; a second accept-all returns early above on the
408
+ // existing-config branch, so this block never re-mints). registerSubstrate
409
+ // upserts the active substrate into the project-root registry under `root`
410
+ // (the project-root-relative substrate dir) with empty aliases — the
411
+ // SoT-drift invariant in validateContext then passes for this substrate.
412
+ if (typeof conception.substrate_id !== "string" || conception.substrate_id.length === 0) {
413
+ conception.substrate_id = mintSubstrateId();
414
+ }
265
415
  writeConfig(cwd, conception);
416
+ registerSubstrate(cwd, conception.substrate_id, root, []);
266
417
  return {
267
418
  adopted: true,
268
419
  configPath: path.relative(cwd, cfgPath),
@@ -287,6 +438,8 @@ const REGISTRY_DESCRIPTORS = {
287
438
  installed_schemas: { kind: "string-array" },
288
439
  installed_blocks: { kind: "string-array" },
289
440
  hierarchy: { kind: "value-array" },
441
+ tool_operations: { kind: "keyed-array", idField: "canonical_id" },
442
+ tool_operations_forbidden: { kind: "string-array" },
290
443
  };
291
444
  /** Canonical identity join for a hierarchy triple (the `value-array` kind). */
292
445
  function hierarchyKey(h) {
@@ -336,6 +489,20 @@ function hierarchyKey(h) {
336
489
  * @throws ValidationError (from `writeConfig`) on a SHAPE violation.
337
490
  */
338
491
  export function amendConfigEntry(cwd, registry, operation, key, entry, ctx, opts) {
492
+ return amendConfigEntryForDir(resolveContextDir(cwd), registry, operation, key, entry, ctx, opts);
493
+ }
494
+ /**
495
+ * Dir-targeted twin of `amendConfigEntry` (Cycle-1 `*ForDir` pattern). Carries
496
+ * the FULL scoped add / replace / remove body; `amendConfigEntry` delegates here
497
+ * via `resolveContextDir(cwd)`, so a cwd call is byte-identical to a ForDir call
498
+ * on the resolved active dir. Targets `<substrateDir>/config.json` directly via
499
+ * `loadConfigForDir` / `writeConfigForDir` (NO pointer resolution) so the
500
+ * Cycle-10 canonicalizer can register block_kinds / relation_types into a
501
+ * non-active work-dupe substrate in place. Both guard tiers
502
+ * (OP-CORRECTNESS + SHAPE-via-AJV) and the deep-clone-then-write-once discipline
503
+ * are identical to the documented `amendConfigEntry` contract above.
504
+ */
505
+ export function amendConfigEntryForDir(substrateDir, registry, operation, key, entry, ctx, opts) {
339
506
  // (1) Discriminator validation.
340
507
  const descriptor = REGISTRY_DESCRIPTORS[registry];
341
508
  if (!descriptor) {
@@ -353,7 +520,7 @@ export function amendConfigEntry(cwd, registry, operation, key, entry, ctx, opts
353
520
  throw new Error(`amendConfigEntry: operation '${op}' requires an entry payload`);
354
521
  }
355
522
  // (2) Load + (3) deep clone (mutate the clone, write once at the end).
356
- const config = loadConfig(cwd);
523
+ const config = loadConfigForDir(substrateDir);
357
524
  if (!config) {
358
525
  throw new Error("amendConfigEntry: no config.json");
359
526
  }
@@ -495,7 +662,7 @@ export function amendConfigEntry(cwd, registry, operation, key, entry, ctx, opts
495
662
  validateFromFile(bundledSchemaPath("config"), nextConfig, "config.json (dry-run)");
496
663
  }
497
664
  else {
498
- writeConfig(cwd, nextConfig, ctx);
665
+ writeConfigForDir(substrateDir, nextConfig, ctx);
499
666
  }
500
667
  }
501
668
  // (6) Outcome.
@@ -615,9 +782,10 @@ export function walkDescendants(parentId, relationType, edges) {
615
782
  continue;
616
783
  visited.add(node);
617
784
  for (const e of edges) {
618
- if (e.parent === node && e.relation_type === relationType) {
619
- out.push(e.child);
620
- stack.push(e.child);
785
+ if (endpointKey(e.parent) === node && e.relation_type === relationType) {
786
+ const childKey = endpointKey(e.child);
787
+ out.push(childKey);
788
+ stack.push(childKey);
621
789
  }
622
790
  }
623
791
  }
@@ -649,9 +817,10 @@ export function walkAncestors(itemId, relationType, edges) {
649
817
  continue;
650
818
  visited.add(node);
651
819
  for (const e of edges) {
652
- if (e.child === node && e.relation_type === relationType) {
653
- out.push(e.parent);
654
- stack.push(e.parent);
820
+ if (endpointKey(e.child) === node && e.relation_type === relationType) {
821
+ const parentKey = endpointKey(e.parent);
822
+ out.push(parentKey);
823
+ stack.push(parentKey);
655
824
  }
656
825
  }
657
826
  }
@@ -687,13 +856,13 @@ export function walkAncestors(itemId, relationType, edges) {
687
856
  */
688
857
  export function findReferences(itemId, edges, direction = "both") {
689
858
  if (direction === "inbound") {
690
- return edges.filter((e) => e.child === itemId);
859
+ return edges.filter((e) => endpointKey(e.child) === itemId);
691
860
  }
692
861
  if (direction === "outbound") {
693
- return edges.filter((e) => e.parent === itemId);
862
+ return edges.filter((e) => endpointKey(e.parent) === itemId);
694
863
  }
695
864
  // "both": single-pass predicate; self-loop matches once via the OR.
696
- return edges.filter((e) => e.child === itemId || e.parent === itemId);
865
+ return edges.filter((e) => endpointKey(e.child) === itemId || endpointKey(e.parent) === itemId);
697
866
  }
698
867
  /**
699
868
  * Project items into bins under a lens. Items reachable through `lensEdges`
@@ -709,9 +878,15 @@ export function groupByLens(items, lens, lensEdges) {
709
878
  const itemById = new Map(items.map((i) => [i.id, i]));
710
879
  const placedIds = new Set();
711
880
  for (const e of lensEdges) {
712
- const item = itemById.get(e.child);
713
- if (item && lens.bins.includes(e.parent)) {
714
- grouped.get(e.parent)?.push(item);
881
+ const item = itemById.get(endpointKey(e.child));
882
+ // Parent bin label: a structured {kind:"lens_bin"} contributes its bin; a
883
+ // legacy string parent stays bin-or-item-ambiguous and is resolved by
884
+ // `lens.bins.includes` (endpointBin is null for strings → fall back to the
885
+ // string key). A structured ITEM parent (endpointBin null, non-bin key)
886
+ // simply fails the includes test and is left for the (uncategorized) pass.
887
+ const binLabel = endpointBin(e.parent) ?? endpointKey(e.parent);
888
+ if (item && lens.bins.includes(binLabel)) {
889
+ grouped.get(binLabel)?.push(item);
715
890
  placedIds.add(item.id);
716
891
  }
717
892
  }
@@ -755,23 +930,7 @@ export function listUncategorized(lens, grouped) {
755
930
  });
756
931
  return { uncategorized, suggestionTemplate };
757
932
  }
758
- // ── validateRelations ────────────────────────────────────────────────────────
759
- /**
760
- * Validate authored edges against `config` registries (lenses + hierarchy +
761
- * relation_types) and the cross-block id index supplied by the caller.
762
- * Emits seven structured issue codes:
763
- * - edge_unknown_relation_type
764
- * - edge_parent_not_in_bins (lens edges)
765
- * - edge_unresolved_parent / edge_parent_wrong_block (hierarchy edges)
766
- * - edge_unresolved_child / edge_child_wrong_block (lens or hierarchy)
767
- * - edge_cycle_detected (DFS recursion-stack on hierarchy relation_types)
768
- *
769
- * `itemsByBlock` is indexed by `BlockKindDecl.canonical_id` for hierarchy
770
- * checks (parent / child must reside in the declared block) and by
771
- * `LensSpec.target` for lens checks. Callers supply the index — this
772
- * module does not read blocks itself, keeping it independent of block-api.
773
- */
774
- export function validateRelations(config, relations, itemsByBlock) {
933
+ export function validateRelations(config, relations, itemsByBlock, resolve) {
775
934
  const issues = [];
776
935
  const lensesByRelType = new Map();
777
936
  for (const l of config.lenses ?? []) {
@@ -796,6 +955,26 @@ export function validateRelations(config, relations, itemsByBlock) {
796
955
  for (const edge of relations) {
797
956
  const lens = lensesByRelType.get(edge.relation_type);
798
957
  const hier = hierarchyByRelType.get(edge.relation_type);
958
+ // Consumer-layer node keys (the load-bearing pivot): string-typed for
959
+ // byte-identical diagnostics. A structured same-substrate item normalizes
960
+ // to its refname; a foreign item / project: string keys on its foreign
961
+ // string → idIndex miss → unresolved, exactly as today's sentinels.
962
+ const parentKey = endpointKey(edge.parent);
963
+ const childKey = endpointKey(edge.child);
964
+ // Block-resolution for the parent/child item endpoints. When a `resolve`
965
+ // hook is supplied (validateContext's F2 resolver), a resolved active OR
966
+ // foreign item contributes its `loc.block` — so a cross-substrate child
967
+ // under a lens/hierarchy relation_type resolves to its foreign block rather
968
+ // than missing the active-only `idIndex`. When OMITTED (test callers /
969
+ // standalone use), this falls back to the inline `idIndex.get(key)` —
970
+ // byte-identical to the pre-F2 behavior. (`undefined` from either path means
971
+ // "not found in any loaded block", exactly as before.)
972
+ const resolveBlock = (endpoint, key) => {
973
+ if (!resolve)
974
+ return idIndex.get(key);
975
+ const r = resolve(endpoint);
976
+ return r.status === "active" || r.status === "foreign" ? r.loc?.block : undefined;
977
+ };
799
978
  if (!declaredRelTypes.has(edge.relation_type)) {
800
979
  issues.push({
801
980
  code: "edge_unknown_relation_type",
@@ -806,57 +985,62 @@ export function validateRelations(config, relations, itemsByBlock) {
806
985
  continue;
807
986
  }
808
987
  if (lens) {
809
- if (!lens.bins.includes(edge.parent)) {
988
+ // Bin label: a structured {kind:"lens_bin"} parent contributes its bin;
989
+ // a legacy string parent is bin-or-item ambiguous, resolved by includes.
990
+ // A structured ITEM parent (endpointBin null) keys on its refname/oid and
991
+ // fails the includes test → edge_parent_not_in_bins (never an idIndex.get).
992
+ const parentBin = endpointBin(edge.parent) ?? parentKey;
993
+ if (!lens.bins.includes(parentBin)) {
810
994
  issues.push({
811
995
  code: "edge_parent_not_in_bins",
812
- message: `lens-edge parent '${edge.parent}' is not in lens '${lens.id}' bins`,
996
+ message: `lens-edge parent '${parentBin}' is not in lens '${lens.id}' bins`,
813
997
  edge,
814
998
  });
815
999
  }
816
- const childBlock = idIndex.get(edge.child);
1000
+ const childBlock = resolveBlock(edge.child, childKey);
817
1001
  if (!childBlock) {
818
1002
  issues.push({
819
1003
  code: "edge_unresolved_child",
820
- message: `lens-edge child '${edge.child}' not found in any loaded block`,
1004
+ message: `lens-edge child '${childKey}' not found in any loaded block`,
821
1005
  edge,
822
1006
  });
823
1007
  }
824
1008
  else if (lens.target && childBlock !== lens.target) {
825
1009
  issues.push({
826
1010
  code: "edge_child_wrong_block",
827
- message: `lens-edge child '${edge.child}' in block '${childBlock}', expected lens.target '${lens.target}'`,
1011
+ message: `lens-edge child '${childKey}' in block '${childBlock}', expected lens.target '${lens.target}'`,
828
1012
  edge,
829
1013
  });
830
1014
  }
831
1015
  }
832
1016
  if (hier) {
833
- const parentBlock = idIndex.get(edge.parent);
1017
+ const parentBlock = resolveBlock(edge.parent, parentKey);
834
1018
  if (!parentBlock) {
835
1019
  issues.push({
836
1020
  code: "edge_unresolved_parent",
837
- message: `hierarchy-edge parent '${edge.parent}' not found in any loaded block`,
1021
+ message: `hierarchy-edge parent '${parentKey}' not found in any loaded block`,
838
1022
  edge,
839
1023
  });
840
1024
  }
841
1025
  else if (parentBlock !== hier.parent_block) {
842
1026
  issues.push({
843
1027
  code: "edge_parent_wrong_block",
844
- message: `hierarchy-edge parent '${edge.parent}' in block '${parentBlock}', expected '${hier.parent_block}'`,
1028
+ message: `hierarchy-edge parent '${parentKey}' in block '${parentBlock}', expected '${hier.parent_block}'`,
845
1029
  edge,
846
1030
  });
847
1031
  }
848
- const childBlock = idIndex.get(edge.child);
1032
+ const childBlock = resolveBlock(edge.child, childKey);
849
1033
  if (!childBlock) {
850
1034
  issues.push({
851
1035
  code: "edge_unresolved_child",
852
- message: `hierarchy-edge child '${edge.child}' not found in any loaded block`,
1036
+ message: `hierarchy-edge child '${childKey}' not found in any loaded block`,
853
1037
  edge,
854
1038
  });
855
1039
  }
856
1040
  else if (childBlock !== hier.child_block) {
857
1041
  issues.push({
858
1042
  code: "edge_child_wrong_block",
859
- message: `hierarchy-edge child '${edge.child}' in block '${childBlock}', expected '${hier.child_block}'`,
1043
+ message: `hierarchy-edge child '${childKey}' in block '${childBlock}', expected '${hier.child_block}'`,
860
1044
  edge,
861
1045
  });
862
1046
  }
@@ -884,9 +1068,13 @@ export function validateRelations(config, relations, itemsByBlock) {
884
1068
  for (const e of relations) {
885
1069
  if (e.relation_type !== rt)
886
1070
  continue;
887
- const arr = adj.get(e.parent) ?? [];
888
- arr.push(e.child);
889
- adj.set(e.parent, arr);
1071
+ // Cycle adjacency is keyed on the consumer node key (refname for items)
1072
+ // so a structured item and a legacy same-refname string collapse to one
1073
+ // graph node — byte-identical cycle diagnostics on string data.
1074
+ const pk = endpointKey(e.parent);
1075
+ const arr = adj.get(pk) ?? [];
1076
+ arr.push(endpointKey(e.child));
1077
+ adj.set(pk, arr);
890
1078
  }
891
1079
  const visited = new Set();
892
1080
  const onStack = new Set();
@@ -962,7 +1150,7 @@ export function resolveComposition(cwd, lens) {
962
1150
  }
963
1151
  const ctx = loadContext(cwd);
964
1152
  if (!ctx.config) {
965
- throw new Error("resolveComposition: no .project/config.json");
1153
+ throw new Error("resolveComposition: no <substrate-dir>/config.json");
966
1154
  }
967
1155
  return resolveCompositionInternal(cwd, lens, ctx.config, new Set());
968
1156
  }