@davidorex/pi-context 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +12 -11
  3. package/dist/block-api.d.ts.map +1 -1
  4. package/dist/block-api.js +27 -2
  5. package/dist/block-api.js.map +1 -1
  6. package/dist/context-sdk.d.ts +71 -33
  7. package/dist/context-sdk.d.ts.map +1 -1
  8. package/dist/context-sdk.js +547 -149
  9. package/dist/context-sdk.js.map +1 -1
  10. package/dist/context.d.ts +153 -2
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +75 -5
  13. package/dist/context.js.map +1 -1
  14. package/dist/index.d.ts +71 -19
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +405 -94
  17. package/dist/index.js.map +1 -1
  18. package/dist/lens-view.d.ts +0 -5
  19. package/dist/lens-view.d.ts.map +1 -1
  20. package/dist/lens-view.js +43 -1
  21. package/dist/lens-view.js.map +1 -1
  22. package/dist/migration-registry-loader.d.ts +20 -12
  23. package/dist/migration-registry-loader.d.ts.map +1 -1
  24. package/dist/migration-registry-loader.js +46 -17
  25. package/dist/migration-registry-loader.js.map +1 -1
  26. package/dist/migrations-store.d.ts +45 -18
  27. package/dist/migrations-store.d.ts.map +1 -1
  28. package/dist/migrations-store.js +56 -22
  29. package/dist/migrations-store.js.map +1 -1
  30. package/dist/ops-registry.d.ts.map +1 -1
  31. package/dist/ops-registry.js +91 -118
  32. package/dist/ops-registry.js.map +1 -1
  33. package/dist/promote-item.d.ts.map +1 -1
  34. package/dist/promote-item.js +41 -12
  35. package/dist/promote-item.js.map +1 -1
  36. package/dist/roadmap-plan.d.ts +121 -99
  37. package/dist/roadmap-plan.d.ts.map +1 -1
  38. package/dist/roadmap-plan.js +281 -345
  39. package/dist/roadmap-plan.js.map +1 -1
  40. package/dist/status-vocab.d.ts +12 -2
  41. package/dist/status-vocab.d.ts.map +1 -1
  42. package/dist/status-vocab.js +14 -1
  43. package/dist/status-vocab.js.map +1 -1
  44. package/package.json +1 -1
  45. package/samples/blocks/milestone.json +3 -0
  46. package/samples/blocks/session-notes.json +1 -0
  47. package/samples/conception.json +308 -15
  48. package/samples/migrations.json +8 -0
  49. package/samples/schemas/context-contracts.schema.json +4 -0
  50. package/samples/schemas/conventions.schema.json +4 -0
  51. package/samples/schemas/decisions.schema.json +4 -0
  52. package/samples/schemas/features.schema.json +4 -0
  53. package/samples/schemas/framework-gaps.schema.json +4 -0
  54. package/samples/schemas/issues.schema.json +28 -0
  55. package/samples/schemas/layer-plans.schema.json +4 -0
  56. package/samples/schemas/milestone.schema.json +79 -0
  57. package/samples/schemas/phase.schema.json +4 -0
  58. package/samples/schemas/rationale.schema.json +4 -0
  59. package/samples/schemas/requirements.schema.json +4 -0
  60. package/samples/schemas/research.schema.json +4 -0
  61. package/samples/schemas/session-notes.schema.json +89 -0
  62. package/samples/schemas/spec-reviews.schema.json +4 -0
  63. package/samples/schemas/story.schema.json +8 -0
  64. package/samples/schemas/tasks.schema.json +4 -0
  65. package/samples/schemas/verification.schema.json +4 -0
  66. package/samples/schemas/work-orders.schema.json +4 -0
  67. package/schemas/config.schema.json +77 -3
  68. package/schemas/migrations.schema.json +25 -0
  69. package/skill-narrative.md +7 -5
  70. package/skills/pi-context/SKILL.md +44 -49
  71. package/skills/pi-context/references/bundled-resources.md +5 -1
package/dist/index.js CHANGED
@@ -9,17 +9,18 @@ import { fileURLToPath } from "node:url";
9
9
  import { forEachBlockArray, readBlock, readBlockForDir, writeBlockForDir } from "./block-api.js";
10
10
  import { computeContentHash, computeFileContentHash } from "./content-hash.js";
