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