@davidorex/pi-context 0.31.0 → 0.33.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 (77) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +16 -14
  3. package/dist/block-api.d.ts +20 -33
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +154 -3
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/content-hash.d.ts +9 -0
  8. package/dist/content-hash.d.ts.map +1 -1
  9. package/dist/content-hash.js +11 -0
  10. package/dist/content-hash.js.map +1 -1
  11. package/dist/context-sdk.d.ts +157 -39
  12. package/dist/context-sdk.d.ts.map +1 -1
  13. package/dist/context-sdk.js +896 -251
  14. package/dist/context-sdk.js.map +1 -1
  15. package/dist/context.d.ts +158 -5
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +75 -5
  18. package/dist/context.js.map +1 -1
  19. package/dist/index.d.ts +151 -19
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +590 -97
  22. package/dist/index.js.map +1 -1
  23. package/dist/lens-view.d.ts +0 -5
  24. package/dist/lens-view.d.ts.map +1 -1
  25. package/dist/lens-view.js +43 -1
  26. package/dist/lens-view.js.map +1 -1
  27. package/dist/migration-registry-loader.d.ts +20 -12
  28. package/dist/migration-registry-loader.d.ts.map +1 -1
  29. package/dist/migration-registry-loader.js +46 -17
  30. package/dist/migration-registry-loader.js.map +1 -1
  31. package/dist/migrations-store.d.ts +45 -18
  32. package/dist/migrations-store.d.ts.map +1 -1
  33. package/dist/migrations-store.js +56 -22
  34. package/dist/migrations-store.js.map +1 -1
  35. package/dist/ops-registry.d.ts +18 -0
  36. package/dist/ops-registry.d.ts.map +1 -1
  37. package/dist/ops-registry.js +474 -130
  38. package/dist/ops-registry.js.map +1 -1
  39. package/dist/promote-item.d.ts.map +1 -1
  40. package/dist/promote-item.js +41 -12
  41. package/dist/promote-item.js.map +1 -1
  42. package/dist/roadmap-plan.d.ts +121 -99
  43. package/dist/roadmap-plan.d.ts.map +1 -1
  44. package/dist/roadmap-plan.js +281 -345
  45. package/dist/roadmap-plan.js.map +1 -1
  46. package/dist/status-vocab.d.ts +12 -2
  47. package/dist/status-vocab.d.ts.map +1 -1
  48. package/dist/status-vocab.js +14 -1
  49. package/dist/status-vocab.js.map +1 -1
  50. package/package.json +2 -2
  51. package/samples/blocks/milestone.json +3 -0
  52. package/samples/blocks/session-notes.json +1 -0
  53. package/samples/conception.json +315 -15
  54. package/samples/migrations.json +8 -0
  55. package/samples/schemas/context-contracts.schema.json +4 -0
  56. package/samples/schemas/conventions.schema.json +4 -0
  57. package/samples/schemas/decisions.schema.json +4 -0
  58. package/samples/schemas/features.schema.json +4 -0
  59. package/samples/schemas/framework-gaps.schema.json +9 -0
  60. package/samples/schemas/issues.schema.json +28 -0
  61. package/samples/schemas/layer-plans.schema.json +4 -0
  62. package/samples/schemas/milestone.schema.json +79 -0
  63. package/samples/schemas/phase.schema.json +4 -0
  64. package/samples/schemas/rationale.schema.json +4 -0
  65. package/samples/schemas/requirements.schema.json +4 -0
  66. package/samples/schemas/research.schema.json +45 -2
  67. package/samples/schemas/session-notes.schema.json +89 -0
  68. package/samples/schemas/spec-reviews.schema.json +4 -0
  69. package/samples/schemas/story.schema.json +8 -0
  70. package/samples/schemas/tasks.schema.json +4 -0
  71. package/samples/schemas/verification.schema.json +4 -0
  72. package/samples/schemas/work-orders.schema.json +4 -0
  73. package/schemas/config.schema.json +90 -4
  74. package/schemas/migrations.schema.json +25 -0
  75. package/skill-narrative.md +8 -6
  76. package/skills/pi-context/SKILL.md +70 -63
  77. package/skills/pi-context/references/bundled-resources.md +5 -1
package/dist/index.js CHANGED
@@ -6,24 +6,27 @@ import { execSync } from "node:child_process";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { forEachBlockArray, readBlock, readBlockForDir, writeBlockForDir } from "./block-api.js";
9
+ import { forEachBlockArray, readBlock, readBlockForDir, updateItemInBlock, writeBlockForDir } from "./block-api.js";
10
10
  import { computeContentHash, computeFileContentHash } from "./content-hash.js";
11
- import { adoptConception, installedBlockDestPath, installedSchemaDestPath, loadConfig, loadContext, mergeCatalogRegistries, reconcileActiveSubstrateRegistration, writeConfig, writeSkeletonConfig, } from "./context.js";
12
- import { BootstrapNotFoundError, flipBootstrapPointer, migrationsPathForDir, resolveContextDir, SCHEMAS_DIR, schemasDir, tryResolveContextDir, writeBootstrapPointer, } from "./context-dir.js";
13
- import { contextState, findAppendableBlocks, validateContext } from "./context-sdk.js";
11
+ import { adoptConception, installedBlockDestPath, installedSchemaDestPath, loadConfig, loadContext, loadRelations, mergeCatalogRegistries, reconcileActiveSubstrateRegistration, writeConfig, writeSkeletonConfig, } from "./context.js";
12
+ import { BootstrapNotFoundError, flipBootstrapPointer, migrationsPathForDir, mintSubstrateId, pendingBlockedPathForDir, resolveContextDir, SCHEMAS_DIR, schemasDir, tryResolveContextDir, writeBootstrapPointer, } from "./context-dir.js";
13
+ import { registerSubstrate } from "./context-registry.js";
14
+ import { buildIdIndex, contextState, derivedRollupComplete, evaluateStalenessCandidates, findAppendableBlocks, validateContext, } from "./context-sdk.js";
14
15
  import { cleanGitEnv } from "./git-env.js";
15
16
  import { buildCurationSuggestions, loadLensView, renderLensView } from "./lens-view.js";
16
17
  import { buildFreshRegistryWithChain, getProjectMigrationRegistryForDir, invalidateMigrationRegistryForDir, } from "./migration-registry-loader.js";
17
- import { appendMigrationDeclForDir, loadMigrationsFileForDir } from "./migrations-store.js";
18
+ import { appendMigrationDeclForDir, loadMigrationsFileForDir, seedCatalogConfigMigrationDecls, } from "./migrations-store.js";
18
19
  import { getObject, putObject } from "./object-store.js";
19
20
  import { registerAll } from "./ops-registry.js";
20
21
  import { buildOrientationBlock, skillsDir } from "./orientation.js";
21
22
  import { loadPendingBlockedForDir, reconcilePendingBlockedForDir, } from "./pending-blocked-store.js";
22
- import { listRoadmaps, loadRoadmap, renderRoadmap, validateRoadmaps } from "./roadmap-plan.js";
23
+ import { discoverArrayKey } from "./read-element.js";
24
+ import { loadRoadmap, renderRoadmap, validateRoadmap } from "./roadmap-plan.js";
23
25
  import { mergeSchema } from "./schema-merge.js";