11
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";
12
+ import { BootstrapNotFoundError, flipBootstrapPointer, migrationsPathForDir, mintSubstrateId, pendingBlockedPathForDir, resolveContextDir, SCHEMAS_DIR, schemasDir, tryResolveContextDir, writeBootstrapPointer, } from "./context-dir.js";
13
+ import { registerSubstrate } from "./context-registry.js";
13
14
  import { contextState, 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 { loadRoadmap, renderRoadmap, validateRoadmap } from "./roadmap-plan.js";
23
24
  import { mergeSchema } from "./schema-merge.js";
24
25
  import { runMigrations } from "./schema-migrations.js";
25
26
  import { ValidationError, validate, validateBlockWithMigrationForDir } from "./schema-validator.js";
@@ -647,8 +648,42 @@ function simulateResyncOutcome(destRoot, samplesRoot, sourceFile, destFile, name
647
648
  const catalogVersion = readDeclaredVersion(sourceFile);
648
649
  const installedVersion = readDeclaredVersion(destFile);
649
650
  // Arm 1 — same version (or either unreadable / non-versioned): no transition
650
- // to migrate across, the live path overwrites verbatim resynced, no decls.
651
+ // to migrate across. Mirrors the live path 1:1 a populated block is
652
+ // re-validated in memory against the incoming catalog body, and a failure
653
+ // predicts blocked (validation-failed, per-item failures); otherwise the
654
+ // live path overwrites verbatim → resynced, no decls.
651
655
  if (installedVersion === catalogVersion || catalogVersion === undefined || installedVersion === undefined) {
656
+ const sameVersionBlockFile = installedBlockDestPath(destRoot, name);
657
+ if (fs.existsSync(sameVersionBlockFile)) {
658
+ let sameVersionBlockData;
659
+ let sameVersionHasItems = false;
660
+ try {
661
+ sameVersionBlockData = JSON.parse(fs.readFileSync(sameVersionBlockFile, "utf-8"));
662
+ forEachBlockArray(sameVersionBlockData, (_arrayKey, arr) => {
663
+ if (arr.length > 0)
664
+ sameVersionHasItems = true;
665
+ });
666
+ }
667
+ catch {
668
+ sameVersionHasItems = true; // unreadable — same safety default as the live path
669
+ }
670
+ if (sameVersionHasItems) {
671
+ try {
672
+ const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
673
+ validate(catalogSchema, sameVersionBlockData, name);
674
+ }
675
+ catch (err) {
676
+ const failures = err instanceof ValidationError
677
+ ? mapValidationFailures(err.errors, sameVersionBlockData)
678
+ : [{ instancePath: "", keyword: "error", message: String(err) }];
679
+ return {
680
+ outcome: "blocked",
681
+ wouldRegister: [],
682
+ detail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
683
+ };
684
+ }
685
+ }
686
+ }
652
687
  return { outcome: "resynced", wouldRegister: [] };
653
688
  }
654
689
  // Arm 2 — version bump with NO shipped chain reaching the catalog version: the
@@ -876,11 +911,67 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
876
911
  return entry;
877
912
  };
878
913
  // (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
914
+ // version transition to migrate across. A verbatim overwrite is safe only
915
+ // when the existing block items still conform to the INCOMING catalog body —
916
+ // a same-version change that narrows validity (a dropped property under
917
+ // additionalProperties:false, an added required, a narrowed enum) would
918
+ // otherwise silently invalidate the block. Populated blocks are therefore
919
+ // re-validated against the incoming body IN MEMORY before any write, and a
920
+ // failure refuses the resync (blocked, per-item failures, pending-blocked
921
+ // recorded) — mirroring the version-bump path's validate-or-refuse
922
+ // discipline. Nothing has been written when the refusal fires, so the
923
+ // schema file, block file, and migrations.json are all byte-unchanged. No
882
924
  // migration is registered on this arm, so it reports an empty decl list.
883
925
  if (installedVersion === catalogVersion || catalogVersion === undefined || installedVersion === undefined) {
926
+ const sameVersionBlockFile = installedBlockDestPath(destRoot, name);
927
+ if (fs.existsSync(sameVersionBlockFile)) {
928
+ let sameVersionBlockData;
929
+ let sameVersionHasItems = false;
930
+ try {
931
+ sameVersionBlockData = JSON.parse(fs.readFileSync(sameVersionBlockFile, "utf-8"));
932
+ forEachBlockArray(sameVersionBlockData, (_arrayKey, arr) => {
933
+ if (arr.length > 0)
934
+ sameVersionHasItems = true;
935
+ });
936
+ }
937
+ catch {
938
+ // Unreadable block — safety default: treat as populated and route it
939
+ // through the validate path (which throws → blocked).
940
+ sameVersionHasItems = true;
941
+ }
942
+ if (sameVersionHasItems) {
943
+ try {
944
+ const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
945
+ validate(catalogSchema, sameVersionBlockData, name);
946
+ }
947
+ catch (err) {
948
+ // Classify the refusal (FGAP-115): only an AJV ValidationError is an
949
+ // item-validation failure; any other throw (unreadable catalog body,
950
+ // a write-boundary precondition) is `write-failed` — no pending entry,
951
+ // so the marker/pending pipeline (validation-only consequences) never
952
+ // fires for it.
953
+ if (err instanceof ValidationError) {
954
+ const failures = mapValidationFailures(err.errors, sameVersionBlockData);
955
+ return {
956
+ status: "blocked",
957
+ registeredMigrations: [],
958
+ blockedDetail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
959
+ pendingEntry: buildPendingEntry("validation-failed", [], failures),
960
+ };
961
+ }
962
+ return {
963
+ status: "blocked",
964
+ registeredMigrations: [],
965
+ blockedDetail: {
966
+ reason: "write-failed",
967
+ from: installedVersion,
968
+ to: catalogVersion,
969
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
970
+ },
971
+ };
972
+ }
973
+ }
974
+ }
884
975
  fs.copyFileSync(sourceFile, destFile);
885
976
  return { status: "resynced", registeredMigrations: [] };
886
977
  }
@@ -910,14 +1001,14 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
910
1001
  // throws on collision). Registration is required BEFORE the validate+migrate
911
1002
  // call so the loaded registry carries the forward edge.
912
1003
  const existing = loadMigrationsFileForDir(destRoot);
913
- const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName}${m.fromVersion}`));
1004
+ const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName}\u0000${m.fromVersion}`));
914
1005
  // Accumulate the decls THIS call actually appends (the not-already-present
