@davidorex/pi-context 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +16 -14
  3. package/dist/block-api.d.ts +20 -33
  4. package/dist/block-api.d.ts.map +1 -1
  5. package/dist/block-api.js +154 -3
  6. package/dist/block-api.js.map +1 -1
  7. package/dist/content-hash.d.ts +9 -0
  8. package/dist/content-hash.d.ts.map +1 -1
  9. package/dist/content-hash.js +11 -0
  10. package/dist/content-hash.js.map +1 -1
  11. package/dist/context-sdk.d.ts +157 -39
  12. package/dist/context-sdk.d.ts.map +1 -1
  13. package/dist/context-sdk.js +896 -251
  14. package/dist/context-sdk.js.map +1 -1
  15. package/dist/context.d.ts +158 -5
  16. package/dist/context.d.ts.map +1 -1
  17. package/dist/context.js +75 -5
  18. package/dist/context.js.map +1 -1
  19. package/dist/index.d.ts +151 -19
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +590 -97
  22. package/dist/index.js.map +1 -1
  23. package/dist/lens-view.d.ts +0 -5
  24. package/dist/lens-view.d.ts.map +1 -1
  25. package/dist/lens-view.js +43 -1
  26. package/dist/lens-view.js.map +1 -1
  27. package/dist/migration-registry-loader.d.ts +20 -12
  28. package/dist/migration-registry-loader.d.ts.map +1 -1
  29. package/dist/migration-registry-loader.js +46 -17
  30. package/dist/migration-registry-loader.js.map +1 -1
  31. package/dist/migrations-store.d.ts +45 -18
  32. package/dist/migrations-store.d.ts.map +1 -1
  33. package/dist/migrations-store.js +56 -22
  34. package/dist/migrations-store.js.map +1 -1
  35. package/dist/ops-registry.d.ts +18 -0
  36. package/dist/ops-registry.d.ts.map +1 -1
  37. package/dist/ops-registry.js +474 -130
  38. package/dist/ops-registry.js.map +1 -1
  39. package/dist/promote-item.d.ts.map +1 -1
  40. package/dist/promote-item.js +41 -12
  41. package/dist/promote-item.js.map +1 -1
  42. package/dist/roadmap-plan.d.ts +121 -99
  43. package/dist/roadmap-plan.d.ts.map +1 -1
  44. package/dist/roadmap-plan.js +281 -345
  45. package/dist/roadmap-plan.js.map +1 -1
  46. package/dist/status-vocab.d.ts +12 -2
  47. package/dist/status-vocab.d.ts.map +1 -1
  48. package/dist/status-vocab.js +14 -1
  49. package/dist/status-vocab.js.map +1 -1
  50. package/package.json +2 -2
  51. package/samples/blocks/milestone.json +3 -0
  52. package/samples/blocks/session-notes.json +1 -0
  53. package/samples/conception.json +315 -15
  54. package/samples/migrations.json +8 -0
  55. package/samples/schemas/context-contracts.schema.json +4 -0
  56. package/samples/schemas/conventions.schema.json +4 -0
  57. package/samples/schemas/decisions.schema.json +4 -0
  58. package/samples/schemas/features.schema.json +4 -0
  59. package/samples/schemas/framework-gaps.schema.json +9 -0
  60. package/samples/schemas/issues.schema.json +28 -0
  61. package/samples/schemas/layer-plans.schema.json +4 -0
  62. package/samples/schemas/milestone.schema.json +79 -0
  63. package/samples/schemas/phase.schema.json +4 -0
  64. package/samples/schemas/rationale.schema.json +4 -0
  65. package/samples/schemas/requirements.schema.json +4 -0
  66. package/samples/schemas/research.schema.json +45 -2
  67. package/samples/schemas/session-notes.schema.json +89 -0
  68. package/samples/schemas/spec-reviews.schema.json +4 -0
  69. package/samples/schemas/story.schema.json +8 -0
  70. package/samples/schemas/tasks.schema.json +4 -0
  71. package/samples/schemas/verification.schema.json +4 -0
  72. package/samples/schemas/work-orders.schema.json +4 -0
  73. package/schemas/config.schema.json +90 -4
  74. package/schemas/migrations.schema.json +25 -0
  75. package/skill-narrative.md +8 -6
  76. package/skills/pi-context/SKILL.md +70 -63
  77. package/skills/pi-context/references/bundled-resources.md +5 -1
@@ -7,20 +7,21 @@ import { execSync } from "node:child_process";
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
- import { readBlock, readBlockForDir, updateItemInBlock } from "./block-api.js";
11
- import { appendRelation, appendRelations, endpointIdentity, endpointKey, findUnmaterializedAssets, isSkeletonConfig, loadConfig, loadRelations, removeRelation, validateRelations, writeRelations, } from "./context.js";
10
+ import { readBlock, readBlockForDir, resolveGitRefOrNull, updateItemInBlock } from "./block-api.js";
11
+ import { computeFileBytesHash } from "./content-hash.js";
12
+ import { appendRelation, appendRelations, counterEndpoint, endpointIdentity, endpointKey, findUnmaterializedAssets, isSkeletonConfig, loadConfig, loadConfigForDir, loadRelations, primaryEndpoint, removeRelation, validateRelations, writeRelations, } from "./context.js";
12
13
  import { resolveContextDir, SCHEMAS_DIR, schemaPath, schemasDir, tryResolveContextDir } from "./context-dir.js";
13
14
  import { loadRegistry, resolveAlias, resolveSubstrateDir } from "./context-registry.js";
14
15
  import { cleanGitEnv } from "./git-env.js";
15
16
  import { getLensValidators } from "./lens-validator.js";
16
- import { findReferencesInRepo } from "./lens-view.js";
17
+ import { getProjectMigrationRegistryForDir } from "./migration-registry-loader.js";
17
18
  import { addressInto, discoverArrayKey, pageArray } from "./read-element.js";
18
- import { validateFromFile } from "./schema-validator.js";
19
+ import { ValidationError, validateBlockWithMigrationForDir, validateFromFile } from "./schema-validator.js";
19
20
  import { findNestedIdBearingArrays } from "./schema-write.js";
20
- import { resolveStatusVocabulary } from "./status-vocab.js";
21
+ import { resolveStateDerivation, resolveStatusVocabulary } from "./status-vocab.js";
21
22
  import { topoSort } from "./topo.js";
22
23
  // Re-export substrate SDK so consumers can keep importing through context-sdk.
