@davidorex/pi-context 0.32.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.
@@ -7,13 +7,13 @@ 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";
10
+ import { readBlock, readBlockForDir, resolveGitRefOrNull, updateItemInBlock } from "./block-api.js";
11
+ import { computeFileBytesHash } from "./content-hash.js";
11
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
17
  import { getProjectMigrationRegistryForDir } from "./migration-registry-loader.js";
18
18
  import { addressInto, discoverArrayKey, pageArray } from "./read-element.js";
19
19
  import { ValidationError, validateBlockWithMigrationForDir, validateFromFile } from "./schema-validator.js";
@@ -573,6 +573,35 @@ function renderReasonTemplate(template, tokens) {
573
573
  return v !== undefined ? v : match;
574
574
  });
575
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.
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
+ }
576
605
  export function currentState(cwd) {
577
606
  // Tolerate any substrate-read failure (no .project, malformed config, etc.)
578
607
  // by collapsing to the empty state — this is a pure read surface.
@@ -620,14 +649,33 @@ export function currentState(cwd) {
620
649
  description: typeof loc.item.description === "string" ? loc.item.description : "",
621
650
  });
622
651
  }
623
- // A target is complete when it resolves to a known item whose status buckets to
624
- // "complete" — the SHARED status-vocab completeness notion (as TASK-065 left it,
625
- // the same check the status-consistency invariant engine uses), kind-general and
626
- // not a per-derivation literal.
627
- const isCompleted = (itemId) => {
628
- const loc = index.byRefname.get(itemId);
629
- return loc !== undefined && bucket(loc.item) === "complete";
630
- };
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);
631
679
  // Blocking-relation adjacency, driven by `sd.blocked_by.relation_types`. The
632
680
  // two stock relations use OPPOSITE endpoint directions, preserved exactly:
633
681
  // • task_depends_on_task — DEPENDENCY direction: parents of edges whose CHILD
@@ -653,11 +701,6 @@ export function currentState(cwd) {
653
701
  // construction (no literal to extend). A blocked_by relation the config does
654
702
  // NOT register with a role_direction reads as the DEPENDENCY default.
655
703
  const blockedByRels = new Set(sd.blocked_by.relation_types);
656
- const roleDirection = new Map();
657
- for (const rt of loadConfig(cwd)?.relation_types ?? []) {
658
- if (rt.role_direction !== undefined)
659
- roleDirection.set(rt.canonical_id, rt.role_direction);
660
- }
661
704
  const gateDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) === "as_child"));
662
705
  const depDirRels = new Set([...blockedByRels].filter((rt) => roleDirection.get(rt) !== "as_child"));
663
706
  const dependencyPredsOf = (itemId) => edges
