@davidorex/pi-context 0.30.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 (90) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +27 -11
  3. package/dist/block-api.d.ts +13 -0
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +28 -3
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/content-hash.d.ts +13 -0
  8. package/dist/content-hash.d.ts.map +1 -1
  9. package/dist/content-hash.js +16 -0
  10. package/dist/content-hash.js.map +1 -1
  11. package/dist/context-dir.d.ts +12 -0
  12. package/dist/context-dir.d.ts.map +1 -1
  13. package/dist/context-dir.js +14 -0
  14. package/dist/context-dir.js.map +1 -1
  15. package/dist/context-sdk.d.ts +71 -33
  16. package/dist/context-sdk.d.ts.map +1 -1
  17. package/dist/context-sdk.js +547 -149
  18. package/dist/context-sdk.js.map +1 -1
  19. package/dist/context.d.ts +213 -2
  20. package/dist/context.d.ts.map +1 -1
  21. package/dist/context.js +119 -5
  22. package/dist/context.js.map +1 -1
  23. package/dist/index.d.ts +595 -9
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +2225 -55
  26. package/dist/index.js.map +1 -1
  27. package/dist/lens-view.d.ts +0 -5
  28. package/dist/lens-view.d.ts.map +1 -1
  29. package/dist/lens-view.js +43 -1
  30. package/dist/lens-view.js.map +1 -1
  31. package/dist/migration-registry-loader.d.ts +36 -12
  32. package/dist/migration-registry-loader.d.ts.map +1 -1
  33. package/dist/migration-registry-loader.js +79 -17
  34. package/dist/migration-registry-loader.js.map +1 -1
  35. package/dist/migrations-store.d.ts +45 -18
  36. package/dist/migrations-store.d.ts.map +1 -1
  37. package/dist/migrations-store.js +56 -22
  38. package/dist/migrations-store.js.map +1 -1
  39. package/dist/ops-registry.d.ts +16 -0
  40. package/dist/ops-registry.d.ts.map +1 -1
  41. package/dist/ops-registry.js +352 -117
  42. package/dist/ops-registry.js.map +1 -1
  43. package/dist/pending-blocked-store.d.ts +83 -0
  44. package/dist/pending-blocked-store.d.ts.map +1 -0
  45. package/dist/pending-blocked-store.js +93 -0
  46. package/dist/pending-blocked-store.js.map +1 -0
  47. package/dist/promote-item.d.ts.map +1 -1
  48. package/dist/promote-item.js +41 -12
  49. package/dist/promote-item.js.map +1 -1
  50. package/dist/roadmap-plan.d.ts +121 -99
  51. package/dist/roadmap-plan.d.ts.map +1 -1
  52. package/dist/roadmap-plan.js +281 -345
  53. package/dist/roadmap-plan.js.map +1 -1
  54. package/dist/schema-merge.d.ts +26 -0
  55. package/dist/schema-merge.d.ts.map +1 -0
  56. package/dist/schema-merge.js +176 -0
  57. package/dist/schema-merge.js.map +1 -0
  58. package/dist/status-vocab.d.ts +12 -2
  59. package/dist/status-vocab.d.ts.map +1 -1
  60. package/dist/status-vocab.js +14 -1
  61. package/dist/status-vocab.js.map +1 -1
  62. package/package.json +2 -1
  63. package/samples/blocks/milestone.json +3 -0
  64. package/samples/blocks/session-notes.json +1 -0
  65. package/samples/conception.json +358 -15
  66. package/samples/migrations.json +8 -0
  67. package/samples/schemas/context-contracts.schema.json +4 -0
  68. package/samples/schemas/conventions.schema.json +4 -0
  69. package/samples/schemas/decisions.schema.json +4 -0
  70. package/samples/schemas/features.schema.json +4 -0
  71. package/samples/schemas/framework-gaps.schema.json +5 -1
  72. package/samples/schemas/issues.schema.json +30 -2
  73. package/samples/schemas/layer-plans.schema.json +6 -2
  74. package/samples/schemas/milestone.schema.json +79 -0
  75. package/samples/schemas/phase.schema.json +4 -0
  76. package/samples/schemas/rationale.schema.json +4 -0
  77. package/samples/schemas/requirements.schema.json +4 -0
  78. package/samples/schemas/research.schema.json +5 -1
  79. package/samples/schemas/session-notes.schema.json +89 -0
  80. package/samples/schemas/spec-reviews.schema.json +4 -0
  81. package/samples/schemas/story.schema.json +8 -0
  82. package/samples/schemas/tasks.schema.json +4 -0
  83. package/samples/schemas/verification.schema.json +4 -0
  84. package/samples/schemas/work-orders.schema.json +6 -2
  85. package/schemas/config.schema.json +101 -3
  86. package/schemas/migrations.schema.json +25 -0
  87. package/schemas/pending-blocked.schema.json +190 -0
  88. package/skill-narrative.md +14 -10
  89. package/skills/pi-context/SKILL.md +127 -49
  90. package/skills/pi-context/references/bundled-resources.md +7 -2
package/dist/index.js CHANGED
@@ -6,15 +6,25 @@ 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 { readBlock } from "./block-api.js";
10
- import { adoptConception, installedBlockDestPath, installedSchemaDestPath, loadConfig, loadContext, reconcileActiveSubstrateRegistration, writeSkeletonConfig, } from "./context.js";
11
- import { BootstrapNotFoundError, flipBootstrapPointer, resolveContextDir, SCHEMAS_DIR, schemasDir, tryResolveContextDir, writeBootstrapPointer, } from "./context-dir.js";
9
+ import { forEachBlockArray, readBlock, readBlockForDir, writeBlockForDir } from "./block-api.js";
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, mintSubstrateId, pendingBlockedPathForDir, resolveContextDir, SCHEMAS_DIR, schemasDir, tryResolveContextDir, writeBootstrapPointer, } from "./context-dir.js";
13
+ import { registerSubstrate } from "./context-registry.js";
12
14
  import { contextState, findAppendableBlocks, validateContext } from "./context-sdk.js";
13
15
  import { cleanGitEnv } from "./git-env.js";
14
16
  import { buildCurationSuggestions, loadLensView, renderLensView } from "./lens-view.js";
17
+ import { buildFreshRegistryWithChain, getProjectMigrationRegistryForDir, invalidateMigrationRegistryForDir, } from "./migration-registry-loader.js";
18
+ import { appendMigrationDeclForDir, loadMigrationsFileForDir, seedCatalogConfigMigrationDecls, } from "./migrations-store.js";
19
+ import { getObject, putObject } from "./object-store.js";
15
20
  import { registerAll } from "./ops-registry.js";
16
21
  import { buildOrientationBlock, skillsDir } from "./orientation.js";
17
- import { listRoadmaps, loadRoadmap, renderRoadmap, validateRoadmaps } from "./roadmap-plan.js";
22
+ import { loadPendingBlockedForDir, reconcilePendingBlockedForDir, } from "./pending-blocked-store.js";
23
+ import { loadRoadmap, renderRoadmap, validateRoadmap } from "./roadmap-plan.js";
24
+ import { mergeSchema } from "./schema-merge.js";
25
+ import { runMigrations } from "./schema-migrations.js";
26
+ import { ValidationError, validate, validateBlockWithMigrationForDir } from "./schema-validator.js";
27
+ import { writeSchemaCheckedForDir } from "./schema-write.js";
18
28
  import { checkForUpdates } from "./update-check.js";
19
29
  // ── Command handlers ────────────────────────────────────────────────────────