23
- export { displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
24
+ export { counterEndpoint, displayName, edgesForLens, endpointBin, endpointIdentity, endpointKey, groupByLens, listUncategorized, loadConfig, loadContext, loadRelations, normalizeEndpoint, primaryEndpoint, synthesizeFromField, validateRelations, walkDescendants, } from "./context.js";
24
25
  export function availableBlocks(cwd) {
25
26
  const workflowDir = tryResolveContextDir(cwd);
26
27
  if (workflowDir === null)
@@ -527,14 +528,80 @@ export function contextState(cwd) {
527
528
  * `.project` substrate. No writes; tolerant of absent optional blocks (every
528
529
  * branch defaults to empty rather than throwing).
529
530
  *
530
- * Edge-direction contract for blocked/ready derivation (verified against
531
- * roadmap-plan.ts:471 — the topoSort-deps mapping for phase_depends_on uses the
532
- * identical convention): a `task_depends_on_task` edge `{parent: D, child: T}`
531
+ * Edge-direction contract for blocked/ready derivation (the same convention as
532
+ * roadmap-plan's topoSort-preds mapping for milestone_precedes_milestone
533
+ * preds are parents of edges whose child is the node): a
534
+ * `task_depends_on_task` edge `{parent: D, child: T}`
533
535
  * means task T DEPENDS ON task D, so D must reach status "completed" before T is
534
536
  * unblocked. (relation name source_verb_target = task_depends_on_task ⇒ child is
535
537
  * the source/dependent, parent is the target/prerequisite; config display_name
536
538
  * "depends on task".)
539
+ *
540
+ * Readiness ALSO honors `task_gated_by_item` gates (FGAP-061 NOW slice): a
541
+ * `task_gated_by_item` edge `{parent: T, child: G}` means task T is GATED BY
542
+ * item G (the gate target, of any kind — gap/decision/feature/task/…), so G must
543
+ * reach the "complete" bucket before T's gate releases. A planned task with any
544
+ * gate target NOT in the "complete" bucket is reported in `blocked` (the target
545
+ * id present in `blockedBy`, unioned with unsatisfied dep-parents) and excluded
546
+ * from `nextActions`; a gate target reaching "complete" releases the gate. Gate
547
+ * satisfaction is `bucket(target) === "complete"` via the same status-vocabulary
548
+ * the status-consistency invariant engine uses — kind-general, no per-kind
549
+ * special-casing (gap→closed, decision→enacted, feature→complete, task→completed
550
+ * all bucket to "complete"). A dangling gate target (id resolves to no item) is
551
+ * treated as satisfied/non-blocking, mirroring the dangling-dep guard.
552
+ *
553
+ * Scope is strictly the literal relation_type `task_gated_by_item`:
554
+ * `decision_gated_by_item` and other non-task `*_gated_by_item` edges are inert
555
+ * here (currentState buckets only tasks). A gate target in a terminal-abandoned
556
+ * status (wontfix/superseded/cancelled — buckets to "unknown", NOT "complete")
557
+ * keeps the gated task blocked under the `=== "complete"` rule; promoting such
558
+ * states to gate-releasing, and config-driven generalization to all gate
559
+ * relation kinds, is the FEAT-004 refinement boundary, out of scope here.
560
+ */
561
+ /**
562
+ * Render a `next_ranked` entry's `reason_template`, substituting `{token}`
563
+ * occurrences from `tokens` (every value stringified). Tokens absent from the
564
+ * map are left literal. An undefined template yields the empty string — the
565
+ * deriver carries no kind-coupled reason default; the stock registry declares
566
+ * the templates that reproduce the canonical reason strings.
567
+ */
568
+ function renderReasonTemplate(template, tokens) {
569
+ if (template === undefined)
570
+ return "";
571
+ return template.replace(/\{(\w+)\}/g, (match, key) => {
572
+ const v = tokens[key];
573
+ return v !== undefined ? v : match;
574
+ });
575
+ }
576
+ /**
577
+ * Rollup-aware completeness — the ONE truth model for "is this item complete"
578
+ * (FEAT-011 — FGAP-116). A kind declared in `state_derivation.rollups` is
579
+ * DERIVED-status: complete iff it has ≥1 member (via the rollup's membership
580
+ * relation, oriented by `role_direction`) and every member is complete under
581
+ * this same function (recursively rollup-aware; a `visiting` guard makes a
582
+ * membership cycle derive not-complete instead of recursing forever). Every
583
+ * other kind is complete iff its status buckets to "complete". SHARED by
584
+ * `currentState` (gate satisfaction + the milestones facet) and the
585
+ * `derived-status` invariant class in `validateContext`, so the derivation and
586
+ * the divergence detector cannot disagree.
537
587
  */
588
+ export function derivedRollupComplete(index, edges, roleDirection, rollupByKind, bucketOf, itemId, visiting = new Set()) {
589
+ const loc = index.byRefname.get(itemId);
590
+ if (loc === undefined)
591
+ return false;
592
+ const entry = rollupByKind.get(loc.block);
593
+ if (entry === undefined)
594
+ return bucketOf(loc.item) === "complete";
595
+ if (visiting.has(itemId))
596
+ return false;
597
+ visiting.add(itemId);
598
+ const dir = roleDirection.get(entry.membership_relation) ?? "as_child";
599
+ const memberIds = edges
600
+ .filter((e) => e.relation_type === entry.membership_relation && endpointKey(primaryEndpoint(e, dir)) === itemId)
601
+ .map((e) => endpointKey(counterEndpoint(e, dir)));
602
+ return (memberIds.length >= 1 &&
603
+ memberIds.every((memberId) => derivedRollupComplete(index, edges, roleDirection, rollupByKind, bucketOf, memberId, visiting)));
604
+ }
538
605
  export function currentState(cwd) {
539
606
  // Tolerate any substrate-read failure (no .project, malformed config, etc.)
540
607
  // by collapsing to the empty state — this is a pure read surface.
@@ -559,12 +626,22 @@ export function currentState(cwd) {
559
626
  // a raw item.status string to its StatusBucket, defaulting to "unknown".
560
627
  const vocab = resolveStatusVocabulary(cwd);
561
628
  const bucket = (item) => vocab[String(item.status)] ?? "unknown";
562
- // ── inFlight: tasks-block items bucketing to in_progress ───────────────────
629
+ // Resolve the config-declared derivation registry (TASK-020 / FGAP-017). When
630
+ // it is ABSENT, every coupling below is unconfigured, so the function returns
631
+ // the truthful "state-derivation not configured" signal — a state distinct
632
+ // from a configured-but-empty substrate (which derives normally to
633
+ // `focus: "no active focus."` + empty arrays). All 16 couplings below read
634
+ // `sd.*`; no kind / relation / rank / status / head-size literal remains.
635
+ const sd = resolveStateDerivation(cwd);
636
+ if (sd === null) {
637
+ return { focus: "state-derivation not configured", inFlight: [], nextActions: [], blocked: [], milestones: [] };
638
+ }
639
+ // ── inFlight: items of an `in_flight.kinds` block bucketing to in_flight.bucket ─
563
640
  const inFlight = [];
564
641
  for (const loc of index.byRefname.values()) {
565
- if (loc.block !== "tasks")
642
+ if (!sd.in_flight.kinds.includes(loc.block))
566
643
  continue;
567
- if (bucket(loc.item) !== "in_progress")
644
+ if (bucket(loc.item) !== sd.in_flight.bucket)
568
645
  continue;
569
646
  inFlight.push({
570
647
  id: loc.id,
@@ -572,108 +649,223 @@ export function currentState(cwd) {
572
649
  description: typeof loc.item.description === "string" ? loc.item.description : "",
573
650
  });
574
651
  }
575
- // Task dependency adjacency from task_depends_on_task edges: for task T,
576
- // depParents(T) = parents of edges {parent, child:T}. T is unblocked iff
577
- // every dep parent that resolves to a known item is completed (deps pointing
578
- // at unknown ids are treated as satisfied — a dangling edge is a relations
579
- // integrity concern surfaced by validateRelations, not a blocker here).
580
- const isCompleted = (taskId) => {
581
- const loc = index.byRefname.get(taskId);
582
- return loc !== undefined && bucket(loc.item) === "complete";
583
- };
584
- const depParentsOf = (taskId) => edges
585
- .filter((e) => e.relation_type === "task_depends_on_task" && endpointKey(e.child) === taskId)
652
+ // Per-relation role_direction declarations (FGAP-113) consulted by the
653
+ // rollup-aware completeness check below, the blocking partition, and the
654
+ // milestones rollup. Hoisted above isCompleted so all three read ONE map.
655
+ const roleDirection = new Map();
656
+ for (const rt of loadConfig(cwd)?.relation_types ?? []) {
657
+ if (rt.role_direction !== undefined)
658
+ roleDirection.set(rt.canonical_id, rt.role_direction);
659
+ }
660
+ // A target is complete when it resolves to a known item that IS complete under
661
+ // its kind's truth model (FEAT-011 criterion 1 — FGAP-116):
662
+ // • a kind declared in `sd.rollups` is DERIVED-status its stored status
663
+ // field is canon-forbidden to author and may lag, so completeness is the
664
+ // membership rollup itself (≥1 member, every member complete), recursively
665
+ // rollup-aware for nested rollup kinds, with a visiting-set guard so a
666
+ // membership cycle derives not-complete instead of recursing forever;
667
+ // • every other kind keeps the SHARED status-vocab bucketing (as TASK-065
668
+ // left it, the same check the status-consistency invariant engine uses),
669
+ // byte-identical to the prior behavior.
670
+ // The milestones[] rollup below consumes THIS function for its reached
671
+ // verdict, so gate satisfaction and the reported milestone status agree by
672
+ // construction within a single read — the two can no longer split-brain. The
673
+ // derived-status invariant class evaluates the SAME shared helper, so
674
+ // validate-side divergence verdicts match the derivation too.
675
+ // Kind set + membership relation come from config (`sd.rollups` +
676
+ // role_direction); no kind / relation literal here (DEC-0025).
677
+ const rollupEntryByKind = new Map(sd.rollups.map((entry) => [entry.kind, entry]));
678
+ const isCompleted = (itemId) => derivedRollupComplete(index, edges, roleDirection, rollupEntryByKind, bucket, itemId);
679
+ // Blocking-relation adjacency, driven by `sd.blocked_by.relation_types`. The
680
+ // two stock relations use OPPOSITE endpoint directions, preserved exactly:
681
+ // • task_depends_on_task — DEPENDENCY direction: parents of edges whose CHILD
682
+ // is the item (the prerequisites of the item).
683
+ // • task_gated_by_item — GATE direction: children of edges whose PARENT is
684
+ // the item (the gate targets the item waits on).
685
+ // A relation present in the set with no known direction rule defaults to the
686
+ // DEPENDENCY direction (parent-of-edge-with-child=item). Which relations
687
+ // participate is gated on membership in `sd.blocked_by.relation_types`, so an
688
+ // empty/absent relation is simply not consulted.
689
+ // Partition the configured blocking relations into GATE-direction vs
690
+ // DEPENDENCY-direction by each relation's declared `role_direction` (FGAP-113),
691
+ // replacing the former single `task_gated_by_item` string literal:
692
+ // • role_direction === "as_child" → GATE direction: the relation's PRIMARY
693
+ // role (the gate) sits at edge.child and the waiting item at edge.parent, so
694
+ // the item's gates are the CHILDREN of edges whose PARENT is the item.
695
+ // • otherwise (as_parent OR unset) → DEPENDENCY direction: the item's
696
+ // prerequisites are the PARENTS of edges whose CHILD is the item.
697
+ // For the stock set {task_depends_on_task (as_parent), task_gated_by_item
698
+ // (as_child)} this partition is identical to the pre-change literal split, so
699
+ // blocked / nextActions / blockedBy stay byte-identical; any *_gated_by_item
700
+ // sibling later added to blocked_by routes to the gate direction by
701
+ // construction (no literal to extend). A blocked_by relation the config does
702
+ // NOT register with a role_direction reads as the DEPENDENCY default.
703
+ const blockedByRels = new Set(sd.blocked_by.relation_types);
704
+ const gateDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) === "as_child"));
705
+ const depDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) !== "as_child"));
706
+ const dependencyPredsOf = (itemId) => edges
707
+ .filter((e) => depDirRels.has(e.relation_type) && endpointKey(e.child) === itemId)
586
708
  .map((e) => endpointKey(e.parent));
587
- const incompleteDeps = (taskId) => depParentsOf(taskId).filter((dep) => index.byRefname.has(dep) && !isCompleted(dep));
588
- // Collect all to-do (ready/queued) tasks once — drives both blocked + ready
589
- // derivations. "todo" bucket = planned/queued work (raw status "planned"
590
- // buckets to todo under STATUS_VOCABULARY_DEFAULTS).
709
+ const gatePredsOf = (itemId) => edges
710
+ .filter((e) => gateDirRels.has(e.relation_type) && endpointKey(e.parent) === itemId)
711
+ .map((e) => endpointKey(e.child));
712
+ // All preds (deps gates) in discovery order — used by the topo ordering below.
713
+ const allPredsOf = (itemId) => [...dependencyPredsOf(itemId), ...gatePredsOf(itemId)];
714
+ const incompletePreds = (itemId) => dependencyPredsOf(itemId).filter((dep) => index.byRefname.has(dep) && !isCompleted(dep));
715
+ const unsatisfiedGates = (itemId) => gatePredsOf(itemId).filter((target) => index.byRefname.has(target) && !isCompleted(target));
716
+ // The "planned tasks" set is the next_ranked entry that has NO rank_field (the
717
+ // stock tasks entry, topo-ordered). It drives both blocked + ready derivations.
718
+ const topoEntry = sd.next_ranked.find((e) => e.rank_field === undefined);
591
719
  const plannedTasks = [];