24
26
  import { runMigrations } from "./schema-migrations.js";
25
27
  import { ValidationError, validate, validateBlockWithMigrationForDir } from "./schema-validator.js";
26
28
  import { writeSchemaCheckedForDir } from "./schema-write.js";
29
+ import { resolveStateDerivation, resolveStatusVocabulary as resolveStatusVocab } from "./status-vocab.js";
27
30
  import { checkForUpdates } from "./update-check.js";
28
31
  // ── Command handlers ────────────────────────────────────────────────────────
29
32
  /**
@@ -647,8 +650,42 @@ function simulateResyncOutcome(destRoot, samplesRoot, sourceFile, destFile, name
647
650
  const catalogVersion = readDeclaredVersion(sourceFile);
648
651
  const installedVersion = readDeclaredVersion(destFile);
649
652
  // Arm 1 — same version (or either unreadable / non-versioned): no transition
650
- // to migrate across, the live path overwrites verbatim resynced, no decls.
653
+ // to migrate across. Mirrors the live path 1:1 a populated block is
654
+ // re-validated in memory against the incoming catalog body, and a failure
655
+ // predicts blocked (validation-failed, per-item failures); otherwise the
656
+ // live path overwrites verbatim → resynced, no decls.
651
657
  if (installedVersion === catalogVersion || catalogVersion === undefined || installedVersion === undefined) {
658
+ const sameVersionBlockFile = installedBlockDestPath(destRoot, name);
659
+ if (fs.existsSync(sameVersionBlockFile)) {
660
+ let sameVersionBlockData;
661
+ let sameVersionHasItems = false;
662
+ try {
663
+ sameVersionBlockData = JSON.parse(fs.readFileSync(sameVersionBlockFile, "utf-8"));
664
+ forEachBlockArray(sameVersionBlockData, (_arrayKey, arr) => {
665
+ if (arr.length > 0)
666
+ sameVersionHasItems = true;
667
+ });
668
+ }
669
+ catch {
670
+ sameVersionHasItems = true; // unreadable — same safety default as the live path
671
+ }
672
+ if (sameVersionHasItems) {
673
+ try {
674
+ const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
675
+ validate(catalogSchema, sameVersionBlockData, name);
676
+ }
677
+ catch (err) {
678
+ const failures = err instanceof ValidationError
679
+ ? mapValidationFailures(err.errors, sameVersionBlockData)
680
+ : [{ instancePath: "", keyword: "error", message: String(err) }];
681
+ return {
682
+ outcome: "blocked",
683
+ wouldRegister: [],
684
+ detail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
685
+ };
686
+ }
687
+ }
688
+ }
652
689
  return { outcome: "resynced", wouldRegister: [] };
653
690
  }
654
691
  // Arm 2 — version bump with NO shipped chain reaching the catalog version: the
@@ -876,11 +913,67 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
876
913
  return entry;
877
914
  };
878
915
  // (A) Same version (or either version unreadable / non-versioned): there is no
879
- // version transition to migrate across, so the drift is description-only
880
- // safe to overwrite the schema verbatim. Items are unaffected by a same-
881
- // version schema body change (the version is the migration contract). No
916
+ // version transition to migrate across. A verbatim overwrite is safe only
917
+ // when the existing block items still conform to the INCOMING catalog body —
918
+ // a same-version change that narrows validity (a dropped property under
919
+ // additionalProperties:false, an added required, a narrowed enum) would
920
+ // otherwise silently invalidate the block. Populated blocks are therefore
921
+ // re-validated against the incoming body IN MEMORY before any write, and a
922
+ // failure refuses the resync (blocked, per-item failures, pending-blocked
923
+ // recorded) — mirroring the version-bump path's validate-or-refuse
924
+ // discipline. Nothing has been written when the refusal fires, so the
925
+ // schema file, block file, and migrations.json are all byte-unchanged. No
882
926
  // migration is registered on this arm, so it reports an empty decl list.
883
927
  if (installedVersion === catalogVersion || catalogVersion === undefined || installedVersion === undefined) {
928
+ const sameVersionBlockFile = installedBlockDestPath(destRoot, name);
929
+ if (fs.existsSync(sameVersionBlockFile)) {
930
+ let sameVersionBlockData;
931
+ let sameVersionHasItems = false;
932
+ try {
933
+ sameVersionBlockData = JSON.parse(fs.readFileSync(sameVersionBlockFile, "utf-8"));
934
+ forEachBlockArray(sameVersionBlockData, (_arrayKey, arr) => {
935
+ if (arr.length > 0)
936
+ sameVersionHasItems = true;
937
+ });
938
+ }
939
+ catch {
940
+ // Unreadable block — safety default: treat as populated and route it
941
+ // through the validate path (which throws → blocked).
942
+ sameVersionHasItems = true;
943
+ }
944
+ if (sameVersionHasItems) {
945
+ try {
946
+ const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
947
+ validate(catalogSchema, sameVersionBlockData, name);
948
+ }
949
+ catch (err) {
950
+ // Classify the refusal (FGAP-115): only an AJV ValidationError is an
951
+ // item-validation failure; any other throw (unreadable catalog body,
952
+ // a write-boundary precondition) is `write-failed` — no pending entry,
953
+ // so the marker/pending pipeline (validation-only consequences) never
954
+ // fires for it.
955
+ if (err instanceof ValidationError) {
956
+ const failures = mapValidationFailures(err.errors, sameVersionBlockData);
957
+ return {
958
+ status: "blocked",
959
+ registeredMigrations: [],
960
+ blockedDetail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
961
+ pendingEntry: buildPendingEntry("validation-failed", [], failures),
962
+ };
963
+ }
964
+ return {
965
+ status: "blocked",
966
+ registeredMigrations: [],
967
+ blockedDetail: {
968
+ reason: "write-failed",
969
+ from: installedVersion,
970
+ to: catalogVersion,
971
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
972
+ },
973
+ };
974
+ }
975
+ }
976
+ }
884
977
  fs.copyFileSync(sourceFile, destFile);
885
978
  return { status: "resynced", registeredMigrations: [] };
886
979
  }
@@ -910,14 +1003,14 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
910
1003
  // throws on collision). Registration is required BEFORE the validate+migrate
911
1004
  // call so the loaded registry carries the forward edge.
912
1005
  const existing = loadMigrationsFileForDir(destRoot);
913
- const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName}${m.fromVersion}`));
1006
+ const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName}\u0000${m.fromVersion}`));
914
1007
  // Accumulate the decls THIS call actually appends (the not-already-present
915
1008
  // subset), reported as the { schema, from, to } shape for the caller to
916
1009
  // surface (FGAP-050). On the blocked rollback path below this list is
917
1010
  // discarded and [] is returned (post-rollback truth: nothing stuck).
918
1011
  const registeredMigrations = [];
919
1012
  for (const decl of chain) {
920
- const key = `${decl.schemaName}${decl.fromVersion}`;
1013
+ const key = `${decl.schemaName}\u0000${decl.fromVersion}`;
921
1014
  if (present.has(key))
922
1015
  continue;
923
1016
  appendMigrationDeclForDir(destRoot, decl);
@@ -998,14 +1091,30 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
998
1091
  // blocked detail records the validation-failed reason + the version pair + the
999
1092
  // per-item failures (a single synthetic failure for a non-AJV throw) so the
1000
1093
  // caller surfaces WHY the resync refused (TASK-048).
1001
- const failures = err instanceof ValidationError
1002
- ? mapValidationFailures(err.errors, blockData)
1003
- : [{ instancePath: "", keyword: "error", message: String(err) }];
1094
+ // Classify the refusal (FGAP-115): only an AJV ValidationError means the
1095
+ // migrated items fail the catalog schema. Any other throw — the mandatory
1096
+ // identity stamp refusing a substrate with no substrate_id, an I/O failure —
1097
+ // is `write-failed`: the items were never flagged invalid, so no pending
1098
+ // entry is built and the marker/pending pipeline (validation-only
1099
+ // consequences) never fires.
1100
+ if (err instanceof ValidationError) {
1101
+ const failures = mapValidationFailures(err.errors, blockData);
1102
+ return {
1103
+ status: "blocked",
1104
+ registeredMigrations: [],
1105
+ blockedDetail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
1106
+ pendingEntry: buildPendingEntry("validation-failed", chain, failures),
1107
+ };
1108
+ }
1004
1109
  return {
1005
1110
  status: "blocked",
1006
1111
  registeredMigrations: [],
1007
- blockedDetail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
1008
- pendingEntry: buildPendingEntry("validation-failed", chain, failures),
1112
+ blockedDetail: {
1113
+ reason: "write-failed",
1114
+ from: installedVersion,
1115
+ to: catalogVersion,
1116
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
1117
+ },
1009
1118
  };
1010
1119
  }
1011
1120
  }
@@ -1020,6 +1129,43 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
1020
1129
  * - Sources missing from the samples catalog are reported as "notFound".
1021
1130
  * - Empty install lists are not an error — the result is a clean no-op.
1022
1131
  */