@@ -785,8 +828,10 @@ export function currentState(cwd) {
785
828
  .filter((e) => e.relation_type === entry.membership_relation && endpointKey(primaryEndpoint(e, dir)) === loc.id)
786
829
  .map((e) => endpointKey(counterEndpoint(e, dir)));
787
830
  const phaseCount = memberIds.length;
788
- const allComplete = memberIds.every((memberId) => isCompleted(memberId));
789
- const reached = phaseCount >= 1 && allComplete;
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);
790
835
  milestones.push({
791
836
  id: loc.id,
792
837
  status: reached ? entry.complete_status : entry.incomplete_status,
@@ -1408,7 +1453,7 @@ function relationKindsOverlap(rt) {
1408
1453
  * - a bare append of a role-less relation, or of a role-bearing DISJOINT-kind
1409
1454
  * relation (self-orienting via the kind gate), passes through unchanged.
1410
1455
  */
1411
- function orientAppendInput(config, rel) {
1456
+ export function orientAppendInput(config, rel) {
1412
1457
  const hasRole = rel.primary !== undefined || rel.counter !== undefined;
1413
1458
  const hasRaw = rel.parent !== undefined || rel.child !== undefined;
1414
1459
  if (hasRole && hasRaw) {
@@ -1804,6 +1849,264 @@ function matchesWhere(item, where) {
1804
1849
  return false;
1805
1850
  return true;
1806
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
+ }
1807
2110
  /**
1808
2111
  * Validate cross-block referential integrity against the EDGE model
1809
2112
  * (DEC-0013: `relations.json` closure-table edges are THE reference surface).
@@ -2007,90 +2310,11 @@ export function validateContext(cwd) {
2007
2310
  /* validateRelations is best-effort for cycle detection; a throw here
2008
2311
  must not mask the authoritative edge-integrity issues collected above. */
2009
2312
  }
2010
- // ── Config-declared invariants (DEC-0025: vocabulary-neutral) ─────────
2011
- // Replaces the two previously-hardcoded invariants (completed-task-has-
2012
- // verification, decision-cites-forcing-artifact). Every block / status /
2013
- // relation_type / direction literal comes from config.invariants[] DATA;
2014
- // this loop contains no vocabulary literal. Each requires-edge invariant:
2015
- // items in `block` matching `where` must occupy `direction`'s endpoint on
2016
- // ≥1 edge whose relation_type ∈ relation_types — else a diagnostic.
2017
- for (const inv of config.invariants ?? []) {
2018
- if (inv.class !== "requires-edge")
2019
- continue; // forward-compat: skip unknown classes
2020
- const relTypeSet = new Set(inv.relation_types);
2021
- const satisfied = new Set();
2022
- for (const edge of relations) {
2023
- if (!relTypeSet.has(edge.relation_type))
2024
- continue;
2025
- satisfied.add(inv.direction === "as_parent" ? endpointKey(edge.parent) : endpointKey(edge.child));
2026
- }
2027
- for (const loc of index.byRefname.values()) {
2028
- const id = loc.id;
2029
- if (loc.block !== inv.block)
2030
- continue;
2031
- if (!matchesWhere(loc.item, inv.where))
2032
- continue;
2033
- if (satisfied.has(id))
2034
- continue;
2035
- issues.push({
2036
- severity: inv.severity ?? "error",
2037
- message: (inv.message ?? `Item '{id}' in block '{block}' violates invariant '${inv.id}'`)
2038
- .replaceAll("{id}", id)
2039
- .replaceAll("{block}", inv.block),
2040
- block: inv.block,
2041
- field: `${id}.${inv.id}`,
2042
- code: inv.id,
2043
- });
2044
- }
2045
- }
2046
- // ── status-consistency invariants (DEC-0040 / FGAP-073) ──────────────
2047
- // Cross-block status drift: for each item in inv.block (optionally gated
2048
- // by when_bucket on the item's own status bucket), inspect edges whose
2049
- // relation_type ∈ inv.relation_types and whose inv.direction endpoint is
2050
- // the item; the OTHER endpoint is the target. Violation when the target's
2051
- // status bucket differs from require_target_bucket, or equals
2052
- // forbid_target_bucket. Vocabulary-free — every literal comes from `inv`
2053
- // or the config-resolved status vocabulary; no block/status/relation
2054
- // string is hardcoded. vocab resolved once, outside the loop.
2055
- const vocab = resolveStatusVocabulary(cwd);
2056
- const bucketOf = (item) => vocab[String(item.status)] ?? "unknown";
2057
- for (const inv of config.invariants ?? []) {
2058
- if (inv.class !== "status-consistency")
2059
- continue;
2060
- const relSet = new Set(inv.relation_types);
2061
- for (const loc of index.byRefname.values()) {
2062
- const id = loc.id;
2063
- if (loc.block !== inv.block)
2064
- continue;
2065
- if (inv.when_bucket && bucketOf(loc.item) !== inv.when_bucket)
2066
- continue;
2067
- for (const edge of relations) {
2068
- if (!relSet.has(edge.relation_type))
2069
- continue;
2070
- const selfIsParent = inv.direction === "as_parent";
2071
- if ((selfIsParent ? endpointKey(edge.parent) : endpointKey(edge.child)) !== id)
2072
- continue;
2073
- const otherId = selfIsParent ? endpointKey(edge.child) : endpointKey(edge.parent);
2074
- const otherLoc = index.byRefname.get(otherId);
2075
- if (!otherLoc)
2076
- continue; // dangling endpoint handled by edge-integrity above
2077
- const otherBucket = bucketOf(otherLoc.item);
2078
- const violateRequire = inv.require_target_bucket !== undefined && otherBucket !== inv.require_target_bucket;
2079
- const violateForbid = inv.forbid_target_bucket !== undefined && otherBucket === inv.forbid_target_bucket;
2080
- if (violateRequire || violateForbid) {
2081
- issues.push({
2082
- severity: inv.severity ?? "error",
2083
- message: (inv.message ?? `Item '{id}' (block '{block}') status-consistency '${inv.id}'`)
2084
- .replaceAll("{id}", id)
2085
- .replaceAll("{block}", inv.block),
2086
- block: inv.block,
2087
- field: `${id}.${inv.id}`,
2088
- code: inv.id,
2089
- });
2090
- }
2091
- }
2092
- }
2093
- }
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));
2094
2318
  }
2095
2319
  // Cross-block status-vocabulary check (FGAP-025): an item status value absent
2096
2320
  // from the declared vocabulary silently buckets to "unknown" in currentState /
@@ -2116,6 +2340,22 @@ export function validateContext(cwd) {
2116
2340
  }
2117
2341
  }
2118
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
+ }
2119
2359
  // ── Nested id-bearing array warning (content-addressed substrate identity,