592
- for (const loc of index.byRefname.values()) {
593
- if (loc.block === "tasks" && bucket(loc.item) === "todo")
594
- plannedTasks.push({ id: loc.id, loc });
720
+ if (topoEntry !== undefined) {
721
+ for (const loc of index.byRefname.values()) {
722
+ if (loc.block === topoEntry.kind && bucket(loc.item) === topoEntry.bucket) {
723
+ plannedTasks.push({ id: loc.id, loc });
724
+ }
725
+ }
595
726
  }
596
- // ── blocked: planned tasks with at least one incomplete dep parent ─────────
727
+ // blockedBy(T) = UNION of T's unsatisfied dependency-direction preds and
728
+ // unsatisfied gate-direction targets, de-duplicated while preserving discovery
729
+ // order (deps first, then gates). With no gate relation configured / no gate
730
+ // edges present this collapses to the dependency-only set.
731
+ const blockersOf = (taskId) => {
732
+ const result = [];
733
+ const seen = new Set();
734
+ for (const blocker of [...incompletePreds(taskId), ...unsatisfiedGates(taskId)]) {
735
+ if (seen.has(blocker))
736
+ continue;
737
+ seen.add(blocker);
738
+ result.push(blocker);
739
+ }
740
+ return result;
741
+ };
742
+ // ── blocked: planned tasks with at least one unsatisfied dep or gate ─────────
597
743
  const blocked = [];
598
744
  const blockedIds = new Set();
599
745
  for (const { id, loc } of plannedTasks) {
600
- const blockedBy = incompleteDeps(id);
746
+ const blockedBy = blockersOf(id);
601
747
  if (blockedBy.length > 0) {
602
748
  blocked.push({ id, block: loc.block, blockedBy });
603
749
  blockedIds.add(id);
604
750
  }
605
751
  }
606
752
  // ── nextActions (atomic-next, ranked) ──────────────────────────────────────
753
+ // Iterate `sd.next_ranked` IN ARRAY ORDER — array order IS the cross-kind push
754
+ // order (stock: topo-ordered tasks, then priority-ranked issues, then
755
+ // priority-ranked gaps).
607
756
  const nextActions = [];
608
- // 1. Open framework-gaps (gaps bucketing to todo — raw status "identified"
609
- // buckets to todo under STATUS_VOCABULARY_DEFAULTS), ranked
610
- // P0 > P1 > P2 > P3 (missing priority sorts last) then by id.
611
- const priorityRank = { P0: 0, P1: 1, P2: 2, P3: 3 };
612
- const openGaps = [];
613
- for (const loc of index.byRefname.values()) {
614
- if (loc.block !== "framework-gaps")
615
- continue;
616
- if (bucket(loc.item) !== "todo")
617
- continue;
618
- openGaps.push({ id: loc.id, priority: typeof loc.item.priority === "string" ? loc.item.priority : undefined });
619
- }
620
- openGaps.sort((a, b) => {
621
- const ra = a.priority !== undefined ? (priorityRank[a.priority] ?? 99) : 99;
622
- const rb = b.priority !== undefined ? (priorityRank[b.priority] ?? 99) : 99;
623
- if (ra !== rb)
624
- return ra - rb;
625
- return a.id.localeCompare(b.id);
626
- });
627
- for (const g of openGaps) {
628
- nextActions.push({
629
- id: g.id,
630
- kind: "framework-gap",
631
- ...(g.priority !== undefined ? { priority: g.priority } : {}),
632
- reason: `open gap (priority ${g.priority ?? "unset"})`,
633
- });
757
+ for (const entry of sd.next_ranked) {
758
+ if (entry.rank_field !== undefined) {
759
+ // Field-ranked entry (stock: framework-gaps by `priority`). Select items of
760
+ // `entry.kind` at `entry.bucket`, rank by index in `entry.rank_order` (value
761
+ // not listed → large sentinel 99) then by id.
762
+ const rankField = entry.rank_field;
763
+ const rankIndex = {};
764
+ (entry.rank_order ?? []).forEach((v, i) => {
765
+ rankIndex[v] = i;
766
+ });
767
+ const selected = [];
768
+ for (const loc of index.byRefname.values()) {
769
+ if (loc.block !== entry.kind)
770
+ continue;
771
+ if (bucket(loc.item) !== entry.bucket)
772
+ continue;
773
+ const raw = loc.item[rankField];
774
+ selected.push({ id: loc.id, value: typeof raw === "string" ? raw : undefined });
775
+ }
776
+ selected.sort((a, b) => {
777
+ const ra = a.value !== undefined ? (rankIndex[a.value] ?? 99) : 99;
778
+ const rb = b.value !== undefined ? (rankIndex[b.value] ?? 99) : 99;
779
+ if (ra !== rb)
780
+ return ra - rb;
781
+ return a.id.localeCompare(b.id);
782
+ });
783
+ for (const s of selected) {
784
+ nextActions.push({
785
+ id: s.id,
786
+ kind: entry.label,
787
+ ...(s.value !== undefined ? { [rankField]: s.value } : {}),
788
+ reason: renderReasonTemplate(entry.reason_template, { rank_value: s.value ?? "unset", id: s.id }),
789
+ });
790
+ }
791
+ }
792
+ else {
793
+ // Topo-ordered entry (stock: tasks). Ready = planned items NOT in `blocked`,
794
+ // ordered via topoSort over the planned nodes with preds = dependency preds
795
+ // ∪ gate targets. topoSort only counts edges between graph nodes, so preds
796
+ // outside the planned set (completed / non-task) don't gate the ordering.
797
+ const { order } = topoSort(plannedTasks, (t) => t.id, (t) => allPredsOf(t.id));
798
+ for (const id of order) {
799
+ if (blockedIds.has(id))
800
+ continue;
801
+ nextActions.push({ id, kind: entry.label, reason: renderReasonTemplate(entry.reason_template, { id }) });
802
+ }
803
+ }
634
804
  }
635
- // 2. Ready tasks: planned tasks NOT in `blocked`, ordered via topoSort over
636
- // the planned-task nodes with deps = their task_depends_on_task parents.
637
- // topoSort only counts edges between nodes present in the graph, so deps
638
- // pointing outside the planned set (e.g. already-completed prerequisites)
639
- // don't gate the ordering we then filter the resulting order to the
640
- // ready (unblocked + planned) subset.
641
- const { order } = topoSort(plannedTasks, (t) => t.id, (t) => depParentsOf(t.id));
642
- for (const id of order) {
643
- if (blockedIds.has(id))
644
- continue;
645
- nextActions.push({ id, kind: "task", reason: "unblocked planned task" });
805
+ // Cap nextActions at the config-declared scannable head derivation can
806
+ // surface a long backlog; the head is the actionable slice for "what's next".
807
+ const cappedNextActions = nextActions.slice(0, sd.head_size);
808
+ // ── milestones: config-declared membership rollups ──────────────────────────
809
+ // For each `sd.rollups` entry, orientation is read from the membership
810
+ // relation's declared `role_direction` (FGAP-113): the CONTAINER (the rollup
811
+ // item itself) sits at the PRIMARY endpoint, its MEMBERS at the COUNTER
812
+ // endpoint. `phase_positioned_in_milestone` is `as_child` (container=milestone
813
+ // at edge.child, member=phase at edge.parent), so `primaryEndpoint`===child /
814
+ // `counterEndpoint`===parent reproduces the prior filter-child / map-parent
815
+ // selection exactly. A membership relation the config does not register with a
816
+ // `role_direction` defaults to `as_child` (the pre-FGAP-113 container=child
817
+ // convention). The rollup emits `complete_status` when ≥1 member exists and
818
+ // every member id resolves to a known item bucketing to complete; else
819
+ // `incomplete_status` (covering no-members + any-incomplete). Every comparison
820
+ // routes through bucket() — no raw status literal.
821
+ const milestones = [];
822
+ for (const entry of sd.rollups) {
823
+ const dir = roleDirection.get(entry.membership_relation) ?? "as_child";
824
+ for (const loc of index.byRefname.values()) {
825
+ if (loc.block !== entry.kind)
826
+ continue;
827
+ const memberIds = edges
828
+ .filter((e) => e.relation_type === entry.membership_relation && endpointKey(primaryEndpoint(e, dir)) === loc.id)
829
+ .map((e) => endpointKey(counterEndpoint(e, dir)));
830
+ const phaseCount = memberIds.length;
831
+ // The reached verdict IS the rollup-aware isCompleted — the same function
832
+ // gate satisfaction consults — so the reported milestone status and the
833
+ // gates it releases cannot diverge within this read (FEAT-011 criterion 1).
834
+ const reached = isCompleted(loc.id);
835
+ milestones.push({
836
+ id: loc.id,
837
+ status: reached ? entry.complete_status : entry.incomplete_status,
838
+ phaseCount,
839
+ });
840
+ }
646
841
  }
647
- // Cap nextActions at a scannable head (first 15) — derivation can surface a
648
- // long backlog; the head is the actionable slice for "what's next".
649
- const NEXT_ACTIONS_CAP = 15;
650
- const cappedNextActions = nextActions.slice(0, NEXT_ACTIONS_CAP);
842
+ milestones.sort((a, b) => a.id.localeCompare(b.id));
651
843
  // ── focus: single derived string ───────────────────────────────────────────
652
844
  let focus;
653
845
  if (inFlight.length > 0) {
654
846
  focus = `in-flight: ${inFlight.map((t) => t.id).join(", ")}`;
655
847
  }
656
848
  else {
657
- // Fall back to a phase bucketing to in_progress (phase.json phases[]
658
- // array-block).
659
- let inProgressPhase = null;
849
+ // Fall back to the first item of `focus_fallback.kind` bucketing to
850
+ // `focus_fallback.bucket` (stock: an in-progress phase).
851
+ let fallbackItem = null;
660
852
  for (const loc of index.byRefname.values()) {
661
- if (loc.block !== "phase")
853
+ if (loc.block !== sd.focus_fallback.kind)
662
854
  continue;
663
- if (bucket(loc.item) !== "in_progress")
855
+ if (bucket(loc.item) !== sd.focus_fallback.bucket)
664
856
  continue;
665
- inProgressPhase = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
857
+ fallbackItem = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
666
858
  break;
667
859
  }
668
- if (inProgressPhase !== null) {
669
- const label = inProgressPhase.name ? `${inProgressPhase.id} (${inProgressPhase.name})` : inProgressPhase.id;
670
- focus = `phase: ${label}`;
860
+ if (fallbackItem !== null) {
861
+ const label = fallbackItem.name ? `${fallbackItem.id} (${fallbackItem.name})` : fallbackItem.id;
862
+ focus = `${sd.focus_fallback.kind}: ${label}`;
671
863
  }
672
864
  else {
673
865
  focus = "no active focus.";
674
866
  }
675
867
  }
676
- return { focus, inFlight, nextActions: cappedNextActions, blocked };
868
+ return { focus, inFlight, nextActions: cappedNextActions, blocked, milestones };
677
869
  }
678
870
  // discoverArrayKey lives in read-element.ts (the lowest pure layer) and is
679
871
  // imported above — ONE copy of the single-top-level-array heuristic shared by
@@ -1034,22 +1226,31 @@ export function resolveItemsByIds(cwd, ids) {
1034
1226
  }
1035
1227
  // ── Relation porcelain (selector → structured EdgeEndpoint → raw append) ─────
1036
1228
  /**
1037
- * Load + JSON-parse a foreign substrate dir's config.json WITHOUT pointer
1038
- * resolution or AJV validation best-effort, returns null on absence/parse
1039
- * failure. Used only to feed `expectedBlockForId`'s prefix invariant when
1040
- * indexing a foreign substrate in the porcelain; the foreign substrate's own
1041
- * write path already AJV-validated its config, so a re-validate here would only
1042
- * add a failure mode to a read.
1229
+ * Load a foreign substrate dir's config.json PREFERRING the migration-aware
1230
+ * loader (`loadConfigForDir`: migrate-then-validate) so this reader sees the
1231
+ * SAME config shape `loadConfig` sees once a shape-changing config migration
1232
+ * exists (no split-brain read of the same file). Best-effort in two tiers:
1233
+ * when the migration-aware load fails (unresolvable version, invalid), fall
1234
+ * back to the prior raw parse — the consumer is `expectedBlockForId`'s prefix
1235
+ * invariant, which is better fed an unvalidated `block_kinds` than none (a
1236
+ * null config would silently disable the invariant and let a mis-filed
1237
+ * foreign id resolve instead of degrading). Absent or unparsable → null,
1238
+ * exactly the prior contract.
1043
1239
  */
1044
1240
  function loadConfigForDirBestEffort(substrateDir) {
1045
1241
  const p = path.join(substrateDir, "config.json");
1046
1242
  if (!fs.existsSync(p))
1047
1243
  return null;
1048
1244
  try {
1049
- return JSON.parse(fs.readFileSync(p, "utf-8"));
1245
+ return loadConfigForDir(substrateDir);
1050
1246
  }
1051
1247
  catch {
1052
- return null;
1248
+ try {
1249
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
1250
+ }
1251
+ catch {
1252
+ return null;
1253
+ }
1053
1254
  }
1054
1255
  }
1055
1256
  /**
@@ -1134,24 +1335,180 @@ function edgeIdentityKey(edge) {
1134
1335
  return `${endpointIdentity(edge.parent)} ${endpointIdentity(edge.child)} ${edge.relation_type}`;
1135
1336
  }
1136
1337
  /**
1137
- * Friendly-selector relation append (Cycle 5 porcelain). Resolves `parent` /
1138
- * `child` STRING selectors to structured `EdgeEndpoint`s via
1139
- * `resolveRelationSelector`, then delegates to the raw `appendRelation` plumbing
1140
- * (atomic, AJV-validated, exact-duplicate no-op same deferred-integrity
1141
- * semantics). Keeps the string param surface its callers (the append-relation
1142
- * Pi tool + the orchestrator CLI) already expose.
1338
+ * Shared edge-registry validator (TASK-062). The single source of edge
1339
+ * registration + endpoint-kind semantics, invoked BOTH at write time (the
1340
+ * `appendRelationByRef` / `appendRelationsByRef` porcelain, so a bad edge throws
1341
+ * before the raw write) AND post-hoc in {@link validateContext} (so write-time
1342
+ * and validate-time verdicts are guaranteed identical).
1343
+ *
1344
+ * Returns an ARRAY of human-readable error messages — empty when the edge is
1345
+ * acceptable. Each message is byte-identical to the wording `validateContext`
1346
+ * historically emitted inline, so the validate-time issue stream is unchanged;
1347
+ * the write path joins the array into the thrown Error message.
1348
+ *
1349
+ * Checks (registration + source/target-kind ONLY — NO `category` check; the
1350
+ * relation_type `category` attribute has no per-edge referent anywhere in code):
1351
+ * - (a) `edge.relation_type` MUST be registered in `config.relation_types[]`
1352
+ * (matched by `canonical_id`). Unregistered → the registration message;
1353
+ * when unregistered the kind check is short-circuited (no rt to read).
1354
+ * - (b) PRESENCE-GATED source/target-kind membership: a relation_type with
1355
+ * NEITHER `source_kinds` NOR `target_kinds` is unchecked (mirrors the
1356
+ * `if (!rt.source_kinds && !rt.target_kinds) continue;` gate). When a set
1357
+ * is present, the resolved endpoint block (via `resolve(...).loc.block`)
1358
+ * MUST be in it, honoring the `"*"` wildcard. A lens_bin / dangling /
1359
+ * unregistered endpoint carries no `loc` and is skipped for the kind
1360
+ * check (endpoint-resolution failures are validateContext's own surface,
1361
+ * not this helper's).
1362
+ *
1363
+ * `resolve` is the caller-supplied endpoint resolver (the same pass-bound
1364
+ * `resolveRef` closure validateContext builds; the write path builds a fresh one
1365
+ * over a freshly-built active index).
1366
+ */
1367
+ export function validateEdgeAgainstRegistry(edge, config, resolve) {
1368
+ const errors = [];
1369
+ const parentKey = endpointKey(edge.parent);
1370
+ const childKey = endpointKey(edge.child);
1371
+ const rt = (config.relation_types ?? []).find((r) => r.canonical_id === edge.relation_type);
1372
+ if (!rt) {
1373
+ errors.push(`Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`);
1374
+ // Short-circuit: with no registered relation_type there is no source/target
1375
+ // metadata to gate on (mirrors validateContext's `if (!rt) continue;`).
1376
+ return errors;
1377
+ }
1378
+ // Presence gate — neither set declared → endpoint kinds unchecked.
1379
+ if (!rt.source_kinds && !rt.target_kinds)
1380
+ return errors;
1381
+ const parentLoc = resolve(edge.parent).loc;
1382
+ const childLoc = resolve(edge.child).loc;
1383
+ if (parentLoc && rt.source_kinds && !(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
1384
+ errors.push(`Edge ${parentKey} -> ${childKey}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`);
1385
+ }
1386
+ if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
1387
+ errors.push(`Edge ${parentKey} -> ${childKey}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`);
1388
+ }
1389
+ return errors;
1390
+ }
1391
+ /**
1392
+ * Build the per-call edge-validation resolver for the WRITE-TIME porcelain — a
1393
+ * `resolveRef` closure bound to a freshly-built active index + a fresh foreign
1394
+ * cache for this write, plus the loaded config. Mirrors the pass-bound resolver
1395
+ * validateContext constructs, so the two paths resolve endpoints identically.
1396
+ * Returns `null` when no config is present (a pre-bootstrap substrate has no
1397
+ * relation_types registry to validate against → write-time check is a no-op,
1398
+ * matching validateContext's `if (config)` gate).
1399
+ */
1400
+ function buildWriteTimeEdgeValidator(cwd) {
1401
+ const config = loadConfig(cwd);
1402
+ if (!config)
1403
+ return null;
1404
+ const activeIndex = buildIdIndex(cwd);
1405
+ const foreignCache = new Map();
1406
+ const resolve = (ref) => resolveRef(cwd, ref, { activeIndex, foreignCache });
1407
+ return { config, resolve };
1408
+ }
1409
+ /**
1410
+ * Single-edge write-time gate (TASK-062): build the validator for `cwd` and
1411
+ * THROW if `edge` fails the shared registry check. A no-op when no config is
1412
+ * present (pre-bootstrap substrate). The thrown message names the offending
1413
+ * endpoint kind / relation_type / expected kinds (the helper's wording).
1414
+ */
1415
+ function assertEdgeValidForWrite(cwd, edge) {
1416
+ const validator = buildWriteTimeEdgeValidator(cwd);
1417
+ if (!validator)
1418
+ return;
1419
+ const edgeErrors = validateEdgeAgainstRegistry(edge, validator.config, validator.resolve);
1420
+ if (edgeErrors.length > 0) {
1421
+ throw new Error(`Edge rejected at write time (invalid relation_type / endpoint kind): ${edgeErrors.join("; ")}`);
1422
+ }
1423
+ }
1424
+ /**
1425
+ * Whether a role-bearing relation is orientation-AMBIGUOUS: its `source_kinds`
1426
+ * and `target_kinds` overlap (a shared kind, or either side unconstrained / the
1427
+ * `"*"` wildcard), so a bare `{parent, child}` append cannot be reliably oriented
1428
+ * from the endpoint kinds alone. Disjoint-kind relations are self-orienting — the
1429
+ * `validateEdgeAgainstRegistry` source/target-kind gate already rejects an
1430
+ * inversion — so a bare append of them stays allowed.
1431
+ */
1432
+ function relationKindsOverlap(rt) {
1433
+ const s = rt?.source_kinds;
1434
+ const t = rt?.target_kinds;
1435
+ if (!s || !t)
1436
+ return true; // an unconstrained endpoint is universal → overlaps everything
1437
+ if (s.includes("*") || t.includes("*"))
1438
+ return true;
1439
+ return s.some((k) => t.includes(k));
1440
+ }
1441
+ /**
1442
+ * Resolve a {@link RelationAppendInput} (raw `{parent,child}` OR role-typed
1443
+ * `{primary,counter}`) to the canonical `{parent, child}` STRING selectors the raw
1444
+ * plumbing consumes, applying the FGAP-113 write-orientation rules:
1445
+ * - the raw and role-typed pairs are mutually exclusive; exactly one complete
1446
+ * pair must be supplied.
1447
+ * - the role-typed form maps `primary`/`counter` → `parent`/`child` via the
1448
+ * relation's declared `role_direction`; it throws when the relation declares no
1449
+ * `role_direction` (there is no primary/counter role to map).
1450
+ * - a bare `{parent,child}` append of a role-BEARING relation that is
1451
+ * orientation-ambiguous (same-kind / wildcard endpoints) is REJECTED, directing
1452
+ * the author to `--primary`/`--counter`. The porcelain never guesses or swaps.
1453
+ * - a bare append of a role-less relation, or of a role-bearing DISJOINT-kind
1454
+ * relation (self-orienting via the kind gate), passes through unchanged.
1455
+ */
1456
+ export function orientAppendInput(config, rel) {
1457
+ const hasRole = rel.primary !== undefined || rel.counter !== undefined;
1458
+ const hasRaw = rel.parent !== undefined || rel.child !== undefined;
1459
+ if (hasRole && hasRaw) {
1460
+ throw new Error(`Relation append for '${rel.relation_type}': --primary/--counter and --parent/--child are mutually exclusive; supply exactly one orientation pair.`);
1461
+ }
1462
+ const rt = (config?.relation_types ?? []).find((r) => r.canonical_id === rel.relation_type);
1463
+ const roleDir = rt?.role_direction;
1464
+ const ordinalPart = rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {};
1465
+ if (hasRole) {
1466
+ if (rel.primary === undefined || rel.counter === undefined) {
1467
+ throw new Error(`Relation append for '${rel.relation_type}': the role-typed form needs BOTH --primary and --counter.`);
1468
+ }
1469
+ if (roleDir === undefined) {
1470
+ throw new Error(`Relation '${rel.relation_type}' declares no role_direction, so it has no primary/counter role to map — author it with --parent/--child.`);
1471
+ }
1472
+ const parent = roleDir === "as_parent" ? rel.primary : rel.counter;
1473
+ const child = roleDir === "as_parent" ? rel.counter : rel.primary;
1474
+ return { parent, child, relation_type: rel.relation_type, ...ordinalPart };
1475
+ }
1476
+ if (rel.parent === undefined || rel.child === undefined) {
1477
+ throw new Error(`Relation append for '${rel.relation_type}': supply either --parent and --child, or --primary and --counter.`);
1478
+ }
1479
+ if (roleDir !== undefined && relationKindsOverlap(rt)) {
1480
+ throw new Error(`Relation '${rel.relation_type}' carries a declared role_direction and is orientation-ambiguous (its ` +
1481
+ `source and target kinds overlap), so a bare --parent/--child append cannot be reliably oriented. Re-issue ` +
1482
+ `with --primary/--counter (primary = the endpoint holding the relation's semantic role, stored at edge.${roleDir === "as_parent" ? "parent" : "child"}).`);
1483
+ }
1484
+ return { parent: rel.parent, child: rel.child, relation_type: rel.relation_type, ...ordinalPart };
1485
+ }
1486
+ /**
1487
+ * Friendly-selector relation append (Cycle 5 porcelain). Accepts EITHER raw
1488
+ * `{parent, child}` selectors OR the role-typed `{primary, counter}` form
1489
+ * (FGAP-113), resolves the (possibly role-mapped) STRING selectors to structured
1490
+ * `EdgeEndpoint`s via `resolveRelationSelector`, then delegates to the raw
1491
+ * `appendRelation` plumbing (atomic, AJV-validated, exact-duplicate no-op — same
1492
+ * deferred-integrity semantics). Keeps the string param surface its callers (the
1493
+ * append-relation Pi tool + the orchestrator CLI) already expose.
1143
1494
  *
1144
1495
  * Returns `{ appended, edge }` where `edge` is the RESOLVED structured edge
1145
1496
  * actually written (so callers can report / dry-run-validate the structured
1146
1497
  * form).
1147
1498
  */
1148
1499
  export function appendRelationByRef(cwd, rel, ctx, opts) {
1500
+ const oriented = orientAppendInput(loadConfig(cwd), rel);
1149
1501
  const edge = {
1150
- parent: resolveRelationSelector(cwd, rel.parent),
1151
- child: resolveRelationSelector(cwd, rel.child),
1152
- relation_type: rel.relation_type,
1153
- ...(rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {}),
1502
+ parent: resolveRelationSelector(cwd, oriented.parent),
1503
+ child: resolveRelationSelector(cwd, oriented.child),
1504
+ relation_type: oriented.relation_type,
1505
+ ...(oriented.ordinal !== undefined ? { ordinal: oriented.ordinal } : {}),
1154
1506
  };
1507
+ // Write-time edge-registry gate (TASK-062): reject an unregistered
1508
+ // relation_type or a source/target-kind-violating endpoint BEFORE any write
1509
+ // (dryRun included — preview must surface the same rejection). The shared
1510
+ // helper guarantees this verdict is identical to validateContext's.
1511
+ assertEdgeValidForWrite(cwd, edge);
1155
1512
  if (opts?.dryRun) {
1156
1513
  // Preview parity: run the SAME validation the write path applies (the
1157
1514
  // prospective Edge[] against the whole relations schema — what
@@ -1262,12 +1619,33 @@ export function replaceRelationByRef(cwd, rels, ctx, opts) {
1262
1619
  * `validateContext`).
1263
1620
  */
1264
1621
  export function appendRelationsByRef(cwd, edges, ctx, opts) {
1265
- const resolved = edges.map((rel) => ({
1266
- parent: resolveRelationSelector(cwd, rel.parent),
1267
- child: resolveRelationSelector(cwd, rel.child),
1268
- relation_type: rel.relation_type,
1269
- ...(rel.ordinal !== undefined ? { ordinal: rel.ordinal } : {}),
1270
- }));
1622
+ // Orient every input once against the loaded config (FGAP-113): a role-typed
1623
+ // {primary,counter} edge maps to parent/child via role_direction; a bare
1624
+ // {parent,child} append of an orientation-ambiguous role-bearing relation is
1625
+ // rejected here (before any write), directing the author to --primary/--counter.
1626
+ const config = loadConfig(cwd);
1627
+ const resolved = edges.map((rel) => {
1628
+ const oriented = orientAppendInput(config, rel);
1629
+ return {
1630
+ parent: resolveRelationSelector(cwd, oriented.parent),
1631
+ child: resolveRelationSelector(cwd, oriented.child),
1632
+ relation_type: oriented.relation_type,
1633
+ ...(oriented.ordinal !== undefined ? { ordinal: oriented.ordinal } : {}),
1634
+ };
1635
+ });
1636
+ // Write-time edge-registry gate (TASK-062): every resolved edge in the batch
1637
+ // is checked BEFORE any write (dryRun included). Build the validator once for
1638
+ // the batch (config + active index built once) and reject if any edge fails —
1639
+ // an all-or-nothing batch (no partial write past a bad edge).
1640
+ const validator = buildWriteTimeEdgeValidator(cwd);
1641
+ if (validator) {
1642
+ for (const edge of resolved) {
1643
+ const edgeErrors = validateEdgeAgainstRegistry(edge, validator.config, validator.resolve);
1644
+ if (edgeErrors.length > 0) {
1645
+ throw new Error(`Edge rejected at write time (invalid relation_type / endpoint kind): ${edgeErrors.join("; ")}`);
1646
+ }
1647
+ }
1648
+ }
1271
1649
  if (opts?.dryRun) {
1272
1650
  // Preview parity: replay the bulk dedup the raw appendRelations applies —
1273
1651
  // skip an edge whose identity is already on disk OR earlier in THIS batch
@@ -1471,6 +1849,264 @@ function matchesWhere(item, where) {
1471
1849
  return false;
1472
1850
  return true;
1473
1851
  }
1852
+ /**
1853
+ * Evaluate every config-declared invariant (requires-edge, status-consistency,
1854
+ * derived-status) against the given substrate state, returning the issue set.
1855
+ * The ONE evaluation path shared by `validateContext` (read-side sweep) and
1856
+ * the ops-layer write-time gate (delta-scoped surfacing/refusal of newly-
1857
+ * introduced violations) — the two consumers cannot classify differently.
1858
+ * Unknown invariant classes are skipped (forward-compat).
1859
+ */
1860
+ export function evaluateConfigInvariants(cwd, config, index, relations) {
1861
+ const issues = [];
1862
+ // ── Config-declared invariants (DEC-0025: vocabulary-neutral) ─────────
1863
+ // Replaces the two previously-hardcoded invariants (completed-task-has-
1864
+ // verification, decision-cites-forcing-artifact). Every block / status /
1865
+ // relation_type / direction literal comes from config.invariants[] DATA;
1866
+ // this loop contains no vocabulary literal. Each requires-edge invariant:
1867
+ // items in `block` matching `where` must occupy `direction`'s endpoint on
1868
+ // ≥1 edge whose relation_type ∈ relation_types — else a diagnostic.
1869
+ for (const inv of config.invariants ?? []) {
1870
+ if (inv.class !== "requires-edge")
1871
+ continue; // forward-compat: skip unknown classes
1872
+ const relTypeSet = new Set(inv.relation_types);
1873
+ const satisfied = new Set();
1874
+ for (const edge of relations) {
1875
+ if (!relTypeSet.has(edge.relation_type))
1876
+ continue;
1877
+ satisfied.add(inv.direction === "as_parent" ? endpointKey(edge.parent) : endpointKey(edge.child));
1878
+ }
1879
+ for (const loc of index.byRefname.values()) {
1880
+ const id = loc.id;
1881
+ if (loc.block !== inv.block)
1882
+ continue;
1883
+ if (!matchesWhere(loc.item, inv.where))
1884
+ continue;
1885
+ if (satisfied.has(id))
1886
+ continue;
1887
+ issues.push({
1888
+ severity: inv.severity ?? "error",
1889
+ message: (inv.message ?? `Item '{id}' in block '{block}' violates invariant '${inv.id}'`)
1890
+ .replaceAll("{id}", id)
1891
+ .replaceAll("{block}", inv.block),
1892
+ block: inv.block,
1893
+ field: `${id}.${inv.id}`,
1894
+ code: inv.id,
1895
+ });
1896
+ }
1897
+ }
1898
+ // ── status-consistency invariants (DEC-0040 / FGAP-073) ──────────────
1899
+ // Cross-block status drift: for each item in inv.block (optionally gated
1900
+ // by when_bucket on the item's own status bucket), inspect edges whose
1901
+ // relation_type ∈ inv.relation_types and whose inv.direction endpoint is
1902
+ // the item; the OTHER endpoint is the target. Violation when the target's
1903
+ // status bucket differs from require_target_bucket, or equals
1904
+ // forbid_target_bucket. Vocabulary-free — every literal comes from `inv`
1905
+ // or the config-resolved status vocabulary; no block/status/relation
1906
+ // string is hardcoded. vocab resolved once, outside the loop.
1907
+ const vocab = resolveStatusVocabulary(cwd);
1908
+ const bucketOf = (item) => vocab[String(item.status)] ?? "unknown";
1909
+ for (const inv of config.invariants ?? []) {
1910
+ if (inv.class !== "status-consistency")
1911
+ continue;
1912
+ const relSet = new Set(inv.relation_types);
1913
+ for (const loc of index.byRefname.values()) {
1914
+ const id = loc.id;
1915
+ if (loc.block !== inv.block)
1916
+ continue;
1917
+ if (inv.when_bucket && bucketOf(loc.item) !== inv.when_bucket)
1918
+ continue;
1919
+ for (const edge of relations) {
1920
+ if (!relSet.has(edge.relation_type))
1921
+ continue;
1922
+ const selfIsParent = inv.direction === "as_parent";
1923
+ if ((selfIsParent ? endpointKey(edge.parent) : endpointKey(edge.child)) !== id)
1924
+ continue;
1925
+ const otherId = selfIsParent ? endpointKey(edge.child) : endpointKey(edge.parent);
1926
+ const otherLoc = index.byRefname.get(otherId);
1927
+ if (!otherLoc)
1928
+ continue; // dangling endpoint handled by edge-integrity above
1929
+ const otherBucket = bucketOf(otherLoc.item);
1930
+ const violateRequire = inv.require_target_bucket !== undefined && otherBucket !== inv.require_target_bucket;
1931
+ const violateForbid = inv.forbid_target_bucket !== undefined && otherBucket === inv.forbid_target_bucket;
1932
+ if (violateRequire || violateForbid) {
1933
+ issues.push({
1934
+ severity: inv.severity ?? "error",
1935
+ message: (inv.message ?? `Item '{id}' (block '{block}') status-consistency '${inv.id}'`)
1936
+ .replaceAll("{id}", id)
1937
+ .replaceAll("{block}", inv.block),
1938
+ block: inv.block,
1939
+ field: `${id}.${inv.id}`,
1940
+ code: inv.id,
1941
+ });
1942
+ }
1943
+ }
1944
+ }
1945
+ }
1946
+ // ── derived-status invariants (FEAT-011 — FGAP-116) ───────────────────
1947
+ // For a rollup-declared kind (state_derivation.rollups), each item's
1948
+ // STORED status must equal its DERIVED membership-rollup status —
1949
+ // divergence in EITHER direction (stored behind or ahead of the
1950
+ // derivation) is reported. Completeness comes from the SAME shared
1951
+ // helper currentState's gate satisfaction uses (derivedRollupComplete),
1952
+ // so validate-side verdicts match the derivation by construction. A
1953
+ // declaration naming a block with no matching rollups entry is inert
1954
+ // (the invariant has nothing to derive against). Vocabulary-free: kind,
1955
+ // membership relation, and the status strings all come from config.
1956
+ {
1957
+ const sdInv = resolveStateDerivation(cwd);
1958
+ const declared = (config.invariants ?? []).filter((inv) => inv.class === "derived-status");
1959
+ if (sdInv !== null && declared.length > 0) {
1960
+ const rollupByKind = new Map(sdInv.rollups.map((r) => [r.kind, r]));
1961
+ const roleDir = new Map();
1962
+ for (const rt of config.relation_types ?? []) {
1963
+ if (rt.role_direction !== undefined)
1964
+ roleDir.set(rt.canonical_id, rt.role_direction);
1965
+ }
1966
+ for (const inv of declared) {
1967
+ const entry = rollupByKind.get(inv.block);
1968
+ if (entry === undefined)
1969
+ continue;
1970
+ for (const loc of index.byRefname.values()) {
1971
+ if (loc.block !== inv.block)
1972
+ continue;
1973
+ const derived = derivedRollupComplete(index, relations, roleDir, rollupByKind, bucketOf, loc.id)
1974
+ ? entry.complete_status
1975
+ : entry.incomplete_status;
1976
+ const stored = String(loc.item.status);
1977
+ if (stored === derived)
1978
+ continue;
1979
+ issues.push({
1980
+ severity: inv.severity ?? "warning",
1981
+ message: (inv.message ??
1982
+ `Item '{id}' (block '{block}') stored status '{stored}' diverges from its derived rollup status '{derived}' (derived-status '${inv.id}')`)
1983
+ .replaceAll("{id}", loc.id)
1984
+ .replaceAll("{block}", inv.block)
1985
+ .replaceAll("{stored}", stored)
1986
+ .replaceAll("{derived}", derived),
1987
+ block: inv.block,
1988
+ field: `${loc.id}.${inv.id}`,
1989
+ code: inv.id,
1990
+ });
1991
+ }
1992
+ }
1993
+ }
1994
+ }
1995
+ return issues;
1996
+ }
1997
+ /**
1998
+ * Declared-baseline currency sweep (FEAT-011 criterion 6 — TASK-089), the ONE
1999
+ * evaluation path shared by `validateContext` (flags) and `reconcileContext`
2000
+ * (the complete-to-stale transition) — detector and repair cannot disagree.
2001
+ * Field-driven and vocabulary-neutral: no block-name literals — any item
2002
+ * carrying a `stale_conditions` array is condition-evaluable, and any
2003
+ * array-of-object field entry carrying a `content_pin` beside a `path`/`file`
2004
+ * is drift-checkable. Bare-string conditions are never machine-judged. Typed
2005
+ * conditions are judged only on items whose status buckets `complete` (the
2006
+ * transition's precondition); pin drift elsewhere degrades to the flag-only
2007
+ * `anchor-drift` kind. Unstamped baselines (no `baseline_hash`/`baseline_sha`
2008
+ * — e.g. the file/ref was unreadable at write) are never judged.
2009
+ */
2010
+ export function evaluateStalenessCandidates(cwd, index) {
2011
+ const out = [];
2012
+ const ctxRoot = tryResolveContextDir(cwd);
2013
+ if (ctxRoot === null)
2014
+ return out;
2015
+ const projectRoot = path.dirname(ctxRoot);
2016
+ const idx = index ?? buildIdIndex(cwd);
2017
+ const vocab = resolveStatusVocabulary(cwd);
2018
+ const bucketOf = (item) => vocab[String(item.status)] ?? "unknown";
2019
+ const substrateAbs = path.resolve(ctxRoot);
2020
+ // Substrate-internal paths are living state, not groundable artifacts — the
2021
+ // write choke never stamps them, and a hand-authored pin/baseline on one is
2022
+ // never judged here (it would drift on every substrate write and flag forever).
2023
+ const isSubstrateInternal = (rel) => {
2024
+ const abs = path.resolve(projectRoot, rel);
2025
+ return abs === substrateAbs || abs.startsWith(substrateAbs + path.sep);
2026
+ };
2027
+ const fileHashOrNull = (rel) => {
2028
+ const abs = path.resolve(projectRoot, rel);
2029
+ try {
2030
+ if (!fs.statSync(abs).isFile())
2031
+ return null;
2032
+ return computeFileBytesHash(abs);
2033
+ }
2034
+ catch {
2035
+ return null;
2036
+ }
2037
+ };
2038
+ for (const loc of idx.byRefname.values()) {
2039
+ const item = loc.item;
2040
+ const hasConditions = Array.isArray(item.stale_conditions);
2041
+ const completeBucket = bucketOf(item) === "complete";
2042
+ const firedReasons = [];
2043
+ if (hasConditions && completeBucket) {
2044
+ for (const cond of item.stale_conditions) {
2045
+ if (!cond || typeof cond !== "object" || Array.isArray(cond))
2046
+ continue; // bare strings stay human-only
2047
+ const c = cond;
2048
+ if (c.kind === "item-status" && typeof c.item === "string" && typeof c.bucket === "string") {
2049
+ const target = idx.byRefname.get(c.item);
2050
+ if (target !== undefined && bucketOf(target.item) === c.bucket) {
2051
+ firedReasons.push(`stale condition fired: item '${c.item}' bucketed '${c.bucket}'`);
2052
+ }
2053
+ }
2054
+ else if (c.kind === "file-changed" &&
2055
+ typeof c.path === "string" &&
2056
+ typeof c.baseline_hash === "string" &&
2057
+ !isSubstrateInternal(c.path)) {
2058
+ const now = fileHashOrNull(c.path);
2059
+ if (now === null) {
2060
+ firedReasons.push(`stale condition fired: file '${c.path}' is gone`);
2061
+ }
2062
+ else if (now !== c.baseline_hash) {
2063
+ firedReasons.push(`stale condition fired: file '${c.path}' changed from its baseline`);
2064
+ }
2065
+ }
2066
+ else if (c.kind === "revision-moved" && typeof c.ref === "string" && typeof c.baseline_sha === "string") {
2067
+ const now = resolveGitRefOrNull(projectRoot, c.ref);
2068
+ if (now !== null && now !== c.baseline_sha) {
2069
+ firedReasons.push(`stale condition fired: ref '${c.ref}' moved from '${c.baseline_sha.slice(0, 12)}'`);
2070
+ }
2071
+ }
2072
+ }
2073
+ }
2074
+ const driftReasons = [];
2075
+ for (const [field, value] of Object.entries(item)) {
2076
+ if (!Array.isArray(value))
2077
+ continue;
2078
+ for (const el of value) {
2079
+ if (!el || typeof el !== "object" || Array.isArray(el))
2080
+ continue;
2081
+ const rec = el;
2082
+ if (typeof rec.content_pin !== "string")
2083
+ continue;
2084
+ const rel = typeof rec.path === "string" ? rec.path : typeof rec.file === "string" ? rec.file : null;
2085
+ if (rel === null || isSubstrateInternal(rel))
2086
+ continue;
2087
+ const now = fileHashOrNull(rel);
2088
+ if (now === null) {
2089
+ driftReasons.push(`pin drift: '${field}' entry file '${rel}' is gone`);
2090
+ }
2091
+ else if (now !== rec.content_pin) {
2092
+ driftReasons.push(`pin drift: '${field}' entry file '${rel}' changed since pinned`);
2093
+ }
2094
+ }
2095
+ }
2096
+ if (hasConditions && completeBucket && (firedReasons.length > 0 || driftReasons.length > 0)) {
2097
+ out.push({
2098
+ id: loc.id,
2099
+ block: loc.block,
2100
+ kind: "staleness-candidate",
2101
+ reasons: [...firedReasons, ...driftReasons],
2102
+ });
2103
+ }
2104
+ else if (driftReasons.length > 0) {
2105
+ out.push({ id: loc.id, block: loc.block, kind: "anchor-drift", reasons: driftReasons });
2106
+ }
2107
+ }
2108
+ return out;
2109
+ }
1474
2110
  /**
1475
2111
  * Validate cross-block referential integrity against the EDGE model
1476
2112
  * (DEC-0013: `relations.json` closure-table edges are THE reference surface).
@@ -1553,7 +2189,6 @@ export function validateContext(cwd) {
1553
2189
  // false-pass a zero-edge substrate. The edge-integrity loop below is a
1554
2190
  // no-op on empty relations, so it needs no separate guard.
1555
2191
  if (config) {
1556
- const registeredRelTypes = new Set((config.relation_types ?? []).map((rt) => rt.canonical_id));
1557
2192
  // Per-pass foreign-index cache (Constraint 3): N foreign edges into the same
1558
2193
  // registered substrate build that substrate's index ONCE within this pass.
1559
2194
  const foreignCache = new Map();
@@ -1607,60 +2242,48 @@ export function validateContext(cwd) {
1607
2242
  code: "edge_endpoint_dangling",
1608
2243
  });
1609
2244
  }
1610
- if (!registeredRelTypes.has(edge.relation_type)) {
1611
- issues.push({
1612
- severity: "error",
1613
- message: `Edge relation_type '${edge.relation_type}' is not registered in config.relation_types`,
1614
- block: "relations",
1615
- field: `${parentKey}->${childKey}`,
1616
- });
1617
- }
1618
2245
  }
1619
- // ── Edge endpoint-kind check (FGAP-086, DEC-0037) ─────────────────────
1620
- // Presence-gated: a relation_type with neither source_kinds nor
1621
- // target_kinds is unchecked, so the frozen .project substrate (whose
1622
- // relation_types carry no endpoint metadata) is not retroactively failed.
1623
- // When metadata is present, an edge endpoint whose resolved block is not in
1624
- // the declared kind set (and the set is not the "*" wildcard) is an error.
1625
- // loc.block is the data-file basename; the source/target_kinds name
1626
- // block_kind canonical_ids the loc.block==canonical_id assumption is
1627
- // inherited from the invariant loop below (~:1140), where inv.block (a
1628
- // canonical_id) is matched directly against loc.block.
2246
+ // ── Edge registration + endpoint-kind check (FGAP-086, DEC-0037; TASK-062
2247
+ // factored into the shared validateEdgeAgainstRegistry helper so the
2248
+ // write-time porcelain and this validate-time loop reach an IDENTICAL
2249
+ // verdict). The helper performs (a) relation_type-registration and
2250
+ // (b) PRESENCE-GATED source/target-kind membership ("*" wildcard honored);
2251
+ // a relation_type with neither kind set is unchecked (the frozen .project
2252
+ // substrate, whose relation_types carry no endpoint metadata, is not
2253
+ // retroactively failed). Its returned messages are byte-identical to the
2254
+ // wording this loop historically emitted inline (registration message +
2255
+ // source/target kind messages), each mapped to the same issue shape
2256
+ // (severity error, block "relations", field parent->child, no code).
2257
+ // Order discipline (TASK-062): context-validate prints issues[], so the
2258
+ // emission order is a UX surface and must match the pre-refactor two-pass
2259
+ // shape — ALL relation_type-registration issues across every edge first,
2260
+ // THEN ALL source/target-kind issues across every edge (class-grouped, not
2261
+ // interleaved per edge). The shared helper returns both classes per edge;
2262
+ // we collect once then partition by message text (registration messages
2263
+ // carry "is not registered"; kind messages carry "source kind"/"target
2264
+ // kind"). Issue set/count/wording/severity and the registration
2265
+ // short-circuit are unchanged — this is order-only.
2266
+ const registrationIssues = [];
2267
+ const kindIssues = [];
1629
2268
  for (const edge of relations) {
1630
- const rt = config.relation_types?.find((r) => r.canonical_id === edge.relation_type);
1631
- if (!rt)
1632
- continue; // unregistered relation_type already reported above
1633
- if (!rt.source_kinds && !rt.target_kinds)
1634
- continue; // metadata absent → unchecked
1635
2269
  const parentKey = endpointKey(edge.parent);
1636
2270
  const childKey = endpointKey(edge.child);
1637
- // Use the resolved location for item endpoints (active OR foreign), so a
1638
- // foreign item's block is kind-checked against source/target_kinds too. A
1639
- // lens_bin endpoint (endpointKind:"lens_bin") carries no loc and is skipped
1640
- // — it never routes through item-block resolution.
1641
- const parentRes = resolve(edge.parent);
1642
- const childRes = resolve(edge.child);
1643
- const parentLoc = parentRes.loc;
1644
- const childLoc = childRes.loc;
1645
- if (parentLoc &&
1646
- rt.source_kinds &&
1647
- !(rt.source_kinds.includes("*") || rt.source_kinds.includes(parentLoc.block))) {
1648
- issues.push({
2271
+ for (const message of validateEdgeAgainstRegistry(edge, config, resolve)) {
2272
+ const issue = {
1649
2273
  severity: "error",
1650
- message: `Edge ${parentKey} -> ${childKey}: source kind '${parentLoc.block}' not in source_kinds [${rt.source_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
2274
+ message,
1651
2275
  block: "relations",
1652
2276
  field: `${parentKey}->${childKey}`,
1653
- });
1654
- }
1655
- if (childLoc && rt.target_kinds && !(rt.target_kinds.includes("*") || rt.target_kinds.includes(childLoc.block))) {
1656
- issues.push({
1657
- severity: "error",
1658
- message: `Edge ${parentKey} -> ${childKey}: target kind '${childLoc.block}' not in target_kinds [${rt.target_kinds.join(", ")}] for relation_type '${edge.relation_type}'`,
1659
- block: "relations",
1660
- field: `${parentKey}->${childKey}`,
1661
- });
2277
+ };
2278
+ if (message.includes("is not registered")) {
2279
+ registrationIssues.push(issue);
2280
+ }
2281
+ else {
2282
+ kindIssues.push(issue);
2283
+ }
1662
2284
  }
1663
2285
  }
2286
+ issues.push(...registrationIssues, ...kindIssues);
1664
2287
  // Cycle detection — delegate to validateRelations. It performs its own
1665
2288
  // lens/hierarchy/relation_type resolution and emits several edge codes;
1666
2289
  // only its cycle diagnostics are merged here (the parent/child/relation_type
@@ -1687,90 +2310,11 @@ export function validateContext(cwd) {
1687
2310
  /* validateRelations is best-effort for cycle detection; a throw here
1688
2311
  must not mask the authoritative edge-integrity issues collected above. */
1689
2312
  }
1690
- // ── Config-declared invariants (DEC-0025: vocabulary-neutral) ─────────
1691
- // Replaces the two previously-hardcoded invariants (completed-task-has-
1692
- // verification, decision-cites-forcing-artifact). Every block / status /
1693
- // relation_type / direction literal comes from config.invariants[] DATA;
1694
- // this loop contains no vocabulary literal. Each requires-edge invariant:
1695
- // items in `block` matching `where` must occupy `direction`'s endpoint on
1696
- // ≥1 edge whose relation_type ∈ relation_types — else a diagnostic.
1697
- for (const inv of config.invariants ?? []) {
1698
- if (inv.class !== "requires-edge")
1699
- continue; // forward-compat: skip unknown classes
1700
- const relTypeSet = new Set(inv.relation_types);
1701
- const satisfied = new Set();
1702
- for (const edge of relations) {
1703
- if (!relTypeSet.has(edge.relation_type))
1704
- continue;
1705
- satisfied.add(inv.direction === "as_parent" ? endpointKey(edge.parent) : endpointKey(edge.child));
1706
- }
1707
- for (const loc of index.byRefname.values()) {
1708
- const id = loc.id;
1709
- if (loc.block !== inv.block)
1710
- continue;
1711
- if (!matchesWhere(loc.item, inv.where))
1712
- continue;
1713
- if (satisfied.has(id))
1714
- continue;
1715
- issues.push({
1716
- severity: inv.severity ?? "error",
1717
- message: (inv.message ?? `Item '{id}' in block '{block}' violates invariant '${inv.id}'`)
1718
- .replaceAll("{id}", id)
1719
- .replaceAll("{block}", inv.block),
1720
- block: inv.block,
1721
- field: `${id}.${inv.id}`,
1722
- code: inv.id,
1723
- });
1724
- }
1725
- }
1726
- // ── status-consistency invariants (DEC-0040 / FGAP-073) ──────────────
1727
- // Cross-block status drift: for each item in inv.block (optionally gated
1728
- // by when_bucket on the item's own status bucket), inspect edges whose
1729
- // relation_type ∈ inv.relation_types and whose inv.direction endpoint is
1730
- // the item; the OTHER endpoint is the target. Violation when the target's
1731
- // status bucket differs from require_target_bucket, or equals
1732
- // forbid_target_bucket. Vocabulary-free — every literal comes from `inv`
1733
- // or the config-resolved status vocabulary; no block/status/relation
1734
- // string is hardcoded. vocab resolved once, outside the loop.
1735
- const vocab = resolveStatusVocabulary(cwd);
1736
- const bucketOf = (item) => vocab[String(item.status)] ?? "unknown";
1737
- for (const inv of config.invariants ?? []) {
1738
- if (inv.class !== "status-consistency")
1739
- continue;
1740
- const relSet = new Set(inv.relation_types);
1741
- for (const loc of index.byRefname.values()) {
1742
- const id = loc.id;
1743
- if (loc.block !== inv.block)
1744
- continue;
1745
- if (inv.when_bucket && bucketOf(loc.item) !== inv.when_bucket)
1746
- continue;
1747
- for (const edge of relations) {
1748
- if (!relSet.has(edge.relation_type))
1749
- continue;
1750
- const selfIsParent = inv.direction === "as_parent";
1751
- if ((selfIsParent ? endpointKey(edge.parent) : endpointKey(edge.child)) !== id)
1752
- continue;
1753
- const otherId = selfIsParent ? endpointKey(edge.child) : endpointKey(edge.parent);
1754
- const otherLoc = index.byRefname.get(otherId);
1755
- if (!otherLoc)
1756
- continue; // dangling endpoint handled by edge-integrity above
1757
- const otherBucket = bucketOf(otherLoc.item);
1758
- const violateRequire = inv.require_target_bucket !== undefined && otherBucket !== inv.require_target_bucket;
1759
- const violateForbid = inv.forbid_target_bucket !== undefined && otherBucket === inv.forbid_target_bucket;
1760
- if (violateRequire || violateForbid) {
1761
- issues.push({
1762
- severity: inv.severity ?? "error",
1763
- message: (inv.message ?? `Item '{id}' (block '{block}') status-consistency '${inv.id}'`)
1764
- .replaceAll("{id}", id)
1765
- .replaceAll("{block}", inv.block),
1766
- block: inv.block,
1767
- field: `${id}.${inv.id}`,
1768
- code: inv.id,
1769
- });
1770
- }
1771
- }
1772
- }
1773
- }
2313
+ // ── Config-declared invariants the three classes evaluate through the
2314
+ // SHARED evaluateConfigInvariants helper (also consumed by the write-time
2315
+ // gate at the ops layer), so validate-side and write-side verdicts are
2316
+ // identical by construction (the TASK-062 edge-gate lift pattern).
2317
+ issues.push(...evaluateConfigInvariants(cwd, config, index, relations));
1774
2318
  }
1775
2319
  // Cross-block status-vocabulary check (FGAP-025): an item status value absent
1776
2320
  // from the declared vocabulary silently buckets to "unknown" in currentState /
@@ -1796,6 +2340,22 @@ export function validateContext(cwd) {
1796
2340
  }
1797
2341
  }
1798
2342
  }
2343
+ // ── Declared-baseline staleness sweep (FEAT-011 criterion 6 — TASK-089) ──
2344
+ // Fired typed stale_conditions and drifted content pins, through the SAME
2345
+ // evaluateStalenessCandidates helper context-reconcile's transition sweep
2346
+ // uses (identical verdicts). Warning-only, and validate NEVER mutates — the
2347
+ // complete-to-stale transition belongs to reconcile or a human.
2348
+ for (const cand of evaluateStalenessCandidates(cwd, index)) {
2349
+ issues.push({
2350
+ severity: "warning",
2351
+ message: cand.kind === "staleness-candidate"
2352
+ ? `Item '${cand.id}' (block '${cand.block}') is a staleness candidate — ${cand.reasons.join("; ")}. Apply the complete-to-stale transition via context-reconcile or by hand; validate never mutates.`
2353
+ : `Item '${cand.id}' (block '${cand.block}') has drifted anchors — ${cand.reasons.join("; ")}. Re-review the cited locations; the flag never rewrites.`,
2354
+ block: cand.block,
2355
+ field: `${cand.id}.${cand.kind}`,
2356
+ code: cand.kind,
2357
+ });
2358
+ }
1799
2359
  // ── Nested id-bearing array warning (content-addressed substrate identity,
1800
2360
  // Cycle 9.2) ─────────────────────────────────────────────────────────────
1801
2361
  // Schema-level, independent of block DATA + config: enumerate the active
@@ -1864,6 +2424,59 @@ export function validateContext(cwd) {
1864
2424
  });
1865
2425
  }
1866
2426
  }
