@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.
- package/CHANGELOG.md +15 -0
- package/README.md +4 -3
- package/dist/block-api.d.ts +20 -33
- package/dist/block-api.d.ts.map +1 -1
- package/dist/block-api.js +127 -1
- 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 +88 -8
- package/dist/context-sdk.d.ts.map +1 -1
- package/dist/context-sdk.js +363 -116
- package/dist/context-sdk.js.map +1 -1
- package/dist/context.d.ts +5 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +185 -3
- package/dist/index.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 +388 -17
- package/dist/ops-registry.js.map +1 -1
- package/package.json +2 -2
- package/samples/conception.json +7 -0
- package/samples/schemas/framework-gaps.schema.json +5 -0
- package/samples/schemas/research.schema.json +41 -2
- package/schemas/config.schema.json +13 -1
- package/skill-narrative.md +1 -1
- package/skills/pi-context/SKILL.md +30 -18
package/dist/context-sdk.js
CHANGED
|
@@ -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
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
const
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
|
2011
|
-
//
|
|
2012
|
-
//
|
|
2013
|
-
//
|
|
2014
|
-
|
|
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
|
-
*
|
|
2274
|
-
* a passing verification entry exists,
|
|
2275
|
-
* closure-table edge
|
|
2276
|
-
*
|
|
2277
|
-
*
|
|
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
|
|
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.
|
|
2319
|
-
// task (child)
|
|
2320
|
-
//
|
|
2321
|
-
//
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|