915
1006
  // subset), reported as the { schema, from, to } shape for the caller to
916
1007
  // surface (FGAP-050). On the blocked rollback path below this list is
917
1008
  // discarded and [] is returned (post-rollback truth: nothing stuck).
918
1009
  const registeredMigrations = [];
919
1010
  for (const decl of chain) {
920
- const key = `${decl.schemaName}${decl.fromVersion}`;
1011
+ const key = `${decl.schemaName}\u0000${decl.fromVersion}`;
921
1012
  if (present.has(key))
922
1013
  continue;
923
1014
  appendMigrationDeclForDir(destRoot, decl);
@@ -998,14 +1089,30 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
998
1089
  // blocked detail records the validation-failed reason + the version pair + the
999
1090
  // per-item failures (a single synthetic failure for a non-AJV throw) so the
1000
1091
  // 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) }];
1092
+ // Classify the refusal (FGAP-115): only an AJV ValidationError means the
1093
+ // migrated items fail the catalog schema. Any other throw — the mandatory
1094
+ // identity stamp refusing a substrate with no substrate_id, an I/O failure —
1095
+ // is `write-failed`: the items were never flagged invalid, so no pending
1096
+ // entry is built and the marker/pending pipeline (validation-only
1097
+ // consequences) never fires.
1098
+ if (err instanceof ValidationError) {
1099
+ const failures = mapValidationFailures(err.errors, blockData);
1100
+ return {
1101
+ status: "blocked",
1102
+ registeredMigrations: [],
1103
+ blockedDetail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
1104
+ pendingEntry: buildPendingEntry("validation-failed", chain, failures),
1105
+ };
1106
+ }
1004
1107
  return {
1005
1108
  status: "blocked",
1006
1109
  registeredMigrations: [],
1007
- blockedDetail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
1008
- pendingEntry: buildPendingEntry("validation-failed", chain, failures),
1110
+ blockedDetail: {
1111
+ reason: "write-failed",
1112
+ from: installedVersion,
1113
+ to: catalogVersion,
1114
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
1115
+ },
1009
1116
  };
1010
1117
  }
1011
1118
  }
@@ -1020,6 +1127,43 @@ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
1020
1127
  * - Sources missing from the samples catalog are reported as "notFound".
1021
1128
  * - Empty install lists are not an error — the result is a clean no-op.
1022
1129
  */