2427
+ // ── Substrate-wide block schema-validity sweep ────────────────────────────
2428
+ // The composed verdict must cover schema validity, not only edge/invariant
2429
+ // integrity: invalidity introduced WITHOUT a write to the block (a schema
2430
+ // resync, a hand edit, catalog drift) previously sat behind a clean verdict
2431
+ // until the next write to that block failed. For every block that has BOTH a
2432
+ // schema in <substrateDir>/schemas/ AND a block file, validate the whole
2433
+ // block against its installed schema — migration-aware when the envelope is
2434
+ // stamped (validateBlockWithMigrationForDir walks a lagging version through
2435
+ // the registered chain), raw otherwise (the same helper: absent version ⇒
2436
+ // straight validate). Every failure surfaces as an ERROR naming the block,
2437
+ // the failing item id (resolved from the AJV instancePath), and the
2438
+ // field/keyword. Guarded per block so one unreadable file cannot mask the
2439
+ // rest of the sweep; pointer-less projects degrade gracefully (no sweep —
2440
+ // there is no substrate to validate), matching validateContext's class rule.
2441
+ {
2442
+ const substrateDir = tryResolveContextDir(cwd);
2443
+ const schemasDir = substrateDir ? path.join(substrateDir, "schemas") : null;
2444
+ if (substrateDir && schemasDir && fs.existsSync(schemasDir)) {
2445
+ const registry = getProjectMigrationRegistryForDir(substrateDir);
2446
+ for (const schemaFile of fs.readdirSync(schemasDir).filter((f) => f.endsWith(".schema.json"))) {
2447
+ const blockName = schemaFile.slice(0, -".schema.json".length);
2448
+ const blockFile = path.join(substrateDir, `${blockName}.json`);
2449
+ if (!fs.existsSync(blockFile))
2450
+ continue;
2451
+ try {
2452
+ const blockData = JSON.parse(fs.readFileSync(blockFile, "utf-8"));
2453
+ validateBlockWithMigrationForDir(substrateDir, blockName, blockData, registry);
2454
+ }
2455
+ catch (err) {
2456
+ if (err instanceof ValidationError) {
2457
+ for (const e of err.errors) {
2458
+ const itemId = blockItemIdForInstancePath(cwd, blockName, e.instancePath);
2459
+ issues.push({
2460
+ severity: "error",
2461
+ message: `Block '${blockName}' fails its installed schema at ${e.instancePath || "(envelope)"}${itemId ? ` (item '${itemId}')` : ""}: ${e.keyword} ${e.message ?? ""}`.trimEnd(),
2462
+ block: blockName,
2463
+ ...(itemId !== undefined ? { field: itemId } : {}),
2464
+ code: "block_schema_invalid",
2465
+ });
2466
+ }
2467
+ }
2468
+ else {
2469
+ issues.push({
2470
+ severity: "error",
2471
+ message: `Block '${blockName}' failed schema-validity sweep: ${err instanceof Error ? err.message : String(err)}`,
2472
+ block: blockName,
2473
+ code: "block_schema_invalid",
2474
+ });
2475
+ }
2476
+ }
2477
+ }
2478
+ }
2479
+ }
1867
2480
  const errorCount = issues.filter((i) => i.severity === "error").length;
