@interf/compiler 0.33.0 → 0.50.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/README.md +122 -226
- package/dist/cli/commands/agents.js +1 -32
- package/dist/cli/commands/benchmark.d.ts +2 -3
- package/dist/cli/commands/benchmark.js +1 -31
- package/dist/cli/commands/build-plan.js +26 -50
- package/dist/cli/commands/build.d.ts +2 -3
- package/dist/cli/commands/build.js +1 -31
- package/dist/cli/commands/graphs.js +177 -32
- package/dist/cli/commands/mcp.d.ts +1 -0
- package/dist/cli/commands/mcp.js +223 -126
- package/dist/cli/commands/project.js +10 -36
- package/dist/cli/commands/reset.d.ts +2 -3
- package/dist/cli/commands/reset.js +1 -22
- package/dist/cli/commands/runs.js +86 -33
- package/dist/cli/commands/status.js +3 -24
- package/dist/cli/commands/traces.js +1 -29
- package/dist/cli/commands/wizard.js +17 -29
- package/dist/cli/lib/http-client.d.ts +39 -0
- package/dist/cli/lib/http-client.js +73 -0
- package/dist/packages/build-plans/authoring/brief.d.ts +25 -4
- package/dist/packages/build-plans/authoring/build-plan-authoring.d.ts +42 -1
- package/dist/packages/build-plans/authoring/build-plan-authoring.js +470 -63
- package/dist/packages/build-plans/authoring/build-plan-edit-session.d.ts +9 -0
- package/dist/packages/build-plans/authoring/build-plan-edit-session.js +27 -10
- package/dist/packages/build-plans/authoring/build-plan-improvement.js +62 -8
- package/dist/packages/build-plans/authoring/lib/build-plan-edit-utils.d.ts +1 -0
- package/dist/packages/build-plans/package/build-plan-definitions.d.ts +0 -1
- package/dist/packages/build-plans/package/build-plan-definitions.js +5 -3
- package/dist/packages/build-plans/package/build-plan-stage-runner.d.ts +1 -0
- package/dist/packages/build-plans/package/build-plan-stage-runner.js +2 -1
- package/dist/packages/build-plans/package/builtin-build-plan.d.ts +2 -2
- package/dist/packages/build-plans/package/builtin-build-plan.js +3 -3
- package/dist/packages/build-plans/package/context-interface.d.ts +3 -0
- package/dist/packages/build-plans/package/context-interface.js +5 -5
- package/dist/packages/build-plans/package/interf-build-plan-package.js +22 -22
- package/dist/packages/build-plans/package/local-build-plans.d.ts +10 -5
- package/dist/packages/build-plans/package/local-build-plans.js +57 -32
- package/dist/packages/contracts/index.d.ts +4 -3
- package/dist/packages/contracts/index.js +2 -1
- package/dist/packages/contracts/lib/context-graph-layer.d.ts +161 -0
- package/dist/packages/contracts/lib/context-graph-layer.js +216 -0
- package/dist/packages/contracts/lib/project-paths.d.ts +7 -0
- package/dist/packages/contracts/lib/project-paths.js +9 -0
- package/dist/packages/contracts/lib/project-schema.d.ts +264 -1
- package/dist/packages/contracts/lib/project-schema.js +38 -13
- package/dist/packages/contracts/lib/schema.d.ts +556 -23
- package/dist/packages/contracts/lib/schema.js +279 -18
- package/dist/packages/contracts/utils/filesystem.d.ts +1 -0
- package/dist/packages/contracts/utils/filesystem.js +29 -1
- package/dist/packages/projects/lib/schema.d.ts +6 -8
- package/dist/packages/projects/lib/schema.js +3 -1
- package/dist/packages/projects/source-config.d.ts +0 -5
- package/dist/packages/projects/source-config.js +9 -22
- package/dist/packages/runtime/actions/fields.d.ts +4 -0
- package/dist/packages/runtime/actions/form-builders.js +79 -31
- package/dist/packages/runtime/actions/form-validators.js +9 -3
- package/dist/packages/runtime/actions/helpers.js +3 -3
- package/dist/packages/runtime/actions/registry.d.ts +1 -1
- package/dist/packages/runtime/actions/registry.js +1 -1
- package/dist/packages/runtime/actions/requests.d.ts +1 -1
- package/dist/packages/runtime/actions/requests.js +12 -6
- package/dist/packages/runtime/actions/schemas.d.ts +7 -0
- package/dist/packages/runtime/actions/schemas.js +1 -0
- package/dist/packages/runtime/agent-handoff.js +8 -7
- package/dist/packages/runtime/agents/lib/execution-profile.d.ts +14 -0
- package/dist/packages/runtime/agents/lib/execution-profile.js +23 -0
- package/dist/packages/runtime/agents/lib/execution.js +14 -8
- package/dist/packages/runtime/agents/lib/executors.d.ts +1 -0
- package/dist/packages/runtime/agents/lib/executors.js +11 -2
- package/dist/packages/runtime/agents/lib/logs.d.ts +10 -0
- package/dist/packages/runtime/agents/lib/logs.js +32 -8
- package/dist/packages/runtime/agents/lib/preflight.js +4 -1
- package/dist/packages/runtime/agents/lib/render.d.ts +18 -0
- package/dist/packages/runtime/agents/lib/render.js +44 -18
- package/dist/packages/runtime/agents/lib/shell-templates.js +105 -63
- package/dist/packages/runtime/agents/lib/shells.d.ts +29 -0
- package/dist/packages/runtime/agents/lib/shells.js +158 -32
- package/dist/packages/runtime/agents/lib/source-context-scan.d.ts +10 -0
- package/dist/packages/runtime/agents/lib/source-context-scan.js +388 -0
- package/dist/packages/runtime/agents/lib/status.js +1 -14
- package/dist/packages/runtime/agents/lib/string-utils.d.ts +16 -0
- package/dist/packages/runtime/agents/lib/string-utils.js +36 -0
- package/dist/packages/runtime/agents/lib/types.d.ts +1 -0
- package/dist/packages/runtime/agents/providers/codex.js +2 -0
- package/dist/packages/runtime/agents/role-executors.js +2 -1
- package/dist/packages/runtime/auth/session-store.js +11 -3
- package/dist/packages/runtime/benchmark-question-draft.d.ts +3 -0
- package/dist/packages/runtime/benchmark-question-draft.js +57 -28
- package/dist/packages/runtime/build/artifact-status.d.ts +1 -1
- package/dist/packages/runtime/build/artifact-status.js +1 -1
- package/dist/packages/runtime/build/build-evidence.d.ts +2 -1
- package/dist/packages/runtime/build/build-evidence.js +11 -5
- package/dist/packages/runtime/build/build-pipeline.js +89 -5
- package/dist/packages/runtime/build/build-stage-plan.js +3 -1
- package/dist/packages/runtime/build/build-stage-runner.js +169 -32
- package/dist/packages/runtime/build/build-target.d.ts +3 -0
- package/dist/packages/runtime/build/build-target.js +25 -1
- package/dist/packages/runtime/build/check-evaluator.d.ts +1 -1
- package/dist/packages/runtime/build/check-evaluator.js +655 -4
- package/dist/packages/runtime/build/context-graph-paths.d.ts +13 -0
- package/dist/packages/runtime/build/context-graph-paths.js +27 -0
- package/dist/packages/runtime/build/index.d.ts +2 -2
- package/dist/packages/runtime/build/index.js +2 -2
- package/dist/packages/runtime/build/inspect-map.d.ts +10 -0
- package/dist/packages/runtime/build/inspect-map.js +270 -0
- package/dist/packages/runtime/build/lib/schema.d.ts +246 -53
- package/dist/packages/runtime/build/lib/schema.js +173 -15
- package/dist/packages/runtime/build/native-entrypoint.d.ts +2 -0
- package/dist/packages/runtime/build/native-entrypoint.js +286 -0
- package/dist/packages/runtime/build/runtime-contracts.js +9 -3
- package/dist/packages/runtime/build/runtime-log-paths.d.ts +3 -0
- package/dist/packages/runtime/build/runtime-log-paths.js +16 -0
- package/dist/packages/runtime/build/runtime-prompt.js +6 -4
- package/dist/packages/runtime/build/runtime-runs.js +63 -10
- package/dist/packages/runtime/build/runtime-types.d.ts +4 -1
- package/dist/packages/runtime/build/runtime.d.ts +3 -1
- package/dist/packages/runtime/build/runtime.js +3 -1
- package/dist/packages/runtime/build/source-files.js +11 -2
- package/dist/packages/runtime/build/source-inventory.d.ts +1 -0
- package/dist/packages/runtime/build/source-inventory.js +246 -7
- package/dist/packages/runtime/build/source-manifest.d.ts +11 -0
- package/dist/packages/runtime/build/source-manifest.js +30 -2
- package/dist/packages/runtime/build/stage-evidence.js +80 -11
- package/dist/packages/runtime/build/stage-manifest.d.ts +45 -0
- package/dist/packages/runtime/build/stage-manifest.js +1125 -0
- package/dist/packages/runtime/build/stage-reuse.js +12 -0
- package/dist/packages/runtime/build/stage-session.d.ts +81 -0
- package/dist/packages/runtime/build/stage-session.js +308 -0
- package/dist/packages/runtime/build/state-io.js +10 -11
- package/dist/packages/runtime/build/state-view.js +1 -1
- package/dist/packages/runtime/build/state.d.ts +1 -1
- package/dist/packages/runtime/build/state.js +1 -1
- package/dist/packages/runtime/build/summary-coverage-index.d.ts +21 -0
- package/dist/packages/runtime/build/summary-coverage-index.js +189 -0
- package/dist/packages/runtime/build/traces.js +3 -3
- package/dist/packages/runtime/build/validate-context-graph.d.ts +1 -1
- package/dist/packages/runtime/build/validate-context-graph.js +5 -5
- package/dist/packages/runtime/build/validate.d.ts +1 -1
- package/dist/packages/runtime/build/validate.js +1 -1
- package/dist/packages/runtime/client.d.ts +3 -3
- package/dist/packages/runtime/client.js +8 -13
- package/dist/packages/runtime/context-checks.js +13 -0
- package/dist/packages/runtime/context-graph-scaffold.js +2 -1
- package/dist/packages/runtime/context-graph-semantic-graph.d.ts +9 -0
- package/dist/packages/runtime/context-graph-semantic-graph.js +416 -0
- package/dist/packages/runtime/execution/lib/schema.d.ts +34 -31
- package/dist/packages/runtime/index.d.ts +2 -2
- package/dist/packages/runtime/index.js +1 -1
- package/dist/packages/runtime/native-run-handlers.d.ts +38 -0
- package/dist/packages/runtime/native-run-handlers.js +52 -33
- package/dist/packages/runtime/plan-artifact-contract.js +1 -1
- package/dist/packages/runtime/project-source-state.d.ts +4 -4
- package/dist/packages/runtime/project-source-state.js +5 -2
- package/dist/packages/runtime/project-store.d.ts +5 -0
- package/dist/packages/runtime/project-store.js +30 -3
- package/dist/packages/runtime/requested-artifacts.js +1 -1
- package/dist/packages/runtime/run-observability.js +9 -4
- package/dist/packages/runtime/runtime-action-proposals.js +3 -3
- package/dist/packages/runtime/runtime-build-plans.js +47 -3
- package/dist/packages/runtime/runtime-build-runs.js +9 -16
- package/dist/packages/runtime/runtime-caches.d.ts +26 -0
- package/dist/packages/runtime/runtime-caches.js +47 -0
- package/dist/packages/runtime/runtime-jobs.js +6 -6
- package/dist/packages/runtime/runtime-project-mutations.js +1 -0
- package/dist/packages/runtime/runtime-project-reads.d.ts +4 -1
- package/dist/packages/runtime/runtime-project-reads.js +229 -36
- package/dist/packages/runtime/runtime-proposal-helpers.js +6 -6
- package/dist/packages/runtime/runtime-resource-builders.d.ts +4 -2
- package/dist/packages/runtime/runtime-resource-builders.js +16 -14
- package/dist/packages/runtime/runtime-status.d.ts +14 -0
- package/dist/packages/runtime/runtime-status.js +15 -0
- package/dist/packages/runtime/runtime-verify-runs.js +6 -5
- package/dist/packages/runtime/runtime.d.ts +439 -22
- package/dist/packages/runtime/runtime.js +16 -2
- package/dist/packages/runtime/schemas/actions.d.ts +24 -0
- package/dist/packages/runtime/schemas/agents.d.ts +28 -0
- package/dist/packages/runtime/schemas/agents.js +33 -0
- package/dist/packages/runtime/schemas/build-plans.d.ts +181 -8
- package/dist/packages/runtime/schemas/build-plans.js +36 -2
- package/dist/packages/runtime/schemas/context-graphs.d.ts +1522 -0
- package/dist/packages/runtime/schemas/context-graphs.js +110 -0
- package/dist/packages/runtime/schemas/files.d.ts +7 -347
- package/dist/packages/runtime/schemas/files.js +1 -24
- package/dist/packages/runtime/schemas/index.d.ts +1 -0
- package/dist/packages/runtime/schemas/index.js +1 -0
- package/dist/packages/runtime/schemas/jobs.js +4 -0
- package/dist/packages/runtime/schemas/projects.d.ts +48 -21
- package/dist/packages/runtime/schemas/projects.js +34 -10
- package/dist/packages/runtime/schemas/runs.d.ts +1009 -240
- package/dist/packages/runtime/schemas/runs.js +17 -0
- package/dist/packages/runtime/service/openapi.js +1 -0
- package/dist/packages/runtime/service/operations.d.ts +1666 -145
- package/dist/packages/runtime/service/operations.js +147 -17
- package/dist/packages/runtime/service/routes.d.ts +11 -3
- package/dist/packages/runtime/service/routes.js +11 -3
- package/dist/packages/runtime/service/server-app-boot.js +2 -2
- package/dist/packages/runtime/service/server-helpers.d.ts +11 -0
- package/dist/packages/runtime/service/server-helpers.js +19 -0
- package/dist/packages/runtime/service/server-routes-action-proposals.js +4 -2
- package/dist/packages/runtime/service/server-routes-agents.js +19 -85
- package/dist/packages/runtime/service/server-routes-build-plans.js +14 -11
- package/dist/packages/runtime/service/server-routes-project-context.js +102 -7
- package/dist/packages/runtime/service/server-routes-project-jobs.js +19 -12
- package/dist/packages/runtime/service/server-routes-project-runs.js +5 -2
- package/dist/packages/runtime/service/server-routes-projects.js +6 -2
- package/dist/packages/runtime/service/server-routes-runs.js +11 -4
- package/dist/packages/runtime/verify/lib/schema.js +12 -0
- package/dist/packages/runtime/verify/test-file-guard.d.ts +2 -0
- package/dist/packages/runtime/verify/test-file-guard.js +29 -0
- package/dist/packages/runtime/verify/verify-execution.d.ts +7 -0
- package/dist/packages/runtime/verify/verify-execution.js +109 -35
- package/dist/packages/runtime/verify/verify-paths.d.ts +1 -0
- package/dist/packages/runtime/verify/verify-paths.js +4 -0
- package/dist/packages/runtime/verify/verify-specs.js +49 -39
- package/dist/packages/runtime/wire-schemas.d.ts +1 -1
- package/dist/packages/runtime/wire-schemas.js +1 -1
- package/package.json +2 -8
- package/public-repo/CONTRIBUTING.md +10 -3
- package/public-repo/README.md +122 -226
- package/public-repo/build-plans/interf-default/README.md +15 -12
- package/public-repo/build-plans/interf-default/build/stages/entrypoint/SKILL.md +74 -0
- package/public-repo/build-plans/interf-default/build/stages/knowledge/SKILL.md +95 -0
- package/public-repo/build-plans/interf-default/build/stages/summarize/SKILL.md +38 -5
- package/public-repo/build-plans/interf-default/build-plan.json +27 -23
- package/public-repo/build-plans/interf-default/build-plan.schema.json +24 -20
- package/public-repo/build-plans/interf-default/use/query/SKILL.md +8 -7
- package/public-repo/openapi/local-service.openapi.json +11637 -4213
- package/public-repo/skills/interf/SKILL.md +174 -134
- package/dist/packages/runtime/build/runtime-paths.d.ts +0 -8
- package/dist/packages/runtime/build/runtime-paths.js +0 -26
- package/dist/packages/runtime/build/state-paths.d.ts +0 -7
- package/dist/packages/runtime/build/state-paths.js +0 -22
- package/public-repo/build-plans/interf-default/build/stages/shape/SKILL.md +0 -34
- package/public-repo/build-plans/interf-default/build/stages/structure/SKILL.md +0 -28
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, join, relative } from "node:path";
|
|
4
|
+
import { ExpectedInputsFileSchema, GraphManifestSchema, MetricCountSchema, ReviewedInputSchema, ReviewedInputsFileSchema, ResourceRefSchema, StageManifestSchema, } from "../../contracts/lib/schema.js";
|
|
5
|
+
import { graphRelativePathPattern, knowledgeNoteKind, layerForPath, normalizeLayerPath, roleForLayerPath, roleForWritePath, } from "../../contracts/lib/context-graph-layer.js";
|
|
6
|
+
import { isFilesystemArtifact, listFilesRecursive } from "../../contracts/utils/filesystem.js";
|
|
7
|
+
import { parseJsonFrontmatter, readJsonFileWithSchema } from "../../contracts/utils/parse.js";
|
|
8
|
+
import { findBuildPlanContextArtifact, readBuildPlanContextFile, } from "./context-graph-schema.js";
|
|
9
|
+
import { buildPlanPackagePathForContextGraph, contextGraphRuntimeGraphManifestPath, contextGraphRuntimeStageExpectedInputsPath, contextGraphRuntimeStageManifestPath, contextGraphRuntimeStageReviewedInputsPath, contextGraphRuntimeStageRoot, } from "./context-graph-paths.js";
|
|
10
|
+
import { loadBuildStageInputs, loadContextGraphSourceManifest, } from "./source-manifest.js";
|
|
11
|
+
import { writeJsonAtomic, } from "./atomic-fs.js";
|
|
12
|
+
import { readExecutionStageLedgerHistory, } from "../runtime-persistence.js";
|
|
13
|
+
const WIKILINK_PATTERN = /\[\[([^\]\n]+)\]\]/g;
|
|
14
|
+
const MARKDOWN_LINK_PATTERN = /!?\[[^\]\n]*\]\(([^)\n]+)\)/g;
|
|
15
|
+
// Shared marker for the per-summary orphaned-summary `missing` reason a
|
|
16
|
+
// knowledge-role stage emits. The GraphManifest readiness rollup keys off it to
|
|
17
|
+
// collapse those per-summary entries into the single FINAL-state
|
|
18
|
+
// `knowledge:summary-backlinks` gate, so one logical orphan gap is counted once
|
|
19
|
+
// in readiness instead of twice (per-summary entry + metric gate).
|
|
20
|
+
const ORPHANED_SUMMARY_REASON = "is cited by a knowledge note's source_refs but never wikilinked; the summary is an orphaned island.";
|
|
21
|
+
// Single source for the "N knowledge note(s) link no summary" message shared by
|
|
22
|
+
// the `knowledge_summary_connectivity` metric detail (no trailing period, metric
|
|
23
|
+
// style) and the readiness `knowledge:summary-connectivity` gate reason (trailing
|
|
24
|
+
// period, sentence style). One formatter so the singular/plural agreement and
|
|
25
|
+
// wording can never drift between the two surfaces.
|
|
26
|
+
function formatDisconnectedNotesMessage(count, includePeriod) {
|
|
27
|
+
return `${count} knowledge note${count === 1 ? " links" : "s link"} no summary (disconnected from the source-backed layer)${includePeriod ? "." : ""}`;
|
|
28
|
+
}
|
|
29
|
+
// Path normalization + the graph-relative link pattern come from the central
|
|
30
|
+
// Context Graph layer model so this scanner and every other consumer share one
|
|
31
|
+
// canonical layer set. `normalizeRelativePath` is kept as a local alias so the
|
|
32
|
+
// many existing call sites below read unchanged.
|
|
33
|
+
const normalizeRelativePath = normalizeLayerPath;
|
|
34
|
+
function relativeToGraph(contextGraphPath, filePath) {
|
|
35
|
+
return normalizeRelativePath(relative(contextGraphPath, filePath));
|
|
36
|
+
}
|
|
37
|
+
function slug(value) {
|
|
38
|
+
const clean = value
|
|
39
|
+
.toLowerCase()
|
|
40
|
+
.replace(/['"]/g, "")
|
|
41
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
42
|
+
.replace(/^-+|-+$/g, "");
|
|
43
|
+
return clean.length > 0 ? clean : "resource";
|
|
44
|
+
}
|
|
45
|
+
// Resource ids stay <=90 slug chars, but the slug alone is NOT injective: two
|
|
46
|
+
// distinct keys whose first 90 slug chars are identical (e.g. sibling files
|
|
47
|
+
// `.../msg-XXXX-<long>/summary.md` and `.../manifest.md`, which only diverge
|
|
48
|
+
// after char ~90) would collapse to the same id. A non-unique id makes the
|
|
49
|
+
// StageManifest dedup guard reject an otherwise-valid manifest, failing the
|
|
50
|
+
// stage on every retry. When (and only when) the slug must be truncated, append
|
|
51
|
+
// a short stable hash of the FULL key so the id is collision-proof and still
|
|
52
|
+
// deterministic across runs (same path -> same id).
|
|
53
|
+
function resourceId(role, key) {
|
|
54
|
+
const full = slug(key);
|
|
55
|
+
if (full.length <= 90)
|
|
56
|
+
return `${role}:${full}`;
|
|
57
|
+
const hash = createHash("sha1").update(key).digest("hex").slice(0, 8);
|
|
58
|
+
return `${role}:${full.slice(0, 81)}-${hash}`;
|
|
59
|
+
}
|
|
60
|
+
function markdownTitle(path, frontmatter, body) {
|
|
61
|
+
const title = stringField(frontmatter.title) ?? stringField(frontmatter.label);
|
|
62
|
+
if (title)
|
|
63
|
+
return title;
|
|
64
|
+
const heading = body.split("\n").find((line) => /^#{1,3}\s+\S/.test(line));
|
|
65
|
+
if (heading)
|
|
66
|
+
return heading.replace(/^#{1,3}\s+/, "").trim();
|
|
67
|
+
return basename(path, extname(path));
|
|
68
|
+
}
|
|
69
|
+
function markdownSummary(frontmatter, body) {
|
|
70
|
+
const explicit = stringField(frontmatter.summary)
|
|
71
|
+
?? stringField(frontmatter.abstract)
|
|
72
|
+
?? stringField(frontmatter.description);
|
|
73
|
+
if (explicit)
|
|
74
|
+
return explicit.length > 240 ? `${explicit.slice(0, 237)}...` : explicit;
|
|
75
|
+
const paragraph = body
|
|
76
|
+
.split(/\n\s*\n/)
|
|
77
|
+
.map((block) => block.replace(/\s+/g, " ").trim())
|
|
78
|
+
.find((block) => block.length > 0 && !block.startsWith("#"));
|
|
79
|
+
if (!paragraph)
|
|
80
|
+
return null;
|
|
81
|
+
return paragraph.length > 240 ? `${paragraph.slice(0, 237)}...` : paragraph;
|
|
82
|
+
}
|
|
83
|
+
function stringField(value) {
|
|
84
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
85
|
+
}
|
|
86
|
+
function stringValues(value) {
|
|
87
|
+
if (Array.isArray(value))
|
|
88
|
+
return value.flatMap((item) => stringValues(item));
|
|
89
|
+
if (typeof value === "string")
|
|
90
|
+
return value.trim().length > 0 ? [value.trim()] : [];
|
|
91
|
+
if (value && typeof value === "object") {
|
|
92
|
+
const record = value;
|
|
93
|
+
return [
|
|
94
|
+
stringField(record.path),
|
|
95
|
+
stringField(record.locator),
|
|
96
|
+
stringField(record.source_path),
|
|
97
|
+
stringField(record.ref),
|
|
98
|
+
stringField(record.summary),
|
|
99
|
+
].filter((item) => Boolean(item));
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
function sourceRefsFromFrontmatter(frontmatter) {
|
|
104
|
+
return Array.from(new Set([
|
|
105
|
+
...stringValues(frontmatter.source_refs),
|
|
106
|
+
...stringValues(frontmatter.source_ref),
|
|
107
|
+
...stringValues(frontmatter.source_path),
|
|
108
|
+
...stringValues(frontmatter.source),
|
|
109
|
+
]));
|
|
110
|
+
}
|
|
111
|
+
function sourceRefsFromContent(content, frontmatter, knownSourceRefs) {
|
|
112
|
+
const refs = new Set(sourceRefsFromFrontmatter(frontmatter));
|
|
113
|
+
for (const sourceRef of knownSourceRefs) {
|
|
114
|
+
if (content.includes(sourceRef))
|
|
115
|
+
refs.add(sourceRef);
|
|
116
|
+
}
|
|
117
|
+
return [...refs];
|
|
118
|
+
}
|
|
119
|
+
function graphLinksFromFrontmatter(frontmatter) {
|
|
120
|
+
return [
|
|
121
|
+
...stringValues(frontmatter.links),
|
|
122
|
+
...stringValues(frontmatter.linked_notes),
|
|
123
|
+
...stringValues(frontmatter.related),
|
|
124
|
+
...stringValues(frontmatter.upstream),
|
|
125
|
+
...stringValues(frontmatter.upstream_refs),
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
function parseLinks(content, frontmatter) {
|
|
129
|
+
const links = [];
|
|
130
|
+
links.push(...graphLinksFromFrontmatter(frontmatter));
|
|
131
|
+
for (const match of content.matchAll(WIKILINK_PATTERN)) {
|
|
132
|
+
const raw = match[1]?.split("|")[0]?.split("#")[0]?.trim();
|
|
133
|
+
if (raw)
|
|
134
|
+
links.push(raw);
|
|
135
|
+
}
|
|
136
|
+
for (const match of content.matchAll(MARKDOWN_LINK_PATTERN)) {
|
|
137
|
+
const raw = match[1]?.split("#")[0]?.trim();
|
|
138
|
+
if (!raw || /^https?:\/\//i.test(raw))
|
|
139
|
+
continue;
|
|
140
|
+
links.push(raw);
|
|
141
|
+
}
|
|
142
|
+
for (const match of content.matchAll(graphRelativePathPattern())) {
|
|
143
|
+
const raw = match[1]?.split("#")[0]?.trim().replace(/[),.;:]+$/g, "");
|
|
144
|
+
if (raw)
|
|
145
|
+
links.push(raw);
|
|
146
|
+
}
|
|
147
|
+
return Array.from(new Set(links));
|
|
148
|
+
}
|
|
149
|
+
function collectMarkdownFiles(rootPath, relativeDir) {
|
|
150
|
+
const absoluteDir = join(rootPath, relativeDir);
|
|
151
|
+
if (!existsSync(absoluteDir))
|
|
152
|
+
return [];
|
|
153
|
+
const stat = statSync(absoluteDir);
|
|
154
|
+
if (stat.isFile()) {
|
|
155
|
+
return relativeDir.toLowerCase().endsWith(".md") ? [normalizeRelativePath(relativeDir)] : [];
|
|
156
|
+
}
|
|
157
|
+
if (!stat.isDirectory())
|
|
158
|
+
return [];
|
|
159
|
+
const files = [];
|
|
160
|
+
for (const entry of readdirSync(absoluteDir, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
161
|
+
if (entry.name.startsWith("."))
|
|
162
|
+
continue;
|
|
163
|
+
const childPath = normalizeRelativePath(join(relativeDir, entry.name));
|
|
164
|
+
if (entry.isDirectory()) {
|
|
165
|
+
files.push(...collectMarkdownFiles(rootPath, childPath));
|
|
166
|
+
}
|
|
167
|
+
else if (entry.isFile() && childPath.toLowerCase().endsWith(".md")) {
|
|
168
|
+
files.push(childPath);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return files;
|
|
172
|
+
}
|
|
173
|
+
// Note-kind label for a StageManifest ResourceRef. The layer (home / artifact /
|
|
174
|
+
// summary / knowledge / other) and the knowledge claim·entity sub-kind both come
|
|
175
|
+
// from the central `context-graph-layer` module so the `knowledge/claims/` /
|
|
176
|
+
// `claim-` conventions can never drift from the semantic-graph classifier. The
|
|
177
|
+
// dotted label vocabulary is this module's own; only the classification is
|
|
178
|
+
// shared. Precedence matches the original chain exactly: layer wins for
|
|
179
|
+
// home/artifact/summary, then claim/entity (recognized by leaf prefix even at
|
|
180
|
+
// the root), then any other knowledge note, then a plain markdown note.
|
|
181
|
+
function noteKindForPath(path) {
|
|
182
|
+
const layer = layerForPath(path);
|
|
183
|
+
if (layer === "home")
|
|
184
|
+
return "entrypoint.home";
|
|
185
|
+
if (layer === "artifacts")
|
|
186
|
+
return "entrypoint.artifact";
|
|
187
|
+
if (layer === "summaries")
|
|
188
|
+
return "summary.note";
|
|
189
|
+
const knowledgeKind = knowledgeNoteKind(path);
|
|
190
|
+
if (knowledgeKind === "claim")
|
|
191
|
+
return "knowledge.claim";
|
|
192
|
+
if (knowledgeKind === "entity")
|
|
193
|
+
return "knowledge.entity";
|
|
194
|
+
if (layer === "knowledge")
|
|
195
|
+
return "knowledge.note";
|
|
196
|
+
return "note.markdown";
|
|
197
|
+
}
|
|
198
|
+
// `roleForPath` and `layerRoleForWritePath` are the central Context Graph layer
|
|
199
|
+
// classifiers, imported as `roleForLayerPath` / `roleForWritePath`. They are the
|
|
200
|
+
// SINGLE source of truth every consumer (note role, stage role, disconnected /
|
|
201
|
+
// web checks, metrics) routes through — case-insensitive policing of where a
|
|
202
|
+
// note or artifact lands on disk, never derived from a stringly id. Local
|
|
203
|
+
// aliases keep the many call sites below reading unchanged.
|
|
204
|
+
const roleForPath = roleForLayerPath;
|
|
205
|
+
const layerRoleForWritePath = roleForWritePath;
|
|
206
|
+
/**
|
|
207
|
+
* Resolve a stage's artifact-id references (its `writes` or `reads`) to the set
|
|
208
|
+
* of layer roles they touch, by looking up each artifact's write *path* in the
|
|
209
|
+
* Build Plan schema. When a referenced token has no schema artifact (e.g. a
|
|
210
|
+
* plan that lists a bare layer path directly), the token is treated as a path
|
|
211
|
+
* so the layer is still recognized. Returns roles derived from disk location,
|
|
212
|
+
* with no reliance on stringly stage ids or artifact ids.
|
|
213
|
+
*/
|
|
214
|
+
function stageArtifactRoles(contextGraphPath, artifactIds) {
|
|
215
|
+
const schema = readBuildPlanContextFile(buildPlanPackagePathForContextGraph(contextGraphPath));
|
|
216
|
+
const roles = new Set();
|
|
217
|
+
for (const artifactId of artifactIds) {
|
|
218
|
+
const artifact = schema ? findBuildPlanContextArtifact(schema, artifactId) : null;
|
|
219
|
+
roles.add(layerRoleForWritePath(artifact?.path ?? artifactId));
|
|
220
|
+
}
|
|
221
|
+
return roles;
|
|
222
|
+
}
|
|
223
|
+
function noteResource(contextGraphPath, path, stageId, knownSourceRefs = []) {
|
|
224
|
+
const content = readFileSync(join(contextGraphPath, path), "utf8");
|
|
225
|
+
const parsed = parseJsonFrontmatter(content);
|
|
226
|
+
const frontmatter = parsed?.frontmatter ?? {};
|
|
227
|
+
const body = parsed?.body ?? content;
|
|
228
|
+
const role = roleForPath(path);
|
|
229
|
+
const links = parseLinks(content, frontmatter)
|
|
230
|
+
.filter((link) => normalizeRelativePath(link).replace(/\.md$/i, "") !== normalizeRelativePath(path).replace(/\.md$/i, ""));
|
|
231
|
+
return ResourceRefSchema.parse({
|
|
232
|
+
id: resourceId(role, path),
|
|
233
|
+
role,
|
|
234
|
+
kind: noteKindForPath(path),
|
|
235
|
+
label: markdownTitle(path, frontmatter, body),
|
|
236
|
+
path,
|
|
237
|
+
...(stageId ? { stage_id: stageId } : {}),
|
|
238
|
+
source_refs: sourceRefsFromContent(content, frontmatter, knownSourceRefs),
|
|
239
|
+
links,
|
|
240
|
+
metadata: {
|
|
241
|
+
...(markdownSummary(frontmatter, body) ? { summary: markdownSummary(frontmatter, body) } : {}),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
function knownSourceRefsForGraph(contextGraphPath) {
|
|
246
|
+
return loadContextGraphSourceManifest(contextGraphPath)?.files.map((file) => file.path) ?? [];
|
|
247
|
+
}
|
|
248
|
+
function listSummaryResources(contextGraphPath) {
|
|
249
|
+
const knownSourceRefs = knownSourceRefsForGraph(contextGraphPath);
|
|
250
|
+
return collectMarkdownFiles(contextGraphPath, "summaries")
|
|
251
|
+
.map((path) => noteResource(contextGraphPath, path, undefined, knownSourceRefs));
|
|
252
|
+
}
|
|
253
|
+
function listKnowledgeResources(contextGraphPath) {
|
|
254
|
+
const knownSourceRefs = knownSourceRefsForGraph(contextGraphPath);
|
|
255
|
+
return collectMarkdownFiles(contextGraphPath, "knowledge")
|
|
256
|
+
.map((path) => noteResource(contextGraphPath, path, undefined, knownSourceRefs));
|
|
257
|
+
}
|
|
258
|
+
function listEntrypointResources(contextGraphPath) {
|
|
259
|
+
const knownSourceRefs = knownSourceRefsForGraph(contextGraphPath);
|
|
260
|
+
const home = existsSync(join(contextGraphPath, "home.md")) ? ["home.md"] : [];
|
|
261
|
+
return [...home, ...collectMarkdownFiles(contextGraphPath, "artifacts")]
|
|
262
|
+
.map((path) => noteResource(contextGraphPath, path, undefined, knownSourceRefs));
|
|
263
|
+
}
|
|
264
|
+
function sourceFileResource(file) {
|
|
265
|
+
return ResourceRefSchema.parse({
|
|
266
|
+
id: resourceId("source", file.id),
|
|
267
|
+
role: "source",
|
|
268
|
+
kind: "source.file",
|
|
269
|
+
label: file.path,
|
|
270
|
+
locator: file.locator,
|
|
271
|
+
source_refs: [file.path],
|
|
272
|
+
required: true,
|
|
273
|
+
metadata: {
|
|
274
|
+
source_file_id: file.id,
|
|
275
|
+
path: file.path,
|
|
276
|
+
kind: file.kind,
|
|
277
|
+
...(typeof file.page_count === "number" ? { page_count: file.page_count } : {}),
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function sourceUnitResources(manifest) {
|
|
282
|
+
const resources = [];
|
|
283
|
+
for (const file of manifest.files) {
|
|
284
|
+
const units = file.inspectable_units.length > 0
|
|
285
|
+
? file.inspectable_units
|
|
286
|
+
: [{ kind: "file", index: 1, label: "whole file" }];
|
|
287
|
+
for (const [index, unit] of units.entries()) {
|
|
288
|
+
const label = unit.label ?? `${unit.kind} ${unit.index ?? index + 1}`;
|
|
289
|
+
resources.push(ResourceRefSchema.parse({
|
|
290
|
+
id: resourceId("source-unit", `${file.id}-${unit.kind}-${unit.index ?? index + 1}`),
|
|
291
|
+
role: "source",
|
|
292
|
+
kind: `source.${unit.kind}`,
|
|
293
|
+
label: `${file.path} · ${label}`,
|
|
294
|
+
locator: file.locator,
|
|
295
|
+
source_refs: [file.path],
|
|
296
|
+
required: true,
|
|
297
|
+
metadata: {
|
|
298
|
+
source_file_id: file.id,
|
|
299
|
+
source_path: file.path,
|
|
300
|
+
expected_summary_folder: `summaries/${file.path}`,
|
|
301
|
+
expected_summary_path: `summaries/${file.path}/summary.md`,
|
|
302
|
+
unit_kind: unit.kind,
|
|
303
|
+
unit_index: unit.index ?? index + 1,
|
|
304
|
+
},
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return resources;
|
|
309
|
+
}
|
|
310
|
+
function loadExpectedInputs(contextGraphPath, stageId) {
|
|
311
|
+
const path = contextGraphRuntimeStageExpectedInputsPath(contextGraphPath, stageId);
|
|
312
|
+
const file = existsSync(path)
|
|
313
|
+
? readJsonFileWithSchema(path, "stage expected inputs", ExpectedInputsFileSchema)
|
|
314
|
+
: null;
|
|
315
|
+
return file?.expected ?? [];
|
|
316
|
+
}
|
|
317
|
+
function loadReviewedInputs(contextGraphPath, stageId) {
|
|
318
|
+
const path = contextGraphRuntimeStageReviewedInputsPath(contextGraphPath, stageId);
|
|
319
|
+
if (!existsSync(path))
|
|
320
|
+
return null;
|
|
321
|
+
const file = readJsonFileWithSchema(path, "stage reviewed inputs", ReviewedInputsFileSchema);
|
|
322
|
+
return file?.reviewed ?? null;
|
|
323
|
+
}
|
|
324
|
+
export function persistStageReviewedInputsFromShell(options) {
|
|
325
|
+
const shellPath = join(options.shellRoot, "runtime", "reviewed-inputs.json");
|
|
326
|
+
if (!existsSync(shellPath)) {
|
|
327
|
+
return {
|
|
328
|
+
ok: false,
|
|
329
|
+
summary: `${options.stageId} did not write runtime/reviewed-inputs.json.`,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
let file;
|
|
333
|
+
try {
|
|
334
|
+
file = readJsonFileWithSchema(shellPath, `${options.stageId} reviewed inputs`, ReviewedInputsFileSchema);
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
return {
|
|
338
|
+
ok: false,
|
|
339
|
+
summary: `Invalid runtime/reviewed-inputs.json for ${options.stageId}: ${error instanceof Error ? error.message : String(error)}`,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
if (!file || file.stage_id !== options.stageId) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
summary: `runtime/reviewed-inputs.json stage_id must be ${options.stageId}.`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
mkdirSync(contextGraphRuntimeStageRoot(options.contextGraphPath, options.stageId), { recursive: true });
|
|
349
|
+
writeJsonAtomic(contextGraphRuntimeStageReviewedInputsPath(options.contextGraphPath, options.stageId), file);
|
|
350
|
+
return {
|
|
351
|
+
ok: true,
|
|
352
|
+
summary: `${options.stageId} reviewed ${file.reviewed.length} expected input${file.reviewed.length === 1 ? "" : "s"}.`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function expectedInputsForStage(options) {
|
|
356
|
+
if (options.stage.id === "source") {
|
|
357
|
+
return options.manifest.files.map(sourceFileResource);
|
|
358
|
+
}
|
|
359
|
+
// Roles are derived from the artifact write/read *paths*, not from artifact
|
|
360
|
+
// ids or stage ids, so a custom plan cannot dodge layer policy by renaming.
|
|
361
|
+
const writeRoles = stageArtifactRoles(options.contextGraphPath, options.stage.writes);
|
|
362
|
+
const readRoles = stageArtifactRoles(options.contextGraphPath, options.stage.reads);
|
|
363
|
+
if (writeRoles.has("summary") || options.stage.contractType === "build-file-evidence") {
|
|
364
|
+
return sourceUnitResources(options.manifest);
|
|
365
|
+
}
|
|
366
|
+
if (writeRoles.has("entrypoint")) {
|
|
367
|
+
const knowledge = listKnowledgeResources(options.contextGraphPath);
|
|
368
|
+
return (knowledge.length > 0 ? knowledge : listSummaryResources(options.contextGraphPath))
|
|
369
|
+
.map((resource) => ({ ...resource, required: true }));
|
|
370
|
+
}
|
|
371
|
+
if (writeRoles.has("knowledge")) {
|
|
372
|
+
return listSummaryResources(options.contextGraphPath).map((resource) => ({
|
|
373
|
+
...resource,
|
|
374
|
+
required: true,
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
const expected = [];
|
|
378
|
+
if (options.stage.reads.includes("source"))
|
|
379
|
+
expected.push(...options.manifest.files.map(sourceFileResource));
|
|
380
|
+
if (readRoles.has("summary"))
|
|
381
|
+
expected.push(...listSummaryResources(options.contextGraphPath));
|
|
382
|
+
if (readRoles.has("knowledge"))
|
|
383
|
+
expected.push(...listKnowledgeResources(options.contextGraphPath));
|
|
384
|
+
return expected.map((resource) => ({ ...resource, required: true }));
|
|
385
|
+
}
|
|
386
|
+
export function writeStageExpectedInputs(options) {
|
|
387
|
+
const manifest = loadContextGraphSourceManifest(options.contextGraphPath);
|
|
388
|
+
if (!manifest)
|
|
389
|
+
return null;
|
|
390
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
391
|
+
const expected = expectedInputsForStage({
|
|
392
|
+
contextGraphPath: options.contextGraphPath,
|
|
393
|
+
manifest,
|
|
394
|
+
stage: options.stage,
|
|
395
|
+
});
|
|
396
|
+
const file = ExpectedInputsFileSchema.parse({
|
|
397
|
+
kind: "interf-stage-expected-inputs",
|
|
398
|
+
version: 1,
|
|
399
|
+
generated_at: generatedAt,
|
|
400
|
+
stage_id: options.stage.id,
|
|
401
|
+
expected,
|
|
402
|
+
});
|
|
403
|
+
mkdirSync(contextGraphRuntimeStageRoot(options.contextGraphPath, options.stage.id), { recursive: true });
|
|
404
|
+
writeJsonAtomic(contextGraphRuntimeStageExpectedInputsPath(options.contextGraphPath, options.stage.id), file);
|
|
405
|
+
return file;
|
|
406
|
+
}
|
|
407
|
+
function metric(key, label, value, total, unit, detail,
|
|
408
|
+
// Optional honest-guarantee tag rendered into MetricCount.metadata.guarantee.
|
|
409
|
+
// "coverage" = every Source file read/summarized (file-level, provable).
|
|
410
|
+
// "traceability" = every claim/summary/knowledge node linked (provable). Plain
|
|
411
|
+
// counts pass no guarantee. Verification (qa_match) is never a metric guarantee.
|
|
412
|
+
guarantee) {
|
|
413
|
+
return MetricCountSchema.parse({
|
|
414
|
+
key,
|
|
415
|
+
label,
|
|
416
|
+
value,
|
|
417
|
+
...(typeof total === "number" ? { total } : {}),
|
|
418
|
+
...(unit ? { unit } : {}),
|
|
419
|
+
primary: true,
|
|
420
|
+
...(typeof total === "number" ? { issue_state: value >= total ? "pass" : "missing" } : {}),
|
|
421
|
+
...(detail ? { detail } : {}),
|
|
422
|
+
...(guarantee ? { metadata: { guarantee } } : {}),
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function producedResourcesForStage(options) {
|
|
426
|
+
const schema = readBuildPlanContextFile(buildPlanPackagePathForContextGraph(options.contextGraphPath));
|
|
427
|
+
const resources = [];
|
|
428
|
+
for (const artifactId of options.stage.writes) {
|
|
429
|
+
const artifact = schema ? findBuildPlanContextArtifact(schema, artifactId) : null;
|
|
430
|
+
if (!artifact)
|
|
431
|
+
continue;
|
|
432
|
+
if (artifact.kind === "file") {
|
|
433
|
+
if (existsSync(join(options.contextGraphPath, artifact.path))) {
|
|
434
|
+
resources.push(noteResource(options.contextGraphPath, artifact.path, options.stage.id, knownSourceRefsForGraph(options.contextGraphPath)));
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const root = join(options.contextGraphPath, artifact.path);
|
|
439
|
+
if (!existsSync(root))
|
|
440
|
+
continue;
|
|
441
|
+
for (const filePath of listFilesRecursive(root)) {
|
|
442
|
+
if (!filePath.toLowerCase().endsWith(".md"))
|
|
443
|
+
continue;
|
|
444
|
+
resources.push(noteResource(options.contextGraphPath, relativeToGraph(options.contextGraphPath, filePath), options.stage.id, knownSourceRefsForGraph(options.contextGraphPath)));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return Array.from(new Map(resources.map((resource) => [resource.id, resource])).values());
|
|
448
|
+
}
|
|
449
|
+
function readLatestStageEvidence(contextGraphPath, stageId) {
|
|
450
|
+
const history = readExecutionStageLedgerHistory(contextGraphPath)
|
|
451
|
+
.filter((entry) => entry.stage === stageId && entry.evidence)
|
|
452
|
+
.sort((left, right) => right.updated_at.localeCompare(left.updated_at));
|
|
453
|
+
return history[0]?.evidence ?? null;
|
|
454
|
+
}
|
|
455
|
+
function synthesizeReviewedInputs(options) {
|
|
456
|
+
const evidence = readLatestStageEvidence(options.contextGraphPath, options.stageId);
|
|
457
|
+
const producedBySourceRef = new Map();
|
|
458
|
+
for (const item of evidence?.accepted.items ?? []) {
|
|
459
|
+
for (const sourceRef of item.source_refs) {
|
|
460
|
+
const paths = item.output_refs.map((ref) => ref.path);
|
|
461
|
+
producedBySourceRef.set(sourceRef.path, [...(producedBySourceRef.get(sourceRef.path) ?? []), ...paths]);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const producedIdsByPath = new Map(options.produced.map((resource) => [resource.path ?? resource.id, resource.id]));
|
|
465
|
+
return options.expected.map((input) => {
|
|
466
|
+
const sourcePath = stringField(input.metadata?.source_path) ?? input.source_refs[0] ?? "";
|
|
467
|
+
const outputPaths = sourcePath ? producedBySourceRef.get(sourcePath) ?? [] : [];
|
|
468
|
+
const outputResourceIds = outputPaths
|
|
469
|
+
.map((path) => producedIdsByPath.get(path))
|
|
470
|
+
.filter((id) => Boolean(id));
|
|
471
|
+
if (outputResourceIds.length > 0) {
|
|
472
|
+
return ReviewedInputSchema.parse({
|
|
473
|
+
resource_id: input.id,
|
|
474
|
+
decision: "used",
|
|
475
|
+
output_resource_ids: outputResourceIds,
|
|
476
|
+
source_refs: input.source_refs,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
return ReviewedInputSchema.parse({
|
|
480
|
+
resource_id: input.id,
|
|
481
|
+
decision: "missing",
|
|
482
|
+
reason: "Stage did not write reviewed-inputs.json for this expected input.",
|
|
483
|
+
source_refs: input.source_refs,
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
function missingInputs(reviewed) {
|
|
488
|
+
return reviewed
|
|
489
|
+
.filter((entry) => entry.decision === "missing" || entry.decision === "blocked" || entry.decision === "not-relevant")
|
|
490
|
+
.map((entry) => ({
|
|
491
|
+
resource_id: entry.resource_id,
|
|
492
|
+
status: entry.decision === "blocked" ? "blocked" : entry.decision === "not-relevant" ? "not-relevant" : "missing",
|
|
493
|
+
reason: entry.reason ?? `${entry.resource_id} was not used by this stage.`,
|
|
494
|
+
}));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* The Source path an expected ResourceRef points at, if any. Source-file and
|
|
498
|
+
* source-unit resources carry it on `metadata.path` / `metadata.source_path`,
|
|
499
|
+
* and always on `source_refs[0]` / `label`.
|
|
500
|
+
*/
|
|
501
|
+
function expectedResourcePath(resource) {
|
|
502
|
+
return stringField(resource.metadata?.path)
|
|
503
|
+
?? stringField(resource.metadata?.source_path)
|
|
504
|
+
?? resource.source_refs[0]
|
|
505
|
+
?? resource.path
|
|
506
|
+
?? resource.label;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Backstop: drop `missing` entries that point at OS-generated filesystem junk
|
|
510
|
+
* (`.DS_Store`, AppleDouble forks, `Thumbs.db`, …). Inventory already filters
|
|
511
|
+
* these out before they become expected inputs, but a pre-existing on-disk
|
|
512
|
+
* manifest (or any future inventory path) could still surface one. Matched by
|
|
513
|
+
* the expected resource's filename only — provable, never by an agent's
|
|
514
|
+
* `not-relevant` label — so a real skipped content file still counts as missing.
|
|
515
|
+
*/
|
|
516
|
+
function dropFilesystemArtifactMissing(missing, expected) {
|
|
517
|
+
if (missing.length === 0)
|
|
518
|
+
return missing;
|
|
519
|
+
const artifactIds = new Set(expected
|
|
520
|
+
.filter((resource) => {
|
|
521
|
+
const path = expectedResourcePath(resource);
|
|
522
|
+
return Boolean(path) && isFilesystemArtifact(path);
|
|
523
|
+
})
|
|
524
|
+
.map((resource) => resource.id));
|
|
525
|
+
if (artifactIds.size === 0)
|
|
526
|
+
return missing;
|
|
527
|
+
return missing.filter((entry) => !artifactIds.has(entry.resource_id));
|
|
528
|
+
}
|
|
529
|
+
function dedupeMissing(missing) {
|
|
530
|
+
const byId = new Map();
|
|
531
|
+
for (const entry of missing) {
|
|
532
|
+
if (!byId.has(entry.resource_id))
|
|
533
|
+
byId.set(entry.resource_id, entry);
|
|
534
|
+
}
|
|
535
|
+
return [...byId.values()];
|
|
536
|
+
}
|
|
537
|
+
function resourceMatchesLink(resource, link) {
|
|
538
|
+
const cleanLink = normalizeRelativePath(link).replace(/\.md$/i, "");
|
|
539
|
+
const path = resource.path ? normalizeRelativePath(resource.path).replace(/\.md$/i, "") : "";
|
|
540
|
+
if (path && (cleanLink === path || cleanLink.endsWith(`/${basename(path)}`)))
|
|
541
|
+
return true;
|
|
542
|
+
return slug(resource.label) === slug(link) || (path ? slug(basename(path)) === slug(link) : false);
|
|
543
|
+
}
|
|
544
|
+
function referencedInputs(expected, produced) {
|
|
545
|
+
const refs = new Map();
|
|
546
|
+
for (const output of produced) {
|
|
547
|
+
for (const link of output.links) {
|
|
548
|
+
const match = expected.find((resource) => resourceMatchesLink(resource, link));
|
|
549
|
+
if (match)
|
|
550
|
+
refs.set(match.id, match);
|
|
551
|
+
}
|
|
552
|
+
for (const sourceRef of output.source_refs) {
|
|
553
|
+
const match = expected.find((resource) => resource.source_refs.some((ref) => sourceRef.includes(ref) || ref.includes(sourceRef)));
|
|
554
|
+
if (match)
|
|
555
|
+
refs.set(match.id, match);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return [...refs.values()];
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Summary-backlink coverage for the knowledge layer, computed in memory from the
|
|
562
|
+
* resources the manifest already holds. A summary is "cited" when a produced note
|
|
563
|
+
* shares a source ref with it; "linked" when a produced note wikilinks the summary
|
|
564
|
+
* note. Orphaned = cited but not linked. Source-agnostic: no task taxonomy, no
|
|
565
|
+
* project-specific input — it reuses the same link/source-ref matching the
|
|
566
|
+
* referenced-inputs rollup uses.
|
|
567
|
+
*/
|
|
568
|
+
function summaryBacklinkCoverage(expectedSummaries, producedNotes) {
|
|
569
|
+
const cited = [];
|
|
570
|
+
const linkedCited = [];
|
|
571
|
+
const orphaned = [];
|
|
572
|
+
for (const summary of expectedSummaries) {
|
|
573
|
+
const isCited = producedNotes.some((note) => note.source_refs.some((noteRef) => summary.source_refs.some((summaryRef) => noteRef.includes(summaryRef) || summaryRef.includes(noteRef))));
|
|
574
|
+
if (!isCited)
|
|
575
|
+
continue;
|
|
576
|
+
cited.push(summary);
|
|
577
|
+
const isLinked = producedNotes.some((note) => note.links.some((link) => resourceMatchesLink(summary, link)));
|
|
578
|
+
if (isLinked)
|
|
579
|
+
linkedCited.push(summary);
|
|
580
|
+
else
|
|
581
|
+
orphaned.push(summary);
|
|
582
|
+
}
|
|
583
|
+
return { cited, linkedCited, orphaned };
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Does a produced note connect to the summary layer? It connects when it
|
|
587
|
+
* wikilinks at least one of the expected summary notes. This is the note-side
|
|
588
|
+
* complement to the orphaned-summary check: it closes the bypass where a
|
|
589
|
+
* knowledge note cites nothing (no source_refs) — so no summary is "cited" and
|
|
590
|
+
* the orphan rollup is vacuously empty — or uses a single global link that
|
|
591
|
+
* doesn't resolve to any summary. Source-agnostic: it reuses the same
|
|
592
|
+
* link-matching the referenced-inputs rollup uses, with no task taxonomy.
|
|
593
|
+
*/
|
|
594
|
+
function noteConnectsToSummaryLayer(note, expectedSummaries) {
|
|
595
|
+
return note.links.some((link) => expectedSummaries.some((summary) => resourceMatchesLink(summary, link)));
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Knowledge-layer notes that link no summary at all. A knowledge note's purpose
|
|
599
|
+
* is to connect prepared knowledge back to the source-backed summary layer; a
|
|
600
|
+
* note that links nothing in `summaries/` is a disconnected island that must not
|
|
601
|
+
* pass coverage silently. Scoped to notes that actually live in the knowledge
|
|
602
|
+
* layer (by write path, via `roleForPath`), so home/artifacts entrypoint notes
|
|
603
|
+
* produced by the same stage are not falsely flagged.
|
|
604
|
+
*/
|
|
605
|
+
/**
|
|
606
|
+
* Does a produced note land in the knowledge layer? A note is knowledge-layer
|
|
607
|
+
* when it carries the `knowledge` role OR its on-disk write path normalizes into
|
|
608
|
+
* the knowledge layer (via the central `roleForPath` classifier). Single
|
|
609
|
+
* definition of the predicate every knowledge-layer rollup routes through, so the
|
|
610
|
+
* note→layer test can never drift between the disconnected-notes, web, and
|
|
611
|
+
* knowledge-notes paths.
|
|
612
|
+
*/
|
|
613
|
+
function isKnowledgeNote(note) {
|
|
614
|
+
return note.role === "knowledge"
|
|
615
|
+
|| (note.path ? roleForPath(normalizeRelativePath(note.path)) === "knowledge" : false);
|
|
616
|
+
}
|
|
617
|
+
function disconnectedKnowledgeNotes(expectedSummaries, producedNotes) {
|
|
618
|
+
if (expectedSummaries.length === 0)
|
|
619
|
+
return [];
|
|
620
|
+
return producedNotes.filter((note) => {
|
|
621
|
+
if (!isKnowledgeNote(note))
|
|
622
|
+
return false;
|
|
623
|
+
return !noteConnectsToSummaryLayer(note, expectedSummaries);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* Knowledge-web connectivity for the produced notes — the manifest-side mirror of
|
|
628
|
+
* the `knowledge_web_connectivity` Check. Distinct from `disconnectedKnowledgeNotes`
|
|
629
|
+
* (notes → summaries, the DOWN-link): this is notes ↔ notes (the WEB). A knowledge
|
|
630
|
+
* note is web-connected when it links another knowledge note OR another knowledge
|
|
631
|
+
* note links it (UNDIRECTED), reusing the in-memory `ResourceRef.links` graph and
|
|
632
|
+
* `resourceMatchesLink` the referenced-inputs rollup already uses. Connectedness,
|
|
633
|
+
* not a count: degree ≥ 1 passes, degree 0 is a disconnected island. Vacuous pass:
|
|
634
|
+
* ≤1 knowledge note cannot form a web (connected = all, no islands). Both this and
|
|
635
|
+
* the Check derive connectedness from the same link graph, so they agree.
|
|
636
|
+
*/
|
|
637
|
+
function knowledgeWebConnectivity(producedNotes) {
|
|
638
|
+
const knowledgeNotes = producedNotes.filter(isKnowledgeNote);
|
|
639
|
+
if (knowledgeNotes.length <= 1) {
|
|
640
|
+
return { knowledgeNotes, connected: knowledgeNotes.length, islands: [] };
|
|
641
|
+
}
|
|
642
|
+
// A directed edge A→B exists when A links a DIFFERENT knowledge note B. Undirected
|
|
643
|
+
// connectivity: a node has degree ≥ 1 when it is the source of any edge (outbound)
|
|
644
|
+
// or the target of any edge from another knowledge note (inbound).
|
|
645
|
+
const hasOutbound = new Set();
|
|
646
|
+
const hasInbound = new Set();
|
|
647
|
+
for (const from of knowledgeNotes) {
|
|
648
|
+
for (const link of from.links) {
|
|
649
|
+
for (const to of knowledgeNotes) {
|
|
650
|
+
if (to.id === from.id)
|
|
651
|
+
continue;
|
|
652
|
+
if (resourceMatchesLink(to, link)) {
|
|
653
|
+
hasOutbound.add(from.id);
|
|
654
|
+
hasInbound.add(to.id);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const islands = knowledgeNotes.filter((note) => !hasOutbound.has(note.id) && !hasInbound.has(note.id));
|
|
660
|
+
return { knowledgeNotes, connected: knowledgeNotes.length - islands.length, islands };
|
|
661
|
+
}
|
|
662
|
+
function stageRole(contextGraphPath, stage) {
|
|
663
|
+
if (stage.id === "source")
|
|
664
|
+
return "source";
|
|
665
|
+
// Role is decided by where the stage's artifacts are written on disk, resolved
|
|
666
|
+
// through the Build Plan schema — never by a stringly stage id or artifact id.
|
|
667
|
+
// A custom plan that writes artifact id "claims" into path `knowledge/` via
|
|
668
|
+
// stage "extract-claims" is still the knowledge layer and is still policed.
|
|
669
|
+
const writeRoles = stageArtifactRoles(contextGraphPath, stage.writes);
|
|
670
|
+
if (writeRoles.has("summary") || stage.contractType === "build-file-evidence")
|
|
671
|
+
return "summary";
|
|
672
|
+
if (writeRoles.has("entrypoint"))
|
|
673
|
+
return "entrypoint";
|
|
674
|
+
if (writeRoles.has("knowledge"))
|
|
675
|
+
return "knowledge";
|
|
676
|
+
return "other";
|
|
677
|
+
}
|
|
678
|
+
/** Produced notes that land in the knowledge layer, by their on-disk path. */
|
|
679
|
+
function knowledgeNotesIn(produced) {
|
|
680
|
+
return produced.filter(isKnowledgeNote);
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Knowledge-layer metrics for the notes a stage produced into `knowledge/`. Emitted
|
|
684
|
+
* whenever a stage produces ANY knowledge note — not gated on the stage's single
|
|
685
|
+
* primary role — so a multi-layer stage (e.g. an entrypoint stage that ALSO writes
|
|
686
|
+
* route/index notes into `knowledge/`) still reports knowledge coverage,
|
|
687
|
+
* backlinks, summary-connectivity, and web connectivity for the notes it added.
|
|
688
|
+
* This is what lets the GraphManifest roll up FINAL knowledge state from whichever
|
|
689
|
+
* stage last mutated the layer.
|
|
690
|
+
*
|
|
691
|
+
* The backlink / summary-connectivity analyses compare produced knowledge notes
|
|
692
|
+
* against the ACTUAL summary layer on disk (`summaries`), NOT against the stage's
|
|
693
|
+
* role-specific `expected` inputs — the entrypoint stage's expected inputs are
|
|
694
|
+
* knowledge notes, not summaries, so reusing `expected` here would compare the
|
|
695
|
+
* wrong layer.
|
|
696
|
+
*
|
|
697
|
+
* `includeCoverageMetric` adds the `knowledge_coverage` traceability count
|
|
698
|
+
* (summaries this stage reviewed/used). It is emitted ONLY for a knowledge-role
|
|
699
|
+
* stage, because only that stage's expected inputs ARE the summaries — emitting
|
|
700
|
+
* it from a multi-layer stage (e.g. the entrypoint stage) would put a confusing
|
|
701
|
+
* "knowledge notes reviewed / summary total" denominator mismatch on the primary
|
|
702
|
+
* metric. The LINK-QUALITY metrics below are emitted for ANY stage that produced
|
|
703
|
+
* knowledge notes, so the GraphManifest rolls up FINAL link state from whichever
|
|
704
|
+
* stage last mutated the layer.
|
|
705
|
+
*/
|
|
706
|
+
function knowledgeLayerMetrics(options) {
|
|
707
|
+
const reviewedCount = options.reviewed.filter((entry) => entry.decision === "used" || entry.decision === "reviewed").length;
|
|
708
|
+
const usedCount = options.reviewed.filter((entry) => entry.decision === "used").length || options.referenced.length;
|
|
709
|
+
const backlinks = summaryBacklinkCoverage(options.expectedSummaries, options.produced);
|
|
710
|
+
const knowledgeNotes = knowledgeNotesIn(options.produced);
|
|
711
|
+
const disconnected = disconnectedKnowledgeNotes(options.expectedSummaries, options.produced);
|
|
712
|
+
const connectedNotes = knowledgeNotes.length - disconnected.length;
|
|
713
|
+
const web = knowledgeWebConnectivity(options.produced);
|
|
714
|
+
return [
|
|
715
|
+
// knowledge_coverage counts summaries the knowledge stage reviewed/used — a
|
|
716
|
+
// TRACEABILITY signal (knowledge linked back to the coverage layer), NOT the
|
|
717
|
+
// provable file-level "every Source file was read" COVERAGE guarantee. Tagged
|
|
718
|
+
// traceability so nothing derived/judgment is mislabeled coverage.
|
|
719
|
+
...(options.includeCoverageMetric
|
|
720
|
+
? [metric("knowledge_coverage", "Knowledge coverage", reviewedCount, options.reviewedTotal, "summaries", `${reviewedCount} / ${options.reviewedTotal} reviewed, ${usedCount} used`, "traceability")]
|
|
721
|
+
: []),
|
|
722
|
+
metric("knowledge_notes", "Knowledge notes", knowledgeNotes.length, undefined, "notes"),
|
|
723
|
+
metric("knowledge_summary_backlinks", "Summaries wikilinked", backlinks.linkedCited.length, backlinks.cited.length, "summaries", backlinks.orphaned.length === 0
|
|
724
|
+
? `${backlinks.linkedCited.length} / ${backlinks.cited.length} cited summaries wikilinked`
|
|
725
|
+
: `${backlinks.orphaned.length} cited summar${backlinks.orphaned.length === 1 ? "y" : "ies"} orphaned (cited but never wikilinked)`, "traceability"),
|
|
726
|
+
metric("knowledge_summary_connectivity", "Knowledge notes linked to summaries", connectedNotes, knowledgeNotes.length, "notes", disconnected.length === 0
|
|
727
|
+
? `${connectedNotes} / ${knowledgeNotes.length} knowledge notes wikilink a summary`
|
|
728
|
+
: formatDisconnectedNotesMessage(disconnected.length, false), "traceability"),
|
|
729
|
+
// notes ↔ notes (the WEB), distinct from knowledge_summary_connectivity
|
|
730
|
+
// (notes → summaries, the DOWN-link). A star (every note links only summaries,
|
|
731
|
+
// none link each other) reads as islands here; a web reads as connected.
|
|
732
|
+
metric("knowledge_web_connectivity", "Knowledge notes linked into the web", web.connected, web.knowledgeNotes.length, "notes", web.islands.length === 0
|
|
733
|
+
? `${web.connected} / ${web.knowledgeNotes.length} knowledge notes link another knowledge note`
|
|
734
|
+
: `${web.islands.length} knowledge note${web.islands.length === 1 ? "" : "s"} link no other knowledge note (disconnected island${web.islands.length === 1 ? "" : "s"})`, "traceability"),
|
|
735
|
+
];
|
|
736
|
+
}
|
|
737
|
+
/** Entrypoint-layer metric for the home spine + entrypoint notes a stage produced. */
|
|
738
|
+
function entrypointLayerMetrics(produced) {
|
|
739
|
+
const homeReady = produced.some((resource) => resource.path === "home.md") ? 1 : 0;
|
|
740
|
+
const entrypointCount = produced.filter((resource) => resource.role === "entrypoint").length;
|
|
741
|
+
return [
|
|
742
|
+
metric("entrypoints", "Entrypoints", homeReady, 1, "entrypoints", `${entrypointCount} entrypoint file${entrypointCount === 1 ? "" : "s"} produced`),
|
|
743
|
+
];
|
|
744
|
+
}
|
|
745
|
+
function stageMetrics(options) {
|
|
746
|
+
if (options.role === "source") {
|
|
747
|
+
return [
|
|
748
|
+
metric("files_processed", "Files processed", options.produced.length, options.manifest.source_total, "files", undefined, "coverage"),
|
|
749
|
+
];
|
|
750
|
+
}
|
|
751
|
+
if (options.role === "summary") {
|
|
752
|
+
const summarizedCount = options.reviewed.filter((entry) => entry.decision === "used" || entry.decision === "reviewed").length;
|
|
753
|
+
return [
|
|
754
|
+
metric("source_units_summarized", "Source units summarized", summarizedCount, options.expected.length, "source units", undefined, "coverage"),
|
|
755
|
+
];
|
|
756
|
+
}
|
|
757
|
+
// Knowledge + entrypoint metrics are LAYER-keyed, not role-keyed: a stage emits
|
|
758
|
+
// the metrics for every canonical layer its produced resources actually touch.
|
|
759
|
+
// The default entrypoint stage writes `home`, `artifacts`, AND `knowledge` (route
|
|
760
|
+
// notes), so it emits BOTH entrypoint and knowledge metrics — the knowledge
|
|
761
|
+
// branch no longer hides behind an "entrypoint vs knowledge" role precedence.
|
|
762
|
+
// The GraphManifest then rolls up the FINAL per-layer metric across stages.
|
|
763
|
+
const metrics = [];
|
|
764
|
+
const producesKnowledge = knowledgeNotesIn(options.produced).length > 0;
|
|
765
|
+
const producesEntrypoint = options.role === "entrypoint"
|
|
766
|
+
|| options.produced.some((resource) => resource.role === "entrypoint");
|
|
767
|
+
if (producesKnowledge) {
|
|
768
|
+
// Compare produced knowledge notes against the ACTUAL summary layer on disk,
|
|
769
|
+
// not this stage's role-specific expected inputs (which, for an entrypoint
|
|
770
|
+
// stage, are knowledge notes — the wrong layer). For a knowledge-role stage
|
|
771
|
+
// these are the same set. Only the knowledge-role stage emits the
|
|
772
|
+
// knowledge_coverage traceability count (summaries reviewed), with its
|
|
773
|
+
// original expected-input denominator.
|
|
774
|
+
const isKnowledgeRole = options.role === "knowledge";
|
|
775
|
+
const expectedSummaries = isKnowledgeRole
|
|
776
|
+
? options.expected
|
|
777
|
+
: listSummaryResources(options.contextGraphPath);
|
|
778
|
+
metrics.push(...knowledgeLayerMetrics({
|
|
779
|
+
expectedSummaries,
|
|
780
|
+
reviewedTotal: options.expected.length,
|
|
781
|
+
produced: options.produced,
|
|
782
|
+
referenced: options.referenced,
|
|
783
|
+
reviewed: options.reviewed,
|
|
784
|
+
includeCoverageMetric: isKnowledgeRole,
|
|
785
|
+
}));
|
|
786
|
+
}
|
|
787
|
+
if (producesEntrypoint) {
|
|
788
|
+
metrics.push(...entrypointLayerMetrics(options.produced));
|
|
789
|
+
}
|
|
790
|
+
if (metrics.length > 0)
|
|
791
|
+
return metrics;
|
|
792
|
+
return [
|
|
793
|
+
metric("outputs_produced", "Outputs produced", options.produced.length, undefined, "outputs"),
|
|
794
|
+
];
|
|
795
|
+
}
|
|
796
|
+
export function writeSourceStageManifest(options) {
|
|
797
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
798
|
+
const expected = options.sourceManifest.files.map(sourceFileResource);
|
|
799
|
+
const reviewed = expected.map((resource) => ReviewedInputSchema.parse({
|
|
800
|
+
resource_id: resource.id,
|
|
801
|
+
decision: "used",
|
|
802
|
+
source_refs: resource.source_refs,
|
|
803
|
+
output_resource_ids: [resource.id],
|
|
804
|
+
}));
|
|
805
|
+
const manifest = StageManifestSchema.parse({
|
|
806
|
+
kind: "interf-stage-manifest",
|
|
807
|
+
version: 1,
|
|
808
|
+
generated_at: generatedAt,
|
|
809
|
+
project: options.sourceManifest.project,
|
|
810
|
+
run_id: options.runId ?? options.sourceManifest.run_id,
|
|
811
|
+
build_plan: options.buildPlanId,
|
|
812
|
+
stage_id: "source",
|
|
813
|
+
stage_label: "Prepare Source",
|
|
814
|
+
role: "source",
|
|
815
|
+
summary: `Source Manifest captured ${options.sourceManifest.source_total} file(s).`,
|
|
816
|
+
input_manifests: [],
|
|
817
|
+
expected,
|
|
818
|
+
reviewed,
|
|
819
|
+
referenced: expected,
|
|
820
|
+
missing: [],
|
|
821
|
+
produced: expected,
|
|
822
|
+
metrics: [
|
|
823
|
+
metric("files_processed", "Files processed", expected.length, options.sourceManifest.source_total, "files", undefined, "coverage"),
|
|
824
|
+
],
|
|
825
|
+
});
|
|
826
|
+
mkdirSync(contextGraphRuntimeStageRoot(options.contextGraphPath, "source"), { recursive: true });
|
|
827
|
+
writeJsonAtomic(contextGraphRuntimeStageManifestPath(options.contextGraphPath, "source"), manifest);
|
|
828
|
+
return manifest;
|
|
829
|
+
}
|
|
830
|
+
export function loadStageManifest(contextGraphPath, stageId) {
|
|
831
|
+
const path = contextGraphRuntimeStageManifestPath(contextGraphPath, stageId);
|
|
832
|
+
if (!existsSync(path))
|
|
833
|
+
return null;
|
|
834
|
+
return readJsonFileWithSchema(path, `stage manifest ${stageId}`, StageManifestSchema);
|
|
835
|
+
}
|
|
836
|
+
export function listStageManifests(contextGraphPath) {
|
|
837
|
+
const stagesRoot = join(contextGraphPath, ".interf", "runtime", "stages");
|
|
838
|
+
if (!existsSync(stagesRoot))
|
|
839
|
+
return [];
|
|
840
|
+
return readdirSync(stagesRoot, { withFileTypes: true })
|
|
841
|
+
.filter((entry) => entry.isDirectory())
|
|
842
|
+
.map((entry) => loadStageManifest(contextGraphPath, entry.name))
|
|
843
|
+
.filter((entry) => entry !== null)
|
|
844
|
+
.sort((left, right) => {
|
|
845
|
+
const order = ["source", "summarize", "knowledge", "entrypoint"];
|
|
846
|
+
const leftOrder = order.indexOf(left.stage_id);
|
|
847
|
+
const rightOrder = order.indexOf(right.stage_id);
|
|
848
|
+
return (leftOrder === -1 ? 99 : leftOrder) - (rightOrder === -1 ? 99 : rightOrder);
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
export function writeStageManifestFromOutputs(options) {
|
|
852
|
+
const sourceManifest = loadContextGraphSourceManifest(options.contextGraphPath);
|
|
853
|
+
if (!sourceManifest)
|
|
854
|
+
return null;
|
|
855
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
856
|
+
const expected = loadExpectedInputs(options.contextGraphPath, options.stage.id);
|
|
857
|
+
const produced = producedResourcesForStage({
|
|
858
|
+
contextGraphPath: options.contextGraphPath,
|
|
859
|
+
stage: options.stage,
|
|
860
|
+
});
|
|
861
|
+
const explicitReviewed = loadReviewedInputs(options.contextGraphPath, options.stage.id);
|
|
862
|
+
const reviewed = explicitReviewed ?? synthesizeReviewedInputs({
|
|
863
|
+
contextGraphPath: options.contextGraphPath,
|
|
864
|
+
expected,
|
|
865
|
+
produced,
|
|
866
|
+
stageId: options.stage.id,
|
|
867
|
+
});
|
|
868
|
+
const referenced = referencedInputs(expected, produced);
|
|
869
|
+
const role = stageRole(options.contextGraphPath, options.stage);
|
|
870
|
+
// Orphaned summaries (cited via a note's source_refs but never wikilinked) are
|
|
871
|
+
// missing coverage for the knowledge layer, even when otherwise reviewed. On a
|
|
872
|
+
// knowledge-role stage the summaries ARE this stage's expected inputs, so the
|
|
873
|
+
// orphan is an expected-input gap and belongs in `missing` (the StageManifest
|
|
874
|
+
// schema requires every `missing.resource_id` to be an expected input, so this
|
|
875
|
+
// is scoped to that case). When a DIFFERENT stage (e.g. the entrypoint stage)
|
|
876
|
+
// also mutates `knowledge/`, its summaries are not its expected inputs, so the
|
|
877
|
+
// orphan cannot live in that stage's `missing`; it instead surfaces as the
|
|
878
|
+
// failing `knowledge_summary_backlinks` metric — which `writeGraphManifest`
|
|
879
|
+
// rolls into graph readiness from FINAL per-layer state, alongside the
|
|
880
|
+
// summary-connectivity and web gaps. So no orphan escapes readiness regardless
|
|
881
|
+
// of which stage last touched the knowledge layer.
|
|
882
|
+
const orphanMissing = role === "knowledge"
|
|
883
|
+
? summaryBacklinkCoverage(expected, produced).orphaned.map((summary) => ({
|
|
884
|
+
resource_id: summary.id,
|
|
885
|
+
status: "missing",
|
|
886
|
+
reason: `${summary.path ?? summary.label} ${ORPHANED_SUMMARY_REASON}`,
|
|
887
|
+
}))
|
|
888
|
+
: [];
|
|
889
|
+
const missing = dropFilesystemArtifactMissing(dedupeMissing([...missingInputs(reviewed), ...orphanMissing]), expected);
|
|
890
|
+
const manifest = StageManifestSchema.parse({
|
|
891
|
+
kind: "interf-stage-manifest",
|
|
892
|
+
version: 1,
|
|
893
|
+
generated_at: generatedAt,
|
|
894
|
+
project: options.projectId,
|
|
895
|
+
run_id: options.runId ?? null,
|
|
896
|
+
build_plan: options.buildPlanId,
|
|
897
|
+
stage_id: options.stage.id,
|
|
898
|
+
stage_label: options.stage.label,
|
|
899
|
+
role,
|
|
900
|
+
summary: `${options.stage.label} produced ${produced.length} resource${produced.length === 1 ? "" : "s"}.`,
|
|
901
|
+
input_manifests: [
|
|
902
|
+
ResourceRefSchema.parse({
|
|
903
|
+
id: `manifest:${sourceManifest.manifest_id}`,
|
|
904
|
+
role: "diagnostic",
|
|
905
|
+
kind: "source.manifest",
|
|
906
|
+
label: "Source Manifest",
|
|
907
|
+
path: ".interf/runtime/source-manifest.json",
|
|
908
|
+
}),
|
|
909
|
+
],
|
|
910
|
+
expected,
|
|
911
|
+
reviewed,
|
|
912
|
+
referenced,
|
|
913
|
+
missing,
|
|
914
|
+
produced,
|
|
915
|
+
metrics: stageMetrics({
|
|
916
|
+
contextGraphPath: options.contextGraphPath,
|
|
917
|
+
expected,
|
|
918
|
+
manifest: sourceManifest,
|
|
919
|
+
produced,
|
|
920
|
+
referenced,
|
|
921
|
+
reviewed,
|
|
922
|
+
role,
|
|
923
|
+
}),
|
|
924
|
+
});
|
|
925
|
+
mkdirSync(contextGraphRuntimeStageRoot(options.contextGraphPath, options.stage.id), { recursive: true });
|
|
926
|
+
writeJsonAtomic(contextGraphRuntimeStageManifestPath(options.contextGraphPath, options.stage.id), manifest);
|
|
927
|
+
return manifest;
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* The FINAL value of a metric across the stage manifests, in execution order.
|
|
931
|
+
* `listStageManifests` returns manifests ordered source → summarize → knowledge →
|
|
932
|
+
* entrypoint, so the LAST manifest that emitted a given metric key reflects the
|
|
933
|
+
* final on-disk state of that layer. Taking the last (not the first) match is
|
|
934
|
+
* what makes the GraphManifest final-state-correct: when the entrypoint stage
|
|
935
|
+
* mutates `knowledge/` after the knowledge stage, its (later) knowledge metrics
|
|
936
|
+
* win over the earlier, now-stale knowledge-stage values.
|
|
937
|
+
*/
|
|
938
|
+
function metricByKey(manifests, key) {
|
|
939
|
+
let latest = null;
|
|
940
|
+
for (const manifest of manifests) {
|
|
941
|
+
const found = manifest.metrics.find((entry) => entry.key === key);
|
|
942
|
+
if (found)
|
|
943
|
+
latest = found;
|
|
944
|
+
}
|
|
945
|
+
return latest;
|
|
946
|
+
}
|
|
947
|
+
function countByKind(resources, kind) {
|
|
948
|
+
return resources.filter((resource) => resource.kind === kind || resource.kind.startsWith(`${kind}.`)).length;
|
|
949
|
+
}
|
|
950
|
+
function graphResourceCounts(resources, links, sourceRefs) {
|
|
951
|
+
return {
|
|
952
|
+
nodes: resources.length,
|
|
953
|
+
edges: links + sourceRefs,
|
|
954
|
+
notes: resources.filter((resource) => resource.path?.endsWith(".md")).length,
|
|
955
|
+
links,
|
|
956
|
+
source_refs: sourceRefs,
|
|
957
|
+
claims: countByKind(resources, "knowledge.claim"),
|
|
958
|
+
entities: countByKind(resources, "knowledge.entity"),
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
export function loadGraphManifest(contextGraphPath) {
|
|
962
|
+
const path = contextGraphRuntimeGraphManifestPath(contextGraphPath);
|
|
963
|
+
if (!existsSync(path))
|
|
964
|
+
return null;
|
|
965
|
+
return readJsonFileWithSchema(path, "graph manifest", GraphManifestSchema);
|
|
966
|
+
}
|
|
967
|
+
export function writeGraphManifest(options) {
|
|
968
|
+
const generatedAt = options.generatedAt ?? new Date().toISOString();
|
|
969
|
+
const manifests = listStageManifests(options.contextGraphPath);
|
|
970
|
+
// FINAL on-disk state per resource: last-write-wins by id, in execution order.
|
|
971
|
+
// A note in `knowledge/` that the knowledge stage produced and the entrypoint
|
|
972
|
+
// stage then re-read (now carrying the route links the entrypoint stage added)
|
|
973
|
+
// must roll up in its FINAL form, not the earlier knowledge-stage snapshot. The
|
|
974
|
+
// previous first-by-id kept the stale copy and could mask links/refs added by a
|
|
975
|
+
// later stage. `set` already overwrites, so iterating in order yields the latest.
|
|
976
|
+
const resourcesById = new Map();
|
|
977
|
+
for (const manifest of manifests) {
|
|
978
|
+
for (const resource of manifest.produced) {
|
|
979
|
+
resourcesById.set(resource.id, resource);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
const resources = [...resourcesById.values()];
|
|
983
|
+
const entrypoints = resources.filter((resource) => resource.role === "entrypoint");
|
|
984
|
+
// Readiness-feeding missing inputs. The per-summary orphaned-summary entries a
|
|
985
|
+
// knowledge stage records are collapsed here into the single FINAL-state
|
|
986
|
+
// `knowledge:summary-backlinks` gate (added below from the metric), so one
|
|
987
|
+
// orphan gap counts once in readiness — not once per summary AND again via the
|
|
988
|
+
// metric gate. The full, unfiltered stage `missing` is still preserved on each
|
|
989
|
+
// GraphManifest stage summary (`missing_required_total`).
|
|
990
|
+
const missing = manifests
|
|
991
|
+
.flatMap((manifest) => manifest.missing)
|
|
992
|
+
.filter((entry) => !entry.reason.endsWith(ORPHANED_SUMMARY_REASON));
|
|
993
|
+
const links = resources.reduce((total, resource) => total + resource.links.length, 0);
|
|
994
|
+
const sourceRefs = resources.reduce((total, resource) => total + resource.source_refs.length, 0);
|
|
995
|
+
const sourceMetric = metricByKey(manifests, "files_processed");
|
|
996
|
+
const summaryMetric = metricByKey(manifests, "source_units_summarized");
|
|
997
|
+
const knowledgeMetric = metricByKey(manifests, "knowledge_coverage");
|
|
998
|
+
const entrypointMetric = metricByKey(manifests, "entrypoints");
|
|
999
|
+
const graphOutputs = graphResourceCounts(resources, links, sourceRefs);
|
|
1000
|
+
const graphOutputMetric = MetricCountSchema.parse({
|
|
1001
|
+
key: "graph_outputs",
|
|
1002
|
+
label: "Graph outputs",
|
|
1003
|
+
value: graphOutputs.notes,
|
|
1004
|
+
unit: "notes",
|
|
1005
|
+
primary: true,
|
|
1006
|
+
detail: `${graphOutputs.notes} notes, ${graphOutputs.links} links, ${graphOutputs.source_refs} source refs`,
|
|
1007
|
+
metadata: graphOutputs,
|
|
1008
|
+
});
|
|
1009
|
+
const primaryMetrics = [
|
|
1010
|
+
sourceMetric,
|
|
1011
|
+
summaryMetric,
|
|
1012
|
+
knowledgeMetric,
|
|
1013
|
+
graphOutputMetric,
|
|
1014
|
+
entrypointMetric,
|
|
1015
|
+
].filter((entry) => entry !== null);
|
|
1016
|
+
// The three knowledge-layer link-quality gates (summary-orphan, summary
|
|
1017
|
+
// down-connectivity, and notes↔notes web) are derived HERE from the FINAL
|
|
1018
|
+
// on-disk resources, not read off any single stage manifest. Stage manifests
|
|
1019
|
+
// are per-stage snapshots; whichever stage ran last is not guaranteed to be the
|
|
1020
|
+
// one that holds the final knowledge layer (the entrypoint stage adds route
|
|
1021
|
+
// notes after the knowledge stage). Computing from `resources` — the
|
|
1022
|
+
// last-write-wins union of every stage's produced notes — makes readiness
|
|
1023
|
+
// final-state-correct by construction, independent of stage ordering or a
|
|
1024
|
+
// stale per-stage metric. These reuse the SAME in-memory analyses the stage
|
|
1025
|
+
// metrics use, so the gate and the displayed metric agree.
|
|
1026
|
+
const finalSummaries = resources.filter((resource) => resource.role === "summary");
|
|
1027
|
+
const finalKnowledgeNotes = knowledgeNotesIn(resources);
|
|
1028
|
+
const finalBacklinks = summaryBacklinkCoverage(finalSummaries, finalKnowledgeNotes);
|
|
1029
|
+
const finalDisconnected = disconnectedKnowledgeNotes(finalSummaries, finalKnowledgeNotes);
|
|
1030
|
+
const finalWeb = knowledgeWebConnectivity(finalKnowledgeNotes);
|
|
1031
|
+
// Orphaned summaries: cited via a note's source_refs but never wikilinked. A
|
|
1032
|
+
// disconnected coverage layer is not ready. (Per-summary detail still lives on
|
|
1033
|
+
// each knowledge-role stage's `missing`; here it is one collapsed gate.)
|
|
1034
|
+
const backlinkMissing = finalBacklinks.orphaned.length > 0
|
|
1035
|
+
? [{
|
|
1036
|
+
resource_id: "knowledge:summary-backlinks",
|
|
1037
|
+
status: "missing",
|
|
1038
|
+
reason: `${finalBacklinks.orphaned.length} cited summar${finalBacklinks.orphaned.length === 1 ? "y is" : "ies are"} orphaned (cited by a knowledge note but never wikilinked).`,
|
|
1039
|
+
}]
|
|
1040
|
+
: [];
|
|
1041
|
+
// Down-connectivity: knowledge notes that wikilink no summary at all — the
|
|
1042
|
+
// no-cite / single-global-link bypass. notes → summaries.
|
|
1043
|
+
const connectivityMissing = finalDisconnected.length > 0
|
|
1044
|
+
? [{
|
|
1045
|
+
resource_id: "knowledge:summary-connectivity",
|
|
1046
|
+
status: "missing",
|
|
1047
|
+
reason: formatDisconnectedNotesMessage(finalDisconnected.length, true),
|
|
1048
|
+
}]
|
|
1049
|
+
: [];
|
|
1050
|
+
// No-orphan knowledge WEB: notes ↔ notes. A disconnected island (a knowledge
|
|
1051
|
+
// note that links no OTHER knowledge note) is not ready. Honest sparsity (≤1
|
|
1052
|
+
// note, or a genuinely connected web) yields no islands and no gap.
|
|
1053
|
+
const webMissing = finalWeb.islands.length > 0
|
|
1054
|
+
? [{
|
|
1055
|
+
resource_id: "knowledge:web-connectivity",
|
|
1056
|
+
status: "missing",
|
|
1057
|
+
reason: `${finalWeb.islands.length} knowledge note${finalWeb.islands.length === 1 ? "" : "s"} link no other knowledge note (disconnected island${finalWeb.islands.length === 1 ? "" : "s"}).`,
|
|
1058
|
+
}]
|
|
1059
|
+
: [];
|
|
1060
|
+
const entrypointMissing = entrypoints.length > 0
|
|
1061
|
+
? []
|
|
1062
|
+
: [{
|
|
1063
|
+
resource_id: "entrypoint:home",
|
|
1064
|
+
status: "missing",
|
|
1065
|
+
reason: "Context Graph has no generated entrypoint.",
|
|
1066
|
+
}];
|
|
1067
|
+
// not-relevant is *not* a free pass for a required input. Required inputs that
|
|
1068
|
+
// are skipped without a substitute still count toward the missing-required
|
|
1069
|
+
// rollup, otherwise readiness can flip ready by tagging real gaps not-relevant.
|
|
1070
|
+
const readinessMissing = [
|
|
1071
|
+
...missing,
|
|
1072
|
+
...backlinkMissing,
|
|
1073
|
+
...connectivityMissing,
|
|
1074
|
+
...webMissing,
|
|
1075
|
+
...entrypointMissing,
|
|
1076
|
+
];
|
|
1077
|
+
const missingRequiredTotal = readinessMissing.length;
|
|
1078
|
+
const readiness = {
|
|
1079
|
+
status: missingRequiredTotal === 0 ? "ready" : "not-ready",
|
|
1080
|
+
ready: missingRequiredTotal === 0,
|
|
1081
|
+
summary: missingRequiredTotal === 0
|
|
1082
|
+
? "Context Graph has complete required coverage metrics."
|
|
1083
|
+
: `${missingRequiredTotal} required coverage item${missingRequiredTotal === 1 ? "" : "s"} missing.`,
|
|
1084
|
+
context_graph_path: options.contextGraphPath,
|
|
1085
|
+
missing_required_total: missingRequiredTotal,
|
|
1086
|
+
missing: readinessMissing,
|
|
1087
|
+
};
|
|
1088
|
+
const graphManifest = GraphManifestSchema.parse({
|
|
1089
|
+
kind: "interf-graph-manifest",
|
|
1090
|
+
version: 1,
|
|
1091
|
+
generated_at: generatedAt,
|
|
1092
|
+
project: options.projectId,
|
|
1093
|
+
graph_id: options.graphId ?? options.runId ?? `${options.projectId}-latest`,
|
|
1094
|
+
run_id: options.runId ?? null,
|
|
1095
|
+
build_plan: options.buildPlanId,
|
|
1096
|
+
intent: options.intent ?? null,
|
|
1097
|
+
graph_path: options.contextGraphPath,
|
|
1098
|
+
primary_metrics: primaryMetrics,
|
|
1099
|
+
stages: manifests.map((manifest) => ({
|
|
1100
|
+
stage_id: manifest.stage_id,
|
|
1101
|
+
stage_label: manifest.stage_label,
|
|
1102
|
+
role: manifest.role,
|
|
1103
|
+
manifest_path: relativeToGraph(options.contextGraphPath, contextGraphRuntimeStageManifestPath(options.contextGraphPath, manifest.stage_id)),
|
|
1104
|
+
summary: manifest.summary,
|
|
1105
|
+
produced_total: manifest.produced.length,
|
|
1106
|
+
missing_required_total: manifest.missing.length,
|
|
1107
|
+
metrics: manifest.metrics,
|
|
1108
|
+
})),
|
|
1109
|
+
entrypoints,
|
|
1110
|
+
resources,
|
|
1111
|
+
graph_outputs: graphOutputs,
|
|
1112
|
+
readiness,
|
|
1113
|
+
});
|
|
1114
|
+
writeJsonAtomic(contextGraphRuntimeGraphManifestPath(options.contextGraphPath), graphManifest);
|
|
1115
|
+
return graphManifest;
|
|
1116
|
+
}
|
|
1117
|
+
export function reviewedInputsFilePath(contextGraphPath, stageId) {
|
|
1118
|
+
return contextGraphRuntimeStageReviewedInputsPath(contextGraphPath, stageId);
|
|
1119
|
+
}
|
|
1120
|
+
export function expectedInputsFilePath(contextGraphPath, stageId) {
|
|
1121
|
+
return contextGraphRuntimeStageExpectedInputsPath(contextGraphPath, stageId);
|
|
1122
|
+
}
|
|
1123
|
+
export function loadStageInputsForManifest(contextGraphPath, stageId) {
|
|
1124
|
+
return loadBuildStageInputs(contextGraphPath, stageId);
|
|
1125
|
+
}
|