20
30
  /**
@@ -262,6 +272,850 @@ export function initProject(cwd, contextDir) {
262
272
  (skeleton.written ? created : skipped).push(configRel);
263
273
  return { created, skipped };
264
274
  }
275
+ /**
276
+ * Resolve the failing block item's `id` from an AJV `instancePath` (TASK-048).
277
+ * The AJV pointer for a block-item error is `/<arrayKey>/<index>/<field>…`; this
278
+ * matches the leading `/<arrayKey>/<index>` segment, resolves
279
+ * `blockData[arrayKey][index]`, and returns its `id` when that is a string.
280
+ * Returns undefined when the path is envelope-level (no `/<arrayKey>/<index>`
281
+ * prefix), the indexed item is absent, or its `id` is not a string.
282
+ */
283
+ function itemIdForPath(blockData, instancePath) {
284
+ const m = /^\/([^/]+)\/(\d+)/.exec(instancePath);
285
+ if (!m)
286
+ return undefined;
287
+ const [, arrayKey, indexStr] = m;
288
+ if (!blockData || typeof blockData !== "object")
289
+ return undefined;
290
+ const arr = blockData[arrayKey];
291
+ if (!Array.isArray(arr))
292
+ return undefined;
293
+ const item = arr[Number(indexStr)];
294
+ if (!item || typeof item !== "object")
295
+ return undefined;
296
+ const id = item.id;
297
+ return typeof id === "string" ? id : undefined;
298
+ }
299
+ /**
300
+ * Map AJV `ErrorObject[]` to the minimal {@link BlockValidationFailure} shape,
301
+ * resolving each error's failing item `id` from `blockData` via {@link
302
+ * itemIdForPath}. `message` is the AJV message (`""` when absent).
303
+ */
304
+ function mapValidationFailures(errors, blockData) {
305
+ return errors.map((e) => ({
306
+ itemId: itemIdForPath(blockData, e.instancePath),
307
+ instancePath: e.instancePath,
308
+ keyword: e.keyword,
309
+ message: e.message ?? "",
310
+ }));
311
+ }
312
+ /**
313
+ * TASK-052 / FGAP-081: locate the 0-based source-text line index of the field a
314
+ * `validation-failed` failure's `instancePath` points at, within the pretty-printed
315
+ * block-file lines. The AJV pointer is `/<arrayKey>/<index>/<field>…`; this finds the
316
+ * `"<arrayKey>":` line, then walks forward counting STRUCTURAL `{`/`[`/`}`/`]` (a
317
+ * per-line lexer skips JSON string literals — quote-to-quote with backslash escapes —
318
+ * so braces/brackets inside string VALUES are never miscounted) to reach the index-th
319
+ * item object, then finds the `"<field>":` line within it. Returns null when the path
320
+ * is envelope-level (no `/<arrayKey>/<index>` prefix) or the line cannot be located —
321
+ * the caller then places a header marker block at the TOP of the file. Line-granular
322
+ * only (no column); the marker write is always a full-line sentinel above/below.
323
+ */
324
+ function locateFailureLine(lines, instancePath) {
325
+ const m = /^\/([^/]+)\/(\d+)(?:\/(.+))?$/.exec(instancePath);
326
+ if (!m)
327
+ return null;
328
+ const [, arrayKey, indexStr, fieldTail] = m;
329
+ const index = Number(indexStr);
330
+ const arrayKeyRe = new RegExp(`^\\s*"${arrayKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
331
+ let arrLine = -1;
332
+ for (let i = 0; i < lines.length; i++) {
333
+ if (arrayKeyRe.test(lines[i])) {
334
+ arrLine = i;
335
+ break;
336
+ }
337
+ }
338
+ if (arrLine === -1)
339
+ return null;
340
+ let depth = 0;
341
+ let inArray = false;
342
+ let itemCount = -1;
343
+ let itemStart = -1;
344
+ for (let i = arrLine; i < lines.length; i++) {
345
+ const line = lines[i];
346
+ // Per-line lexer: count structural brackets only OUTSIDE string literals so a
347
+ // brace/bracket inside a string VALUE never shifts the depth (the string-aware
348
+ // fix — a naive char scan would mis-place the sentinel).
349
+ let inString = false;
350
+ let escaped = false;
351
+ for (let c = 0; c < line.length; c++) {
352
+ const ch = line[c];
353
+ if (inString) {
354
+ if (escaped) {
355
+ escaped = false;
356
+ }
357
+ else if (ch === "\\") {
358
+ escaped = true;
359
+ }
360
+ else if (ch === '"') {
361
+ inString = false;
362
+ }
363
+ continue;
364
+ }
365
+ if (ch === '"') {
366
+ inString = true;
367
+ continue;
368
+ }
369
+ if (ch === "[") {
370
+ if (!inArray)
371
+ inArray = true;
372
+ }
373
+ else if (ch === "]") {
374
+ if (inArray && depth === 0)
375
+ return null;
376
+ }
377
+ else if (ch === "{") {
378
+ if (inArray && depth === 0) {
379
+ itemCount++;
380
+ if (itemCount === index)
381
+ itemStart = i;
382
+ }
383
+ depth++;
384
+ }
385
+ else if (ch === "}") {
386
+ depth--;
387
+ if (inArray && depth === 0 && itemCount === index) {
388
+ if (!fieldTail)
389
+ return itemStart >= 0 ? itemStart : null;
390
+ const fieldKey = fieldTail.split("/")[0];
391
+ const fieldRe = new RegExp(`^\\s*"${fieldKey.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`);
392
+ for (let j = itemStart; j <= i; j++) {
393
+ if (fieldRe.test(lines[j]))
394
+ return j;
395
+ }
396
+ return itemStart >= 0 ? itemStart : null;
397
+ }
398
+ }
399
+ }
400
+ }
401
+ return null;
402
+ }
403
+ /**
404
+ * The git-style conflict sentinel contract — ONE source of truth shared by the
405
+ * marker WRITER (`composeMarkerText` builds its open/close lines from these
406
+ * tokens) and the marker DETECTORS (every blocked-marker scan derives its regex
407
+ * from `MARKER_LINE_RE`). The seven-character `<<<<<<<` / `>>>>>>>` literals are
408
+ * intentionally confined to these two token constants so the writer and the
409
+ * detectors can never drift to different sentinels. `MARKER_LINE_RE` matches a
410
+ * line that STARTS with either token (no `m` flag — single-line tests);
411
+ * `MARKER_LINE_RE_MULTILINE` is its multiline twin (same source, `m` flag) for
412
+ * whole-file scans where the marker may be on any line.
413
+ */
414
+ const MARKER_OPEN = "<<<<<<<";
415
+ const MARKER_CLOSE = ">>>>>>>";
416
+ const MARKER_LINE_RE = new RegExp(`^(${MARKER_OPEN}|${MARKER_CLOSE})`);
417
+ const MARKER_LINE_RE_MULTILINE = new RegExp(MARKER_LINE_RE.source, "m");
418
+ /**
419
+ * TASK-052 / FGAP-081: compose the marker-bearing block-file text for a
420
+ * validation-blocked resync. Given the raw pretty-printed block bytes and the
421
+ * per-item failures, inserts FULL-LINE git-style conflict sentinels around the
422
+ * offending line(s):
423
+ *
424
+ * `<<<<<<< BLOCKED <name> <from> -> <to> <instancePath> [<keyword>]: <message>`
425
+ * ...the offending line...
426
+ * `>>>>>>> target: <name>@<to>`
427
+ *
428
+ * The message reuses `describeBlockedFailure`'s keyword phrasing so the in-file text
429
+ * and the CLI `renderBlocked` report read identically. A failure whose line cannot be
430
+ * located (envelope-level path, or the field line is not found) gets a marker block at
431
+ * the TOP of the file. Sentinels are inserted bottom-up by descending line index so an
432
+ * earlier insertion does not shift a later one. The result is NOT valid JSON (by
433
+ * design — the markers make the block fail any JSON read until stripped by
434
+ * `resolveBlocked`).
435
+ */
436
+ function composeMarkerText(rawBytes, name, from, to, failures) {
437
+ const fromTok = from ?? "?";
438
+ const toTok = to ?? "?";
439
+ const lines = rawBytes.split("\n");
440
+ const topMarkers = [];
441
+ const byLine = new Map();
442
+ for (const f of failures) {
443
+ const idx = locateFailureLine(lines, f.instancePath);
444
+ if (idx === null) {
445
+ topMarkers.push(f);
446
+ }
447
+ else {
448
+ const list = byLine.get(idx) ?? [];
449
+ list.push(f);
450
+ byLine.set(idx, list);
451
+ }
452
+ }
453
+ const openFor = (f) => {
454
+ const kw = f.keyword && f.keyword !== "error" ? ` [${f.keyword}]` : "";
455
+ const ip = f.instancePath ? ` ${f.instancePath}` : "";
456
+ return `${MARKER_OPEN} BLOCKED ${name} ${fromTok} -> ${toTok}${ip}${kw}: ${describeBlockedFailure(f)}`;
457
+ };
458
+ const closeLine = `${MARKER_CLOSE} target: ${name}@${toTok}`;
459
+ const targetIdxs = [...byLine.keys()].sort((a, b) => b - a);
460
+ for (const idx of targetIdxs) {
461
+ const lineFailures = byLine.get(idx) ?? [];
462
+ const opens = lineFailures.map(openFor);
463
+ lines.splice(idx + 1, 0, closeLine);
464
+ lines.splice(idx, 0, ...opens);
465
+ }
466
+ if (topMarkers.length > 0) {
467
+ const header = [...topMarkers.map(openFor), closeLine];
468
+ lines.unshift(...header);
469
+ }
470
+ return lines.join("\n");
471
+ }
472
+ /**
473
+ * Resolve the package samples catalog once: the absolute `samplesRoot` plus a
474
+ * `byId` map from each block_kind's `canonical_id` to its declared
475
+ * `schema_path` / `data_path` (relative to `samplesRoot`). Shared read helper
476
+ * extracted from `installContext` so the installer and the read-only
477
+ * `checkStatus` drift detector resolve the catalog identically (no divergence).
478
+ *
479
+ * lazy fileURLToPath idiom (FGAP-088): import.meta.dirname is undefined under
480
+ * tsx's CJS-interop dist-load; import.meta.url is not. Reads the conception once
481
+ * for the canonical_id→paths map so callers resolve sources by the same
482
+ * block_kind declarations the accept-all conception ships (DEC-0037/0038).
483
+ */
484
+ function resolveCatalog() {
485
+ const samplesRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "samples");
486
+ const conception = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
487
+ const byId = new Map();
488
+ for (const bk of conception.block_kinds ?? []) {
489
+ byId.set(bk.canonical_id, { schema_path: bk.schema_path, data_path: bk.data_path });
490
+ }
491
+ return { samplesRoot, byId };
492
+ }
493
+ /**
494
+ * Read a JSON file's own declared `version` field (the schema/block envelope
495
+ * `version`). Returns undefined when the file is absent, unreadable, not valid
496
+ * JSON, or carries no string `version`. Used by resyncSchema to compare the
497
+ * catalog vs installed schema versions without crashing on a corrupt file.
498
+ */
499
+ function readDeclaredVersion(file) {
500
+ try {
501
+ const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
502
+ return typeof parsed.version === "string" ? parsed.version : undefined;
503
+ }
504
+ catch {
505
+ return undefined;
506
+ }
507
+ }
508
+ /**
509
+ * Read the verbatim bundled catalog `*.schema.json` body for a named
510
+ * block_kind. Resolves the catalog via the shared `resolveCatalog()` (same
511
+ * `samplesRoot` + `canonical_id`→`schema_path` map the installer and the drift
512
+ * detector use), then reads the catalog schema file's raw bytes. The returned
513
+ * `text` is the unparsed source (raw JSON Schema — properties/definitions/$id),
514
+ * NOT the `read-samples-catalog` projection, so an operator can diff it locally
515
+ * against the installed `<substrate>/schemas/<name>.schema.json` without
516
+ * touching node_modules (STORY-010 / FGAP-079, TASK-050).
517
+ *
518
+ * Read-only and substrate-independent: it touches only the package's bundled
519
+ * `samplesRoot` and takes no cwd, so no installed schema, block, or config is
520
+ * reachable from this path. Throws on an unknown kind, matching the unknown-kind
521
+ * throw idiom in validateBlockItemsAgainstCatalog / resolveConflict.
522
+ */
523
+ export function readCatalogSchemaText(kindName) {
524
+ const { samplesRoot, byId } = resolveCatalog();
525
+ const kind = byId.get(kindName);
526
+ if (!kind) {
527
+ throw new Error(`no catalog block_kind named '${kindName}'`);
528
+ }
529
+ const schemaPath = path.join(samplesRoot, kind.schema_path);
530
+ const text = fs.readFileSync(schemaPath, "utf-8");
531
+ return { kind: kindName, schemaPath, text };
532
+ }
533
+ /**
534
+ * Walk the shipped catalog migration chain for `schemaName` from `fromVersion`
535
+ * to `toVersion`, returning the ordered MigrationDecl list (one per hop) when a
536
+ * complete chain exists, or `null` when no chain reaches `toVersion`.
537
+ *
538
+ * Chain semantics mirror the registry's one-outgoing-edge-per-(schemaName,
539
+ * fromVersion) discipline: at each step we look for the single decl whose
540
+ * (schemaName, fromVersion) matches the current cursor and advance the cursor to
541
+ * its `toVersion`. A cycle guard bounds the walk to the number of available
542
+ * decls so a malformed catalog cannot loop forever. The catalog migrations file
543
+ * is read from `samplesRoot/migrations.json` (the SAME catalog the schema source
544
+ * is copied from), so the migration declarations and the schema versions cannot
545
+ * drift apart.
546
+ */
547
+ function findCatalogMigrationChain(samplesRoot, schemaName, fromVersion, toVersion) {
548
+ const catalogMigrationsPath = path.join(samplesRoot, "migrations.json");
549
+ let catalogDecls;
550
+ try {
551
+ const parsed = JSON.parse(fs.readFileSync(catalogMigrationsPath, "utf-8"));
552
+ catalogDecls = Array.isArray(parsed.migrations) ? parsed.migrations : [];
553
+ }
554
+ catch {
555
+ return null;
556
+ }
557
+ const chain = [];
558
+ let cursor = fromVersion;
559
+ const maxHops = catalogDecls.length;
560
+ for (let i = 0; i <= maxHops; i++) {
561
+ if (cursor === toVersion)
562
+ return chain;
563
+ const next = catalogDecls.find((d) => d.schemaName === schemaName && d.fromVersion === cursor);
564
+ if (!next)
565
+ return null;
566
+ chain.push(next);
567
+ cursor = next.toVersion;
568
+ }
569
+ // Exhausted maxHops without reaching toVersion → cyclic / non-terminating.
570
+ return null;
571
+ }
572
+ /**
573
+ * Read-only computation of the migration declarations a version-bump re-sync of
574
+ * `name` WOULD register into the substrate's migrations.json, WITHOUT writing
575
+ * anything (FGAP-050 — surfaced migration reporting). Mirrors the filter
576
+ * `resyncSchema`'s registration loop applies: read the installed vs catalog
577
+ * `version`, walk the shipped catalog chain, and subtract the decls whose
578
+ * `(schemaName, fromVersion)` pair is already present on disk. Returns the
579
+ * absent decls as the `{ schema, from, to }` report shape (empty array when the
580
+ * versions match, either version is unreadable, no chain reaches the catalog
581
+ * version, or every chain decl is already registered). Reads only — never
582
+ * appends, never overwrites the schema. Used by `updateContext`'s dryRun
583
+ * catalog-ahead arm to report the would-register set; the live path lets
584
+ * `resyncSchema` itself report the decls it actually appended.
585
+ */
586
+ function computeWouldRegisterMigrations(destRoot, samplesRoot, sourceFile, destFile, name) {
587
+ const catalogVersion = readDeclaredVersion(sourceFile);
588
+ const installedVersion = readDeclaredVersion(destFile);
589
+ if (installedVersion === undefined || catalogVersion === undefined || installedVersion === catalogVersion) {
590
+ return [];
591
+ }
592
+ const chain = findCatalogMigrationChain(samplesRoot, name, installedVersion, catalogVersion);
593
+ if (chain === null)
594
+ return [];
595
+ const existing = loadMigrationsFileForDir(destRoot);
596
+ const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName} ${m.fromVersion}`));
597
+ const out = [];
598
+ for (const decl of chain) {
599
+ const key = `${decl.schemaName} ${decl.fromVersion}`;
600
+ if (present.has(key))
601
+ continue;
602
+ out.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
603
+ present.add(key);
604
+ }
605
+ return out;
606
+ }
607
+ /**
608
+ * Pure-read simulation of `resyncSchema`'s outcome for ONE catalog-ahead schema
609
+ * (FGAP-066 / TASK-046 faithful dryRun). Predicts which of `resyncSchema`'s
610
+ * three terminal statuses (`resynced` / `migrated` / `blocked`) a live re-sync
611
+ * WOULD produce, by mirroring `resyncSchema`'s five decision arms 1:1 over an
612
+ * IN-MEMORY forward-migration + re-validation — WITHOUT writing any file and
613
+ * WITHOUT touching the project's cached migration registry. The aim is a dryRun
614
+ * plan whose per-schema bucket matches what `--update` (no dryRun) would land,
615
+ * so the preview no longer lists every catalog-ahead schema as `resynced`
616
+ * regardless of the true outcome.
617
+ *
618
+ * Arm mapping to `resyncSchema` (lines noted are that helper's, not this one's):
619
+ * 1. Same/either-undefined version → `resynced`, `wouldRegister: []`
620
+ * (mirrors the same-version verbatim-overwrite arm).
621
+ * 2. No catalog chain reaching the catalog version → `blocked`,
622
+ * `wouldRegister: []` (mirrors the no-chain refuse arm).
623
+ * 3. `wouldRegister` = the chain decls not already present in migrations.json
624
+ * (reuses `computeWouldRegisterMigrations`' dedup, shared by call).
625
+ * 4. Block file absent / zero items → `migrated`, `wouldRegister` (mirrors the
626
+ * no-items register-the-chain + advance arm). An UNREADABLE block file is
627
+ * treated as POPULATED (the same safety default `resyncSchema` applies),
628
+ * routing it through the validate path which throws → `blocked`.
629
+ * 5. Populated block → build a FRESH in-memory registry (the substrate's
630
+ * existing decls + the catalog chain's absent edges via `migrationFnFor`,
631
+ * deduped on (schemaName, fromVersion) since `register` throws on
632
+ * duplicates), then mirror `validateBlockWithMigrationForDir`'s keying in
633
+ * memory: when the block carries a string `schema_version` differing from
634
+ * the catalog version, `runMigrations(registry, name, blockVersion,
635
+ * catalogVersion, blockData)`; absent ⇒ validate as-is. Then
636
+ * `validate(catalogSchema, migrated, name)`. Pass → `migrated`; any throw
637
+ * (no path, migration throw, validation failure) → `blocked` with
638
+ * `wouldRegister: []` and a `detail` (TASK-048): `reason:"validation-failed"`,
639
+ * the version pair, and the per-item failures mapped from
640
+ * `ValidationError.errors` (a single synthetic failure for a non-AJV throw).
641
+ *
642
+ * `detail` carries the blocked diagnostic the dryRun arm pushes into
643
+ * `UpdateResult.blockedDetail`; the `blocked` arms still report `wouldRegister:
644
+ * []`, mirroring `resyncSchema`'s post-rollback truth that a refused re-sync
645
+ * leaves nothing registered. (Supersedes the prior unconsumed `errors` field.)
646
+ */
647
+ function simulateResyncOutcome(destRoot, samplesRoot, sourceFile, destFile, name) {
648
+ const catalogVersion = readDeclaredVersion(sourceFile);
649
+ const installedVersion = readDeclaredVersion(destFile);
650
+ // Arm 1 — same version (or either unreadable / non-versioned): no transition
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.
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
+ }
687
+ return { outcome: "resynced", wouldRegister: [] };
688
+ }
689
+ // Arm 2 — version bump with NO shipped chain reaching the catalog version: the
690
+ // live path refuses, leaving everything unchanged → blocked, no decls. The
691
+ // blocked detail records the no-chain reason + the version pair (TASK-048).
692
+ const chain = findCatalogMigrationChain(samplesRoot, name, installedVersion, catalogVersion);
693
+ if (chain === null) {
694
+ return {
695
+ outcome: "blocked",
696
+ wouldRegister: [],
697
+ detail: { reason: "no-migration-chain", from: installedVersion, to: catalogVersion },
698
+ };
699
+ }
700
+ // Arm 3 — the decls a live resync WOULD register (chain minus already-present),
701
+ // reusing the same dedup the live path applies.
702
+ const wouldRegister = computeWouldRegisterMigrations(destRoot, samplesRoot, sourceFile, destFile, name);
703
+ // Arm 4 — load the block exactly as the live path does (same dest path; missing
704
+ // file ⇒ no items; unreadable ⇒ treat populated AND route to the validate path).
705
+ const blockFile = installedBlockDestPath(destRoot, name);
706
+ let blockData;
707
+ let hasItems = false;
708
+ if (fs.existsSync(blockFile)) {
709
+ try {
710
+ blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
711
+ forEachBlockArray(blockData, (_arrayKey, arr) => {
712
+ if (arr.length > 0)
713
+ hasItems = true;
714
+ });
715
+ }
716
+ catch {
717
+ // Unreadable block — POPULATED safety default; the validate attempt below
718
+ // will throw on the undefined blockData, predicting the live blocked path.
719
+ hasItems = true;
720
+ }
721
+ }
722
+ if (!hasItems) {
723
+ // No items to migrate — the live path overwrites + registers the chain and
724
+ // reports migrated. Mirror that bucket; the decls would register.
725
+ return { outcome: "migrated", wouldRegister };
726
+ }
727
+ // Arm 5 — populated block: simulate the forward-migrate + re-validate IN MEMORY
728
+ // against a FRESH registry (never the project's cached registry, which a dryRun
729
+ // must not warm) and the catalog schema object read off disk.
730
+ try {
731
+ // Build the registry the live path would resolve against: the substrate's
732
+ // existing decls plus the catalog chain's absent edges, deduped on
733
+ // (schemaName, fromVersion). FRESH, never the project's cached registry.
734
+ const registry = buildFreshRegistryWithChain(destRoot, chain);
735
+ const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
736
+ // Mirror validateBlockWithMigrationForDir's keying IN MEMORY: fromVersion =
737
+ // the block's declared schema_version (when a string), toVersion = the
738
+ // catalog schema's version. Absent / non-string envelope ⇒ validate as-is.
739
+ const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
740
+ ? blockData.schema_version
741
+ : undefined;
742
+ let toValidate = blockData;
743
+ if (typeof blockVersion === "string" && blockVersion !== catalogVersion) {
744
+ toValidate = runMigrations(registry, name, blockVersion, catalogVersion, blockData);
745
+ }
746
+ validate(catalogSchema, toValidate, name);
747
+ return { outcome: "migrated", wouldRegister };
748
+ }
749
+ catch (err) {
750
+ // Migrated items would NOT validate against the catalog schema (or the
751
+ // migration walk threw) → the live path refuses → blocked. Post-rollback
752
+ // truth: nothing registers, so report no decls. The blocked detail records
753
+ // the validation-failed reason + the version pair + (when the throw is an AJV
754
+ // ValidationError) the per-item failures mapped against the loaded block data;
755
+ // a non-AJV throw becomes a single synthetic failure (TASK-048).
756
+ const failures = err instanceof ValidationError
757
+ ? mapValidationFailures(err.errors, blockData)
758
+ : [{ instancePath: "", keyword: "error", message: String(err) }];
759
+ return {
760
+ outcome: "blocked",
761
+ wouldRegister: [],
762
+ detail: { reason: "validation-failed", from: installedVersion, to: catalogVersion, failures },
763
+ };
764
+ }
765
+ }
766
+ /**
767
+ * Validate ONE installed block's items against the CATALOG schema version,
768
+ * read-only (TASK-048 — FGAP-077). The standalone diagnostic underneath the
769
+ * `validate-block-items` op: it answers "would these items pass the catalog
770
+ * schema (after the shipped forward-migration, when the block lags the catalog
771
+ * version)?" WITHOUT writing anything — no schema overwrite, no block re-write, no
772
+ * migration registration.
773
+ *
774
+ * Resolution mirrors the catalog-ahead resync path so the diagnostic predicts the
775
+ * same pass/fail `resyncSchema` would reach:
776
+ * - resolve the block_kind via `resolveCatalog().byId` (an unknown block throws
777
+ * a field-named Error); read the catalog schema body off `samplesRoot`.
778
+ * - load the installed block via `installedBlockDestPath` (a missing block file
779
+ * throws field-named).
780
+ * - when the block's declared envelope `schema_version` is a string differing
781
+ * from the catalog `version` AND a shipped chain reaches the catalog version,
782
+ * forward-migrate the block IN MEMORY through a FRESH registry seeded from the
783
+ * substrate's existing decls + the chain's absent edges (deduped on
784
+ * (schemaName, fromVersion)); otherwise validate as-is. No registry warming.
785
+ * - `validate(catalogSchema, data, blockName)` in try/catch → pass:
786
+ * `{valid:true, failures:[]}`; ValidationError → `{valid:false, failures}`
787
+ * mapped against the (migrated) data; any other throw → a single synthetic
788
+ * `{instancePath:"", keyword:"error", message:String(err)}` failure.
789
+ *
790
+ * Returns `{ block, from?, to?, valid, failures }`: `from`/`to` are the block's
791
+ * declared version and the catalog version (each undefined when unreadable).
792
+ * NEVER writes.
793
+ */
794
+ export function validateBlockItemsAgainstCatalog(cwd, blockName) {
795
+ const destRoot = tryResolveContextDir(cwd);
796
+ if (destRoot === null) {
797
+ throw new Error("No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.");
798
+ }
799
+ const { samplesRoot, byId } = resolveCatalog();
800
+ const kind = byId.get(blockName);
801
+ if (!kind) {
802
+ throw new Error(`block: '${blockName}' is not a known catalog block_kind (no canonical_id matches)`);
803
+ }
804
+ const sourceFile = path.join(samplesRoot, kind.schema_path);
805
+ const blockFile = installedBlockDestPath(destRoot, blockName);
806
+ if (!fs.existsSync(blockFile)) {
807
+ throw new Error(`block: installed block file not found for '${blockName}' at ${blockFile}`);
808
+ }
809
+ const catalogVersion = readDeclaredVersion(sourceFile);
810
+ let blockData;
811
+ try {
812
+ blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
813
+ }
814
+ catch (err) {
815
+ return {
816
+ block: blockName,
817
+ to: catalogVersion,
818
+ valid: false,
819
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
820
+ };
821
+ }
822
+ const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
823
+ ? blockData.schema_version
824
+ : undefined;
825
+ const fromVersion = typeof blockVersion === "string" ? blockVersion : undefined;
826
+ try {
827
+ const catalogSchema = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
828
+ let toValidate = blockData;
829
+ if (typeof blockVersion === "string" && catalogVersion !== undefined && blockVersion !== catalogVersion) {
830
+ const chain = findCatalogMigrationChain(samplesRoot, blockName, blockVersion, catalogVersion);
831
+ if (chain !== null) {
832
+ // FRESH registry seeded from the substrate's existing decls + the chain's
833
+ // absent edges, deduped on (schemaName, fromVersion) — never warm the
834
+ // project's cached registry from a read-only diagnostic.
835
+ const registry = buildFreshRegistryWithChain(destRoot, chain);
836
+ toValidate = runMigrations(registry, blockName, blockVersion, catalogVersion, blockData);
837
+ }
838
+ }
839
+ validate(catalogSchema, toValidate, blockName);
840
+ return { block: blockName, from: fromVersion, to: catalogVersion, valid: true, failures: [] };
841
+ }
842
+ catch (err) {
843
+ const failures = err instanceof ValidationError
844
+ ? mapValidationFailures(err.errors, blockData)
845
+ : [{ instancePath: "", keyword: "error", message: String(err) }];
846
+ return { block: blockName, from: fromVersion, to: catalogVersion, valid: false, failures };
847
+ }
848
+ }
849
+ /**
850
+ * Migration-aware re-sync of ONE installed schema under `/context install
851
+ * --update` (FGAP-029 safe re-sync, slice S4). Replaces the blind
852
+ * `fs.copyFileSync` the schema loop used to perform with a forward-migrate-or-
853
+ * refuse decision so a catalog schema version bump never strands the block's
854
+ * already-filed items under a schema they no longer satisfy.
855
+ *
856
+ * Precondition: the dest schema file EXISTS (the caller routes fresh installs
857
+ * straight to copyFileSync). Returns one of:
858
+ * - `"resynced"`: same installed/catalog `version` (description-only drift, or
859
+ * non-versioned schemas) → safe verbatim overwrite; OR a version bump whose
860
+ * block file is absent / holds zero items → no items to migrate, overwrite +
861
+ * register the chain.
862
+ * - `"migrated"`: a version bump with a populated block whose items
863
+ * forward-migrated through the shipped chain AND re-validated against the new
864
+ * schema → block re-written via the migration path.
865
+ * - `"blocked"`: a version bump with NO shipped chain reaching the catalog
866
+ * version, OR migrated items that would FAIL the new schema. The schema file,
867
+ * the block file, AND migrations.json are all left BYTE-UNCHANGED.
868
+ *
869
+ * Byte-unchanged guarantee for `"blocked"`: this helper captures the original
870
+ * schema bytes BEFORE any overwrite, and the original migrations.json bytes
871
+ * BEFORE the first decl append. For the version-bump path it must validate the
872
+ * migrated items against the NEW schema (which validateBlockWithMigrationFor-
873
+ * Dir reads from disk), so it registers the shipped chain into migrations.json
874
+ * and overwrites the schema first, then on ANY failure (no chain, append
875
+ * failure, validation throw) RESTORES the captured original schema bytes
876
+ * verbatim, RESTORES migrations.json to its captured pre-call bytes (or removes
877
+ * it when it did not exist pre-call, since the append loop may have created it),
878
+ * and invalidates the registry cache it may have warmed. The block file is only
879
+ * ever touched via writeBlockForDir on the SUCCESS path, so a `"blocked"`
880
+ * outcome never writes the block. The net effect for `"blocked"` is the schema
881
+ * file, the block file, and migrations.json all identical to their pre-call
882
+ * bytes (migrations.json absent if it was absent pre-call).
883
+ */
884
+ function resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name) {
885
+ const catalogVersion = readDeclaredVersion(sourceFile);
886
+ const installedVersion = readDeclaredVersion(destFile);
887
+ // TASK-051 / FGAP-080: build the pending-blocked record for a refused resync.
888
+ // Pin the TARGET catalog schema body into the object store (computeContentHash
889
+ // + putObject — idempotent on the content hash) so resolve-blocked can later
890
+ // re-validate the corrected block against the SAME pinned target this run
891
+ // blocked on, and carry the chain reaching it (empty for a no-chain refusal).
892
+ // resyncSchema RETURNS this entry; updateContext owns the sidecar write so the
893
+ // helper's contract stays narrow (it does not touch pending-blocked.json).
894
+ const buildPendingEntry = (reason, chain, failures) => {
895
+ const targetBody = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
896
+ const targetHash = computeContentHash(targetBody);
897
+ putObject(destRoot, targetHash, targetBody);
898
+ const entry = {
899
+ name,
900
+ reason,
901
+ target_hash: targetHash,
902
+ chain,
903
+ blocked_at: new Date().toISOString(),
904
+ };
905
+ if (installedVersion !== undefined)
906
+ entry.from = installedVersion;
907
+ if (catalogVersion !== undefined)
908
+ entry.to = catalogVersion;
909
+ if (failures)
910
+ entry.failures = failures;
911
+ return entry;
912
+ };
913
+ // (A) Same version (or either version unreadable / non-versioned): there is 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
924
+ // migration is registered on this arm, so it reports an empty decl list.
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
+ }
975
+ fs.copyFileSync(sourceFile, destFile);
976
+ return { status: "resynced", registeredMigrations: [] };
977
+ }
978
+ // (B) Version bump — migrate-or-refuse. installedVersion ≠ catalogVersion,
979
+ // both defined. The chain is sought in the catalog's OWN migrations.json so
980
+ // the declarations and the schema versions stay coherent.
981
+ const chain = findCatalogMigrationChain(samplesRoot, name, installedVersion, catalogVersion);
982
+ if (chain === null) {
983
+ // No shipped chain reaches the catalog version → refuse, leave unchanged.
984
+ // Nothing was registered, so the report is empty. The blocked detail records
985
+ // the no-chain reason + the version pair (TASK-048).
986
+ return {
987
+ status: "blocked",
988
+ registeredMigrations: [],
989
+ blockedDetail: { reason: "no-migration-chain", from: installedVersion, to: catalogVersion },
990
+ pendingEntry: buildPendingEntry("no-migration-chain", []),
991
+ };
992
+ }
993
+ // Capture migrations.json raw bytes BEFORE any decl append so the refuse path
994
+ // can restore it byte-for-byte — appendMigrationDeclForDir below mutates it, and
995
+ // a later validation throw must leave migrations.json byte-unchanged too (not
996
+ // just the schema file). Mirrors the originalSchemaBytes capture below.
997
+ const migrationsPath = migrationsPathForDir(destRoot);
998
+ const originalMigrationsBytes = fs.existsSync(migrationsPath) ? fs.readFileSync(migrationsPath) : null;
999
+ // Register each shipped decl into the substrate's migrations.json (idempotent:
1000
+ // skip a decl whose (schemaName, fromVersion) is already present — append
1001
+ // throws on collision). Registration is required BEFORE the validate+migrate
1002
+ // call so the loaded registry carries the forward edge.
1003
+ const existing = loadMigrationsFileForDir(destRoot);
1004
+ const present = new Set((existing?.migrations ?? []).map((m) => `${m.schemaName}\u0000${m.fromVersion}`));
1005
+ // Accumulate the decls THIS call actually appends (the not-already-present
1006
+ // subset), reported as the { schema, from, to } shape for the caller to
1007
+ // surface (FGAP-050). On the blocked rollback path below this list is
1008
+ // discarded and [] is returned (post-rollback truth: nothing stuck).
1009
+ const registeredMigrations = [];
1010
+ for (const decl of chain) {
1011
+ const key = `${decl.schemaName}\u0000${decl.fromVersion}`;
1012
+ if (present.has(key))
1013
+ continue;
1014
+ appendMigrationDeclForDir(destRoot, decl);
1015
+ present.add(key);
1016
+ registeredMigrations.push({ schema: decl.schemaName, from: decl.fromVersion, to: decl.toVersion });
1017
+ }
1018
+ // Determine whether the block file carries items to migrate. Absent / zero-
1019
+ // item blocks need no migration: register-the-chain + overwrite the schema and
1020
+ // report `migrated` (the version model advanced even though no items moved).
1021
+ const blockFile = installedBlockDestPath(destRoot, name);
1022
+ let blockData;
1023
+ let hasItems = false;
1024
+ if (fs.existsSync(blockFile)) {
1025
+ try {
1026
+ blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
1027
+ forEachBlockArray(blockData, (_arrayKey, arr) => {
1028
+ if (arr.length > 0)
1029
+ hasItems = true;
1030
+ });
1031
+ }
1032
+ catch {
1033
+ // Unreadable block — treat as POPULATED (safety default) and route it
1034
+ // through the validate path, which will throw and trigger rollback.
1035
+ hasItems = true;
1036
+ }
1037
+ }
1038
+ // Capture the original schema bytes for an airtight rollback, then overwrite
1039
+ // the schema so validateBlockWithMigrationForDir (which reads the schema from
1040
+ // disk) validates the migrated items against the NEW schema.
1041
+ const originalSchemaBytes = fs.readFileSync(destFile);
1042
+ fs.copyFileSync(sourceFile, destFile);
1043
+ if (!hasItems) {
1044
+ // No items to migrate — schema overwritten, chain registered. Done.
1045
+ return { status: "migrated", registeredMigrations };
1046
+ }
1047
+ try {
1048
+ const registry = getProjectMigrationRegistryForDir(destRoot);
1049
+ const migrated = validateBlockWithMigrationForDir(destRoot, name, blockData, registry);
1050
+ // Persist the forward-migrated block (identity ⇒ byte-equal items). When the
1051
+ // block carries a `schema_version` envelope field, advance it to the catalog
1052
+ // version so the on-disk block declares the version it now conforms to —
1053
+ // runMigrations applies the item transforms but does NOT stamp the envelope
1054
+ // version, so we advance it here before the write. writeBlockForDir re-routes
1055
+ // through the migration path; with the envelope now at catalogVersion it
1056
+ // validates straight (no re-migration) against the just-installed schema.
1057
+ if (migrated &&
1058
+ typeof migrated === "object" &&
1059
+ !Array.isArray(migrated) &&
1060
+ typeof migrated.schema_version === "string") {
1061
+ migrated.schema_version = catalogVersion;
1062
+ }
1063
+ // Persist the migrated block via the full whole-block write so the migrated
1064
+ // items are identity-stamped (mint-or-preserve oid, recompute content_hash).
1065
+ // Identity stamping is mandatory for every write.
1066
+ writeBlockForDir(destRoot, name, migrated);
1067
+ return { status: "migrated", registeredMigrations };
1068
+ }
1069
+ catch (err) {
1070
+ // Migrated items would NOT validate against the new schema (or migration
1071
+ // threw). Refuse: restore the original schema bytes verbatim so the schema
1072
+ // file is byte-unchanged, and invalidate the registry cache warmed above so
1073
+ // a subsequent read rebuilds against the on-disk (restored) state. The block
1074
+ // file was never written on this path, so it is already byte-unchanged.
1075
+ fs.writeFileSync(destFile, originalSchemaBytes);
1076
+ // Restore migrations.json to its pre-call bytes: if it existed, write the
1077
+ // captured bytes back; if it did NOT exist pre-call, the append loop created
1078
+ // it — remove it so the refuse path leaves no trace.
1079
+ if (originalMigrationsBytes === null) {
1080
+ if (fs.existsSync(migrationsPath))
1081
+ fs.unlinkSync(migrationsPath);
1082
+ }
1083
+ else {
1084
+ fs.writeFileSync(migrationsPath, originalMigrationsBytes);
1085
+ }
1086
+ invalidateMigrationRegistryForDir(destRoot);
1087
+ // Rollback reverted migrations.json to its pre-call bytes, so report no
1088
+ // registered migrations (post-rollback truth — nothing is stuck on disk). The
1089
+ // blocked detail records the validation-failed reason + the version pair + the
1090
+ // per-item failures (a single synthetic failure for a non-AJV throw) so the
1091
+ // caller surfaces WHY the resync refused (TASK-048).
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
+ }
1107
+ return {
1108
+ status: "blocked",
1109
+ registeredMigrations: [],
1110
+ blockedDetail: {
1111
+ reason: "write-failed",
1112
+ from: installedVersion,
1113
+ to: catalogVersion,
1114
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
1115
+ },
1116
+ };
1117
+ }
1118
+ }
265
1119
  /**
266
1120
  * /context install opt-in mechanism (DEC-0011). Reads config.installed_schemas
267
1121
  * and config.installed_blocks, copies declared assets from the package
@@ -273,8 +1127,54 @@ export function initProject(cwd, contextDir) {
273
1127
  * - Sources missing from the samples catalog are reported as "notFound".
274
1128
  * - Empty install lists are not an error — the result is a clean no-op.
275
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
+ }
276
1167
  export function installContext(cwd, options = {}) {
277
- const result = { installed: [], updated: [], skipped: [], notFound: [] };
1168
+ const result = {
1169
+ installed: [],
1170
+ updated: [],
1171
+ skipped: [],
1172
+ notFound: [],
1173
+ preserved: [],
1174
+ resynced: [],
1175
+ migrated: [],
1176
+ blocked: [],
1177
+ };
278
1178
  const overwrite = options.overwrite === true;
279
1179
  const destRoot = tryResolveContextDir(cwd);
280
1180
  if (destRoot === null) {
@@ -282,6 +1182,16 @@ export function installContext(cwd, options = {}) {
282
1182
  "No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
283
1183
  return result;
284
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;
285
1195
  const config = loadConfig(cwd);
286
1196
  if (!config) {
287
1197
  result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
@@ -296,16 +1206,10 @@ export function installContext(cwd, options = {}) {
296
1206
  const schemasRoot = path.join(destRoot, SCHEMAS_DIR);
297
1207
  if (!fs.existsSync(schemasRoot))
298
1208
  fs.mkdirSync(schemasRoot, { recursive: true });
299
- // lazy fileURLToPath idiom (FGAP-088): import.meta.dirname is undefined under
300
- // tsx's CJS-interop dist-load; import.meta.url is not. Read the conception once for
301
- // the canonical_id→paths map so install resolves sources by the same
302
- // block_kind declarations the accept-all conception ships (DEC-0037/0038).
303
- const samplesRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "samples");
304
- const conception = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
305
- const byId = new Map();
306
- for (const bk of conception.block_kinds ?? []) {
307
- byId.set(bk.canonical_id, { schema_path: bk.schema_path, data_path: bk.data_path });
308
- }
1209
+ // Catalog resolution (samplesRoot + canonical_id→paths map) is shared with
1210
+ // the read-only checkStatus drift detector via resolveCatalog so installer
1211
+ // and detector cannot drift in how they resolve sources.
1212
+ const { samplesRoot, byId } = resolveCatalog();
309
1213
  for (const name of config.installed_schemas ?? []) {
310
1214
  const relDest = `${SCHEMAS_DIR}/${name}.schema.json`;
311
1215
  const kind = byId.get(name);
@@ -326,8 +1230,33 @@ export function installContext(cwd, options = {}) {
326
1230
  result.skipped.push(relDest);
327
1231
  continue;
328
1232
  }
329
- fs.copyFileSync(sourceFile, destFile);
330
- (destExists ? result.updated : result.installed).push(relDest);
1233
+ if (!destExists) {
1234
+ // Fresh install no installed copy yet, so there are no items to
1235
+ // migrate. Copy the catalog schema verbatim (unchanged behaviour).
1236
+ fs.copyFileSync(sourceFile, destFile);
1237
+ result.installed.push(relDest);
1238
+ continue;
1239
+ }
1240
+ // destExists && overwrite — migration-aware schema re-sync (FGAP-029 S4).
1241
+ // resyncSchema decides between same-version overwrite, version-bump
1242
+ // forward-migration, and refuse-and-leave-unchanged; it never strands the
1243
+ // block's items under a schema they fail.
1244
+ // resyncSchema now returns { status, registeredMigrations }; installContext
1245
+ // reports only the status bucket (the migration-decl reporting surface is on
1246
+ // /context update — FGAP-050 — so the appended decls are intentionally
1247
+ // ignored here).
1248
+ const { status: outcome } = resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name);
1249
+ switch (outcome) {
1250
+ case "resynced":
1251
+ result.resynced.push(relDest);
1252
+ break;
1253
+ case "migrated":
1254
+ result.migrated.push(relDest);
1255
+ break;
1256
+ case "blocked":
1257
+ result.blocked.push(relDest);
1258
+ break;
1259
+ }
331
1260
  }
332
1261
  for (const name of config.installed_blocks ?? []) {
333
1262
  const relDest = `${name}.json`;
@@ -343,15 +1272,1240 @@ export function installContext(cwd, options = {}) {
343
1272
  continue;
344
1273
  }
345
1274
  const destExists = fs.existsSync(destFile);
346
- if (destExists && !overwrite) {
347
- result.skipped.push(relDest);
1275
+ if (destExists) {
1276
+ // Block-data preservation (FGAP-029 safe re-sync): never copy a catalog
1277
+ // starter over a block that already holds items, even under --update.
1278
+ // Catalog block starters are empty ({"tasks": []}); copying one over a
1279
+ // populated block would delete the filed items. Read the existing block
1280
+ // and treat ANY top-level (or nested) array with length > 0 as populated.
1281
+ // Safety default: if the block can't be read/confirmed-empty (throw, or
1282
+ // migration-validation failure), treat it as POPULATED — never overwrite
1283
+ // something we could not read.
1284
+ let populated = true;
1285
+ try {
1286
+ const existing = readBlockForDir(destRoot, name);
1287
+ let hasItems = false;
1288
+ forEachBlockArray(existing, (_arrayKey, arr) => {
1289
+ if (arr.length > 0)
1290
+ hasItems = true;
1291
+ });
1292
+ populated = hasItems;
1293
+ }
1294
+ catch {
1295
+ populated = true;
1296
+ }
1297
+ if (populated) {
1298
+ result.preserved.push(relDest);
1299
+ continue;
1300
+ }
1301
+ if (!overwrite) {
1302
+ result.skipped.push(relDest);
1303
+ continue;
1304
+ }
1305
+ // FGAP-051 idempotent skip: the on-disk empty block already equals the
1306
+ // catalog starter (JCS-canonical content equality, key-order/whitespace
1307
+ // insensitive) — rewriting it would be a no-op churn (mtime bump, identical
1308
+ // bytes), so skip it and report `skipped` rather than `updated`. Reaches
1309
+ // here only for an itemless block under overwrite; a starter whose content
1310
+ // differs (e.g. an extra top-level field) still falls through to the copy.
1311
+ if (computeFileContentHash(destFile) === computeFileContentHash(sourceFile)) {
1312
+ result.skipped.push(relDest);
1313
+ continue;
1314
+ }
1315
+ fs.copyFileSync(sourceFile, destFile);
1316
+ result.updated.push(relDest);
348
1317
  continue;
349
1318
  }
350
1319
  fs.copyFileSync(sourceFile, destFile);
351
- (destExists ? result.updated : result.installed).push(relDest);
1320
+ result.installed.push(relDest);
1321
+ }
1322
+ // ── Install baseline of the installed SCHEMAS (FGAP-029 safe re-sync) ──────
1323
+ // Record where the installed schema model came from + a per-schema content
1324
+ // fingerprint, so a later slice can detect installed-vs-catalog drift. BLOCKS
1325
+ // are user data and are deliberately NOT baselined (only the re-syncable model
1326
+ // — schemas — is fingerprinted). The fingerprint is taken from the INSTALLED
1327
+ // dest file (via the SAME `installedSchemaDestPath` derivation the copy loop
1328
+ // uses), not the catalog source, so the baseline reflects what is actually on
1329
+ // disk. `version` is the installed schema file's own declared `version` field.
1330
+ const assets = {};
1331
+ for (const name of config.installed_schemas ?? []) {
1332
+ const destSchemaFile = installedSchemaDestPath(destRoot, name);
1333
+ if (!fs.existsSync(destSchemaFile))
1334
+ continue;
1335
+ // Safety default (mirror of the block-preservation try/catch above): a
1336
+ // declared schema file present-but-corrupt (not valid JSON, unreadable, or
1337
+ // not hashable) must NOT crash installContext. Skip baselining it — it is
1338
+ // simply omitted from `installed_from.assets`; drift tracking resumes once
1339
+ // the file is valid. Install proceeds for all other declared schemas.
1340
+ try {
1341
+ const schemaJson = JSON.parse(fs.readFileSync(destSchemaFile, "utf-8"));
1342
+ const content_hash = computeFileContentHash(destSchemaFile);
1343
+ // Base-stamp (TASK-035 / FEAT-006 T2): persist the as-installed schema body
1344
+ // into the content-addressed object store keyed by its install-baseline
1345
+ // content_hash, so the merge base is retrievable later (TASK-036 precondition).
1346
+ // putObject is idempotent (content-addressed) — re-installing unchanged content
1347
+ // re-stamps identical bytes harmlessly. Reuses the already-parsed schemaJson and
1348
+ // already-computed content_hash; no re-read or re-hash.
1349
+ putObject(destRoot, content_hash, schemaJson);
1350
+ assets[name] = {
1351
+ content_hash,
1352
+ version: typeof schemaJson.version === "string" ? schemaJson.version : "",
1353
+ };
1354
+ }
1355
+ catch { }
1356
+ }
1357
+ // `catalog` is the pi-context package "name@version", resolved from the SAME
1358
+ // package root `samplesRoot` is derived from (one dir up from this module).
1359
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
1360
+ const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgRoot, "package.json"), "utf-8"));
1361
+ const catalog = `${pkgJson.name ?? ""}@${pkgJson.version ?? ""}`;
1362
+ // `catalog_version` is the conception's own schema_version (samplesRoot resolved above).
1363
+ const conceptionVersion = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
1364
+ const catalog_version = conceptionVersion.schema_version ?? "";
1365
+ // Idempotency: when the EXISTING baseline has deep-equal catalog + catalog_version
1366
+ // + assets, PRESERVE it verbatim (including its `at`) so a re-run on an unchanged
1367
+ // substrate produces a byte-identical config.json. Only refresh `at` when the
1368
+ // baseline content differs.
1369
+ const existingFrom = config.installed_from;
1370
+ const sameBaseline = existingFrom !== undefined &&
1371
+ existingFrom.catalog === catalog &&
1372
+ existingFrom.catalog_version === catalog_version &&
1373
+ JSON.stringify(existingFrom.assets) === JSON.stringify(assets);
1374
+ const installed_from = sameBaseline
1375
+ ? existingFrom
1376
+ : { catalog, catalog_version, at: new Date().toISOString(), assets };
1377
+ writeConfig(cwd, { ...config, installed_from });
1378
+ return result;
1379
+ }
1380
+ /**
1381
+ * PURE-READ drift detector for `/context check-status` (FGAP-029 safe
1382
+ * re-sync, slice S3). Compares, per installed schema, the S2 install baseline
1383
+ * against the catalog's current schema file and the currently-installed schema
1384
+ * file, classifies the drift, and RETURNS the report. Writes NOTHING anywhere —
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.
1390
+ *
1391
+ * For each `config.installed_schemas` entry:
1392
+ * - baseline = config.installed_from?.assets?.[name]?.content_hash
1393
+ * - catalog-now = computeFileContentHash(samplesRoot/<kind.schema_path>)
1394
+ * (state `missing-catalog` when the source file is absent
1395
+ * or unhashable)
1396
+ * - installed-now = computeFileContentHash(installedSchemaDestPath(destRoot,name))
1397
+ * (state `missing-installed` when the dest file is absent
1398
+ * or unhashable)
1399
+ *
1400
+ * Each file-hash read is wrapped in try/catch so a corrupt file degrades to a
1401
+ * `missing-*` / diverged classification rather than throwing — mirroring S2's
1402
+ * safety default. A schema whose name has no catalog block_kind is reported
1403
+ * `missing-catalog`.
1404
+ */
1405
+ export function checkStatus(cwd) {
1406
+ const emptySummary = () => ({
1407
+ "in-sync": 0,
1408
+ "catalog-ahead": 0,
1409
+ "locally-modified": 0,
1410
+ "both-diverged": 0,
1411
+ "no-baseline": 0,
1412
+ "missing-catalog": 0,
1413
+ "missing-installed": 0,
1414
+ total: 0,
1415
+ });
1416
+ const perAsset = [];
1417
+ const destRoot = tryResolveContextDir(cwd);
1418
+ if (destRoot === null) {
1419
+ return { perAsset, summary: emptySummary() };
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);
1424
+ const config = loadConfig(cwd);
1425
+ if (!config) {
1426
+ return { perAsset, summary: emptySummary() };
1427
+ }
1428
+ const { samplesRoot, byId } = resolveCatalog();
1429
+ for (const name of config.installed_schemas ?? []) {
1430
+ const baselineAsset = config.installed_from?.assets?.[name];
1431
+ const baseline = baselineAsset?.content_hash;
1432
+ const kind = byId.get(name);
1433
+ // Catalog-now hash (undefined when the source is absent/unhashable).
1434
+ let catalogHash;
1435
+ let catalogVersion;
1436
+ if (kind) {
1437
+ const sourceFile = path.join(samplesRoot, kind.schema_path);
1438
+ try {
1439
+ catalogHash = computeFileContentHash(sourceFile);
1440
+ const parsed = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
1441
+ catalogVersion = typeof parsed.version === "string" ? parsed.version : undefined;
1442
+ }
1443
+ catch {
1444
+ catalogHash = undefined;
1445
+ }
1446
+ }
1447
+ // Installed-now hash (undefined when the dest is absent/unhashable).
1448
+ let installedHash;
1449
+ try {
1450
+ installedHash = computeFileContentHash(installedSchemaDestPath(destRoot, name));
1451
+ }
1452
+ catch {
1453
+ installedHash = undefined;
1454
+ }
1455
+ const installed_modified = baseline !== undefined && installedHash !== undefined && installedHash !== baseline;
1456
+ let state;
1457
+ if (installedHash === undefined) {
1458
+ state = "missing-installed";
1459
+ }
1460
+ else if (catalogHash === undefined) {
1461
+ state = "missing-catalog";
1462
+ }
1463
+ else if (baseline === undefined) {
1464
+ state = "no-baseline";
1465
+ }
1466
+ else {
1467
+ const catalogDrift = catalogHash !== baseline;
1468
+ const installedDrift = installedHash !== baseline;
1469
+ if (!catalogDrift && !installedDrift) {
1470
+ state = "in-sync";
1471
+ }
1472
+ else if (catalogDrift && !installedDrift) {
1473
+ state = "catalog-ahead";
1474
+ }
1475
+ else if (!catalogDrift && installedDrift) {
1476
+ state = "locally-modified";
1477
+ }
1478
+ else {
1479
+ state = "both-diverged";
1480
+ }
1481
+ }
1482
+ // FGAP-078 / STORY-007: surface, per asset, whether the catalog has moved
1483
+ // past the install baseline and by what version gap. Computed AFTER the
1484
+ // classification arm above (the arm is unchanged). `behind` is true exactly
1485
+ // for the catalog-moved states; the version delta carries the baseline →
1486
+ // catalog version pair and a `basis` that records whether the catalog drift
1487
+ // was a declared version bump or a content-only change (catalog-ahead is a
1488
+ // hash comparison, so the version string can be unchanged while the body moved).
1489
+ const behind = state === "catalog-ahead" || state === "both-diverged";
1490
+ const baselineVersion = baselineAsset?.version;
1491
+ const versionDelta = behind
1492
+ ? {
1493
+ from: baselineVersion,
1494
+ to: catalogVersion,
1495
+ basis: baselineVersion !== undefined && catalogVersion !== undefined && baselineVersion !== catalogVersion
1496
+ ? "version-bump"
1497
+ : "content-only",
1498
+ }
1499
+ : undefined;
1500
+ perAsset.push({
1501
+ name,
1502
+ state,
1503
+ baseline_version: baselineVersion,
1504
+ catalog_version: catalogVersion,
1505
+ installed_modified,
1506
+ ...(behind ? { behind: true, version_delta: versionDelta } : {}),
1507
+ });
1508
+ }
1509
+ const summary = emptySummary();
1510
+ for (const a of perAsset) {
1511
+ summary[a.state] += 1;
1512
+ summary.total += 1;
1513
+ }
1514
+ return { perAsset, summary };
1515
+ }
1516
+ /**
1517
+ * Render a `CheckStatusReport` (from the read-only `checkStatus` detector) as a
1518
+ * scannable per-state grouping for `/context check-status`. Groups the
1519
+ * affected schema names under each non-empty state, then a total line. Mirrors
1520
+ * the install-handler `lines.push` style.
1521
+ */
1522
+ export function renderCheckStatus(report) {
1523
+ const lines = [];
1524
+ lines.push("Schema drift — installed vs catalog (read-only; no writes):");
1525
+ const order = [
1526
+ "in-sync",
1527
+ "catalog-ahead",
1528
+ "locally-modified",
1529
+ "both-diverged",
1530
+ "no-baseline",
1531
+ "missing-catalog",
1532
+ "missing-installed",
1533
+ ];
1534
+ for (const state of order) {
1535
+ const assets = report.perAsset.filter((a) => a.state === state);
1536
+ if (assets.length === 0)
1537
+ continue;
1538
+ // For a behind asset (catalog-ahead / both-diverged) annotate the name with
1539
+ // the version gap: `name (1.0.0 -> 1.0.1)` for a declared bump, or
1540
+ // `name (1.0.1, content changed)` / `name (content changed)` (versions
1541
+ // undefined) for a content-only drift — so the version pair is scannable
1542
+ // inline (FGAP-078 / STORY-007).
1543
+ const labels = assets.map((a) => {
1544
+ if (!a.behind || !a.version_delta)
1545
+ return a.name;
1546
+ const { from, to, basis } = a.version_delta;
1547
+ if (basis === "version-bump")
1548
+ return `${a.name} (${from} -> ${to})`;
1549
+ const v = to ?? from;
1550
+ return v !== undefined ? `${a.name} (${v}, content changed)` : `${a.name} (content changed)`;
1551
+ });
1552
+ lines.push(` ${state} (${assets.length}): ${labels.join(", ")}`);
1553
+ }
1554
+ if (report.perAsset.length === 0) {
1555
+ lines.push(" (no installed schemas declared — nothing to compare)");
1556
+ }
1557
+ lines.push(`Total: ${report.summary.total} schema(s).`);
1558
+ return lines.join("\n");
1559
+ }
1560
+ /**
1561
+ * `/context update` engine (FEAT-006 T1 — TASK-034 / DEC-0017). Brings the
1562
+ * installed substrate MODEL (schemas) current with the packaged catalog by
1563
+ * consulting the read-only `checkStatus` drift detector per installed schema and
1564
+ * routing each by its drift `state`:
1565
+ *
1566
+ * - `in-sync` → no-op (recorded under `inSync`).
1567
+ * - `catalog-ahead` → re-sync via the EXISTING `resyncSchema` (the SAME
1568
+ * call shape `/context install --update`'s schema loop
1569
+ * uses for that asset: `resyncSchema(destRoot,
1570
+ * samplesRoot, sourceFile, destFile, name)` with
1571
+ * `sourceFile = samplesRoot/<kind.schema_path>` and
1572
+ * `destFile = installedSchemaDestPath(destRoot,
1573
+ * name)`). Its `resynced`/`migrated`/`blocked` outcome
1574
+ * routes into the matching array.
1575
+ * - `locally-modified` /
1576
+ * `both-diverged` → REFUSE-AND-REPORT: do NOT call `resyncSchema`, do NOT
1577
+ * overwrite; record under `refused`. The first increment
1578
+ * (DEC-0017) never clobbers a locally-edited schema; the
1579
+ * three-way merge is deferred (TASK-036).
1580
+ * [TASK-036 — FEAT-006 T3, now implemented]: the merge is no
1581
+ * longer deferred. BASE is reconstructed from the baseline's
1582
+ * content-addressed body (`getObject(destRoot,
1583
+ * installed_from.assets[name].content_hash)`) and key/path-
1584
+ * merged with OURS (installed file) + THEIRS (catalog file)
1585
+ * via `mergeSchema`. Conflict-free → write via
1586
+ * `writeSchemaCheckedForDir` (meta-validated; `dryRun`
1587
+ * validates without writing), record under `merged`; any
1588
+ * conflict → record `{name, conflicts}` under `conflicts`,
1589
+ * write NOTHING; no retrievable base body / parse / merge /
1590
+ * validation throw → fall back to `refused`. An auto-merged
1591
+ * body is base-refreshed post-loop like a resync.
1592
+ * - `no-baseline` /
1593
+ * `missing-catalog` /
1594
+ * `missing-installed` → record under `reported` (with the state) — undecidable
1595
+ * or absent, not acted on.
1596
+ *
1597
+ * When `dryRun` is true NO writes occur: `checkStatus` is consulted and the action
1598
+ * plan is computed, but `resyncSchema` is NOT invoked. For each `catalog-ahead`
1599
+ * schema the dryRun arm calls `simulateResyncOutcome`, which mirrors
1600
+ * `resyncSchema`'s decision arms 1:1 over an IN-MEMORY forward-migration +
1601
+ * re-validation (FGAP-066 / TASK-046) and predicts the precise outcome bucket —
1602
+ * `resynced` / `migrated` / `blocked` — the live path would land, so the schema is
1603
+ * pushed onto `result[outcome]` rather than unconditionally onto `resynced`. The
1604
+ * would-register migration decls it returns (the same FGAP-050 read-only set:
1605
+ * catalog chain minus the decls already on disk; empty on a blocked prediction)
1606
+ * are surfaced onto `migrationsRegistered` without writing. The live path mutates
1607
+ * only via `resyncSchema` (the catalog-ahead branch) and surfaces the decls it
1608
+ * appended onto `migrationsRegistered`; `installContext` and its install handler
1609
+ * are NOT touched.
1610
+ * Resolves the catalog / dest paths through the SAME `resolveCatalog` +
1611
+ * `installedSchemaDestPath` helpers the installer + detector use.
1612
+ */
1613
+ export function updateContext(cwd, { dryRun = false } = {}) {
1614
+ const result = {
1615
+ dryRun,
1616
+ resynced: [],
1617
+ migrated: [],
1618
+ blocked: [],
1619
+ blockedDetail: [],
1620
+ refused: [],
1621
+ merged: [],
1622
+ conflicts: [],
1623
+ reported: [],
1624
+ inSync: [],
1625
+ registryAdditions: { relation_types: [], invariants: [], block_kinds: [], lenses: [] },
1626
+ migrationsRegistered: [],
1627
+ };
1628
+ const destRoot = tryResolveContextDir(cwd);
1629
+ if (destRoot === null) {
1630
+ result.error =
1631
+ "No .pi-context.json bootstrap pointer found. Run /context init <substrate-dir> first to bootstrap the substrate.";
1632
+ return result;
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
+ }
1651
+ const config = loadConfig(cwd);
1652
+ if (!config) {
1653
+ result.error = "No config.json found in substrate dir — run /context init <substrate-dir> first.";
1654
+ return result;
1655
+ }
1656
+ // Drift classification is the read-only checkStatus detector (shares
1657
+ // resolveCatalog + installedSchemaDestPath with the installer, so update and
1658
+ // install cannot diverge on how they resolve sources / dests). The routing
1659
+ // below acts ONLY on the classified state; it never re-derives drift.
1660
+ const report = checkStatus(cwd);
1661
+ // Catalog resolution for the catalog-ahead resync branch — the SAME helper the
1662
+ // installer + detector use, so the sourceFile derivation matches checkStatus's.
1663
+ const { samplesRoot, byId } = resolveCatalog();
1664
+ // TASK-051 / FGAP-080: the live run's pending-blocked records. Each blocked
1665
+ // catalog-ahead resync returns a pinned `pendingEntry`; after the schema loop
1666
+ // the LIVE path reconciles pending-blocked.json to exactly this set (an empty
1667
+ // set removes the sidecar — no stale empty file). The dryRun path collects
1668
+ // nothing and the post-loop reconcile is !dryRun-guarded, so a preview never
1669
+ // touches the sidecar (nor the object store, since resyncSchema is never called
1670
+ // under dryRun).
1671
+ const pendingBlockedEntries = [];
1672
+ for (const asset of report.perAsset) {
1673
+ const { name, state } = asset;
1674
+ switch (state) {
1675
+ case "in-sync":
1676
+ result.inSync.push(name);
1677
+ break;
1678
+ case "catalog-ahead": {
1679
+ if (dryRun) {
1680
+ // Preview only — never call resyncSchema (it writes).
1681
+ // FGAP-066 / TASK-046: predict the PRECISE per-schema outcome by running
1682
+ // resyncSchema’s decision arms 1:1 over an in-memory forward-migration +
1683
+ // re-validation (simulateResyncOutcome), so the plan buckets the schema as
1684
+ // the resynced / migrated / blocked it WOULD land — not as resynced
1685
+ // unconditionally. The would-register decls it returns are the same FGAP-050
1686
+ // set (empty on a blocked prediction — post-rollback truth that a refused
1687
+ // resync registers nothing). Wrapped in the merge-arm per-asset error
1688
+ // tolerance: on a thrown helper failure, fall back to the prior behavior
1689
+ // (resynced + no decls) so the plan never crashes.
1690
+ try {
1691
+ const kind = byId.get(name);
1692
+ if (kind) {
1693
+ const sourceFile = path.join(samplesRoot, kind.schema_path);
1694
+ const destFile = installedSchemaDestPath(destRoot, name);
1695
+ const { outcome, wouldRegister, detail } = simulateResyncOutcome(destRoot, samplesRoot, sourceFile, destFile, name);
1696
+ result[outcome].push(name);
1697
+ for (const m of wouldRegister)
1698
+ result.migrationsRegistered.push(m);
1699
+ // TASK-048: a predicted-blocked schema carries its diagnostic detail
1700
+ // (reason + version pair + per-item failures) into blockedDetail so the
1701
+ // dryRun plan surfaces WHY it would refuse, matching the live run.
1702
+ if (outcome === "blocked" && detail) {
1703
+ result.blockedDetail.push({ name, ...detail });
1704
+ }
1705
+ }
1706
+ else {
1707
+ result.resynced.push(name);
1708
+ }
1709
+ }
1710
+ catch {
1711
+ result.resynced.push(name);
1712
+ }
1713
+ break;
1714
+ }
1715
+ const kind = byId.get(name);
1716
+ if (!kind) {
1717
+ // A catalog-ahead classification implies a catalog block_kind existed
1718
+ // when checkStatus ran; defend against a races/edge by reporting rather
1719
+ // than throwing (mirrors checkStatus's missing-catalog default).
1720
+ result.reported.push({ name, state: "missing-catalog" });
1721
+ break;
1722
+ }
1723
+ const sourceFile = path.join(samplesRoot, kind.schema_path);
1724
+ const destFile = installedSchemaDestPath(destRoot, name);
1725
+ const { status: outcome, registeredMigrations, blockedDetail, pendingEntry, } = resyncSchema(destRoot, samplesRoot, sourceFile, destFile, name);
1726
+ // FGAP-050: surface the decls the live resync actually appended.
1727
+ for (const m of registeredMigrations)
1728
+ result.migrationsRegistered.push(m);
1729
+ switch (outcome) {
1730
+ case "resynced":
1731
+ result.resynced.push(name);
1732
+ break;
1733
+ case "migrated":
1734
+ result.migrated.push(name);
1735
+ break;
1736
+ case "blocked":
1737
+ result.blocked.push(name);
1738
+ // TASK-048: carry the live refusal diagnostic (reason + version pair +
1739
+ // per-item failures) into blockedDetail alongside the bare name.
1740
+ if (blockedDetail)
1741
+ result.blockedDetail.push({ name, ...blockedDetail });
1742
+ // TASK-051 / FGAP-080: collect the pinned pending-blocked record so the
1743
+ // post-loop reconcile persists it (resolve-blocked consumes it later).
1744
+ if (pendingEntry)
1745
+ pendingBlockedEntries.push(pendingEntry);
1746
+ break;
1747
+ }
1748
+ break;
1749
+ }
1750
+ case "locally-modified":
1751
+ case "both-diverged": {
1752
+ // 3-way merge (TASK-036 — FEAT-006 T3): a locally-edited schema is no
1753
+ // longer blindly refused. Reconstruct BASE from the recorded install
1754
+ // baseline's content-addressed body, take OURS = the installed file and
1755
+ // THEIRS = the catalog file, and key/path-merge. Conflict-free → write
1756
+ // (or, under dryRun, validate-only); any conflict → record + do NOT write.
1757
+ const kind = byId.get(name);
1758
+ if (!kind) {
1759
+ // Mirrors the catalog-ahead arm's missing-catalog guard: a drift
1760
+ // classification implies a catalog block_kind existed at check time;
1761
+ // defend a race by reporting rather than throwing.
1762
+ result.reported.push({ name, state: "missing-catalog" });
1763
+ break;
1764
+ }
1765
+ const sourceFile = path.join(samplesRoot, kind.schema_path);
1766
+ const destFile = installedSchemaDestPath(destRoot, name);
1767
+ const baseHash = config.installed_from?.assets?.[name]?.content_hash;
1768
+ const base = baseHash ? getObject(destRoot, baseHash) : null;
1769
+ if (!base) {
1770
+ // No retrievable stamped base body ⇒ no safe 3-way merge possible;
1771
+ // fall back to refuse-and-report (DEC-0017) so the drift signal stays.
1772
+ result.refused.push(name);
1773
+ break;
1774
+ }
1775
+ try {
1776
+ const ours = JSON.parse(fs.readFileSync(destFile, "utf-8"));
1777
+ const theirs = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
1778
+ const { merged, conflicts } = mergeSchema(base, ours, theirs);
1779
+ if (conflicts.length === 0) {
1780
+ // writeSchemaCheckedForDir meta-validates + guards nested-id arrays;
1781
+ // dryRun validates without writing. The refresh loop (below) is
1782
+ // !dryRun-guarded, so a dryRun merge stamps/refreshes nothing.
1783
+ writeSchemaCheckedForDir(destRoot, name, merged, "replace", undefined, { dryRun });
1784
+ result.merged.push(name);
1785
+ // Stamp the merge BASE := the CATALOG body (theirs), not the merged
1786
+ // on-disk body (FGAP-070). A 3-way merge that KEEPS a local divergence
1787
+ // (a reconciled conflict OR a disjoint auto-merge: installed === merged
1788
+ // === R, R ≠ catalog) must persist as `locally-modified` on the next
1789
+ // check-status — so the next update re-derives base === theirs (catalog)
1790
+ // → ours === R via the `base === theirs → ours` rule (schema-merge.ts),
1791
+ // keeping R durable at a stable fixed point. Were the baseline left at
1792
+ // the merged on-disk body, the next check would read `catalog-ahead` and
1793
+ // RESYNC the schema to the catalog, clobbering R. Mirrors resolveConflict's
1794
+ // base-advance. dryRun-guarded so a dry-run merge stamps nothing.
1795
+ if (!dryRun)
1796
+ stampBaselineFromBody(cwd, name, theirs, readDeclaredVersion(sourceFile) ?? "");
1797
+ }
1798
+ else {
1799
+ result.conflicts.push({ name, conflicts });
1800
+ }
1801
+ }
1802
+ catch {
1803
+ // A parse/merge/meta-validation throw must not crash the per-asset
1804
+ // loop; fall back to refuse-and-report so the schema keeps its drift
1805
+ // signal and is surfaced for manual reconciliation.
1806
+ result.refused.push(name);
1807
+ }
1808
+ break;
1809
+ }
1810
+ default:
1811
+ // no-baseline / missing-catalog / missing-installed — undecidable or
1812
+ // absent; report with the state, take no action.
1813
+ result.reported.push({ name, state });
1814
+ break;
1815
+ }
1816
+ }
1817
+ // TASK-051 / FGAP-080: reconcile pending-blocked.json to THIS run's blocked set
1818
+ // (live only — the dryRun arm collected nothing and writes nothing). When the
1819
+ // run produced blocked entries, persist exactly them; when it produced none,
1820
+ // REMOVE any prior sidecar so a now-unblocked model leaves no stale record. The
1821
+ // blocked contract (schema/block/migrations.json byte-unchanged) is untouched —
1822
+ // this sidecar + the pinned object are additive, outside that contract.
1823
+ // TASK-052 / FGAP-081: git-style in-file failure markers are the DEFAULT behavior
1824
+ // of a live validation-blocked schema (no flag, no mode). For every blocked entry
1825
+ // whose reason is validation-failed with non-empty failures, inscribe full-line
1826
+ // conflict sentinels INTO the block file at the offending items/fields so the
1827
+ // operator sees the problem inline (STORY-013). (1) Pin the PRE-MARKER block-file
1828
+ // bytes (wrapped so the object faithfully identifies the raw text) into the object
1829
+ // store and set `premarker_hash` on the entry BEFORE the sidecar write, so the
1830
+ // record carries the byte-exact restore point. (2) Compose the marker-bearing text
1831
+ // and raw-write it back via tmp+rename — NOT writeBlockForDir (the marker file is
1832
+ // not valid JSON; routing it through the block writer would throw). The SCHEMA and
1833
+ // migrations.json stay byte-unchanged (the blocked contract for those is intact);
1834
+ // only the block file changes, by design. A re-run over a block whose file ALREADY
1835
+ // carries sentinels RETAINS the prior entry's premarker_hash and does NOT re-mark
1836
+ // (CHANGE 4) — the marker file is composed once, from the genuine pre-marker bytes.
1837
+ // dryRun writes nothing (this whole block is !dryRun-guarded); a no-chain entry is
1838
+ // never marked (validation-failed only).
1839
+ if (!dryRun) {
1840
+ const priorPending = loadPendingBlockedForDir(destRoot);
1841
+ for (const entry of pendingBlockedEntries) {
1842
+ if (entry.reason !== "validation-failed" || !entry.failures || entry.failures.length === 0)
1843
+ continue;
1844
+ const blockFile = installedBlockDestPath(destRoot, entry.name);
1845
+ if (!fs.existsSync(blockFile))
1846
+ continue;
1847
+ const rawBytes = fs.readFileSync(blockFile, "utf-8");
1848
+ if (MARKER_LINE_RE_MULTILINE.test(rawBytes)) {
1849
+ // Already marked (a re-run): the freshly-built candidate `entry` is DEGRADED
1850
+ // — its failures were re-derived from the marker-bearing (non-JSON) block file,
1851
+ // which parses to a synthetic envelope-level failure ([{instancePath:"",
1852
+ // keyword:"type",message:"must be object"}]) rather than the genuine per-item
1853
+ // failures. RETAIN the prior pending entry WHOLE (failures, chain, from/to,
1854
+ // reason, blocked_at, premarker_hash) and discard the degraded candidate; the
1855
+ // marker file is left untouched (do not re-mark, do not re-pin). When no prior
1856
+ // entry exists (sentinels present but no sidecar — e.g. a hand-marked file or a
1857
+ // removed sidecar), there is nothing genuine to retain; keep the candidate as-is.
1858
+ const prior = priorPending?.entries.find((e) => e.name === entry.name);
1859
+ if (prior) {
1860
+ const idx = pendingBlockedEntries.indexOf(entry);
1861
+ if (idx >= 0)
1862
+ pendingBlockedEntries[idx] = prior;
1863
+ // Re-derive this run's blockedDetail for the schema from the retained entry
1864
+ // (the per-loop result.blockedDetail push carried the same degraded failures
1865
+ // the candidate did). Carry premarker_hash so renderBlocked can truthfully
1866
+ // assert that markers were written for this entry.
1867
+ const detail = result.blockedDetail.find((d) => d.name === prior.name);
1868
+ if (detail) {
1869
+ detail.reason = prior.reason;
1870
+ detail.from = prior.from;
1871
+ detail.to = prior.to;
1872
+ detail.failures = prior.failures;
1873
+ detail.premarker_hash = prior.premarker_hash;
1874
+ }
1875
+ }
1876
+ continue;
1877
+ }
1878
+ const wrapper = { kind: "raw-block-bytes", block: entry.name, bytes: rawBytes };
1879
+ const premarkerHash = computeContentHash(wrapper);
1880
+ putObject(destRoot, premarkerHash, wrapper);
1881
+ entry.premarker_hash = premarkerHash;
1882
+ // Mirror the pin onto this run's blockedDetail so renderBlocked can truthfully
1883
+ // assert that markers were written for this entry (only marker-bearing entries
1884
+ // carry premarker_hash; dryRun and no-chain entries never reach this arm).
1885
+ const detail = result.blockedDetail.find((d) => d.name === entry.name);
1886
+ if (detail)
1887
+ detail.premarker_hash = premarkerHash;
1888
+ const markerText = composeMarkerText(rawBytes, entry.name, entry.from, entry.to, entry.failures);
1889
+ const tmpPath = `${blockFile}.markers-${process.pid}.tmp`;
1890
+ fs.writeFileSync(tmpPath, markerText);
1891
+ fs.renameSync(tmpPath, blockFile);
1892
+ }
1893
+ reconcilePendingBlockedForDir(destRoot, pendingBlockedEntries);
1894
+ }
1895
+ // Baseline refresh for the schemas this run actually brought current. A resync
1896
+ // overwrites the installed schema file with the catalog source but does NOT, by
1897
+ // itself, refresh the recorded install baseline (config.installed_from.assets) —
1898
+ // so without this step a just-resynced schema would still read as drifted
1899
+ // (installed === catalog ≠ stale-baseline → both-diverged) on the next
1900
+ // check-status. Mirror installContext's post-loop baseline write, but SURGICALLY:
1901
+ // refresh ONLY the resynced/migrated assets (their on-disk body IS the catalog
1902
+ // post-resync, so base === catalog either way). A merged schema is EXCLUDED here
1903
+ // (FGAP-070): it already stamped its baseline := the CATALOG body in the merge
1904
+ // arm, so re-fingerprinting its merged on-disk body would overwrite that with
1905
+ // the merged body and resync away a kept-local divergence on the next update. A
1906
+ // `refused` (locally-modified) schema is likewise not in `brought_current`, so it
1907
+ // keeps its drift signal. dryRun performs no writes, so it never refreshes.
1908
+ const brought_current = [...result.resynced, ...result.migrated];
1909
+ if (!dryRun && brought_current.length > 0) {
1910
+ // Refresh the install baseline + base-stamp the body for each schema this run
1911
+ // actually brought current, via the shared `refreshBaselineForSchema` helper
1912
+ // (TASK-037 — FEAT-006 T4 DRY-out of the prior inline body). The helper owns
1913
+ // its own per-name config load + content-addressed stamp + config write, and
1914
+ // is internally guarded against an absent / corrupt schema file (returns
1915
+ // false, leaving the stale baseline entry untouched) — so a present-but-
1916
+ // corrupt schema is skipped rather than crashing the update, mirroring the
1917
+ // prior inline safety default. A `refused` (locally-modified) schema is NOT
1918
+ // in `brought_current`, so it keeps its drift signal (re-fingerprinting it
1919
+ // would falsely mark it in-sync). dryRun is excluded by the outer guard, so a
1920
+ // dry-run never refreshes.
1921
+ for (const name of brought_current) {
1922
+ refreshBaselineForSchema(cwd, name);
1923
+ }
1924
+ }
1925
+ // Config-registry propagation (TASK-038 — FEAT-006 T5). Bring catalog-new
1926
+ // keyed-array config-registry entries (relation_types / invariants /
1927
+ // block_kinds / lenses) that are ABSENT from the substrate config current with
1928
+ // the packaged catalog, ADDITIVELY: a user-authored entry (absent from the
1929
+ // catalog) and an existing entry whose body diverges from the catalog are both
1930
+ // preserved untouched (mergeCatalogRegistries never replaces a present id).
1931
+ // Re-load config FRESH — the baseline-refresh loop above wrote config.json, so
1932
+ // the `config` captured at function entry is stale (would drop those baseline
1933
+ // updates on write-back). Under dryRun: compute + record additions, write
1934
+ // nothing. A read/parse failure is swallowed so the schema-update result
1935
+ // (already computed) is not lost — registryAdditions simply stays empty.
1936
+ try {
1937
+ const catalog = JSON.parse(fs.readFileSync(path.join(samplesRoot, "conception.json"), "utf-8"));
1938
+ const fresh = loadConfig(cwd);
1939
+ if (fresh) {
1940
+ const { merged, additions } = mergeCatalogRegistries(fresh, catalog);
1941
+ result.registryAdditions = additions;
1942
+ if (!dryRun &&
1943
+ (additions.relation_types.length ||
1944
+ additions.invariants.length ||
1945
+ additions.block_kinds.length ||
1946
+ additions.lenses.length)) {
1947
+ writeConfig(cwd, merged);
1948
+ }
1949
+ }
1950
+ }
1951
+ catch {
1952
+ // Catalog read / parse / config-load failure: leave registryAdditions empty
1953
+ // (its initialized value) and return the schema-update result unchanged.
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
+ };
352
2013
  }
353
2014
  return result;
354
2015
  }
2016
+ /**
2017
+ * Stamp an in-memory schema `body` as the install baseline
2018
+ * (`config.installed_from.assets[name]`) for one schema (TASK-037 — FEAT-006 T4
2019
+ * / FGAP-069). The shared stamp mechanics extracted from
2020
+ * `refreshBaselineForSchema`: compute the content_hash of `body`, store it into
2021
+ * the content-addressed object store (`putObject`) under that hash, set
2022
+ * `config.installed_from.assets[name] = { content_hash, version }` (refreshing
2023
+ * `at`), and write the config. Self-contained + idempotent: it owns its config
2024
+ * load + write. Returns the stamped `content_hash`, or `null` (no write) when
2025
+ * the substrate dir is unresolvable or the config carries no `installed_from`.
2026
+ *
2027
+ * Two callers stamp via this: `refreshBaselineForSchema` (re-baselines the
2028
+ * ON-DISK body — `update`'s post-loop refresh) and `resolveConflict` (advances
2029
+ * the baseline to the CATALOG body so the next `update` re-derives a resolved
2030
+ * schema as `locally-modified`, not a recurring conflict).
2031
+ */
2032
+ export function stampBaselineFromBody(cwd, name, body, version) {
2033
+ const destRoot = tryResolveContextDir(cwd);
2034
+ if (destRoot === null)
2035
+ return null;
2036
+ const config = loadConfig(cwd);
2037
+ if (!config?.installed_from)
2038
+ return null;
2039
+ const hash = computeContentHash(body);
2040
+ putObject(destRoot, hash, body);
2041
+ const installed_from = {
2042
+ ...config.installed_from,
2043
+ at: new Date().toISOString(),
2044
+ assets: {
2045
+ ...config.installed_from.assets,
2046
+ [name]: { content_hash: hash, version },
2047
+ },
2048
+ };
2049
+ writeConfig(cwd, { ...config, installed_from });
2050
+ return hash;
2051
+ }
2052
+ /**
2053
+ * Re-stamp the install baseline (`config.installed_from.assets[name]`) for one
2054
+ * schema from its CURRENT on-disk body (TASK-037 — FEAT-006 T4). Self-contained
2055
+ * + idempotent. Used by `updateContext`'s post-loop refresh to re-baseline each
2056
+ * brought-current schema (resynced / migrated / auto-merged) so a follow-up
2057
+ * `/context check-status` reports it `in-sync`:
2058
+ *
2059
+ * - returns `false` (no write) when the installed schema file is absent, OR
2060
+ * its freshly-computed `content_hash` already equals the recorded baseline
2061
+ * hash (a true no-op — nothing was reconciled / written).
2062
+ * - otherwise delegates the stamp to `stampBaselineFromBody` (object-store
2063
+ * put + `assets[name]` set + config write from the on-disk body + its
2064
+ * declared version) and returns `true`.
2065
+ *
2066
+ * This is a pure idempotent re-stamp action (re-stamps the on-disk body as the
2067
+ * new baseline; false when the file is absent or its hash already equals the
2068
+ * baseline). Mirrors `updateContext`'s post-loop refresh body for ONE name.
2069
+ */
2070
+ export function refreshBaselineForSchema(cwd, name) {
2071
+ const destRoot = tryResolveContextDir(cwd);
2072
+ if (destRoot === null)
2073
+ return false;
2074
+ const config = loadConfig(cwd);
2075
+ if (!config?.installed_from)
2076
+ return false;
2077
+ const destFile = installedSchemaDestPath(destRoot, name);
2078
+ if (!fs.existsSync(destFile))
2079
+ return false;
2080
+ const newHash = computeFileContentHash(destFile);
2081
+ if (newHash === config.installed_from.assets?.[name]?.content_hash)
2082
+ return false;
2083
+ const body = JSON.parse(fs.readFileSync(destFile, "utf-8"));
2084
+ const stamped = stampBaselineFromBody(cwd, name, body, typeof body.version === "string" ? body.version : "");
2085
+ return stamped !== null;
2086
+ }
2087
+ /**
2088
+ * Reconciliation-commit op (FGAP-069) — completes the caller-as-reconciler model
2089
+ * end-to-end. After `update` surfaces a both-diverged schema CONFLICT, the
2090
+ * calling agent reconciles the conflicting paths into a resolved body R and runs
2091
+ * this op. It does two things atomically per call:
2092
+ *
2093
+ * 1. WRITES R, when a `schema` is supplied: parse-if-string (mirroring the
2094
+ * write-schema op's tolerant JSON-string handling) then
2095
+ * `writeSchemaCheckedForDir(destRoot, name, R, "replace", ctx)` (AJV
2096
+ * meta-validate + nested-id guard + atomic write). When `schema` is omitted
2097
+ * the current on-disk body is treated as already reconciled — no write.
2098
+ * 2. ADVANCES the merge base to the CATALOG body (theirs): it reads the
2099
+ * catalog source schema, stamps it as the install baseline via
2100
+ * `stampBaselineFromBody`. This is the fix the bare write-schema lacks —
2101
+ * with the baseline advanced to the catalog, the next `update`'s 3-way
2102
+ * check resolves the schema as `locally-modified` (base === catalog ≠ R),
2103
+ * and `mergeSchema(base=catalog, ours=R, theirs=catalog)` takes R via the
2104
+ * `base === theirs → ours` rule → auto-merge, zero conflicts, R preserved.
2105
+ * Without this advance, the baseline stays at the original pre-conflict
2106
+ * body and `update` re-derives the SAME both-diverged conflict forever.
2107
+ *
2108
+ * Throws a clear error when the substrate dir is unresolvable, the config /
2109
+ * catalog kind for `name` is missing, or the catalog source schema is absent —
2110
+ * the base cannot be advanced without a catalog body to advance it to.
2111
+ *
2112
+ * Returns `{ schemaName, wroteSchema, baseAdvancedTo }`: `wroteSchema` is true
2113
+ * iff a `schema` was supplied and written; `baseAdvancedTo` is the content_hash
2114
+ * of the catalog body now stamped as the baseline.
2115
+ */
2116
+ export function resolveConflict(cwd, name, schema, ctx) {
2117
+ const destRoot = tryResolveContextDir(cwd);
2118
+ if (destRoot === null) {
2119
+ throw new Error(`resolve-conflict: no active substrate resolved for '${cwd}'`);
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);
2125
+ const { samplesRoot, byId } = resolveCatalog();
2126
+ const kind = byId.get(name);
2127
+ if (!kind) {
2128
+ throw new Error(`resolve-conflict: no catalog block_kind named '${name}' — cannot advance the merge base`);
2129
+ }
2130
+ const sourceFile = path.join(samplesRoot, kind.schema_path);
2131
+ if (!fs.existsSync(sourceFile)) {
2132
+ throw new Error(`resolve-conflict: catalog schema source missing at ${sourceFile} for '${name}'`);
2133
+ }
2134
+ // 1. Write the reconciled body R when supplied. Type.Unknown() params may
2135
+ // arrive as JSON strings (mirror the write-schema op handler): parse if
2136
+ // possible, otherwise keep raw (meta-validation rejects a non-object body).
2137
+ let wroteSchema = false;
2138
+ if (schema !== undefined) {
2139
+ let body = schema;
2140
+ if (typeof body === "string") {
2141
+ try {
2142
+ body = JSON.parse(body);
2143
+ }
2144
+ catch {
2145
+ /* keep raw string — meta-validation will reject a non-object */
2146
+ }
2147
+ }
2148
+ writeSchemaCheckedForDir(destRoot, name, body, "replace", ctx);
2149
+ wroteSchema = true;
2150
+ }
2151
+ // 2. Advance the merge base to the CATALOG body (theirs). Read + parse the
2152
+ // catalog source, stamp it as the new install baseline so the next update's
2153
+ // 3-way check sees base === catalog and takes R via base === theirs → ours.
2154
+ const catalogBody = JSON.parse(fs.readFileSync(sourceFile, "utf-8"));
2155
+ const version = readDeclaredVersion(sourceFile) ?? "";
2156
+ const catalogHash = stampBaselineFromBody(cwd, name, catalogBody, version);
2157
+ if (catalogHash === null) {
2158
+ throw new Error(`resolve-conflict: could not advance the merge base for '${name}' (no install baseline in config)`);
2159
+ }
2160
+ return { schemaName: name, wroteSchema, baseAdvancedTo: catalogHash };
2161
+ }
2162
+ /**
2163
+ * Blocked-resolution commit op (TASK-051 — FGAP-080) — the resolution half of
2164
+ * the blocked-resync loop `update` opens. After `update` REFUSES a catalog-ahead
2165
+ * resync (blocked) it persists a pending-blocked record pinning the TARGET
2166
+ * catalog schema body (in the object store) + the migration chain reaching it.
2167
+ * The calling agent then fixes the block's failing items (or widens the local
2168
+ * schema) and runs THIS op to commit the resolution against the SAME pinned
2169
+ * target the run blocked on — so a subsequent `update` converges (in-sync)
2170
+ * instead of re-blocking.
2171
+ *
2172
+ * Flow:
2173
+ * 1. Load the pending-blocked record; an absent entry for `name` throws a
2174
+ * field-named error (run `update` first to produce one).
2175
+ * 2. Retrieve the pinned target schema body by its `target_hash` from the
2176
+ * object store; a missing object throws (the pin is the resolution contract).
2177
+ * 3. Re-validate the CURRENT block against the PINNED target body: load the
2178
+ * installed block, forward-migrate its items IN MEMORY through the entry's
2179
+ * chain when the block's declared `schema_version` differs from the target
2180
+ * `to` version (a FRESH registry seeded existing-decls-first + the chain,
2181
+ * mirroring validateBlockItemsAgainstCatalog), then `validate`.
2182
+ * 4. FAIL → return `{ resolved: false, failures }` and WRITE NOTHING — the
2183
+ * pending record stays intact so the caller can correct + retry.
2184
+ * 5. PASS → in order: register the chain decls not already on disk (collecting
2185
+ * the registered set), write the target schema (replace), advance the
2186
+ * migrated block's `schema_version` envelope to `to` + persist it (skipping
2187
+ * the block write when it had no items — schema still written, base still
2188
+ * advanced, mirroring the live no-items handling), advance the merge base to
2189
+ * the target body, and clear the entry from pending-blocked.json (removing
2190
+ * the file when it becomes empty). Return `{ resolved: true,
2191
+ * registeredMigrations, baseAdvancedTo }`.
2192
+ *
2193
+ * Throws (no write) when the substrate dir is unresolvable, no pending entry
2194
+ * names `name`, or the pinned target object is missing.
2195
+ */
2196
+ export function resolveBlocked(cwd, name, ctx) {
2197
+ const destRoot = tryResolveContextDir(cwd);
2198
+ if (destRoot === null) {
2199
+ throw new Error(`resolve-blocked: no active substrate resolved for '${cwd}'`);
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);
2210
+ const pending = loadPendingBlockedForDir(destRoot);
2211
+ const entry = pending?.entries.find((e) => e.name === name);
2212
+ if (!entry) {
2213
+ throw new Error(`schemaName: no pending-blocked entry for '${name}' — run update first`);
2214
+ }
2215
+ const targetBody = getObject(destRoot, entry.target_hash);
2216
+ if (targetBody === null) {
2217
+ throw new Error(`schemaName: pinned target schema object ${entry.target_hash} missing for '${name}' — cannot re-validate the block`);
2218
+ }
2219
+ // Load the installed block (the validateBlockItemsAgainstCatalog load pattern).
2220
+ // TASK-052 / FGAP-081: read the RAW text first. A live update inscribes git-style
2221
+ // failure markers INTO the block file (full-line `<<<<<<<`/`>>>>>>>` sentinels), so
2222
+ // the file is no longer valid JSON. Detect the sentinels by a full-line scan, STRIP
2223
+ // the marker lines, and parse the remainder. `strippedText` is retained so the PASS
2224
+ // path can raw-write it to disk BEFORE the commit's writeBlockForDir — the identity
2225
+ // stamp's prior-read then parses the ON-DISK stripped file and matches items by oid,
2226
+ // preserving oids (content_parent advances only on genuinely changed items, no
2227
+ // re-mint). A strip that still does not parse falls through to the parse-fail path;
2228
+ // on FAIL the marker file is left untouched (the no-write-on-fail contract).
2229
+ const blockFile = installedBlockDestPath(destRoot, name);
2230
+ let blockData;
2231
+ let wasMarked = false;
2232
+ let strippedText;
2233
+ try {
2234
+ if (fs.existsSync(blockFile)) {
2235
+ const rawText = fs.readFileSync(blockFile, "utf-8");
2236
+ wasMarked = MARKER_LINE_RE_MULTILINE.test(rawText);
2237
+ strippedText = wasMarked
2238
+ ? rawText
2239
+ .split("\n")
2240
+ .filter((line) => !MARKER_LINE_RE.test(line))
2241
+ .join("\n")
2242
+ : rawText;
2243
+ blockData = JSON.parse(strippedText);
2244
+ }
2245
+ else {
2246
+ blockData = undefined;
2247
+ }
2248
+ }
2249
+ catch (err) {
2250
+ return {
2251
+ schemaName: name,
2252
+ resolved: false,
2253
+ failures: [{ instancePath: "", keyword: "error", message: String(err) }],
2254
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2255
+ };
2256
+ }
2257
+ const blockVersion = blockData && typeof blockData === "object" && "schema_version" in blockData
2258
+ ? blockData.schema_version
2259
+ : undefined;
2260
+ const targetVersion = entry.to;
2261
+ // Determine whether the block carries items (mirrors resyncSchema's hasItems).
2262
+ let hasItems = false;
2263
+ if (blockData && typeof blockData === "object") {
2264
+ forEachBlockArray(blockData, (_arrayKey, arr) => {
2265
+ if (arr.length > 0)
2266
+ hasItems = true;
2267
+ });
2268
+ }
2269
+ // Re-validate the block against the PINNED target body, forward-migrating its
2270
+ // items in memory through the entry chain when the block lags the target. A
2271
+ // FRESH registry seeded existing-decls-first + the chain, deduped on
2272
+ // (schemaName, fromVersion) — never warm the project's cached registry.
2273
+ let migrated = blockData;
2274
+ try {
2275
+ if (typeof blockVersion === "string" &&
2276
+ typeof targetVersion === "string" &&
2277
+ blockVersion !== targetVersion &&
2278
+ entry.chain.length > 0) {
2279
+ const registry = buildFreshRegistryWithChain(destRoot, entry.chain);
2280
+ migrated = runMigrations(registry, name, blockVersion, targetVersion, blockData);
2281
+ }
2282
+ validate(targetBody, migrated, name);
2283
+ }
2284
+ catch (err) {
2285
+ const failures = err instanceof ValidationError
2286
+ ? mapValidationFailures(err.errors, blockData)
2287
+ : [{ instancePath: "", keyword: "error", message: String(err) }];
2288
+ return {
2289
+ schemaName: name,
2290
+ resolved: false,
2291
+ failures,
2292
+ ...(establishedId ? { substrateIdEstablished: establishedId } : {}),
2293
+ };
2294
+ }
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);
2315
+ }
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 });
2329
+ }
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);
2354
+ }
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
+ };
2387
+ }
2388
+ }
2389
+ /**
2390
+ * Render an `UpdateResult["conflicts"]` set as a readable conflict report
2391
+ * (TASK-037 — FEAT-006 T4 / FGAP-069) — the surface the `update` op + CLI hand
2392
+ * to the CALLING agent, which reconciles each conflict into a resolved body and
2393
+ * commits it via the `resolve-conflict` op (writes the body AND advances the
2394
+ * merge base to the catalog so `update` stops re-reporting it; no subordinate
2395
+ * resolver is spawned). Mirrors `renderCheckStatus`'s grouping
2396
+ * style: one section per conflicting schema `name`, then each irreconcilable
2397
+ * `{ path, base, ours, theirs }` with its three values JSON-compacted for a
2398
+ * side-by-side scan, then a trailing guidance line stating how to apply a
2399
+ * reconciliation. Pure: no I/O, no writes.
2400
+ */
2401
+ export function renderConflicts(conflicts) {
2402
+ const lines = [];
2403
+ lines.push("Schema merge conflicts — manual reconciliation required (no writes performed):");
2404
+ if (conflicts.length === 0) {
2405
+ lines.push(" (no conflicts)");
2406
+ return lines.join("\n");
2407
+ }
2408
+ for (const { name, conflicts: set } of conflicts) {
2409
+ lines.push(` ${name} (${set.length} conflict${set.length === 1 ? "" : "s"}):`);
2410
+ for (const c of set) {
2411
+ lines.push(` ${c.path}`);
2412
+ lines.push(` base: ${JSON.stringify(c.base)}`);
2413
+ lines.push(` ours: ${JSON.stringify(c.ours)}`);
2414
+ lines.push(` theirs: ${JSON.stringify(c.theirs)}`);
2415
+ }
2416
+ }
2417
+ lines.push("To resolve each: reconcile the conflicting paths into a schema, then resolve-conflict --schemaName <name> --schema <reconciled> — it writes your schema AND advances the merge base to the catalog so update stops re-reporting it.");
2418
+ return lines.join("\n");
2419
+ }
2420
+ /**
2421
+ * Render the per-schema blocked-resync diagnostic (TASK-048 — FGAP-077) as a
2422
+ * readable report the CLI surfaces below `update`'s output when a catalog-ahead
2423
+ * resync was refused. One section per blocked schema `name`:
2424
+ * - header `blocked: <name> (<from> -> <to>)` (the installed→catalog version
2425
+ * pair; `?` substitutes a missing version).
2426
+ * - `no-migration-chain` → one line `no migration chain reaches <to> from
2427
+ * <from>`.
2428
+ * - `validation-failed` → one line per failing item, naming the item id (or the
2429
+ * `instancePath` when no id resolved), the field (the tail of `instancePath`),
2430
+ * and the constraint phrased keyword-aware — MIRRORING the CLI's
2431
+ * `formatAjvError` keyword switch (required / type / enum / additionalProperties
2432
+ * fall through to the raw message), reproduced here rather than imported to
2433
+ * avoid a pi-context → pi-context-cli dependency cycle (render.ts imports this
2434
+ * package). A failure carrying no AJV `keyword` mapping prints its raw message.
2435
+ *
2436
+ * TASK-052 / FGAP-081: a LIVE `update` that blocks a `validation-failed` resync
2437
+ * inscribes git-style failure markers INTO the block file at the offending items —
2438
+ * and ONLY then does the trailing guidance claim, in the past tense, that markers
2439
+ * "were written INTO the block file(s)". That claim is keyed on the per-entry
2440
+ * `premarker_hash` (set only when markers were actually inscribed): a dryRun preview
2441
+ * writes nothing and a `no-migration-chain` entry is never marked, so neither carries
2442
+ * `premarker_hash` — for those the report keeps each entry's reason line + neutral
2443
+ * fix-then-resolve guidance WITHOUT the past-tense write claim. In all cases the
2444
+ * schema + `migrations.json` stay byte-unchanged. Pure: no I/O, no writes.
2445
+ */
2446
+ export function renderBlocked(blockedDetail) {
2447
+ const lines = [];
2448
+ lines.push("Schema resync blocked (schema + migrations.json unchanged):");
2449
+ if (blockedDetail.length === 0) {
2450
+ lines.push(" (no blocked schemas)");
2451
+ return lines.join("\n");
2452
+ }
2453
+ for (const d of blockedDetail) {
2454
+ const from = d.from ?? "?";
2455
+ const to = d.to ?? "?";
2456
+ lines.push(` blocked: ${d.name} (${from} -> ${to})`);
2457
+ if (d.reason === "no-migration-chain") {
2458
+ lines.push(` no migration chain reaches ${to} from ${from}`);
2459
+ continue;
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
+ }
2470
+ // validation-failed
2471
+ for (const f of d.failures ?? []) {
2472
+ const subject = f.itemId ?? f.instancePath ?? "(item)";
2473
+ const field = f.instancePath ? f.instancePath.split("/").filter(Boolean).pop() : undefined;
2474
+ const fieldClause = field ? ` field \`${field}\`` : "";
2475
+ lines.push(` ${subject}:${fieldClause} ${describeBlockedFailure(f)}`);
2476
+ }
2477
+ }
2478
+ // Past-tense write claim ONLY when at least one entry actually carries markers
2479
+ // (premarker_hash present). Otherwise the resync was refused without inscribing
2480
+ // anything (dryRun preview, or only no-migration-chain entries) — emit neutral
2481
+ // fix-then-resolve guidance that does NOT assert a write that did not happen.
2482
+ const anyMarked = blockedDetail.some((d) => d.premarker_hash);
2483
+ if (anyMarked) {
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.");
2485
+ }
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).
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.");
2493
+ }
2494
+ return lines.join("\n");
2495
+ }
2496
+ /**
2497
+ * Phrase ONE blocked validation failure keyword-aware, mirroring the CLI's
2498
+ * `formatAjvError` keyword switch (TASK-048). The minimal {@link
2499
+ * BlockValidationFailure} shape drops the AJV `params`, so the required /
2500
+ * additionalProperties branches that need `missingProperty` / `additionalProperty`
2501
+ * fall back to the raw AJV `message` (which already names them); type / enum and
2502
+ * every other keyword likewise surface the AJV message, prefixed by the constraint
2503
+ * keyword so the failing constraint is named even without params.
2504
+ */
2505
+ function describeBlockedFailure(f) {
2506
+ const msg = f.message || "invalid";
2507
+ return f.keyword && f.keyword !== "error" ? `${f.keyword} — ${msg}` : msg;
2508
+ }
355
2509
  /**
356
2510
  * /context init — scaffold the substrate dir (bootstrap pointer + substrate +
357
2511
  * schemas directories only; no asset copying). Run accept-all + install to
@@ -536,6 +2690,11 @@ export function switchToExisting(cwd, targetDir, writerIdentity) {
536
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.`);
537
2691
  }
538
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));
539
2698
  // Register the now-active substrate's identity if the target carried a
540
2699
  // config-bearing-but-unregistered substrate_id, so the SoT-drift invariant
541
2700
  // does not raise a false substrate_id_unregistered after the flip.
@@ -565,6 +2724,11 @@ export function switchToPrevious(cwd, writerIdentity) {
565
2724
  throw new Error(`/context switch -: existing pointer at ${bootstrapPath} lacks a string contextDir; refuses to flip an unreadable pointer`);
566
2725
  }
567
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));
568
2732
  // Register the now-active substrate's identity if flipping back landed on a
569
2733
  // config-bearing-but-unregistered substrate_id, so the SoT-drift invariant
570
2734
  // does not raise a false substrate_id_unregistered after the flip. Mirrors
@@ -804,6 +2968,18 @@ const extension = (pi) => {
804
2968
  if (result.updated.length > 0) {
805
2969
  lines.push(`Updated (${result.updated.length}): ${result.updated.join(", ")}`);
806
2970
  }
2971
+ if (result.resynced.length > 0) {
2972
+ lines.push(`Re-synced (${result.resynced.length}): ${result.resynced.join(", ")}`);
2973
+ }
2974
+ if (result.migrated.length > 0) {
2975
+ lines.push(`Migrated (${result.migrated.length}, schema bumped — block items forward-migrated): ${result.migrated.join(", ")}`);
2976
+ }
2977
+ if (result.blocked.length > 0) {
2978
+ lines.push(`Blocked (${result.blocked.length}, no safe migration — left unchanged): ${result.blocked.join(", ")}`);
2979
+ }
2980
+ if (result.preserved.length > 0) {
2981
+ lines.push(`Preserved (${result.preserved.length}, populated — block data is never overwritten): ${result.preserved.join(", ")}`);
2982
+ }
807
2983
  if (result.skipped.length > 0) {
808
2984
  lines.push(`Skipped (${result.skipped.length}, exists — pass --update to overwrite): ${result.skipped.join(", ")}`);
809
2985
  }
@@ -813,10 +2989,16 @@ const extension = (pi) => {
813
2989
  if (lines.length === 0) {
814
2990
  lines.push("Nothing declared in installed_schemas / installed_blocks — edit the substrate dir's config.json to add entries.");
815
2991
  }
816
- const level = result.notFound.length > 0 ? "warning" : "info";
2992
+ const level = result.notFound.length > 0 || result.blocked.length > 0 ? "warning" : "info";
817
2993
  ctx.ui.notify(lines.join("\n"), level);
818
2994
  },
819
2995
  },
2996
+ "check-status": {
2997
+ description: "Preview installed-vs-catalog schema drift (read-only; writes nothing)",
2998
+ handler: (_args, ctx) => {
2999
+ ctx.ui.notify(renderCheckStatus(checkStatus(ctx.cwd)), "info");
3000
+ },
3001
+ },
820
3002
  "accept-all": {
821
3003
  description: "Adopt the canonical packaged conception as config.json (writes config only; run install after)",
822
3004
  handler: (args, ctx) => handleAcceptAll(args, ctx),
@@ -865,49 +3047,30 @@ const extension = (pi) => {
865
3047
  });
866
3048
  },
867
3049
  },
868
- "roadmap-list": {
869
- description: "List every roadmap in <config.root>/roadmap.json with id, title, status, and phase count",
870
- handler: (_args, ctx) => {
871
- const list = listRoadmaps(ctx.cwd);
872
- if (list.length === 0) {
873
- ctx.ui.notify("No roadmaps found. Install the roadmap block via the substrate dir's config.json installed_blocks, then author roadmap.json.", "info");
874
- return;
875
- }
876
- const lines = list.map((r) => `${r.id} [${r.status ?? "(unspecified)"}] ${r.title} (${r.phaseCount} phase${r.phaseCount === 1 ? "" : "s"})`);
877
- ctx.ui.notify(lines.join("\n"), "info");
878
- },
879
- },
880
3050
  "roadmap-view": {
881
- 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.",
882
- handler: (args, ctx) => {
883
- const roadmapId = args.trim().split(/\s+/)[0];
884
- if (!roadmapId) {
885
- ctx.ui.notify("Usage: /context roadmap-view <ROADMAP-id>", "error");
886
- return;
887
- }
888
- 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);
889
3054
  if ("error" in view) {
890
3055
  ctx.ui.notify(view.error, "error");
891
3056
  return;
892
3057
  }
893
- const naming = loadContext(ctx.cwd).config?.naming;
894
- ctx.ui.notify(renderRoadmap(view, naming), "info");
3058
+ ctx.ui.notify(renderRoadmap(view), "info");
895
3059
  },
896
3060
  },
897
3061
  "roadmap-validate": {
898
- description: "Validate every roadmap (or a single one when ROADMAP-id supplied) — surfaces structured issues",
899
- handler: (args, ctx) => {
900
- const roadmapId = args.trim().split(/\s+/)[0] || undefined;
901
- const result = validateRoadmaps(ctx.cwd);
902
- const filtered = roadmapId
903
- ? result.issues.filter((i) => !i.roadmap_id || i.roadmap_id === roadmapId)
904
- : result.issues;
905
- if (filtered.length === 0) {
906
- 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");
907
3067
  return;
908
3068
  }
909
- const lines = filtered.map((i) => `✗ [${i.code}] ${i.roadmap_id ?? ""}/${i.phase_id ?? ""}: ${i.message}`);
910
- 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";
911
3074
  ctx.ui.notify(lines.join("\n"), level);
912
3075
  },
913
3076
  },
@@ -997,9 +3160,16 @@ const extension = (pi) => {
997
3160
  });
998
3161
  };
999
3162
  export default extension;
3163
+ // Re-export the config-registry-propagation surface (TASK-038 — FEAT-006 T5) so
3164
+ // consumers can type `UpdateResult.registryAdditions` and call the pure merge
3165
+ // helper against the public `@davidorex/pi-context` surface.
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";
1000
3170
  export { contextRegistryPath, invalidateRegistry, loadRegistry, REGISTRY_FILE_VERSION, registerSubstrate, resolveAlias, resolveSubstrateDir, writeRegistry, } from "./context-registry.js";
1001
3171
  // Re-export for consumers
1002
3172
  export { blockStructure, buildIdIndex, CONTEXT_BLOCK_TYPES, completeTask, findAppendableBlocks, resolveItemById, resolveRef, schemaInfo, schemaVocabulary, } from "./context-sdk.js";
1003
3173
  export { renameCanonicalId } from "./rename-canonical-id.js";
1004
- export { listRoadmaps, loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmaps, } from "./roadmap-plan.js";
3174
+ export { loadRoadmap, renderRoadmap, resolveStatusVocabulary, rollupPhaseStatus, topoSort, validateRoadmap, } from "./roadmap-plan.js";
1005
3175
  //# sourceMappingURL=index.js.map