1868
2481
  const warningCount = issues.filter((i) => i.severity === "warning").length;
1869
2482
  return {
@@ -1872,13 +2485,44 @@ export function validateContext(cwd) {
1872
2485
  };
1873
2486
  }
1874
2487
  /**
1875
- * Gate task completion on verification. Reads the verification block to confirm
1876
- * a passing verification entry exists, asserts a `verification_verifies_item`
1877
- * closure-table edge links that verification (parent) to this task (child), then
1878
- * atomically updates the task status to "completed". The edge IS the linkage —
1879
- * no `verification` field is embedded on the task (the verification task
2488
+ * Resolve the failing block item's `id` from an AJV `instancePath` of the form
2489
+ * `/<arrayKey>/<index>[/...]` by re-reading the block file. Returns undefined
2490
+ * for envelope-level paths or when the item carries no string id.
2491
+ */
2492
+ function blockItemIdForInstancePath(cwd, blockName, instancePath) {
2493
+ const m = /^\/([^/]+)\/(\d+)/.exec(instancePath);
2494
+ if (!m)
2495
+ return undefined;
2496
+ try {
2497
+ const substrateDir = resolveContextDir(cwd);
2498
+ const data = JSON.parse(fs.readFileSync(path.join(substrateDir, `${blockName}.json`), "utf-8"));
2499
+ const arr = data[m[1]];
2500
+ if (!Array.isArray(arr))
2501
+ return undefined;
2502
+ const item = arr[Number(m[2])];
2503
+ if (!item || typeof item !== "object")
2504
+ return undefined;
2505
+ const id = item.id;
2506
+ return typeof id === "string" ? id : undefined;
2507
+ }
2508
+ catch {
2509
+ return undefined;
2510
+ }
2511
+ }
2512
+ /**
2513
+ * Verification-gated task completion — the closure transition ATOM. Reads the
2514
+ * verification block to confirm a passing verification entry exists, FILES the
2515
+ * `verification_verifies_item` closure-table edge itself (verification as
2516
+ * parent, task as child; idempotent — an exact pre-existing edge is a no-op via
2517
+ * appendRelation's dedup), then updates the task status to "completed", all in
2518
+ * one call. Filing the edge here rather than requiring a prior append-relation
2519
+ * is load-bearing: an error-severity invariant pair (passed-verification-
2520
+ * verifies-completed-task / completed-task-has-verification) forbids BOTH
2521
+ * intermediate resting states, so no two-op sequence can thread the write-time
2522
+ * gate — only the joint end-state is legal. The edge IS the linkage — no
2523
+ * `verification` field is embedded on the task (the verification → task
1880
2524
  * `verification.target`/`target_type` fields were removed from the verification
1881
- * schema in favor of the edge; this gate reads the edge, not the removed fields).
2525
+ * schema in favor of the edge).
1882
2526
  */
