@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.
- package/CHANGELOG.md +73 -0
- package/README.md +16 -14
- package/dist/block-api.d.ts +20 -33
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +154 -3
- package/dist/block-api.js.map +1 -1
- package/dist/content-hash.d.ts +9 -0
- package/dist/content-hash.d.ts.map +1 -1
- package/dist/content-hash.js +11 -0
- package/dist/content-hash.js.map +1 -1
- package/dist/context-sdk.d.ts +157 -39
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +896 -251
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +158 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +75 -5
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +151 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +590 -97
- package/dist/index.js.map +1 -1
- package/dist/lens-view.d.ts +0 -5
- package/dist/lens-view.d.ts.map +1 -1
- package/dist/lens-view.js +43 -1
- package/dist/lens-view.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +20 -12
- package/dist/migration-registry-loader.d.ts.map +1 -1
- package/dist/migration-registry-loader.js +46 -17
- package/dist/migration-registry-loader.js.map +1 -1
- package/dist/migrations-store.d.ts +45 -18
- package/dist/migrations-store.d.ts.map +1 -1
- package/dist/migrations-store.js +56 -22
- package/dist/migrations-store.js.map +1 -1
- package/dist/ops-registry.d.ts +18 -0
- package/dist/ops-registry.d.ts.map +1 -1
- package/dist/ops-registry.js +474 -130
- package/dist/ops-registry.js.map +1 -1
- package/dist/promote-item.d.ts.map +1 -1
- package/dist/promote-item.js +41 -12
- package/dist/promote-item.js.map +1 -1
- package/dist/roadmap-plan.d.ts +121 -99
- package/dist/roadmap-plan.d.ts.map +1 -1
- package/dist/roadmap-plan.js +281 -345
- package/dist/roadmap-plan.js.map +1 -1
- package/dist/status-vocab.d.ts +12 -2
- package/dist/status-vocab.d.ts.map +1 -1
- package/dist/status-vocab.js +14 -1
- package/dist/status-vocab.js.map +1 -1
- package/package.json +2 -2
- package/samples/blocks/milestone.json +3 -0
- package/samples/blocks/session-notes.json +1 -0
- package/samples/conception.json +315 -15
- package/samples/migrations.json +8 -0
- package/samples/schemas/context-contracts.schema.json +4 -0
- package/samples/schemas/conventions.schema.json +4 -0
- package/samples/schemas/decisions.schema.json +4 -0
- package/samples/schemas/features.schema.json +4 -0
- package/samples/schemas/framework-gaps.schema.json +9 -0
- package/samples/schemas/issues.schema.json +28 -0
- package/samples/schemas/layer-plans.schema.json +4 -0
- package/samples/schemas/milestone.schema.json +79 -0
- package/samples/schemas/phase.schema.json +4 -0
- package/samples/schemas/rationale.schema.json +4 -0
- package/samples/schemas/requirements.schema.json +4 -0
- package/samples/schemas/research.schema.json +45 -2
- package/samples/schemas/session-notes.schema.json +89 -0
- package/samples/schemas/spec-reviews.schema.json +4 -0
- package/samples/schemas/story.schema.json +8 -0
- package/samples/schemas/tasks.schema.json +4 -0
- package/samples/schemas/verification.schema.json +4 -0
- package/samples/schemas/work-orders.schema.json +4 -0
- package/schemas/config.schema.json +90 -4
- package/schemas/migrations.schema.json +25 -0
- package/skill-narrative.md +8 -6
- package/skills/pi-context/SKILL.md +70 -63
- 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 {
|
|
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 {
|
|
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
|
|
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
|
|
880
|
-
//
|
|
881
|
-
// version
|
|
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}
|
|
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}
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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: {
|
|
1008
|
-
|
|
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 {
|
|
2470
|
+
return {
|
|
2471
|
+
schemaName: name,
|
|
2472
|
+
resolved: false,
|
|
2473
|
+
failures,
|
|
2474
|
+
...(establishedId ? { substrateIdEstablished: establishedId } : {}),
|
|
2475
|
+
};
|
|
2038
2476
|
}
|
|
2039
|
-
// PASS — commit the resolution
|
|
2040
|
-
//
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
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
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
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
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
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
|
-
|
|
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
|
|
2737
|
-
handler: (
|
|
2738
|
-
const
|
|
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
|
-
|
|
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
|
|
2754
|
-
handler: (
|
|
2755
|
-
const
|
|
2756
|
-
|
|
2757
|
-
|
|
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 =
|
|
2765
|
-
|
|
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
|
-
|
|
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 {
|
|
3356
|
+
export { loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmap, } from "./roadmap-plan.js";
|
|
2864
3357
|
//# sourceMappingURL=index.js.map
|