@davidorex/pi-context 0.31.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +73 -0
- package/README.md +16 -14
- package/dist/block-api.d.ts +20 -33
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +154 -3
- package/dist/block-api.js.map +1 -1
- package/dist/content-hash.d.ts +9 -0
- package/dist/content-hash.d.ts.map +1 -1
- package/dist/content-hash.js +11 -0
- package/dist/content-hash.js.map +1 -1
- package/dist/context-sdk.d.ts +157 -39
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +896 -251
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +158 -5
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +75 -5
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +151 -19
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +590 -97
- package/dist/index.js.map +1 -1
- package/dist/lens-view.d.ts +0 -5
- package/dist/lens-view.d.ts.map +1 -1
- package/dist/lens-view.js +43 -1
- package/dist/lens-view.js.map +1 -1
- package/dist/migration-registry-loader.d.ts +20 -12
- package/dist/migration-registry-loader.d.ts.map +1 -1
- package/dist/migration-registry-loader.js +46 -17
- package/dist/migration-registry-loader.js.map +1 -1
- package/dist/migrations-store.d.ts +45 -18
- package/dist/migrations-store.d.ts.map +1 -1
- package/dist/migrations-store.js +56 -22
- package/dist/migrations-store.js.map +1 -1
- package/dist/ops-registry.d.ts +18 -0
- package/dist/ops-registry.d.ts.map +1 -1
- package/dist/ops-registry.js +474 -130
- package/dist/ops-registry.js.map +1 -1
- package/dist/promote-item.d.ts.map +1 -1
- package/dist/promote-item.js +41 -12
- package/dist/promote-item.js.map +1 -1
- package/dist/roadmap-plan.d.ts +121 -99
- package/dist/roadmap-plan.d.ts.map +1 -1
- package/dist/roadmap-plan.js +281 -345
- package/dist/roadmap-plan.js.map +1 -1
- package/dist/status-vocab.d.ts +12 -2
- package/dist/status-vocab.d.ts.map +1 -1
- package/dist/status-vocab.js +14 -1
- package/dist/status-vocab.js.map +1 -1
- package/package.json +2 -2
- package/samples/blocks/milestone.json +3 -0
- package/samples/blocks/session-notes.json +1 -0
- package/samples/conception.json +315 -15
- package/samples/migrations.json +8 -0
- package/samples/schemas/context-contracts.schema.json +4 -0
- package/samples/schemas/conventions.schema.json +4 -0
- package/samples/schemas/decisions.schema.json +4 -0
- package/samples/schemas/features.schema.json +4 -0
- package/samples/schemas/framework-gaps.schema.json +9 -0
- package/samples/schemas/issues.schema.json +28 -0
- package/samples/schemas/layer-plans.schema.json +4 -0
- package/samples/schemas/milestone.schema.json +79 -0
- package/samples/schemas/phase.schema.json +4 -0
- package/samples/schemas/rationale.schema.json +4 -0
- package/samples/schemas/requirements.schema.json +4 -0
- package/samples/schemas/research.schema.json +45 -2
- package/samples/schemas/session-notes.schema.json +89 -0
- package/samples/schemas/spec-reviews.schema.json +4 -0
- package/samples/schemas/story.schema.json +8 -0
- package/samples/schemas/tasks.schema.json +4 -0
- package/samples/schemas/verification.schema.json +4 -0
- package/samples/schemas/work-orders.schema.json +4 -0
- package/schemas/config.schema.json +90 -4
- package/schemas/migrations.schema.json +25 -0
- package/skill-narrative.md +8 -6
- package/skills/pi-context/SKILL.md +70 -63
- package/skills/pi-context/references/bundled-resources.md +5 -1
package/dist/context-sdk.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 (
|
|
531
|
-
* roadmap-plan
|
|
532
|
-
*
|
|
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
|
-
//
|
|
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
|
|
642
|
+
if (!sd.in_flight.kinds.includes(loc.block))
|
|
566
643
|
continue;
|
|
567
|
-
if (bucket(loc.item) !==
|
|
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
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
//
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
//
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
|
658
|
-
//
|
|
659
|
-
let
|
|
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 !==
|
|
853
|
+
if (loc.block !== sd.focus_fallback.kind)
|
|
662
854
|
continue;
|
|
663
|
-
if (bucket(loc.item) !==
|
|
855
|
+
if (bucket(loc.item) !== sd.focus_fallback.bucket)
|
|
664
856
|
continue;
|
|
665
|
-
|
|
857
|
+
fallbackItem = { id: loc.id, name: typeof loc.item.name === "string" ? loc.item.name : undefined };
|
|
666
858
|
break;
|
|
667
859
|
}
|
|
668
|
-
if (
|
|
669
|
-
const label =
|
|
670
|
-
focus =
|
|
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
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
*
|
|
1042
|
-
*
|
|
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
|
|
1245
|
+
return loadConfigForDir(substrateDir);
|
|
1050
1246
|
}
|
|
1051
1247
|
catch {
|
|
1052
|
-
|
|
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
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
* `
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1142
|
-
*
|
|
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,
|
|
1151
|
-
child: resolveRelationSelector(cwd,
|
|
1152
|
-
relation_type:
|
|
1153
|
-
...(
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
1627
|
-
//
|
|
1628
|
-
//
|
|
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
|
-
|
|
1638
|
-
|
|
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
|
|
2274
|
+
message,
|
|
1651
2275
|
block: "relations",
|
|
1652
2276
|
field: `${parentKey}->${childKey}`,
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
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
|
|
1691
|
-
//
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
1694
|
-
|
|
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
|
-
*
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
1878
|
-
|
|
1879
|
-
|
|
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
|
|
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.
|
|
1921
|
-
// task (child)
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|