1132
+ /**
1133
+ * Ceremony-entry identity establishment (DEC-0020 — FGAP-033). When the
1134
+ * substrate's config.json lacks a `substrate_id`, mint one (the SAME
1135
+ * `mintSubstrateId` init uses), persist it through the sanctioned config write
1136
+ * path, and register it in the project registry (the SAME `registerSubstrate`
1137
+ * call init/accept-all make) — BEFORE the ceremony's first write that stamps
1138
+ * identity, so a pre-identity substrate HEALS on the sanctioned ceremony
1139
+ * instead of refusing at the stamping guard. Called at the entry of every
1140
+ * substrate-lifecycle ceremony that can reach an identity-stamping write
1141
+ * (update, install, resolve-blocked), at the same seam as the config-migration
1142
+ * seeding. Returns the minted id when establishment happened (the ceremony
1143
+ * reports it in its result), undefined when identity was already established
1144
+ * (never re-mints — the on-disk id is immutable) or no config is loadable (the
1145
+ * ceremony's own config handling reports that). This is explicit
1146
+ * ceremony-boundary provisioning, not a lazy mint inside a block write —
1147
+ * `substrateIdForDir`'s loud guard stays load-bearing for any write reaching
1148
+ * it without identity (defense in depth, DEC-0012 preserved).
1149
+ */
1150
+ function establishSubstrateIdentityAtEntry(cwd, destRoot) {
1151
+ let config;
1152
+ try {
1153
+ config = loadConfig(cwd);
1154
+ }
1155
+ catch {
1156
+ return undefined; // unloadable config — the ceremony's own path surfaces it
1157
+ }
1158
+ if (!config)
1159
+ return undefined;
1160
+ const existing = config.substrate_id;
1161
+ if (typeof existing === "string" && /^sub-[0-9a-f]{16}$/.test(existing))
1162
+ return undefined;
1163
+ const substrate_id = mintSubstrateId();
1164
+ config.substrate_id = substrate_id;
1165
+ writeConfig(cwd, config);
1166
+ registerSubstrate(cwd, substrate_id, path.relative(cwd, destRoot) || ".", []);
1167
+ return substrate_id;
1168
+ }
1023
1169
  export function installContext(cwd, options = {}) {
1024
1170
  const result = {
1025
1171
  installed: [],
@@ -1038,6 +1184,16 @@ export function installContext(cwd, options = {}) {
1038
1184
  "No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
1039
1185
  return result;
1040
1186
  }
1187
+ // Seed the catalog's `config` migration chain (idempotent) before the config
1188
+ // read below, and before resyncSchema's pre-call migrations.json byte capture
1189
+ // — so a blocked-resync rollback restores to the seeded state, preserving the
1190
+ // seed.
1191
+ seedCatalogConfigMigrationDecls(destRoot);
1192
+ // Ceremony-entry identity establishment (DEC-0020) — before any write that
1193
+ // stamps identity (the --update resync path's writeBlockForDir).
1194
+ const establishedId = establishSubstrateIdentityAtEntry(cwd, destRoot);
1195
+ if (establishedId)
1196
+ result.substrateIdEstablished = establishedId;
1041
1197
  const config = loadConfig(cwd);
1042
1198
  if (!config) {
1043
1199
  result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
@@ -1228,7 +1384,11 @@ export function installContext(cwd, options = {}) {
1228
1384
  * re-sync, slice S3). Compares, per installed schema, the S2 install baseline
1229
1385
  * against the catalog's current schema file and the currently-installed schema
1230
1386
  * file, classifies the drift, and RETURNS the report. Writes NOTHING anywhere —
1231
- * no config write, no file copy, no mkdir; only reads.
1387
+ * no config write, no file copy, no mkdir; only reads. One designed exception:
1388
+ * like every ceremony entry point it seeds the catalog's `config` migration
1389
+ * chain into `migrations.json` (idempotent) before its first config read — the
1390
+ * heal semantic, consistent with idempotent re-init healing — so a
1391
+ * version-lagging legacy substrate is diagnosable instead of throwing.
1232
1392
  *
1233
1393
  * For each `config.installed_schemas` entry:
1234
1394
  * - baseline = config.installed_from?.assets?.[name]?.content_hash
@@ -1260,6 +1420,9 @@ export function checkStatus(cwd) {
1260
1420
  if (destRoot === null) {
1261
1421
  return { perAsset, summary: emptySummary() };
1262
1422
  }
1423
+ // Ceremony seed (idempotent) before the config read below — the one
1424
+ // sanctioned write in this otherwise pure-read detector (see docstring).
1425
+ seedCatalogConfigMigrationDecls(destRoot);
1263
1426
  const config = loadConfig(cwd);
1264
1427
  if (!config) {
1265
1428
  return { perAsset, summary: emptySummary() };
@@ -1470,6 +1633,23 @@ export function updateContext(cwd, { dryRun = false } = {}) {
1470
1633
  "No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
1471
1634
  return result;
1472
1635
  }
1636
+ // Seed the catalog's `config` migration chain (idempotent) before the config
1637
+ // read below — every ceremony entry point seeds before its first config read,
1638
+ // so a version-lagging legacy substrate heals on update instead of throwing.
1639
+ seedCatalogConfigMigrationDecls(destRoot);
1640
+ // Ceremony-entry identity establishment (DEC-0020) — before any write that
1641
+ // stamps identity (the version-bump resync's writeBlockForDir), so a
1642
+ // pre-identity substrate heals here instead of refusing at the stamping
1643
+ // guard. LIVE runs only: a dryRun performs no stamping write, so there is
1644
+ // nothing to establish ahead of — the preview keeps its writes-nothing
1645
+ // contract (beyond the idempotent ceremony seed), and its predicted
1646
+ // per-schema plan already matches the healed live outcome (the in-memory
1647
+ // simulation never consults the stamping precondition).
1648
+ if (!dryRun) {
1649
+ const establishedId = establishSubstrateIdentityAtEntry(cwd, destRoot);
1650
+ if (establishedId)
1651
+ result.substrateIdEstablished = establishedId;
1652
+ }
1473
1653
  const config = loadConfig(cwd);
1474
1654
  if (!config) {
1475
1655
  result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
@@ -1774,6 +1954,65 @@ export function updateContext(cwd, { dryRun = false } = {}) {
1774
1954
  // Catalog read / parse / config-load failure: leave registryAdditions empty
1775
1955
  // (its initialized value) and return the schema-update result unchanged.
1776
1956
  }
1957
+ // Partial-application legibility (FGAP-076): computed LAST, after every
1958
+ // channel above is final (the registry propagation just ran), so the field is
1959
+ // a pure derivation of the finished result. Populated exactly when the run
1960
+ // both refused something and applied something; a blocked run whose registry
1961
+ // additions (or other-schema resyncs) landed must never read as a no-op.
1962
+ // dryRun runs derive the same shape from the predicted plan.
1963
+ const ra = result.registryAdditions;
1964
+ const registryAdditionCount = ra.relation_types.length + ra.invariants.length + ra.block_kinds.length + ra.lenses.length;
1965
+ const refusedSomething = result.blocked.length > 0 || result.refused.length > 0 || result.conflicts.length > 0;
1966
+ const appliedSomething = result.resynced.length > 0 ||
1967
+ result.migrated.length > 0 ||
1968
+ result.merged.length > 0 ||
1969
+ result.migrationsRegistered.length > 0 ||
1970
+ registryAdditionCount > 0;
1971
+ if (refusedSomething && appliedSomething) {
1972
+ const appliedParts = [];
1973
+ if (registryAdditionCount > 0) {
1974
+ const perRegistry = ["relation_types", "invariants", "block_kinds", "lenses"]
1975
+ .filter((k) => ra[k].length > 0)
1976
+ .map((k) => `${ra[k].length} ${k}`)
1977
+ .join(", ");
1978
+ appliedParts.push(`registry additions (${perRegistry})`);
1979
+ }
1980
+ if (result.resynced.length > 0)
1981
+ appliedParts.push(`resynced: ${result.resynced.join(", ")}`);
1982
+ if (result.migrated.length > 0)
1983
+ appliedParts.push(`migrated: ${result.migrated.join(", ")}`);
1984
+ if (result.merged.length > 0)
1985
+ appliedParts.push(`merged: ${result.merged.join(", ")}`);
1986
+ if (result.migrationsRegistered.length > 0)
1987
+ appliedParts.push(`migration declarations registered: ${result.migrationsRegistered.map((m) => `${m.schema} ${m.from}->${m.to}`).join(", ")}`);
1988
+ const notAppliedParts = [];
1989
+ if (result.blocked.length > 0)
1990
+ notAppliedParts.push(`blocked: ${result.blocked
1991
+ .map((name) => {
1992
+ const detail = result.blockedDetail.find((d) => d.name === name);
1993
+ return detail ? `'${name}' (${detail.reason})` : `'${name}'`;
1994
+ })
1995
+ .join(", ")}`);
1996
+ if (result.refused.length > 0)
1997
+ notAppliedParts.push(`refused (local modifications preserved): ${result.refused.map((n) => `'${n}'`).join(", ")}`);
1998
+ if (result.conflicts.length > 0)
1999
+ notAppliedParts.push(`merge conflicts: ${result.conflicts.map((c) => `'${c.name}'`).join(", ")}`);
2000
+ result.partialApplication = {
2001
+ applied: {
2002
+ resynced: [...result.resynced],
2003
+ migrated: [...result.migrated],
2004
+ merged: [...result.merged],
2005
+ registryAdditions: ra,
2006
+ migrationsRegistered: [...result.migrationsRegistered],
2007
+ },
2008
+ notApplied: {
2009
+ blocked: [...result.blocked],
2010
+ refused: [...result.refused],
2011
+ conflicts: result.conflicts.map((c) => c.name),
2012
+ },
2013
+ summary: `PARTIAL APPLICATION${dryRun ? " (dryRun preview)" : ""}: applied — ${appliedParts.join("; ")}. Not applied — ${notAppliedParts.join("; ")}. The applied portion ${dryRun ? "WOULD change" : "changed"} the substrate even though schemas were refused${dryRun ? " (nothing written in this preview)" : ""}.`,
2014
+ };
2015
+ }
1777
2016
  return result;
1778
2017
  }
1779
2018
  /**
@@ -1881,6 +2120,10 @@ export function resolveConflict(cwd, name, schema, ctx) {
1881
2120
  if (destRoot === null) {
1882
2121
  throw new Error(`resolve-conflict: no active substrate resolved for '${cwd}'`);
1883
2122
  }
2123
+ // Ceremony seed (idempotent) — update-family class rule: every ceremony
2124
+ // entry point seeds the catalog's `config` migration chain before its first
2125
+ // config read (here reached via stampBaselineFromBody's loadConfig).
2126
+ seedCatalogConfigMigrationDecls(destRoot);
1884
2127
  const { samplesRoot, byId } = resolveCatalog();
1885
2128
  const kind = byId.get(name);
1886
2129
  if (!kind) {
@@ -1918,6 +2161,186 @@ export function resolveConflict(cwd, name, schema, ctx) {
1918
2161
  }
1919
2162
  return { schemaName: name, wroteSchema, baseAdvancedTo: catalogHash };
1920
2163
  }
2164
+ /**
2165
+ * `/context reconcile` engine (FEAT-011 — the repair half of the derived-status
2166
+ * invariant class). Computes every stored-vs-derived status delta for the
2167
+ * kinds the config's derived-status invariants declare (paired with their
2168
+ * `state_derivation.rollups` entries), using the SAME shared completeness
2169
+ * helper the state derivation and the invariant class use
2170
+ * (`derivedRollupComplete`) — the preview, the detector, and the repair
2171
+ * cannot disagree. A live run converges each delta through `updateItemInBlock`
2172
+ * (identity-stamped, AJV-validated, envelope-stamped, attested to the invoking
2173
+ * writer via `ctx`): a converge-write is not authoring — the written value IS
2174
+ * the derivation (the schema_version stamp argument). Ceremony discipline:
2175
+ * seeds the catalog config decls at entry, and a LIVE run establishes
2176
+ * substrate identity when absent (it reaches identity-stamping writes;
2177
+ * DEC-0020). Deltas are deduplicated per (block, id) across invariants.
2178
+ */
2179
+ export function reconcileContext(cwd, { dryRun = false } = {}, ctx) {
2180
+ const result = { dryRun, deltas: [], applied: 0, stalenessTransitions: [], stalenessApplied: 0 };
2181
+ const destRoot = tryResolveContextDir(cwd);
2182
+ if (destRoot === null) {
2183
+ result.error =
2184
+ "No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
2185
+ return result;
2186
+ }
2187
+ seedCatalogConfigMigrationDecls(destRoot);
2188
+ if (!dryRun) {
2189
+ const establishedId = establishSubstrateIdentityAtEntry(cwd, destRoot);
2190
+ if (establishedId)
2191
+ result.substrateIdEstablished = establishedId;
2192
+ }
2193
+ const config = loadConfig(cwd);
2194
+ if (!config) {
2195
+ result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
2196
+ return result;
2197
+ }
2198
+ result.deltas = computeDerivedStatusDeltas(cwd, config);
2199
+ result.stalenessTransitions = computeStalenessTransitions(cwd);
2200
+ if (dryRun)
2201
+ return result;
2202
+ result.applied = applyDerivedStatusDeltas(cwd, config, result.deltas, ctx);
2203
+ result.stalenessApplied = applyStalenessTransitions(cwd, config, result.stalenessTransitions, ctx);
2204
+ return result;
2205
+ }
2206
+ /**
2207
+ * The transition slice of the declared-baseline staleness sweep: candidates
2208
+ * from the SAME evaluateStalenessCandidates helper validate flags with,
2209
+ * restricted to the transition-eligible kind, each becoming a stored-status
2210
+ * delta to `stale`. Flag-only anchor-drift candidates never transition.
2211
+ */
2212
+ function computeStalenessTransitions(cwd) {
2213
+ const index = buildIdIndex(cwd);
2214
+ return evaluateStalenessCandidates(cwd, index)
2215
+ .filter((c) => c.kind === "staleness-candidate")
2216
+ .map((c) => ({
2217
+ id: c.id,
2218
+ block: c.block,
2219
+ from: String(index.byRefname.get(c.id)?.item.status ?? ""),
2220
+ to: "stale",
2221
+ reasons: c.reasons,
2222
+ }));
2223
+ }
2224
+ /** Apply the complete-to-stale transitions through the standard validated write path. */
2225
+ function applyStalenessTransitions(cwd, config, transitions, ctx) {
2226
+ let applied = 0;
2227
+ for (const t of transitions) {
2228
+ const arrayKey = config.block_kinds?.find((bk) => bk.canonical_id === t.block)?.array_key ??
2229
+ (() => {
2230
+ const data = readBlock(cwd, t.block);
2231
+ const discovered = discoverArrayKey(data);
2232
+ if (discovered === null) {
2233
+ throw new Error(`context-reconcile: no array key discoverable for block '${t.block}'`);
2234
+ }
2235
+ return discovered;
2236
+ })();
2237
+ updateItemInBlock(cwd, t.block, arrayKey, (item) => String(item.id) === t.id, { status: t.to }, ctx);
2238
+ applied += 1;
2239
+ }
2240
+ return applied;
2241
+ }
2242
+ /**
2243
+ * Read-only core of the derived-status repair: the stored-vs-derived delta set
2244
+ * for every kind a `derived-status` invariant declares (paired with its
2245
+ * `state_derivation.rollups` entry), deduplicated per (block, id). Shared by
2246
+ * `reconcileContext` and the post-write convergence hook so the ceremony and
2247
+ * the hook compute the identical set.
2248
+ */
2249
+ function computeDerivedStatusDeltas(cwd, config) {
2250
+ const deltas = [];
2251
+ const sd = resolveStateDerivation(cwd);
2252
+ const declared = (config.invariants ?? []).filter((inv) => inv.class === "derived-status");
2253
+ if (sd === null || declared.length === 0)
2254
+ return deltas;
2255
+ const index = buildIdIndex(cwd);
2256
+ const edges = loadRelations(cwd);
2257
+ const vocab = resolveStatusVocab(cwd);
2258
+ const bucketOf = (item) => vocab[String(item.status)] ?? "unknown";
2259
+ const rollupByKind = new Map(sd.rollups.map((r) => [r.kind, r]));
2260
+ const roleDir = new Map();
2261
+ for (const rt of config.relation_types ?? []) {
2262
+ if (rt.role_direction !== undefined)
2263
+ roleDir.set(rt.canonical_id, rt.role_direction);
2264
+ }
2265
+ const seen = new Set();
2266
+ for (const inv of declared) {
2267
+ const entry = rollupByKind.get(inv.block);
2268
+ if (entry === undefined)
2269
+ continue; // inert declaration — nothing to derive against
2270
+ for (const loc of index.byRefname.values()) {
2271
+ if (loc.block !== inv.block)
2272
+ continue;
2273
+ const key = `${inv.block} ${loc.id}`;
2274
+ if (seen.has(key))
2275
+ continue;
2276
+ const derived = derivedRollupComplete(index, edges, roleDir, rollupByKind, bucketOf, loc.id)
2277
+ ? entry.complete_status
2278
+ : entry.incomplete_status;
2279
+ const stored = String(loc.item.status);
2280
+ if (stored === derived)
2281
+ continue;
2282
+ seen.add(key);
2283
+ deltas.push({ id: loc.id, block: inv.block, from: stored, to: derived, invariant: inv.id });
2284
+ }
2285
+ }
2286
+ return deltas;
2287
+ }
2288
+ /** Apply a computed delta set through the standard validated write path. */
2289
+ function applyDerivedStatusDeltas(cwd, config, deltas, ctx) {
2290
+ let applied = 0;
2291
+ for (const delta of deltas) {
2292
+ const arrayKey = config.block_kinds?.find((bk) => bk.canonical_id === delta.block)?.array_key ??
2293
+ (() => {
2294
+ const data = readBlock(cwd, delta.block);
2295
+ const discovered = discoverArrayKey(data);
2296
+ if (discovered === null) {
2297
+ throw new Error(`context-reconcile: no array key discoverable for block '${delta.block}'`);
2298
+ }
2299
+ return discovered;
2300
+ })();
2301
+ updateItemInBlock(cwd, delta.block, arrayKey, (item) => String(item.id) === delta.id, { status: delta.to }, ctx);
2302
+ applied += 1;
2303
+ }
2304
+ return applied;
2305
+ }
2306
+ /**
2307
+ * Converge-on-write hook (FEAT-011 criterion 2 — FGAP-116's last mechanism,
2308
+ * the schema_version template applied to rollup-kind stored status). Invoked
2309
+ * AFTER a sanctioned mutating op's write lands (the op's own lock is already
2310
+ * released — sequential lock acquisition, no nesting): recomputes the
2311
+ * derived-status delta set with the SAME core the reconcile ceremony uses and
2312
+ * stamps every affected rollup item's stored status through the standard
2313
+ * validated write path — a converge-stamp is not authoring; the written value
2314
+ * IS the derivation. Config-driven opt-in: a substrate with no
2315
+ * `derived-status` invariant (or no `state_derivation.rollups`) computes an
2316
+ * empty set and performs no writes, so non-declaring and legacy substrates
2317
+ * are byte-identical to before. BEST-EFFORT by design: it never establishes
2318
+ * identity (a convergence side-effect minting a substrate_id would be a lazy
2319
+ * mint, DEC-0012) and never fails the caller's already-landed write — an
2320
+ * apply failure leaves the divergence for the `derived-status` invariant to
2321
+ * detect and `context-reconcile` (the ceremony with the establishment +
2322
+ * error surface) to repair. Returns the converged set, or null when there was
2323
+ * nothing to converge or the hook could not run.
2324
+ */
2325
+ export function convergeDerivedStatusAfterWrite(cwd, ctx) {
2326
+ try {
2327
+ const config = loadConfig(cwd);
2328
+ if (!config)
2329
+ return null;
2330
+ const deltas = computeDerivedStatusDeltas(cwd, config);
2331
+ if (deltas.length === 0)
2332
+ return null;
2333
+ applyDerivedStatusDeltas(cwd, config, deltas, ctx);
2334
+ return deltas;
2335
+ }
2336
+ catch {
2337
+ // Best-effort: the caller's write already landed; a convergence failure
2338
+ // (pre-identity stamping guard, unreadable sibling block) leaves the
2339
+ // divergence detectable by the derived-status invariant and repairable by
2340
+ // context-reconcile — never a failure of the triggering write.
2341
+ return null;
2342
+ }
2343
+ }
1921
2344
  /**
1922
2345
  * Blocked-resolution commit op (TASK-051 — FGAP-080) — the resolution half of
1923
2346
  * the blocked-resync loop `update` opens. After `update` REFUSES a catalog-ahead
@@ -1957,6 +2380,15 @@ export function resolveBlocked(cwd, name, ctx) {
1957
2380
  if (destRoot === null) {
1958
2381
  throw new Error(`resolve-blocked: no active substrate resolved for '${cwd}'`);
1959
2382
  }
2383
+ // Ceremony seed (idempotent) — update-family class rule: every ceremony
2384
+ // entry point seeds the catalog's `config` migration chain before its first
2385
+ // config read (here reached via stampBaselineFromBody's loadConfig on the
2386
+ // pass path).
2387
+ seedCatalogConfigMigrationDecls(destRoot);
2388
+ // Ceremony-entry identity establishment (DEC-0020) — before the commit's
2389
+ // writeBlockForDir stamping write, so a pre-identity substrate's resolution
2390
+ // heals identity instead of refusing mid-commit.
2391
+ const establishedId = establishSubstrateIdentityAtEntry(cwd, destRoot);
1960
2392
  const pending = loadPendingBlockedForDir(destRoot);
1961
2393
  const entry = pending?.entries.find((e) => e.name === name);
1962
2394
  if (!entry) {
@@ -2001,6 +2433,7 @@ export function resolveBlocked(cwd, name, ctx) {
2001
2433
  schemaName: name,
2002
2434
  resolved: false,
2003
2435
  failures: [{ instancePath: "", keyword: "error", message: String(err) }],
2436
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2004
2437
  };
2005
2438
  }
2006
2439
  const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
@@ -2034,54 +2467,106 @@ export function resolveBlocked(cwd, name, ctx) {
2034
2467
  const failures = err instanceof ValidationError
2035
2468
  ? mapValidationFailures(err.errors, blockData)
2036
2469
  : [{ instancePath: "", keyword: "error", message: String(err) }];
2037
- return { schemaName: name, resolved: false, failures };
2470
+ return {
2471
+ schemaName: name,
2472
+ resolved: false,
2473
+ failures,
2474
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2475
+ };
2038
2476
  }
2039
- // PASS — commit the resolution. (1) Register the chain decls not already on
2040
- // disk (the resyncSchema dedup), collecting the registered set.
2041
- const existing = loadMigrationsFileForDir(destRoot);
2042
- const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName} ${m.fromVersion}`));
2043
- const registeredMigrations = [];
2044
- for (const decl of entry.chain) {
2045
- const key = `${decl.schemaName} ${decl.fromVersion}`;
2046
- if (present.has(key))
2047
- continue;
2048
- appendMigrationDeclForDir(destRoot, decl, ctx);
2049
- present.add(key);
2050
- registeredMigrations.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
2477
+ // PASS — commit the resolution, ALL-OR-NOTHING (FGAP-115). The commit touches
2478
+ // up to five files; a throw partway (e.g. the mandatory identity stamp inside
2479
+ // writeBlockForDir refusing a substrate with no substrate_id) previously
2480
+ // stranded a partial commit schema advanced, markers stripped, block
2481
+ // unwritten, pending entry stale. Capture the raw pre-commit bytes of every
2482
+ // touched file up front; on any commit-phase throw restore them byte-exact
2483
+ // (per-component byte-exact refuse discipline, DEC-0017/DEC-0018), invalidate
2484
+ // the migration-registry cache warmed by the reverted decl appends, and
2485
+ // return resolved:false carrying the truthful failure. Object-store putObject
2486
+ // entries are content-addressed and harmless to leave behind.
2487
+ const commitFiles = [
2488
+ migrationsPathForDir(destRoot),
2489
+ installedSchemaDestPath(destRoot, name),
2490
+ blockFile,
2491
+ path.join(destRoot, "config.json"),
2492
+ pendingBlockedPathForDir(destRoot),
2493
+ ];
2494
+ const preCommitBytes = new Map();
2495
+ for (const f of commitFiles) {
2496
+ preCommitBytes.set(f, fs.existsSync(f) ? fs.readFileSync(f) : null);
2051
2497
  }
2052
- // (2) Write the target schema (replace — meta-validated, nested-id-guarded, atomic).
2053
- writeSchemaCheckedForDir(destRoot, name, targetBody, "replace", ctx);
2054
- // (3) Advance the migrated block's schema_version envelope to the target + persist
2055
- // it (skip when the block had no items — schema still written, base still advanced,
2056
- // mirroring the live no-items handling). Identity stamping re-runs on the write.
2057
- if (hasItems) {
2058
- // TASK-052 / FGAP-081 (oid stability): when the on-disk block carried markers,
2059
- // raw-write the STRIPPED text to the block file (tmp+rename) BEFORE
2060
- // writeBlockForDir, so the identity-stamp prior-read parses the marker-free
2061
- // on-disk file and preserves each item's oid (no re-mint; content_parent advances
2062
- // only on genuinely changed items).
2063
- if (wasMarked && strippedText !== undefined) {
2064
- const tmpPath = `${blockFile}.unmark-${process.pid}.tmp`;
2065
- fs.writeFileSync(tmpPath, strippedText);
2066
- fs.renameSync(tmpPath, blockFile);
2498
+ try {
2499
+ // (1) Register the chain decls not already on disk (the resyncSchema dedup),
2500
+ // collecting the registered set.
2501
+ const existing = loadMigrationsFileForDir(destRoot);
2502
+ const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName} ${m.fromVersion}`));
2503
+ const registeredMigrations = [];
2504
+ for (const decl of entry.chain) {
2505
+ const key = `${decl.schemaName} ${decl.fromVersion}`;
2506
+ if (present.has(key))
2507
+ continue;
2508
+ appendMigrationDeclForDir(destRoot, decl, ctx);
2509
+ present.add(key);
2510
+ registeredMigrations.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
2067
2511
  }
2068
- if (migrated &&
2069
- typeof migrated === "object" &&
2070
- !Array.isArray(migrated) &&
2071
- typeof migrated.schema_version === "string" &&
2072
- typeof targetVersion === "string") {
2073
- migrated.schema_version = targetVersion;
2512
+ // (2) Write the target schema (replace — meta-validated, nested-id-guarded, atomic).
2513
+ writeSchemaCheckedForDir(destRoot, name, targetBody, "replace", ctx);
2514
+ // (3) Advance the migrated block's schema_version envelope to the target + persist
2515
+ // it (skip when the block had no items — schema still written, base still advanced,
2516
+ // mirroring the live no-items handling). Identity stamping re-runs on the write.
2517
+ if (hasItems) {
2518
+ // TASK-052 / FGAP-081 (oid stability): when the on-disk block carried markers,
2519
+ // raw-write the STRIPPED text to the block file (tmp+rename) BEFORE
2520
+ // writeBlockForDir, so the identity-stamp prior-read parses the marker-free
2521
+ // on-disk file and preserves each item's oid (no re-mint; content_parent advances
2522
+ // only on genuinely changed items).
2523
+ if (wasMarked && strippedText !== undefined) {
2524
+ const tmpPath = `${blockFile}.unmark-${process.pid}.tmp`;
2525
+ fs.writeFileSync(tmpPath, strippedText);
2526
+ fs.renameSync(tmpPath, blockFile);
2527
+ }
2528
+ if (migrated &&
2529
+ typeof migrated === "object" &&
2530
+ !Array.isArray(migrated) &&
2531
+ typeof migrated.schema_version === "string" &&
2532
+ typeof targetVersion === "string") {
2533
+ migrated.schema_version = targetVersion;
2534
+ }
2535
+ writeBlockForDir(destRoot, name, migrated);
2074
2536
  }
2075
- writeBlockForDir(destRoot, name, migrated);
2537
+ // (4) Advance the merge base to the target body so a subsequent update converges
2538
+ // (base === catalog) instead of re-deriving drift.
2539
+ const baseAdvancedTo = stampBaselineFromBody(cwd, name, targetBody, targetVersion ?? "");
2540
+ // (5) Clear the resolved entry from pending-blocked.json (remove the file when
2541
+ // it becomes empty — no stale empty sidecar).
2542
+ const remaining = (pending?.entries ?? []).filter((e) => e.name !== name);
2543
+ reconcilePendingBlockedForDir(destRoot, remaining, ctx);
2544
+ return {
2545
+ schemaName: name,
2546
+ resolved: true,
2547
+ registeredMigrations,
2548
+ baseAdvancedTo,
2549
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2550
+ };
2551
+ }
2552
+ catch (err) {
2553
+ for (const [f, bytes] of preCommitBytes) {
2554
+ if (bytes === null) {
2555
+ if (fs.existsSync(f))
2556
+ fs.unlinkSync(f);
2557
+ }
2558
+ else {
2559
+ fs.writeFileSync(f, bytes);
2560
+ }
2561
+ }
2562
+ invalidateMigrationRegistryForDir(destRoot);
2563
+ return {
2564
+ schemaName: name,
2565
+ resolved: false,
2566
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
2567
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2568
+ };
2076
2569
  }
2077
- // (4) Advance the merge base to the target body so a subsequent update converges
2078
- // (base === catalog) instead of re-deriving drift.
2079
- const baseAdvancedTo = stampBaselineFromBody(cwd, name, targetBody, targetVersion ?? "");
2080
- // (5) Clear the resolved entry from pending-blocked.json (remove the file when
2081
- // it becomes empty — no stale empty sidecar).
2082
- const remaining = (pending?.entries ?? []).filter((e) => e.name !== name);
2083
- reconcilePendingBlockedForDir(destRoot, remaining, ctx);
2084
- return { schemaName: name, resolved: true, registeredMigrations, baseAdvancedTo };
2085
2570
  }
2086
2571
  /**
2087
2572
  * Render an `UpdateResult["conflicts"]` set as a readable conflict report
@@ -2155,6 +2640,15 @@ export function renderBlocked(blockedDetail) {
2155
2640
  lines.push(` no migration chain reaches ${to} from ${from}`);
2156
2641
  continue;
2157
2642
  }
2643
+ if (d.reason === "write-failed") {
2644
+ // FGAP-115: a non-validation refusal at the write boundary. The items were
2645
+ // NOT flagged invalid — do not direct the operator at them.
2646
+ for (const f of d.failures ?? []) {
2647
+ lines.push(` ${f.message}`);
2648
+ }
2649
+ lines.push(" write refused (not an item-validation failure) — the block's items were not flagged invalid; do not edit items. Address the named precondition, then re-run update.");
2650
+ continue;
2651
+ }
2158
2652
  // validation-failed
2159
2653
  for (const f of d.failures ?? []) {
2160
2654
  const subject = f.itemId ?? f.instancePath ?? "(item)";
@@ -2171,7 +2665,12 @@ export function renderBlocked(blockedDetail) {
2171
2665
  if (anyMarked) {
2172
2666
  lines.push("Git-style failure markers were written INTO the block file(s): open each block file, fix the items between the `<<<<<<< BLOCKED …` / `>>>>>>> target: …` markers, then resolve-blocked --schemaName <name> --yes — it strips the markers, re-validates the corrected block against the pinned target, writes the target schema, advances the merge base, and clears the block so update converges.");
2173
2667
  }
2174
- else {
2668
+ else if (blockedDetail.some((d) => d.reason !== "write-failed")) {
2669
+ // The fix-items-then-resolve-blocked flow applies only to entries that
2670
+ // persist a pending record (validation-failed / no-migration-chain). A
2671
+ // report whose entries are ALL write-failed carries its own per-entry
2672
+ // guidance (address the precondition, re-run update) — appending the
2673
+ // item-fixing flow here would contradict it (FGAP-115).
2175
2674
  lines.push("No markers were written (preview, or no migration chain to mark against). Resolve a validation-failed block by correcting the offending items in the block file, then resolve-blocked --schemaName <name> --yes — it re-validates the corrected block against the pinned target, writes the target schema, advances the merge base, and clears the block so update converges.");
2176
2675
  }
2177
2676
  return lines.join("\n");
@@ -2373,6 +2872,11 @@ export function switchToExisting(cwd, targetDir, writerIdentity) {
2373
2872
  throw new Error(`/context switch: target dir '${targetDir}' has no config.json at ${targetConfigPath} — refusing to flip the bootstrap pointer to a non-substrate dir. Use '/context switch -c ${targetDir}' to bootstrap a fresh substrate at that dir AND flip the pointer.`);
2374
2873
  }
2375
2874
  flipBootstrapPointer(cwd, targetDir, writerIdentity);
2875
+ // Seed the TARGET substrate's catalog `config` migration chain (idempotent)
2876
+ // right after the flip — every ceremony entry point seeds before its first
2877
+ // config read, so the first read on the now-active substrate (reconcile's
2878
+ // below, or any later one) cannot throw on a version-lagging legacy config.
2879
+ seedCatalogConfigMigrationDecls(resolveContextDir(cwd));
2376
2880
  // Register the now-active substrate's identity if the target carried a
2377
2881
  // config-bearing-but-unregistered substrate_id, so the SoT-drift invariant
2378
2882
  // does not raise a false substrate_id_unregistered after the flip.
@@ -2402,6 +2906,11 @@ export function switchToPrevious(cwd, writerIdentity) {
2402
2906
  throw new Error(`/context switch -: existing pointer at ${bootstrapPath} lacks a string contextDir; refuses to flip an unreadable pointer`);
2403
2907
  }
2404
2908
  flipBootstrapPointer(cwd, previous, writerIdentity);
2909
+ // Seed the flipped-back-to substrate's catalog `config` migration chain
2910
+ // (idempotent) right after the flip — same switch-family ceremony rule as
2911
+ // switchToExisting: the first config read on the now-active substrate must
2912
+ // not throw on a version-lagging legacy config.
2913
+ seedCatalogConfigMigrationDecls(resolveContextDir(cwd));
2405
2914
  // Register the now-active substrate's identity if flipping back landed on a
2406
2915
  // config-bearing-but-unregistered substrate_id, so the SoT-drift invariant
2407
2916
  // does not raise a false substrate_id_unregistered after the flip. Mirrors
@@ -2720,49 +3229,30 @@ const extension = (pi) => {
2720
3229
  });
2721
3230
  },
2722
3231
  },
2723
- "roadmap-list": {
2724
- description: "List every roadmap in <config.root>/roadmap.json with id, title, status, and phase count",
2725
- handler: (_args, ctx) => {
2726
- const list = listRoadmaps(ctx.cwd);
2727
- if (list.length === 0) {
2728
- ctx.ui.notify("No roadmaps found. Install the roadmap block via the substrate dir's config.json installed_blocks, then author roadmap.json.", "info");
2729
- return;
2730
- }
2731
- const lines = list.map((r) => `${r.id} [${r.status ?? "(unspecified)"}] ${r.title} (${r.phaseCount} phase${r.phaseCount === 1 ? "" : "s"})`);
2732
- ctx.ui.notify(lines.join("\n"), "info");
2733
- },
2734
- },
2735
3232
  "roadmap-view": {
2736
- description: "Render a roadmap as pure-textual markdown (phase order, per-phase adjacency from authored phase_depends_on edges, status rollup, milestone resolution). NO mermaid.",
2737
- handler: (args, ctx) => {
2738
- const roadmapId = args.trim().split(/\s+/)[0];
2739
- if (!roadmapId) {
2740
- ctx.ui.notify("Usage: /context roadmap-view <ROADMAP-id>", "error");
2741
- return;
2742
- }
2743
- const view = loadRoadmap(ctx.cwd, roadmapId);
3233
+ description: "Render the derived roadmap as pure-textual markdown (milestone order over authored milestone_precedes_milestone edges, per-milestone phase/task rollups, adjacency strictly from edges). NO mermaid.",
3234
+ handler: (_args, ctx) => {
3235
+ const view = loadRoadmap(ctx.cwd);
2744
3236
  if ("error" in view) {
2745
3237
  ctx.ui.notify(view.error, "error");
2746
3238
  return;
2747
3239
  }
2748
- const naming = loadContext(ctx.cwd).config?.naming;
2749
- ctx.ui.notify(renderRoadmap(view, naming), "info");
3240
+ ctx.ui.notify(renderRoadmap(view), "info");
2750
3241
  },
2751
3242
  },
2752
3243
  "roadmap-validate": {
2753
- description: "Validate every roadmap (or a single one when ROADMAP-id supplied) — surfaces structured issues",
2754
- handler: (args, ctx) => {
2755
- const roadmapId = args.trim().split(/\s+/)[0] || undefined;
2756
- const result = validateRoadmaps(ctx.cwd);
2757
- const filtered = roadmapId
2758
- ? result.issues.filter((i) => !i.roadmap_id || i.roadmap_id === roadmapId)
2759
- : result.issues;
2760
- if (filtered.length === 0) {
2761
- ctx.ui.notify(`✓ Roadmap validation passed${roadmapId ? ` for ${roadmapId}` : ""}.`, "info");
3244
+ description: "Validate the derived milestone roadmap — surfaces structured issues (error/warning/info codes)",
3245
+ handler: (_args, ctx) => {
3246
+ const result = validateRoadmap(ctx.cwd);
3247
+ if (result.issues.length === 0) {
3248
+ ctx.ui.notify("✓ Roadmap validation passed.", "info");
2762
3249
  return;
2763
3250
  }
2764
- const lines = filtered.map((i) => `✗ [${i.code}] ${i.roadmap_id ?? ""}/${i.phase_id ?? ""}: ${i.message}`);
2765
- const level = result.status === "invalid" ? "error" : "warning";
3251
+ const lines = result.issues.map((i) => {
3252
+ const where = `${i.milestone_id ? ` ${i.milestone_id}` : ""}${i.phase_id ? ` ${i.phase_id}` : ""}`;
3253
+ return `✗ [${i.code}]${where}: ${i.message}`;
3254
+ });
3255
+ const level = result.status === "invalid" ? "error" : result.status === "warnings" ? "warning" : "info";
2766
3256
  ctx.ui.notify(lines.join("\n"), level);
2767
3257
  },
2768
3258
  },
@@ -2855,10 +3345,13 @@ export default extension;
2855
3345
  // Re-export the config-registry-propagation surface (TASK-038 — FEAT-006 T5) so
2856
3346
  // consumers can type `UpdateResult.registryAdditions` and call the pure merge
2857
3347
  // helper against the public `@davidorex/pi-context` surface.
2858
- export { mergeCatalogRegistries } from "./context.js";
3348
+ // mergeCatalogRegistries + the FGAP-113 edge-orientation helpers
3349
+ // (counterEndpoint / primaryEndpoint — the single source of truth for reading a
3350
+ // relation's primary/counter endpoint under its config-declared role_direction).
3351
+ export { counterEndpoint, mergeCatalogRegistries, primaryEndpoint } from "./context.js";
2859
3352
  export { contextRegistryPath, invalidateRegistry, loadRegistry, REGISTRY_FILE_VERSION, registerSubstrate, resolveAlias, resolveSubstrateDir, writeRegistry, } from "./context-registry.js";
2860
3353
  // Re-export for consumers
2861
3354
  export { blockStructure, buildIdIndex, CONTEXT_BLOCK_TYPES, completeTask, findAppendableBlocks, resolveItemById, resolveRef, schemaInfo, schemaVocabulary, } from "./context-sdk.js";
2862
3355
  export { renameCanonicalId } from "./rename-canonical-id.js";
2863
- export { listRoadmaps, loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmaps, } from "./roadmap-plan.js";
3356
+ export { loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmap, } from "./roadmap-plan.js";
2864
3357
  //# sourceMappingURL=index.js.map