2120
2360
  // Cycle 9.2) ─────────────────────────────────────────────────────────────
2121
2361
  // Schema-level, independent of block DATA + config: enumerate the active
@@ -2270,13 +2510,19 @@ function blockItemIdForInstancePath(cwd, blockName, instancePath) {
2270
2510
  }
2271
2511
  }
2272
2512
  /**
2273
- * Gate task completion on verification. Reads the verification block to confirm
2274
- * a passing verification entry exists, asserts a `verification_verifies_item`
2275
- * closure-table edge links that verification (parent) to this task (child), then
2276
- * atomically updates the task status to "completed". The edge IS the linkage
2277
- * no `verification` field is embedded on the task (the verification task
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
2278
2524
  * `verification.target`/`target_type` fields were removed from the verification
2279
- * schema in favor of the edge; this gate reads the edge, not the removed fields).
2525
+ * schema in favor of the edge).
2280
2526
  */
2281
2527
  export function completeTask(cwd, taskId, verificationId, ctx) {
2282
2528
  // 1. Read and validate verification entry
@@ -2315,14 +2561,14 @@ export function completeTask(cwd, taskId, verificationId, ctx) {
2315
2561
  if (currentStatus === "cancelled") {
2316
2562
  throw new Error(`Task '${taskId}' is already cancelled`);
2317
2563
  }
2318
- // 3. Assert the verification_verifies_item edge: verification (parent) →
2319
- // task (child). The closure-table edge not a verification field — is the
2320
- // canonical linkage. Inbound edges point AT the task; compare the parent
2321
- // endpoint via endpointKey (the codebase's endpoint-comparison idiom).
2322
- const verifiesEdge = findReferencesInRepo(cwd, taskId, "inbound").find((e) => e.relation_type === "verification_verifies_item" && endpointKey(e.parent) === verificationId);
2323
- if (!verifiesEdge) {
2324
- 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`);
2325
- }
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);
2326
2572
  // 4. Update task status. The edge is the linkage — no `verification` field is
2327
2573
  // embedded (a populated additionalProperties:false tasks schema would reject it).
2328
2574
  updateItemInBlock(cwd, "tasks", "tasks", (t) => t.id === taskId, { status: "completed" }, ctx);
@@ -2331,6 +2577,7 @@ export function completeTask(cwd, taskId, verificationId, ctx) {
2331
2577
  verificationId,
2332
2578
  verificationStatus: String(verification.status),
2333
2579
  previousStatus: currentStatus,
2580
+ edgeAppended: appended,
2334
2581
  };
2335
2582
  }
2336
2583
  //# sourceMappingURL=context-sdk.js.map