1883
2527
  export function completeTask(cwd, taskId, verificationId, ctx) {
1884
2528
  // 1. Read and validate verification entry
@@ -1917,14 +2561,14 @@ export function completeTask(cwd, taskId, verificationId, ctx) {
1917
2561
  if (currentStatus === "cancelled") {
1918
2562
  throw new Error(`Task '${taskId}' is already cancelled`);
1919
2563
  }
1920
- // 3. Assert the verification_verifies_item edge: verification (parent) →
1921
- // task (child). The closure-table edge not a verification field — is the
1922
- // canonical linkage. Inbound edges point AT the task; compare the parent
1923
- // endpoint via endpointKey (the codebase's endpoint-comparison idiom).
1924
- const verifiesEdge = findReferencesInRepo(cwd, taskId, "inbound").find((e) => e.relation_type === "verification_verifies_item" && endpointKey(e.parent) === verificationId);
1925
- if (!verifiesEdge) {
1926
- throw new Error(`verification '${verificationId}' does not verify task '${taskId}' — no verification_verifies_item edge; file the link (append-relation parent=${verificationId} child=${taskId} relation_type=verification_verifies_item) before completing`);
1927
- }
2564
+ // 3. File the verification_verifies_item edge: verification (parent) →
2565
+ // task (child), through the same porcelain a standalone append-relation
2566
+ // uses (registered-type + endpoint-kind validation, atomic write,
2567
+ // exact-duplicate no-op). The closure-table edge not a verification
2568
+ // field is the canonical linkage; filing it in the SAME call that flips
2569
+ // status makes closure a single transition atom under the write-time
2570
+ // invariant gate.
2571
+ const { appended } = appendRelationByRef(cwd, { parent: verificationId, child: taskId, relation_type: "verification_verifies_item" }, ctx);
1928
2572
  // 4. Update task status. The edge is the linkage — no `verification` field is
1929
2573
  // embedded (a populated additionalProperties:false tasks schema would reject it).
1930
2574
  updateItemInBlock(cwd, "tasks", "tasks", (t) => t.id === taskId, { status: "completed" }, ctx);
@@ -1933,6 +2577,7 @@ export function completeTask(cwd, taskId, verificationId, ctx) {
1933
2577
  verificationId,
1934
2578
  verificationStatus: String(verification.status),
1935
2579
  previousStatus: currentStatus,
2580
+ edgeAppended: appended,
1936
2581
  };
1937
2582
  }
1938
2583
  //# sourceMappingURL=context-sdk.js.map