1130
+ /**
1131
+ * Ceremony-entry identity establishment (DEC-0020 — FGAP-033). When the
1132
+ * substrate's config.json lacks a `substrate_id`, mint one (the SAME
1133
+ * `mintSubstrateId` init uses), persist it through the sanctioned config write
1134
+ * path, and register it in the project registry (the SAME `registerSubstrate`
1135
+ * call init/accept-all make) — BEFORE the ceremony's first write that stamps
1136
+ * identity, so a pre-identity substrate HEALS on the sanctioned ceremony
1137
+ * instead of refusing at the stamping guard. Called at the entry of every
1138
+ * substrate-lifecycle ceremony that can reach an identity-stamping write
1139
+ * (update, install, resolve-blocked), at the same seam as the config-migration
1140
+ * seeding. Returns the minted id when establishment happened (the ceremony
1141
+ * reports it in its result), undefined when identity was already established
1142
+ * (never re-mints — the on-disk id is immutable) or no config is loadable (the
1143
+ * ceremony's own config handling reports that). This is explicit
1144
+ * ceremony-boundary provisioning, not a lazy mint inside a block write —
1145
+ * `substrateIdForDir`'s loud guard stays load-bearing for any write reaching
1146
+ * it without identity (defense in depth, DEC-0012 preserved).
1147
+ */
1148
+ function establishSubstrateIdentityAtEntry(cwd, destRoot) {
1149
+ let config;
1150
+ try {
1151
+ config = loadConfig(cwd);
1152
+ }
1153
+ catch {
1154
+ return undefined; // unloadable config — the ceremony's own path surfaces it
1155
+ }
1156
+ if (!config)
1157
+ return undefined;
1158
+ const existing = config.substrate_id;
1159
+ if (typeof existing === "string" && /^sub-[0-9a-f]{16}$/.test(existing))
1160
+ return undefined;
1161
+ const substrate_id = mintSubstrateId();
1162
+ config.substrate_id = substrate_id;
1163
+ writeConfig(cwd, config);
1164
+ registerSubstrate(cwd, substrate_id, path.relative(cwd, destRoot) || ".", []);
1165
+ return substrate_id;
1166
+ }
1023
1167
  export function installContext(cwd, options = {}) {
1024
1168
  const result = {
1025
1169
  installed: [],
@@ -1038,6 +1182,16 @@ export function installContext(cwd, options = {}) {
1038
1182
  "No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
1039
1183
  return result;
1040
1184
  }
1185
+ // Seed the catalog's `config` migration chain (idempotent) before the config
1186
+ // read below, and before resyncSchema's pre-call migrations.json byte capture
1187
+ // — so a blocked-resync rollback restores to the seeded state, preserving the
1188
+ // seed.
1189
+ seedCatalogConfigMigrationDecls(destRoot);
1190
+ // Ceremony-entry identity establishment (DEC-0020) — before any write that
1191
+ // stamps identity (the --update resync path's writeBlockForDir).
1192
+ const establishedId = establishSubstrateIdentityAtEntry(cwd, destRoot);
1193
+ if (establishedId)
1194
+ result.substrateIdEstablished = establishedId;
1041
1195
  const config = loadConfig(cwd);
1042
1196
  if (!config) {
1043
1197
  result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
@@ -1228,7 +1382,11 @@ export function installContext(cwd, options = {}) {
1228
1382
  * re-sync, slice S3). Compares, per installed schema, the S2 install baseline
1229
1383
  * against the catalog's current schema file and the currently-installed schema
1230
1384
  * file, classifies the drift, and RETURNS the report. Writes NOTHING anywhere —
1231
- * no config write, no file copy, no mkdir; only reads.
1385
+ * no config write, no file copy, no mkdir; only reads. One designed exception:
1386
+ * like every ceremony entry point it seeds the catalog's `config` migration
1387
+ * chain into `migrations.json` (idempotent) before its first config read — the
1388
+ * heal semantic, consistent with idempotent re-init healing — so a
1389
+ * version-lagging legacy substrate is diagnosable instead of throwing.
1232
1390
  *
1233
1391
  * For each `config.installed_schemas` entry:
1234
1392
  * - baseline = config.installed_from?.assets?.[name]?.content_hash
@@ -1260,6 +1418,9 @@ export function checkStatus(cwd) {
1260
1418
  if (destRoot === null) {
1261
1419
  return { perAsset, summary: emptySummary() };
1262
1420
  }
1421
+ // Ceremony seed (idempotent) before the config read below — the one
1422
+ // sanctioned write in this otherwise pure-read detector (see docstring).
1423
+ seedCatalogConfigMigrationDecls(destRoot);
1263
1424
  const config = loadConfig(cwd);
1264
1425
  if (!config) {
1265
1426
  return { perAsset, summary: emptySummary() };
@@ -1470,6 +1631,23 @@ export function updateContext(cwd, { dryRun = false } = {}) {
1470
1631
  "No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
1471
1632
  return result;
1472
1633
  }
1634
+ // Seed the catalog's `config` migration chain (idempotent) before the config
1635
+ // read below — every ceremony entry point seeds before its first config read,
1636
+ // so a version-lagging legacy substrate heals on update instead of throwing.
1637
+ seedCatalogConfigMigrationDecls(destRoot);
1638
+ // Ceremony-entry identity establishment (DEC-0020) — before any write that
1639
+ // stamps identity (the version-bump resync's writeBlockForDir), so a
1640
+ // pre-identity substrate heals here instead of refusing at the stamping
1641
+ // guard. LIVE runs only: a dryRun performs no stamping write, so there is
1642
+ // nothing to establish ahead of — the preview keeps its writes-nothing
1643
+ // contract (beyond the idempotent ceremony seed), and its predicted
1644
+ // per-schema plan already matches the healed live outcome (the in-memory
1645
+ // simulation never consults the stamping precondition).
1646
+ if (!dryRun) {
1647
+ const establishedId = establishSubstrateIdentityAtEntry(cwd, destRoot);
1648
+ if (establishedId)
1649
+ result.substrateIdEstablished = establishedId;
1650
+ }
1473
1651
  const config = loadConfig(cwd);
1474
1652
  if (!config) {
1475
1653
  result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
@@ -1774,6 +1952,65 @@ export function updateContext(cwd, { dryRun = false } = {}) {
1774
1952
  // Catalog read / parse / config-load failure: leave registryAdditions empty
1775
1953
  // (its initialized value) and return the schema-update result unchanged.
1776
1954
  }
1955
+ // Partial-application legibility (FGAP-076): computed LAST, after every
1956
+ // channel above is final (the registry propagation just ran), so the field is
1957
+ // a pure derivation of the finished result. Populated exactly when the run
1958
+ // both refused something and applied something; a blocked run whose registry
1959
+ // additions (or other-schema resyncs) landed must never read as a no-op.
1960
+ // dryRun runs derive the same shape from the predicted plan.
1961
+ const ra = result.registryAdditions;
1962
+ const registryAdditionCount = ra.relation_types.length + ra.invariants.length + ra.block_kinds.length + ra.lenses.length;
1963
+ const refusedSomething = result.blocked.length > 0 || result.refused.length > 0 || result.conflicts.length > 0;
1964
+ const appliedSomething = result.resynced.length > 0 ||
1965
+ result.migrated.length > 0 ||
1966
+ result.merged.length > 0 ||
1967
+ result.migrationsRegistered.length > 0 ||
1968
+ registryAdditionCount > 0;
1969
+ if (refusedSomething && appliedSomething) {
1970
+ const appliedParts = [];
1971
+ if (registryAdditionCount > 0) {
1972
+ const perRegistry = ["relation_types", "invariants", "block_kinds", "lenses"]
1973
+ .filter((k) => ra[k].length > 0)
1974
+ .map((k) => `${ra[k].length} ${k}`)
1975
+ .join(", ");
1976
+ appliedParts.push(`registry additions (${perRegistry})`);
1977
+ }
1978
+ if (result.resynced.length > 0)
1979
+ appliedParts.push(`resynced: ${result.resynced.join(", ")}`);
1980
+ if (result.migrated.length > 0)
1981
+ appliedParts.push(`migrated: ${result.migrated.join(", ")}`);
1982
+ if (result.merged.length > 0)
1983
+ appliedParts.push(`merged: ${result.merged.join(", ")}`);
1984
+ if (result.migrationsRegistered.length > 0)
1985
+ appliedParts.push(`migration declarations registered: ${result.migrationsRegistered.map((m) => `${m.schema} ${m.from}->${m.to}`).join(", ")}`);
1986
+ const notAppliedParts = [];
1987
+ if (result.blocked.length > 0)
1988
+ notAppliedParts.push(`blocked: ${result.blocked
1989
+ .map((name) => {
1990
+ const detail = result.blockedDetail.find((d) => d.name === name);
1991
+ return detail ? `'${name}' (${detail.reason})` : `'${name}'`;
1992
+ })
1993
+ .join(", ")}`);
1994
+ if (result.refused.length > 0)
1995
+ notAppliedParts.push(`refused (local modifications preserved): ${result.refused.map((n) => `'${n}'`).join(", ")}`);
1996
+ if (result.conflicts.length > 0)
1997
+ notAppliedParts.push(`merge conflicts: ${result.conflicts.map((c) => `'${c.name}'`).join(", ")}`);
1998
+ result.partialApplication = {
1999
+ applied: {
2000
+ resynced: [...result.resynced],
2001
+ migrated: [...result.migrated],
2002
+ merged: [...result.merged],
2003
+ registryAdditions: ra,
2004
+ migrationsRegistered: [...result.migrationsRegistered],
2005
+ },
2006
+ notApplied: {
2007
+ blocked: [...result.blocked],
2008
+ refused: [...result.refused],
2009
+ conflicts: result.conflicts.map((c) => c.name),
2010
+ },
2011
+ 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)" : ""}.`,
2012
+ };
2013
+ }
1777
2014
  return result;
1778
2015
  }
1779
2016
  /**
@@ -1881,6 +2118,10 @@ export function resolveConflict(cwd, name, schema, ctx) {
1881
2118
  if (destRoot === null) {
1882
2119
  throw new Error(`resolve-conflict: no active substrate resolved for '${cwd}'`);
1883
2120
  }
2121
+ // Ceremony seed (idempotent) — update-family class rule: every ceremony
2122
+ // entry point seeds the catalog's `config` migration chain before its first
2123
+ // config read (here reached via stampBaselineFromBody's loadConfig).
2124
+ seedCatalogConfigMigrationDecls(destRoot);
1884
2125
  const { samplesRoot, byId } = resolveCatalog();
1885
2126
  const kind = byId.get(name);
1886
2127
  if (!kind) {
@@ -1957,6 +2198,15 @@ export function resolveBlocked(cwd, name, ctx) {
1957
2198
  if (destRoot === null) {
1958
2199
  throw new Error(`resolve-blocked: no active substrate resolved for '${cwd}'`);
1959
2200
  }
2201
+ // Ceremony seed (idempotent) — update-family class rule: every ceremony
2202
+ // entry point seeds the catalog's `config` migration chain before its first
2203
+ // config read (here reached via stampBaselineFromBody's loadConfig on the
2204
+ // pass path).
2205
+ seedCatalogConfigMigrationDecls(destRoot);
2206
+ // Ceremony-entry identity establishment (DEC-0020) — before the commit's
2207
+ // writeBlockForDir stamping write, so a pre-identity substrate's resolution
2208
+ // heals identity instead of refusing mid-commit.
2209
+ const establishedId = establishSubstrateIdentityAtEntry(cwd, destRoot);
1960
2210
  const pending = loadPendingBlockedForDir(destRoot);
1961
2211
  const entry = pending?.entries.find((e) => e.name === name);
1962
2212
  if (!entry) {
@@ -2001,6 +2251,7 @@ export function resolveBlocked(cwd, name, ctx) {
2001
2251
  schemaName: name,
2002
2252
  resolved: false,
2003
2253
  failures: [{ instancePath: "", keyword: "error", message: String(err) }],
2254
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2004
2255
  };
2005
2256
  }
2006
2257
  const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
@@ -2034,54 +2285,106 @@ export function resolveBlocked(cwd, name, ctx) {
2034
2285
  const failures = err instanceof ValidationError
2035
2286
  ? mapValidationFailures(err.errors, blockData)
2036
2287
  : [{ instancePath: "", keyword: "error", message: String(err) }];
2037
- return { schemaName: name, resolved: false, failures };
2288
+ return {
2289
+ schemaName: name,
2290
+ resolved: false,
2291
+ failures,
2292
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2293
+ };
2038
2294
  }
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 });
2295
+ // PASS — commit the resolution, ALL-OR-NOTHING (FGAP-115). The commit touches
2296
+ // up to five files; a throw partway (e.g. the mandatory identity stamp inside
2297
+ // writeBlockForDir refusing a substrate with no substrate_id) previously
2298
+ // stranded a partial commit schema advanced, markers stripped, block
2299
+ // unwritten, pending entry stale. Capture the raw pre-commit bytes of every
2300
+ // touched file up front; on any commit-phase throw restore them byte-exact
2301
+ // (per-component byte-exact refuse discipline, DEC-0017/DEC-0018), invalidate
2302
+ // the migration-registry cache warmed by the reverted decl appends, and
2303
+ // return resolved:false carrying the truthful failure. Object-store putObject
2304
+ // entries are content-addressed and harmless to leave behind.
2305
+ const commitFiles = [
2306
+ migrationsPathForDir(destRoot),
2307
+ installedSchemaDestPath(destRoot, name),
2308
+ blockFile,
2309
+ path.join(destRoot, "config.json"),
2310
+ pendingBlockedPathForDir(destRoot),
2311
+ ];
2312
+ const preCommitBytes = new Map();
2313
+ for (const f of commitFiles) {
2314
+ preCommitBytes.set(f, fs.existsSync(f) ? fs.readFileSync(f) : null);
2051
2315
  }
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);
2316
+ try {
2317
+ // (1) Register the chain decls not already on disk (the resyncSchema dedup),
2318
+ // collecting the registered set.
2319
+ const existing = loadMigrationsFileForDir(destRoot);
2320
+ const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName} ${m.fromVersion}`));
2321
+ const registeredMigrations = [];
2322
+ for (const decl of entry.chain) {
2323
+ const key = `${decl.schemaName} ${decl.fromVersion}`;
2324
+ if (present.has(key))
2325
+ continue;
2326
+ appendMigrationDeclForDir(destRoot, decl, ctx);
2327
+ present.add(key);
2328
+ registeredMigrations.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
2067
2329
  }
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;
2330
+ // (2) Write the target schema (replace — meta-validated, nested-id-guarded, atomic).
2331
+ writeSchemaCheckedForDir(destRoot, name, targetBody, "replace", ctx);
2332
+ // (3) Advance the migrated block's schema_version envelope to the target + persist
2333
+ // it (skip when the block had no items — schema still written, base still advanced,
2334
+ // mirroring the live no-items handling). Identity stamping re-runs on the write.
2335
+ if (hasItems) {
2336
+ // TASK-052 / FGAP-081 (oid stability): when the on-disk block carried markers,
2337
+ // raw-write the STRIPPED text to the block file (tmp+rename) BEFORE
2338
+ // writeBlockForDir, so the identity-stamp prior-read parses the marker-free
2339
+ // on-disk file and preserves each item's oid (no re-mint; content_parent advances
2340
+ // only on genuinely changed items).
2341
+ if (wasMarked && strippedText !== undefined) {
2342
+ const tmpPath = `${blockFile}.unmark-${process.pid}.tmp`;
2343
+ fs.writeFileSync(tmpPath, strippedText);
2344
+ fs.renameSync(tmpPath, blockFile);
2345
+ }
2346
+ if (migrated &&
2347
+ typeof migrated === "object" &&
2348
+ !Array.isArray(migrated) &&
2349
+ typeof migrated.schema_version === "string" &&
2350
+ typeof targetVersion === "string") {
2351
+ migrated.schema_version = targetVersion;
2352
+ }
2353
+ writeBlockForDir(destRoot, name, migrated);
2074
2354
  }
2075
- writeBlockForDir(destRoot, name, migrated);
2355
+ // (4) Advance the merge base to the target body so a subsequent update converges
2356
+ // (base === catalog) instead of re-deriving drift.
2357
+ const baseAdvancedTo = stampBaselineFromBody(cwd, name, targetBody, targetVersion ?? "");
2358
+ // (5) Clear the resolved entry from pending-blocked.json (remove the file when
2359
+ // it becomes empty — no stale empty sidecar).
2360
+ const remaining = (pending?.entries ?? []).filter((e) => e.name !== name);
2361
+ reconcilePendingBlockedForDir(destRoot, remaining, ctx);
2362
+ return {
2363
+ schemaName: name,
2364
+ resolved: true,
2365
+ registeredMigrations,
2366
+ baseAdvancedTo,
2367
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2368
+ };
2369
+ }
2370
+ catch (err) {
2371
+ for (const [f, bytes] of preCommitBytes) {
2372
+ if (bytes === null) {
2373
+ if (fs.existsSync(f))
2374
+ fs.unlinkSync(f);
2375
+ }
2376
+ else {
2377
+ fs.writeFileSync(f, bytes);
2378
+ }
2379
+ }
2380
+ invalidateMigrationRegistryForDir(destRoot);
2381
+ return {
2382
+ schemaName: name,
2383
+ resolved: false,
2384
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
2385
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2386
+ };
2076
2387
  }
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
2388
  }
2086
2389
  /**
2087
2390
  * Render an `UpdateResult["conflicts"]` set as a readable conflict report
@@ -2155,6 +2458,15 @@ export function renderBlocked(blockedDetail) {
2155
2458
  lines.push(` no migration chain reaches ${to} from ${from}`);
2156
2459
  continue;
2157
2460
  }
2461
+ if (d.reason === "write-failed") {
2462
+ // FGAP-115: a non-validation refusal at the write boundary. The items were
2463
+ // NOT flagged invalid — do not direct the operator at them.
2464
+ for (const f of d.failures ?? []) {
2465
+ lines.push(` ${f.message}`);
2466
+ }
2467
+ 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.");
2468
+ continue;
2469
+ }
2158
2470
  // validation-failed
2159
2471
  for (const f of d.failures ?? []) {
2160
2472
  const subject = f.itemId ?? f.instancePath ?? "(item)";
@@ -2171,7 +2483,12 @@ export function renderBlocked(blockedDetail) {
2171
2483
  if (anyMarked) {
2172
2484
  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
2485
  }
2174
- else {
2486
+ else if (blockedDetail.some((d) => d.reason !== "write-failed")) {
2487
+ // The fix-items-then-resolve-blocked flow applies only to entries that
2488
+ // persist a pending record (validation-failed / no-migration-chain). A
2489
+ // report whose entries are ALL write-failed carries its own per-entry
2490
+ // guidance (address the precondition, re-run update) — appending the
2491
+ // item-fixing flow here would contradict it (FGAP-115).
2175
2492
  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
2493
  }
2177
2494
  return lines.join("\n");
@@ -2373,6 +2690,11 @@ export function switchToExisting(cwd, targetDir, writerIdentity) {
2373
2690
  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
2691
  }
2375
2692
  flipBootstrapPointer(cwd, targetDir, writerIdentity);
2693
+ // Seed the TARGET substrate's catalog `config` migration chain (idempotent)
2694
+ // right after the flip — every ceremony entry point seeds before its first
2695
+ // config read, so the first read on the now-active substrate (reconcile's
2696
+ // below, or any later one) cannot throw on a version-lagging legacy config.
2697
+ seedCatalogConfigMigrationDecls(resolveContextDir(cwd));
2376
2698
  // Register the now-active substrate's identity if the target carried a
2377
2699
  // config-bearing-but-unregistered substrate_id, so the SoT-drift invariant
2378
2700
  // does not raise a false substrate_id_unregistered after the flip.
@@ -2402,6 +2724,11 @@ export function switchToPrevious(cwd, writerIdentity) {
2402
2724
  throw new Error(`/context switch -: existing pointer at ${bootstrapPath} lacks a string contextDir; refuses to flip an unreadable pointer`);
2403
2725
  }
2404
2726
  flipBootstrapPointer(cwd, previous, writerIdentity);
2727
+ // Seed the flipped-back-to substrate's catalog `config` migration chain
2728
+ // (idempotent) right after the flip — same switch-family ceremony rule as
2729
+ // switchToExisting: the first config read on the now-active substrate must
2730
+ // not throw on a version-lagging legacy config.
2731
+ seedCatalogConfigMigrationDecls(resolveContextDir(cwd));
2405
2732
  // Register the now-active substrate's identity if flipping back landed on a
2406
2733
  // config-bearing-but-unregistered substrate_id, so the SoT-drift invariant
2407
2734
  // does not raise a false substrate_id_unregistered after the flip. Mirrors
@@ -2720,49 +3047,30 @@ const extension = (pi) => {
2720
3047
  });
2721
3048
  },
2722
3049
  },
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
3050
  "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);
3051
+ 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.",
3052
+ handler: (_args, ctx) => {
3053
+ const view = loadRoadmap(ctx.cwd);
2744
3054
  if ("error" in view) {
2745
3055
  ctx.ui.notify(view.error, "error");
2746
3056
  return;
2747
3057
  }
2748
- const naming = loadContext(ctx.cwd).config?.naming;
2749
- ctx.ui.notify(renderRoadmap(view, naming), "info");
3058
+ ctx.ui.notify(renderRoadmap(view), "info");
2750
3059
  },
2751
3060
  },
2752
3061
  "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");
3062
+ description: "Validate the derived milestone roadmap — surfaces structured issues (error/warning/info codes)",
3063
+ handler: (_args, ctx) => {
3064
+ const result = validateRoadmap(ctx.cwd);
3065
+ if (result.issues.length === 0) {
3066
+ ctx.ui.notify("✓ Roadmap validation passed.", "info");
2762
3067
  return;
2763
3068
  }
2764
- const lines = filtered.map((i) => `✗ [${i.code}] ${i.roadmap_id ?? ""}/${i.phase_id ?? ""}: ${i.message}`);
2765
- const level = result.status === "invalid" ? "error" : "warning";
3069
+ const lines = result.issues.map((i) => {
3070
+ const where = `${i.milestone_id ? ` ${i.milestone_id}` : ""}${i.phase_id ? ` ${i.phase_id}` : ""}`;
3071
+ return `✗ [${i.code}]${where}: ${i.message}`;
3072
+ });
3073
+ const level = result.status === "invalid" ? "error" : result.status === "warnings" ? "warning" : "info";
2766
3074
  ctx.ui.notify(lines.join("\n"), level);
2767
3075
  },
2768
3076
  },
@@ -2855,10 +3163,13 @@ export default extension;
2855
3163
  // Re-export the config-registry-propagation surface (TASK-038 — FEAT-006 T5) so
2856
3164
  // consumers can type `UpdateResult.registryAdditions` and call the pure merge
2857
3165
  // helper against the public `@davidorex/pi-context` surface.
2858
- export { mergeCatalogRegistries } from "./context.js";
3166
+ // mergeCatalogRegistries + the FGAP-113 edge-orientation helpers
3167
+ // (counterEndpoint / primaryEndpoint — the single source of truth for reading a
3168
+ // relation's primary/counter endpoint under its config-declared role_direction).
3169
+ export { counterEndpoint, mergeCatalogRegistries, primaryEndpoint } from "./context.js";
2859
3170
  export { contextRegistryPath, invalidateRegistry, loadRegistry, REGISTRY_FILE_VERSION, registerSubstrate, resolveAlias, resolveSubstrateDir, writeRegistry, } from "./context-registry.js";
2860
3171
  // Re-export for consumers
2861
3172
  export { blockStructure, buildIdIndex, CONTEXT_BLOCK_TYPES, completeTask, findAppendableBlocks, resolveItemById, resolveRef, schemaInfo, schemaVocabulary, } from "./context-sdk.js";
2862
3173
  export { renameCanonicalId } from "./rename-canonical-id.js";
2863
- export { listRoadmaps, loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmaps, } from "./roadmap-plan.js";
3174
+ export { loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmap, } from "./roadmap-plan.js";
2864
3175
  //# sourceMappingURL=index.js.map