@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.
Files changed (234) hide show
  1. package/README.md +122 -226
  2. package/dist/cli/commands/agents.js +1 -32
  3. package/dist/cli/commands/benchmark.d.ts +2 -3
  4. package/dist/cli/commands/benchmark.js +1 -31
  5. package/dist/cli/commands/build-plan.js +26 -50
  6. package/dist/cli/commands/build.d.ts +2 -3
  7. package/dist/cli/commands/build.js +1 -31
  8. package/dist/cli/commands/graphs.js +177 -32
  9. package/dist/cli/commands/mcp.d.ts +1 -0
  10. package/dist/cli/commands/mcp.js +223 -126
  11. package/dist/cli/commands/project.js +10 -36
  12. package/dist/cli/commands/reset.d.ts +2 -3
  13. package/dist/cli/commands/reset.js +1 -22
  14. package/dist/cli/commands/runs.js +86 -33
  15. package/dist/cli/commands/status.js +3 -24
  16. package/dist/cli/commands/traces.js +1 -29
  17. package/dist/cli/commands/wizard.js +17 -29
  18. package/dist/cli/lib/http-client.d.ts +39 -0
  19. package/dist/cli/lib/http-client.js +73 -0
  20. package/dist/packages/build-plans/authoring/brief.d.ts +25 -4
  21. package/dist/packages/build-plans/authoring/build-plan-authoring.d.ts +42 -1
  22. package/dist/packages/build-plans/authoring/build-plan-authoring.js +470 -63
  23. package/dist/packages/build-plans/authoring/build-plan-edit-session.d.ts +9 -0
  24. package/dist/packages/build-plans/authoring/build-plan-edit-session.js +27 -10
  25. package/dist/packages/build-plans/authoring/build-plan-improvement.js +62 -8
  26. package/dist/packages/build-plans/authoring/lib/build-plan-edit-utils.d.ts +1 -0
  27. package/dist/packages/build-plans/package/build-plan-definitions.d.ts +0 -1
  28. package/dist/packages/build-plans/package/build-plan-definitions.js +5 -3
  29. package/dist/packages/build-plans/package/build-plan-stage-runner.d.ts +1 -0
  30. package/dist/packages/build-plans/package/build-plan-stage-runner.js +2 -1
  31. package/dist/packages/build-plans/package/builtin-build-plan.d.ts +2 -2
  32. package/dist/packages/build-plans/package/builtin-build-plan.js +3 -3
  33. package/dist/packages/build-plans/package/context-interface.d.ts +3 -0
  34. package/dist/packages/build-plans/package/context-interface.js +5 -5
  35. package/dist/packages/build-plans/package/interf-build-plan-package.js +22 -22
  36. package/dist/packages/build-plans/package/local-build-plans.d.ts +10 -5
  37. package/dist/packages/build-plans/package/local-build-plans.js +57 -32
  38. package/dist/packages/contracts/index.d.ts +4 -3
  39. package/dist/packages/contracts/index.js +2 -1
  40. package/dist/packages/contracts/lib/context-graph-layer.d.ts +161 -0
  41. package/dist/packages/contracts/lib/context-graph-layer.js +216 -0
  42. package/dist/packages/contracts/lib/project-paths.d.ts +7 -0
  43. package/dist/packages/contracts/lib/project-paths.js +9 -0
  44. package/dist/packages/contracts/lib/project-schema.d.ts +264 -1
  45. package/dist/packages/contracts/lib/project-schema.js +38 -13
  46. package/dist/packages/contracts/lib/schema.d.ts +556 -23
  47. package/dist/packages/contracts/lib/schema.js +279 -18
  48. package/dist/packages/contracts/utils/filesystem.d.ts +1 -0
  49. package/dist/packages/contracts/utils/filesystem.js +29 -1
  50. package/dist/packages/projects/lib/schema.d.ts +6 -8
  51. package/dist/packages/projects/lib/schema.js +3 -1
  52. package/dist/packages/projects/source-config.d.ts +0 -5
  53. package/dist/packages/projects/source-config.js +9 -22
  54. package/dist/packages/runtime/actions/fields.d.ts +4 -0
  55. package/dist/packages/runtime/actions/form-builders.js +79 -31
  56. package/dist/packages/runtime/actions/form-validators.js +9 -3
  57. package/dist/packages/runtime/actions/helpers.js +3 -3
  58. package/dist/packages/runtime/actions/registry.d.ts +1 -1
  59. package/dist/packages/runtime/actions/registry.js +1 -1
  60. package/dist/packages/runtime/actions/requests.d.ts +1 -1
  61. package/dist/packages/runtime/actions/requests.js +12 -6
  62. package/dist/packages/runtime/actions/schemas.d.ts +7 -0
  63. package/dist/packages/runtime/actions/schemas.js +1 -0
  64. package/dist/packages/runtime/agent-handoff.js +8 -7
  65. package/dist/packages/runtime/agents/lib/execution-profile.d.ts +14 -0
  66. package/dist/packages/runtime/agents/lib/execution-profile.js +23 -0
  67. package/dist/packages/runtime/agents/lib/execution.js +14 -8
  68. package/dist/packages/runtime/agents/lib/executors.d.ts +1 -0
  69. package/dist/packages/runtime/agents/lib/executors.js +11 -2
  70. package/dist/packages/runtime/agents/lib/logs.d.ts +10 -0
  71. package/dist/packages/runtime/agents/lib/logs.js +32 -8
  72. package/dist/packages/runtime/agents/lib/preflight.js +4 -1
  73. package/dist/packages/runtime/agents/lib/render.d.ts +18 -0
  74. package/dist/packages/runtime/agents/lib/render.js +44 -18
  75. package/dist/packages/runtime/agents/lib/shell-templates.js +105 -63
  76. package/dist/packages/runtime/agents/lib/shells.d.ts +29 -0
  77. package/dist/packages/runtime/agents/lib/shells.js +158 -32
  78. package/dist/packages/runtime/agents/lib/source-context-scan.d.ts +10 -0
  79. package/dist/packages/runtime/agents/lib/source-context-scan.js +388 -0
  80. package/dist/packages/runtime/agents/lib/status.js +1 -14
  81. package/dist/packages/runtime/agents/lib/string-utils.d.ts +16 -0
  82. package/dist/packages/runtime/agents/lib/string-utils.js +36 -0
  83. package/dist/packages/runtime/agents/lib/types.d.ts +1 -0
  84. package/dist/packages/runtime/agents/providers/codex.js +2 -0
  85. package/dist/packages/runtime/agents/role-executors.js +2 -1
  86. package/dist/packages/runtime/auth/session-store.js +11 -3
  87. package/dist/packages/runtime/benchmark-question-draft.d.ts +3 -0
  88. package/dist/packages/runtime/benchmark-question-draft.js +57 -28
  89. package/dist/packages/runtime/build/artifact-status.d.ts +1 -1
  90. package/dist/packages/runtime/build/artifact-status.js +1 -1
  91. package/dist/packages/runtime/build/build-evidence.d.ts +2 -1
  92. package/dist/packages/runtime/build/build-evidence.js +11 -5
  93. package/dist/packages/runtime/build/build-pipeline.js +89 -5
  94. package/dist/packages/runtime/build/build-stage-plan.js +3 -1
  95. package/dist/packages/runtime/build/build-stage-runner.js +169 -32
  96. package/dist/packages/runtime/build/build-target.d.ts +3 -0
  97. package/dist/packages/runtime/build/build-target.js +25 -1
  98. package/dist/packages/runtime/build/check-evaluator.d.ts +1 -1
  99. package/dist/packages/runtime/build/check-evaluator.js +655 -4
  100. package/dist/packages/runtime/build/context-graph-paths.d.ts +13 -0
  101. package/dist/packages/runtime/build/context-graph-paths.js +27 -0
  102. package/dist/packages/runtime/build/index.d.ts +2 -2
  103. package/dist/packages/runtime/build/index.js +2 -2
  104. package/dist/packages/runtime/build/inspect-map.d.ts +10 -0
  105. package/dist/packages/runtime/build/inspect-map.js +270 -0
  106. package/dist/packages/runtime/build/lib/schema.d.ts +246 -53
  107. package/dist/packages/runtime/build/lib/schema.js +173 -15
  108. package/dist/packages/runtime/build/native-entrypoint.d.ts +2 -0
  109. package/dist/packages/runtime/build/native-entrypoint.js +286 -0
  110. package/dist/packages/runtime/build/runtime-contracts.js +9 -3
  111. package/dist/packages/runtime/build/runtime-log-paths.d.ts +3 -0
  112. package/dist/packages/runtime/build/runtime-log-paths.js +16 -0
  113. package/dist/packages/runtime/build/runtime-prompt.js +6 -4
  114. package/dist/packages/runtime/build/runtime-runs.js +63 -10
  115. package/dist/packages/runtime/build/runtime-types.d.ts +4 -1
  116. package/dist/packages/runtime/build/runtime.d.ts +3 -1
  117. package/dist/packages/runtime/build/runtime.js +3 -1
  118. package/dist/packages/runtime/build/source-files.js +11 -2
  119. package/dist/packages/runtime/build/source-inventory.d.ts +1 -0
  120. package/dist/packages/runtime/build/source-inventory.js +246 -7
  121. package/dist/packages/runtime/build/source-manifest.d.ts +11 -0
  122. package/dist/packages/runtime/build/source-manifest.js +30 -2
  123. package/dist/packages/runtime/build/stage-evidence.js +80 -11
  124. package/dist/packages/runtime/build/stage-manifest.d.ts +45 -0
  125. package/dist/packages/runtime/build/stage-manifest.js +1125 -0
  126. package/dist/packages/runtime/build/stage-reuse.js +12 -0
  127. package/dist/packages/runtime/build/stage-session.d.ts +81 -0
  128. package/dist/packages/runtime/build/stage-session.js +308 -0
  129. package/dist/packages/runtime/build/state-io.js +10 -11
  130. package/dist/packages/runtime/build/state-view.js +1 -1
  131. package/dist/packages/runtime/build/state.d.ts +1 -1
  132. package/dist/packages/runtime/build/state.js +1 -1
  133. package/dist/packages/runtime/build/summary-coverage-index.d.ts +21 -0
  134. package/dist/packages/runtime/build/summary-coverage-index.js +189 -0
  135. package/dist/packages/runtime/build/traces.js +3 -3
  136. package/dist/packages/runtime/build/validate-context-graph.d.ts +1 -1
  137. package/dist/packages/runtime/build/validate-context-graph.js +5 -5
  138. package/dist/packages/runtime/build/validate.d.ts +1 -1
  139. package/dist/packages/runtime/build/validate.js +1 -1
  140. package/dist/packages/runtime/client.d.ts +3 -3
  141. package/dist/packages/runtime/client.js +8 -13
  142. package/dist/packages/runtime/context-checks.js +13 -0
  143. package/dist/packages/runtime/context-graph-scaffold.js +2 -1
  144. package/dist/packages/runtime/context-graph-semantic-graph.d.ts +9 -0
  145. package/dist/packages/runtime/context-graph-semantic-graph.js +416 -0
  146. package/dist/packages/runtime/execution/lib/schema.d.ts +34 -31
  147. package/dist/packages/runtime/index.d.ts +2 -2
  148. package/dist/packages/runtime/index.js +1 -1
  149. package/dist/packages/runtime/native-run-handlers.d.ts +38 -0
  150. package/dist/packages/runtime/native-run-handlers.js +52 -33
  151. package/dist/packages/runtime/plan-artifact-contract.js +1 -1
  152. package/dist/packages/runtime/project-source-state.d.ts +4 -4
  153. package/dist/packages/runtime/project-source-state.js +5 -2
  154. package/dist/packages/runtime/project-store.d.ts +5 -0
  155. package/dist/packages/runtime/project-store.js +30 -3
  156. package/dist/packages/runtime/requested-artifacts.js +1 -1
  157. package/dist/packages/runtime/run-observability.js +9 -4
  158. package/dist/packages/runtime/runtime-action-proposals.js +3 -3
  159. package/dist/packages/runtime/runtime-build-plans.js +47 -3
  160. package/dist/packages/runtime/runtime-build-runs.js +9 -16
  161. package/dist/packages/runtime/runtime-caches.d.ts +26 -0
  162. package/dist/packages/runtime/runtime-caches.js +47 -0
  163. package/dist/packages/runtime/runtime-jobs.js +6 -6
  164. package/dist/packages/runtime/runtime-project-mutations.js +1 -0
  165. package/dist/packages/runtime/runtime-project-reads.d.ts +4 -1
  166. package/dist/packages/runtime/runtime-project-reads.js +229 -36
  167. package/dist/packages/runtime/runtime-proposal-helpers.js +6 -6
  168. package/dist/packages/runtime/runtime-resource-builders.d.ts +4 -2
  169. package/dist/packages/runtime/runtime-resource-builders.js +16 -14
  170. package/dist/packages/runtime/runtime-status.d.ts +14 -0
  171. package/dist/packages/runtime/runtime-status.js +15 -0
  172. package/dist/packages/runtime/runtime-verify-runs.js +6 -5
  173. package/dist/packages/runtime/runtime.d.ts +439 -22
  174. package/dist/packages/runtime/runtime.js +16 -2
  175. package/dist/packages/runtime/schemas/actions.d.ts +24 -0
  176. package/dist/packages/runtime/schemas/agents.d.ts +28 -0
  177. package/dist/packages/runtime/schemas/agents.js +33 -0
  178. package/dist/packages/runtime/schemas/build-plans.d.ts +181 -8
  179. package/dist/packages/runtime/schemas/build-plans.js +36 -2
  180. package/dist/packages/runtime/schemas/context-graphs.d.ts +1522 -0
  181. package/dist/packages/runtime/schemas/context-graphs.js +110 -0
  182. package/dist/packages/runtime/schemas/files.d.ts +7 -347
  183. package/dist/packages/runtime/schemas/files.js +1 -24
  184. package/dist/packages/runtime/schemas/index.d.ts +1 -0
  185. package/dist/packages/runtime/schemas/index.js +1 -0
  186. package/dist/packages/runtime/schemas/jobs.js +4 -0
  187. package/dist/packages/runtime/schemas/projects.d.ts +48 -21
  188. package/dist/packages/runtime/schemas/projects.js +34 -10
  189. package/dist/packages/runtime/schemas/runs.d.ts +1009 -240
  190. package/dist/packages/runtime/schemas/runs.js +17 -0
  191. package/dist/packages/runtime/service/openapi.js +1 -0
  192. package/dist/packages/runtime/service/operations.d.ts +1666 -145
  193. package/dist/packages/runtime/service/operations.js +147 -17
  194. package/dist/packages/runtime/service/routes.d.ts +11 -3
  195. package/dist/packages/runtime/service/routes.js +11 -3
  196. package/dist/packages/runtime/service/server-app-boot.js +2 -2
  197. package/dist/packages/runtime/service/server-helpers.d.ts +11 -0
  198. package/dist/packages/runtime/service/server-helpers.js +19 -0
  199. package/dist/packages/runtime/service/server-routes-action-proposals.js +4 -2
  200. package/dist/packages/runtime/service/server-routes-agents.js +19 -85
  201. package/dist/packages/runtime/service/server-routes-build-plans.js +14 -11
  202. package/dist/packages/runtime/service/server-routes-project-context.js +102 -7
  203. package/dist/packages/runtime/service/server-routes-project-jobs.js +19 -12
  204. package/dist/packages/runtime/service/server-routes-project-runs.js +5 -2
  205. package/dist/packages/runtime/service/server-routes-projects.js +6 -2
  206. package/dist/packages/runtime/service/server-routes-runs.js +11 -4
  207. package/dist/packages/runtime/verify/lib/schema.js +12 -0
  208. package/dist/packages/runtime/verify/test-file-guard.d.ts +2 -0
  209. package/dist/packages/runtime/verify/test-file-guard.js +29 -0
  210. package/dist/packages/runtime/verify/verify-execution.d.ts +7 -0
  211. package/dist/packages/runtime/verify/verify-execution.js +109 -35
  212. package/dist/packages/runtime/verify/verify-paths.d.ts +1 -0
  213. package/dist/packages/runtime/verify/verify-paths.js +4 -0
  214. package/dist/packages/runtime/verify/verify-specs.js +49 -39
  215. package/dist/packages/runtime/wire-schemas.d.ts +1 -1
  216. package/dist/packages/runtime/wire-schemas.js +1 -1
  217. package/package.json +2 -8
  218. package/public-repo/CONTRIBUTING.md +10 -3
  219. package/public-repo/README.md +122 -226
  220. package/public-repo/build-plans/interf-default/README.md +15 -12
  221. package/public-repo/build-plans/interf-default/build/stages/entrypoint/SKILL.md +74 -0
  222. package/public-repo/build-plans/interf-default/build/stages/knowledge/SKILL.md +95 -0
  223. package/public-repo/build-plans/interf-default/build/stages/summarize/SKILL.md +38 -5
  224. package/public-repo/build-plans/interf-default/build-plan.json +27 -23
  225. package/public-repo/build-plans/interf-default/build-plan.schema.json +24 -20
  226. package/public-repo/build-plans/interf-default/use/query/SKILL.md +8 -7
  227. package/public-repo/openapi/local-service.openapi.json +11637 -4213
  228. package/public-repo/skills/interf/SKILL.md +174 -134
  229. package/dist/packages/runtime/build/runtime-paths.d.ts +0 -8
  230. package/dist/packages/runtime/build/runtime-paths.js +0 -26
  231. package/dist/packages/runtime/build/state-paths.d.ts +0 -7
  232. package/dist/packages/runtime/build/state-paths.js +0 -22
  233. package/public-repo/build-plans/interf-default/build/stages/shape/SKILL.md +0 -34
  234. package/public-repo/build-plans/interf-default/build/stages/structure/SKILL.md +0 -28
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import { listFilesRecursive } from "../../contracts/utils/filesystem.js";
4
4
  import { parseJsonFrontmatter } from "../../contracts/utils/parse.js";
5
+ import { CANONICAL_LAYER_DIRS, HOME_SPINE_FILE, graphRelativePathPattern, } from "../../contracts/lib/context-graph-layer.js";
5
6
  import { CheckKindSchema, SourceManifestSchema, } from "../../contracts/lib/schema.js";
6
7
  import { countBrokenWikilinks, isOutputMarkdownFile, validateSynthFiles, } from "./validate.js";
7
8
  /**
@@ -143,6 +144,464 @@ function sourceSummaryCoverage(absolutePath, manifest, options) {
143
144
  extraSummaryFolders: [...evidenceDirs].filter((dir) => !matchedDirs.has(dir)).sort((left, right) => left.localeCompare(right)),
144
145
  };
145
146
  }
147
+ const WIKILINK_TARGET_PATTERN = /\[\[([^[\]\n]+)\]\]/g;
148
+ const MARKDOWN_LINK_TARGET_PATTERN = /!?\[[^\]\n]*\]\(([^)\n]+)\)/g;
149
+ function normalizeGraphPath(value) {
150
+ return value.replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/\.md$/i, "").replace(/\/+$/g, "");
151
+ }
152
+ /**
153
+ * Like normalizeGraphPath but preserves a trailing `.md` — summary folders are
154
+ * named after the source file and legitimately end in `.md` (e.g.
155
+ * `summaries/meeting-notes.md/`). Stripping it would split the folder identity.
156
+ */
157
+ function normalizeFolderPath(value) {
158
+ return value.replaceAll("\\", "/").replace(/^\.\/+/, "").replace(/\/+$/g, "");
159
+ }
160
+ /**
161
+ * Wikilink targets referenced by a single note, normalized to graph-relative
162
+ * basenames (no `.md`, no `#anchor`, no `|alias`). Source-agnostic: reads only
163
+ * the `[[...]]` syntax, never a task taxonomy.
164
+ */
165
+ function noteWikilinkTargets(content) {
166
+ const targets = [];
167
+ for (const match of content.matchAll(WIKILINK_TARGET_PATTERN)) {
168
+ const raw = match[1]?.split("|")[0]?.split("#")[0]?.trim();
169
+ if (raw)
170
+ targets.push(normalizeGraphPath(raw));
171
+ }
172
+ return targets;
173
+ }
174
+ /**
175
+ * Every link token a note references, across the three link forms the
176
+ * StageManifest's `parseLinks` reads: `[[wikilinks]]`, `[markdown](relative.md)`
177
+ * links (http(s) excluded), and bare graph-relative path mentions in body text
178
+ * (`knowledge/foo`, via the shared `graphRelativePathPattern` so the scanner and
179
+ * the layer model can never list different folders). Each token is normalized to
180
+ * a graph-relative path with no `.md`, anchor, or alias. Reusing the same link
181
+ * surface the manifest uses is what keeps the Check and the manifest rollup in
182
+ * lockstep on which notes are web-connected.
183
+ */
184
+ function noteLinkTargets(content) {
185
+ const targets = new Set();
186
+ for (const target of noteWikilinkTargets(content))
187
+ targets.add(target);
188
+ for (const match of content.matchAll(MARKDOWN_LINK_TARGET_PATTERN)) {
189
+ const raw = match[1]?.split("#")[0]?.trim();
190
+ if (!raw || /^https?:\/\//i.test(raw))
191
+ continue;
192
+ targets.add(normalizeGraphPath(raw));
193
+ }
194
+ for (const match of content.matchAll(graphRelativePathPattern())) {
195
+ const raw = match[1]?.split("#")[0]?.trim().replace(/[),.;:]+$/g, "");
196
+ if (raw)
197
+ targets.add(normalizeGraphPath(raw));
198
+ }
199
+ return [...targets].filter((target) => target.length > 0);
200
+ }
201
+ /**
202
+ * Resolve a single link token to the knowledge note it names, or report that it
203
+ * is ambiguous. Mirrors the basename-aliasing discipline `existingSummaryFolderSet`
204
+ * already uses in this file: a full graph-path always resolves (paths are unique
205
+ * within the subtree); a token ending `/<basename>` resolves to the unique note
206
+ * with that path suffix; a BARE basename resolves ONLY when exactly one note
207
+ * carries it. A bare basename two or more notes share (e.g. `[[claim]]` for both
208
+ * `topics/claim` and `entities/claim`) is `ambiguous` — it must NOT credit a web
209
+ * edge to one arbitrarily, which would silently connect the linker and hide the
210
+ * other namesake's island. Returns the matched note, `ambiguous`, or `null`.
211
+ */
212
+ function resolveLinkToNote(token, byGraphPath, byBasename, ambiguousBasenames) {
213
+ const clean = normalizeGraphPath(token);
214
+ if (clean.length === 0)
215
+ return null;
216
+ const exact = byGraphPath.get(clean);
217
+ if (exact)
218
+ return exact;
219
+ if (clean.includes("/")) {
220
+ // A path-shaped token: credit a note whose full path is the token's trailing
221
+ // segments. Unique by construction (graph paths are unique), so no ambiguity.
222
+ const suffix = basename(clean);
223
+ for (const note of byGraphPath.values()) {
224
+ if (clean.endsWith(`/${note.base}`) && note.base === suffix && clean.endsWith(note.graphPath)) {
225
+ return note;
226
+ }
227
+ }
228
+ return null;
229
+ }
230
+ // A bare basename: resolves only when unambiguous; a shared basename is a gap.
231
+ if (ambiguousBasenames.has(clean))
232
+ return "ambiguous";
233
+ return byBasename.get(clean) ?? null;
234
+ }
235
+ /**
236
+ * Resolve a note-relative link token (`./x`, `../x`) against the linking note's
237
+ * directory into an absolute graph path, so a relative wikilink credits a web
238
+ * edge the same way the wikilink validator resolves it. Without this, a note
239
+ * that links `[[../launch]]` is falsely scored a disconnected island. Non-relative
240
+ * tokens (full graph paths, bare basenames) are returned untouched for the global
241
+ * lookup.
242
+ */
243
+ function resolveRelativeGraphPath(fromGraphPath, token) {
244
+ const lastSlash = fromGraphPath.lastIndexOf("/");
245
+ const segments = lastSlash >= 0 ? fromGraphPath.slice(0, lastSlash).split("/") : [];
246
+ for (const part of token.split("/")) {
247
+ if (part === "" || part === ".")
248
+ continue;
249
+ if (part === "..") {
250
+ segments.pop();
251
+ continue;
252
+ }
253
+ segments.push(part);
254
+ }
255
+ return normalizeGraphPath(segments.join("/"));
256
+ }
257
+ /**
258
+ * Markdown notes that form the CONTENT of a Context Graph — the canonical content
259
+ * layers (`summaries/`, `knowledge/`, `artifacts/`) plus the `home.md` spine —
260
+ * resolved to absolute file paths. Deliberately EXCLUDES the runtime scaffolding
261
+ * that also lives under the graph root (`.interf/`, `.claude/`, `.agents/`,
262
+ * skill `SKILL.md` docs, `CLAUDE.md`, `AGENTS.md`, view specs): those are not
263
+ * graph notes and must never be scored for web connectivity — they are always
264
+ * "islands" and several share the basename `SKILL`, which would inject false
265
+ * ambiguity. This mirrors the dot-entry skipping the semantic-graph builder
266
+ * already does, and keys off the central `CANONICAL_LAYER_DIRS` / `HOME_SPINE_FILE`
267
+ * so the connectivity floor and the layer model never list different folders.
268
+ */
269
+ function collectGraphContentNotes(graphRoot) {
270
+ const files = [];
271
+ for (const layer of CANONICAL_LAYER_DIRS) {
272
+ files.push(...listMarkdownFiles(join(graphRoot, layer)));
273
+ }
274
+ const home = join(graphRoot, HOME_SPINE_FILE);
275
+ if (existsSync(home) && isOutputMarkdownFile(home))
276
+ files.push(home);
277
+ return files;
278
+ }
279
+ /**
280
+ * Note-web connectivity over a pre-collected note set — the filesystem-side
281
+ * mirror of the StageManifest's `knowledgeWebConnectivity`. Each file in `noteFiles`
282
+ * IS a note in the web (the caller chooses the scope and the file set: the
283
+ * knowledge layer for `knowledge_web_connectivity`, the canonical CONTENT layers
284
+ * for `graph_notes_connected`), so no layer re-derivation is needed here. A note
285
+ * is web-connected when it links a DIFFERENT note in the same set OR is linked
286
+ * by one (UNDIRECTED, degree ≥ 1); degree 0 is a disconnected island.
287
+ * Connectedness, not a count. Vacuous pass: ≤ 1 note cannot form a web, so it is
288
+ * reported connected with no islands. Edges and matching reuse the same link
289
+ * surface and basename-aliasing rule the manifest and the backlink check use, so
290
+ * the gates agree on islands.
291
+ */
292
+ function analyzeNoteWeb(noteFiles, context) {
293
+ const root = resolve(context.rootPath);
294
+ const notes = noteFiles.map((file) => {
295
+ const graphPath = normalizeGraphPath(relative(root, file).replaceAll("\\", "/"));
296
+ return { file, graphPath, base: basename(graphPath) };
297
+ });
298
+ if (notes.length <= 1) {
299
+ return { notes: notes.length, connected: notes.length, islands: [], ambiguousLinkNotes: [] };
300
+ }
301
+ // Full path always keys a note; a basename keys a note only when unique. A
302
+ // basename two or more notes share is ambiguous and never resolves a bare link.
303
+ const byGraphPath = new Map();
304
+ const basenameCounts = new Map();
305
+ for (const note of notes) {
306
+ byGraphPath.set(note.graphPath, note);
307
+ basenameCounts.set(note.base, (basenameCounts.get(note.base) ?? 0) + 1);
308
+ }
309
+ const byBasename = new Map();
310
+ const ambiguousBasenames = new Set();
311
+ for (const note of notes) {
312
+ if ((basenameCounts.get(note.base) ?? 0) > 1) {
313
+ ambiguousBasenames.add(note.base);
314
+ continue;
315
+ }
316
+ byBasename.set(note.base, note);
317
+ }
318
+ const hasOutbound = new Set();
319
+ const hasInbound = new Set();
320
+ const ambiguousLinkNotes = new Set();
321
+ for (const from of notes) {
322
+ for (const token of noteLinkTargets(readFileSync(from.file, "utf8"))) {
323
+ const candidate = token.startsWith("../") || token.startsWith("./")
324
+ ? resolveRelativeGraphPath(from.graphPath, token)
325
+ : token;
326
+ const resolved = resolveLinkToNote(candidate, byGraphPath, byBasename, ambiguousBasenames);
327
+ if (resolved === "ambiguous") {
328
+ ambiguousLinkNotes.add(from.graphPath);
329
+ continue;
330
+ }
331
+ if (!resolved || resolved.graphPath === from.graphPath)
332
+ continue;
333
+ hasOutbound.add(from.graphPath);
334
+ hasInbound.add(resolved.graphPath);
335
+ }
336
+ }
337
+ const islands = notes
338
+ .filter((note) => !hasOutbound.has(note.graphPath) && !hasInbound.has(note.graphPath))
339
+ .map((note) => note.graphPath)
340
+ .sort((left, right) => left.localeCompare(right));
341
+ return {
342
+ notes: notes.length,
343
+ connected: notes.length - islands.length,
344
+ islands,
345
+ ambiguousLinkNotes: [...ambiguousLinkNotes].sort((left, right) => left.localeCompare(right)),
346
+ };
347
+ }
348
+ /**
349
+ * Source refs a note declares, drawn from frontmatter source keys and from any
350
+ * literal source path mentioned in the body. Mirrors the StageManifest reader so
351
+ * the check and the manifest agree on what a note "cites".
352
+ */
353
+ function noteSourceRefs(content, frontmatter, knownSourcePaths) {
354
+ const refs = new Set();
355
+ for (const key of ["source_refs", "source_ref", "source_path", "source"]) {
356
+ const value = frontmatter[key];
357
+ if (typeof value === "string" && value.trim().length > 0)
358
+ refs.add(value.trim());
359
+ if (Array.isArray(value)) {
360
+ for (const entry of value) {
361
+ if (typeof entry === "string" && entry.trim().length > 0)
362
+ refs.add(entry.trim());
363
+ }
364
+ }
365
+ }
366
+ for (const sourcePath of knownSourcePaths) {
367
+ if (content.includes(sourcePath))
368
+ refs.add(sourcePath);
369
+ }
370
+ return [...refs];
371
+ }
372
+ /**
373
+ * Resolve which summary folders actually exist on disk (folders under the
374
+ * summaries directory that hold a summary or manifest file). Returns a lookup
375
+ * from any reasonable reference form — the folder's graph-relative path and its
376
+ * basename — to the canonical folder path, plus the set of basenames that more
377
+ * than one folder carries. Reuses the same summary/manifest evidence convention
378
+ * the summary-coverage check uses.
379
+ *
380
+ * A basename alias is registered ONLY when that basename is unambiguous across
381
+ * summary folders. When two folders share a basename (e.g. `dept/report.pdf`
382
+ * and `legal/report.pdf` both basename `report.pdf`), aliasing one of them
383
+ * would silently resolve a basename-only ref to the wrong folder and HIDE a
384
+ * real orphan. Ambiguous basenames are left out of the `lookup` so a
385
+ * basename-only ref to them never resolves to one arbitrary folder; the
386
+ * `ambiguousBasenames` set lets the caller treat such a ref as an unresolved
387
+ * gap to surface rather than a silent pass. Full-path refs always resolve
388
+ * regardless of basename collisions.
389
+ */
390
+ function existingSummaryFolderSet(summariesAbsolutePath, options) {
391
+ const lookup = new Map();
392
+ const evidenceDirs = summaryDirectoriesWithEvidence(summariesAbsolutePath, options);
393
+ const folders = [];
394
+ const basenameCounts = new Map();
395
+ for (const dir of evidenceDirs) {
396
+ const folder = normalizeFolderPath(dir);
397
+ if (folder.length === 0)
398
+ continue;
399
+ folders.push(folder);
400
+ // Full-path key always wins; it is unique per folder.
401
+ lookup.set(folder, folder);
402
+ const base = basename(folder);
403
+ if (base)
404
+ basenameCounts.set(base, (basenameCounts.get(base) ?? 0) + 1);
405
+ }
406
+ const ambiguousBasenames = new Set();
407
+ // Second pass: alias a basename to its folder only when exactly one folder
408
+ // carries that basename. Skip any basename that already collides with a
409
+ // full-path key (a folder literally named like another folder's basename) —
410
+ // the path key must not be shadowed.
411
+ for (const folder of folders) {
412
+ const base = basename(folder);
413
+ if (!base || base === folder)
414
+ continue;
415
+ if ((basenameCounts.get(base) ?? 0) > 1) {
416
+ ambiguousBasenames.add(base);
417
+ continue;
418
+ }
419
+ if (lookup.has(base))
420
+ continue;
421
+ lookup.set(base, folder);
422
+ }
423
+ return { lookup, ambiguousBasenames };
424
+ }
425
+ /**
426
+ * Whether one normalized graph path contains another at a path-segment (or
427
+ * trailing-delimiter) boundary, not mid-segment. `decks/q3.pptx` contains
428
+ * `decks/q3.pptx/pages/3` and `decks/q3.pptx#page=25`, but `decks/q3.pptx` does
429
+ * NOT contain `decks/q3.pptx-archive` — the suffix must begin at a `/` or `#`
430
+ * boundary. Source-agnostic: pure string-shape, no task taxonomy.
431
+ */
432
+ function containsAtBoundary(container, inner) {
433
+ if (inner.length === 0 || container.length < inner.length)
434
+ return false;
435
+ const index = container.indexOf(inner);
436
+ if (index < 0)
437
+ return false;
438
+ // Inner must start at a segment boundary (path start or right after a "/").
439
+ if (index > 0 && container[index - 1] !== "/")
440
+ return false;
441
+ // Inner must end at a segment boundary (path end, or before a "/" / "#"
442
+ // anchor). Anything else (e.g. "q3.pptx" inside "q3.pptx-archive") is a
443
+ // mid-segment false match.
444
+ const after = container[index + inner.length];
445
+ return after === undefined || after === "/" || after === "#";
446
+ }
447
+ /**
448
+ * Map an arbitrary source ref to the summary-folder names that could hold its
449
+ * summary. Generic across any Source: a ref like `decks/q3.pptx` matches the
450
+ * `decks/q3.pptx` folder, the basename `q3.pptx` (when unambiguous), or the
451
+ * manifest file id. Never hardcodes a task or filename.
452
+ */
453
+ function summaryFolderCandidatesForRef(ref, manifest, basenameCounts) {
454
+ const normalizedRef = ref.replaceAll("\\", "/");
455
+ const normalized = normalizeFolderPath(ref).replace(/^summaries\//, "");
456
+ const candidates = new Set();
457
+ candidates.add(normalized);
458
+ const base = basename(normalized);
459
+ if (base)
460
+ candidates.add(base);
461
+ // Resolve through the Source Manifest so a page-level or partial ref still
462
+ // points at the file-level summary folder. Match only at path-segment
463
+ // boundaries: a bare substring test over-matches (`q3` would hit
464
+ // `q3-archive`), crediting the wrong summary and hiding a real orphan.
465
+ for (const file of manifest?.files ?? []) {
466
+ const filePath = file.path.replaceAll("\\", "/");
467
+ if (containsAtBoundary(normalizedRef, filePath) ||
468
+ containsAtBoundary(filePath, normalized) ||
469
+ containsAtBoundary(normalized, filePath)) {
470
+ candidates.add(filePath);
471
+ if (basenameCounts.get(basename(filePath)) === 1)
472
+ candidates.add(basename(filePath));
473
+ candidates.add(file.id);
474
+ }
475
+ }
476
+ return [...candidates].filter((candidate) => candidate.length > 0);
477
+ }
478
+ /**
479
+ * Orphaned-summary analysis for a knowledge-style layer: a summary folder that is
480
+ * cited by some note's source_refs but is wikilinked by no note in the layer.
481
+ * Fully generic — derives expected backlinks from the notes' own refs and the
482
+ * Source Manifest, with no project-specific or task-specific input.
483
+ */
484
+ function analyzeSummaryBacklinks(layerDir, context, options) {
485
+ const root = resolve(context.rootPath);
486
+ const manifest = loadSourceManifestForCheck(context).manifest;
487
+ const knownSourcePaths = manifest?.files.map((file) => file.path.replaceAll("\\", "/")) ?? [];
488
+ const basenameCounts = new Map();
489
+ for (const path of knownSourcePaths) {
490
+ const base = basename(path);
491
+ basenameCounts.set(base, (basenameCounts.get(base) ?? 0) + 1);
492
+ }
493
+ // Summary folders that actually exist on disk. You can only orphan a summary
494
+ // that exists — a note citing a source with no summary folder is not a
495
+ // backlink violation (there is nothing to link to). The lookup keys a folder
496
+ // by its full path and, only when unambiguous, its basename; ambiguousBasenames
497
+ // names the basenames carried by more than one folder.
498
+ const { lookup: existingSummaryFolders, ambiguousBasenames } = existingSummaryFolderSet(join(root, options.summariesDir), { summaryFile: options.summaryFile, manifestFile: options.manifestFile });
499
+ // Resolve a ref to a canonical summary folder. Three outcomes:
500
+ // "resolved" — a candidate matched an existing folder.
501
+ // "ambiguous" — nothing matched, but a basename candidate collides with two
502
+ // or more existing folders, so we refuse to pick one. This is a
503
+ // gap to surface, not a clean miss.
504
+ // "absent" — nothing matched and no summary folder exists for the ref.
505
+ const resolveCitedFolder = (ref) => {
506
+ const candidates = summaryFolderCandidatesForRef(ref, manifest, basenameCounts);
507
+ for (const candidate of candidates) {
508
+ const folder = existingSummaryFolders.get(candidate);
509
+ if (folder)
510
+ return { kind: "resolved", folder };
511
+ }
512
+ if (candidates.some((candidate) => ambiguousBasenames.has(candidate))) {
513
+ return { kind: "ambiguous" };
514
+ }
515
+ return { kind: "absent" };
516
+ };
517
+ // Set of every summary-folder backlink wikilink target present anywhere in the
518
+ // layer, normalized to the folder name (drop the trailing summary/manifest leaf).
519
+ const linkedSummaries = new Set();
520
+ const summaryLeaf = normalizeGraphPath(options.summaryFile);
521
+ const manifestLeaf = normalizeGraphPath(options.manifestFile);
522
+ const citedSummaryToNotes = new Map();
523
+ const notesWithUnlinkedCitations = new Set();
524
+ const notesWithAmbiguousCitations = new Set();
525
+ let notesScanned = 0;
526
+ const notes = listMarkdownFiles(layerDir);
527
+ // First pass: every existing summary folder linked anywhere in the layer,
528
+ // resolved to its canonical folder name so basename and path links agree. A
529
+ // wikilink is a concrete graph path, so it credits a backlink ONLY when the
530
+ // target resolves to a real summary folder. `existingSummaryFolders` already
531
+ // keys both each folder's full path and its basename (the latter only when
532
+ // unambiguous), so a legitimate bare-basename link still resolves. We do NOT
533
+ // fall back to a separate basename(folderRef) lookup: that would let a broken
534
+ // wikilink — one whose folder path does not exist (e.g.
535
+ // `[[summaries/typo/q3.pptx/summary]]`) — launder through its basename and
536
+ // wrongly credit an unrelated namesake folder, hiding a real orphan.
537
+ for (const note of notes) {
538
+ const content = readFileSync(note, "utf8");
539
+ for (const target of noteWikilinkTargets(content)) {
540
+ const withinSummaries = target.startsWith(`${options.summariesDir}/`)
541
+ ? target.slice(options.summariesDir.length + 1)
542
+ : null;
543
+ if (withinSummaries === null)
544
+ continue;
545
+ const segments = withinSummaries.split("/");
546
+ const leaf = segments[segments.length - 1] ?? "";
547
+ const folderRef = leaf === summaryLeaf || leaf === manifestLeaf
548
+ ? segments.slice(0, -1).join("/")
549
+ : withinSummaries;
550
+ const folder = existingSummaryFolders.get(folderRef);
551
+ if (folder)
552
+ linkedSummaries.add(folder);
553
+ }
554
+ }
555
+ // Second pass: every existing summary a note cites, and whether it is linked.
556
+ for (const note of notes) {
557
+ notesScanned += 1;
558
+ const content = readFileSync(note, "utf8");
559
+ const parsed = parseJsonFrontmatter(content);
560
+ const frontmatter = parsed?.frontmatter ?? {};
561
+ const refs = noteSourceRefs(content, frontmatter, knownSourcePaths);
562
+ if (refs.length === 0)
563
+ continue;
564
+ const noteRel = relative(root, note).replaceAll("\\", "/");
565
+ let noteHasUnlinked = false;
566
+ let noteHasAmbiguous = false;
567
+ for (const ref of refs) {
568
+ const resolution = resolveCitedFolder(ref);
569
+ // An ambiguous basename-only citation cannot be proven backlinked; treat it
570
+ // as a surfaced gap rather than skipping it (which would hide the orphan).
571
+ if (resolution.kind === "ambiguous") {
572
+ noteHasAmbiguous = true;
573
+ continue;
574
+ }
575
+ // Only an existing summary folder can be orphaned; skip refs that point at
576
+ // a source with no summary in this graph.
577
+ if (resolution.kind === "absent")
578
+ continue;
579
+ const folder = resolution.folder;
580
+ const linked = linkedSummaries.has(folder);
581
+ const list = citedSummaryToNotes.get(folder) ?? [];
582
+ list.push(noteRel);
583
+ citedSummaryToNotes.set(folder, list);
584
+ if (!linked)
585
+ noteHasUnlinked = true;
586
+ }
587
+ if (noteHasUnlinked)
588
+ notesWithUnlinkedCitations.add(noteRel);
589
+ if (noteHasAmbiguous)
590
+ notesWithAmbiguousCitations.add(noteRel);
591
+ }
592
+ const citedSummaries = [...citedSummaryToNotes.keys()];
593
+ const orphanedSummaries = citedSummaries
594
+ .filter((summary) => !linkedSummaries.has(summary))
595
+ .sort((left, right) => left.localeCompare(right));
596
+ return {
597
+ citedSummaries,
598
+ linkedSummaries,
599
+ orphanedSummaries,
600
+ ambiguousCitations: [...notesWithAmbiguousCitations].sort((left, right) => left.localeCompare(right)),
601
+ notesWithUnlinkedCitations: [...notesWithUnlinkedCitations].sort((left, right) => left.localeCompare(right)),
602
+ notesScanned,
603
+ };
604
+ }
146
605
  function checkPhrases(check) {
147
606
  if (Array.isArray(check.params?.phrases)) {
148
607
  return check.params.phrases.filter((phrase) => typeof phrase === "string");
@@ -183,6 +642,44 @@ function collectFrontmatterFailures(files, predicate) {
183
642
  }
184
643
  return { invalid, missing };
185
644
  }
645
+ /**
646
+ * Declarative exemption clause for `source_refs_required`: `params.exempt_when`
647
+ * maps a frontmatter key to the values that exempt a note from the requirement
648
+ * (an empty value list means "any non-empty value exempts"). A note is exempt
649
+ * when it declares any matching signal — e.g. a pure index/navigation note that
650
+ * asserts nothing can opt out with `note_role: index` rather than being pushed
651
+ * to fabricate `source_refs`. This is config, not engine taxonomy: the keys and
652
+ * values live in the Build Plan, so no concept is hardcoded in the runtime.
653
+ */
654
+ function exemptWhenClause(check) {
655
+ const raw = check.params?.exempt_when;
656
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
657
+ return {};
658
+ const out = {};
659
+ for (const [key, value] of Object.entries(raw)) {
660
+ if (typeof key !== "string" || key.trim().length === 0)
661
+ continue;
662
+ out[key] = Array.isArray(value)
663
+ ? value.filter((entry) => typeof entry === "string").map((entry) => entry.trim().toLowerCase())
664
+ : [];
665
+ }
666
+ return out;
667
+ }
668
+ function isExemptFromSourceRefs(frontmatter, exemptWhen) {
669
+ for (const [key, allowed] of Object.entries(exemptWhen)) {
670
+ const value = frontmatter[key];
671
+ if (!hasNonEmptyFrontmatterValue(value))
672
+ continue;
673
+ if (allowed.length === 0)
674
+ return true;
675
+ if (typeof value === "string" && allowed.includes(value.trim().toLowerCase()))
676
+ return true;
677
+ if (Array.isArray(value) && value.some((entry) => typeof entry === "string" && allowed.includes(entry.trim().toLowerCase()))) {
678
+ return true;
679
+ }
680
+ }
681
+ return false;
682
+ }
186
683
  function loadSourceManifestForCheck(context, path = ".interf/runtime/source-manifest.json") {
187
684
  const manifestPath = resolve(context.rootPath, path);
188
685
  if (!existsSync(manifestPath))
@@ -379,7 +876,7 @@ const EVALUATORS = {
379
876
  if (!target) {
380
877
  return makeCheckResult(check, false, "No target path provided for frontmatter_required_keys check.");
381
878
  }
382
- const keys = Array.isArray(check.params?.keys) ? check.params.keys.filter((k) => typeof k === "string") : [];
879
+ const keys = frontmatterKeys(check);
383
880
  if (keys.length === 0) {
384
881
  return makeCheckResult(check, false, "Build Plan check is missing required frontmatter keys. Use `params.keys: string[]`.");
385
882
  }
@@ -419,16 +916,170 @@ const EVALUATORS = {
419
916
  }
420
917
  const keys = frontmatterKeys(check);
421
918
  const sourceRefKeys = keys.length > 0 ? keys : ["source_refs", "source_ref", "source_path"];
919
+ const exemptWhen = exemptWhenClause(check);
422
920
  const files = listMarkdownFiles(target);
423
921
  if (files.length === 0) {
424
922
  return makeCheckResult(check, false, "No markdown files to validate.");
425
923
  }
426
- const { invalid, missing } = collectFrontmatterFailures(files, (frontmatter) => sourceRefKeys.some((key) => hasNonEmptyFrontmatterValue(frontmatter[key])));
924
+ const { invalid, missing } = collectFrontmatterFailures(files, (frontmatter) => isExemptFromSourceRefs(frontmatter, exemptWhen)
925
+ || sourceRefKeys.some((key) => hasNonEmptyFrontmatterValue(frontmatter[key])));
427
926
  if (invalid.length === 0 && missing.length === 0) {
428
927
  return makeCheckResult(check, true, `All ${files.length} markdown file(s) have source refs.`);
429
928
  }
430
929
  return makeCheckResult(check, false, `${invalid.length + missing.length} of ${files.length} markdown file(s) are missing source refs.`, { invalid, missing, sourceRefKeys });
431
930
  },
931
+ summary_backlinks_present(check, context) {
932
+ const target = resolveTargetPath(check, context);
933
+ if (!target) {
934
+ return makeCheckResult(check, false, "No target path provided for summary_backlinks_present check.");
935
+ }
936
+ const summariesDir = typeof check.params?.summaries_dir === "string" ? check.params.summaries_dir : "summaries";
937
+ const summaryFile = typeof check.params?.summary_file === "string" ? check.params.summary_file : "summary.md";
938
+ const manifestFile = typeof check.params?.manifest_file === "string" ? check.params.manifest_file : "manifest.md";
939
+ const analysis = analyzeSummaryBacklinks(target, context, { summariesDir, summaryFile, manifestFile });
940
+ // An ambiguous basename-only citation cannot be proven backlinked: it names a
941
+ // basename two or more summary folders share, so we refuse to credit one
942
+ // arbitrarily. Surface it as a gap rather than silently passing — that is the
943
+ // exact orphan the old first-basename-wins alias hid. Fails even when nothing
944
+ // resolved cleanly, because the citation itself is unverifiable.
945
+ if (analysis.ambiguousCitations.length > 0) {
946
+ return makeCheckResult(check, false, `${analysis.ambiguousCitations.length} note(s) cite a summary by an ambiguous basename that two or more summary folders share; cite the full summary-folder path so the backlink can be verified.`, {
947
+ cited_summaries: analysis.citedSummaries.length,
948
+ linked_summaries: analysis.linkedSummaries.size,
949
+ ambiguous_citations: analysis.ambiguousCitations.length,
950
+ notes_with_ambiguous_citations: analysis.ambiguousCitations,
951
+ orphaned_summaries: analysis.orphanedSummaries.length,
952
+ orphaned_summary_folders: analysis.orphanedSummaries,
953
+ });
954
+ }
955
+ if (analysis.citedSummaries.length === 0) {
956
+ return makeCheckResult(check, true, "No notes cite a summary source ref yet; nothing to backlink.", { notesScanned: analysis.notesScanned });
957
+ }
958
+ if (analysis.orphanedSummaries.length === 0) {
959
+ return makeCheckResult(check, true, `All ${analysis.citedSummaries.length} cited summary folder(s) are wikilinked from this layer.`, {
960
+ cited_summaries: analysis.citedSummaries.length,
961
+ linked_summaries: analysis.linkedSummaries.size,
962
+ orphaned_summaries: 0,
963
+ });
964
+ }
965
+ return makeCheckResult(check, false, `${analysis.orphanedSummaries.length} of ${analysis.citedSummaries.length} cited summary folder(s) are orphaned (cited via source_refs but never wikilinked).`, {
966
+ cited_summaries: analysis.citedSummaries.length,
967
+ linked_summaries: analysis.linkedSummaries.size,
968
+ orphaned_summaries: analysis.orphanedSummaries.length,
969
+ orphaned_summary_folders: analysis.orphanedSummaries,
970
+ notes_with_unlinked_citations: analysis.notesWithUnlinkedCitations,
971
+ });
972
+ },
973
+ knowledge_web_connectivity(check, context) {
974
+ const target = resolveTargetPath(check, context);
975
+ if (!target) {
976
+ return makeCheckResult(check, false, "No target path provided for knowledge_web_connectivity check.");
977
+ }
978
+ // The artifact's target path IS the knowledge layer to scan — derived from the
979
+ // artifact this check is attached to, never hardcoded to `knowledge/` from the
980
+ // root. `params.knowledge_dir` is a graph-root-relative override that may only
981
+ // NARROW the scan to a sub-directory inside the target; an override that escapes
982
+ // the target layer (e.g. a sibling layer) is rejected so the check cannot be
983
+ // pointed at a different, possibly-passing subtree.
984
+ let scanRoot = target;
985
+ if (typeof check.params?.knowledge_dir === "string" && check.params.knowledge_dir.trim().length > 0) {
986
+ const narrowed = resolve(context.rootPath, check.params.knowledge_dir);
987
+ if (narrowed !== target && !narrowed.startsWith(`${target}/`)) {
988
+ return makeCheckResult(check, false, "params.knowledge_dir resolves outside this check's target layer; it may only narrow the scan inside the target.", { knowledge_dir: check.params.knowledge_dir, target_dir: context.targetPath });
989
+ }
990
+ scanRoot = narrowed;
991
+ }
992
+ const web = analyzeNoteWeb(listMarkdownFiles(scanRoot), context);
993
+ // A bare basename two or more notes share cannot be proven to connect either
994
+ // namesake, so it credits no edge — surface it as a "cite the full path" gap
995
+ // rather than silently connecting the linker and hiding an island. Fails even
996
+ // when no island remains, because the link itself is unverifiable.
997
+ if (web.ambiguousLinkNotes.length > 0) {
998
+ return makeCheckResult(check, false, `${web.ambiguousLinkNotes.length} knowledge note(s) link another knowledge note by an ambiguous basename two or more notes share; link the full graph path so the web edge can be verified.`, {
999
+ knowledge_notes: web.notes,
1000
+ connected: web.connected,
1001
+ islands: web.islands.length,
1002
+ island_notes: web.islands,
1003
+ ambiguous_links: web.ambiguousLinkNotes.length,
1004
+ notes_with_ambiguous_links: web.ambiguousLinkNotes,
1005
+ });
1006
+ }
1007
+ if (web.islands.length === 0) {
1008
+ return makeCheckResult(check, true, web.notes <= 1
1009
+ ? `${web.notes} knowledge note(s); too few to form a web.`
1010
+ : `${web.connected} / ${web.notes} knowledge notes link another knowledge note.`, { knowledge_notes: web.notes, connected: web.connected, islands: 0 });
1011
+ }
1012
+ return makeCheckResult(check, false, `${web.islands.length} of ${web.notes} knowledge note(s) link no other knowledge note (disconnected island${web.islands.length === 1 ? "" : "s"}).`, {
1013
+ knowledge_notes: web.notes,
1014
+ connected: web.connected,
1015
+ islands: web.islands.length,
1016
+ island_notes: web.islands,
1017
+ });
1018
+ },
1019
+ /**
1020
+ * Whole-graph connectivity floor. The `knowledge_web_connectivity` check only
1021
+ * scans the `knowledge/` layer, and `summary_backlinks_present` only flags a
1022
+ * summary that a knowledge note CITES via source_refs but does not wikilink.
1023
+ * Neither sees a summary that no note cites at all — so a Context Graph can
1024
+ * pass readiness while the bulk of its `summaries/` notes are free-floating
1025
+ * islands no entrypoint or note reaches. This is the "no disconnected island
1026
+ * passing silently" rule applied to the WHOLE graph, not just the 7 knowledge
1027
+ * notes: every markdown note across `summaries/`, `knowledge/`, `artifacts/`,
1028
+ * and `home.md` must have undirected degree ≥ 1 in the note-link web. A note no
1029
+ * other note links AND that links no other note is a disconnected island and
1030
+ * fails readiness. Connectedness, not a count — one genuine inbound or outbound
1031
+ * edge is enough. By default the scan root is the Context Graph root; a Build
1032
+ * Plan may pass `params.graph_root` to narrow the floor to one layer (it may
1033
+ * only narrow inside the graph root, never escape it).
1034
+ */
1035
+ graph_notes_connected(check, context) {
1036
+ // Default scope: the canonical CONTENT layers of the whole Context Graph
1037
+ // (summaries/, knowledge/, artifacts/, home.md) as one web — NOT the raw graph
1038
+ // root, which also holds runtime scaffolding (.interf/, .claude/, SKILL.md
1039
+ // docs, CLAUDE.md) that is not graph content and would inject false islands.
1040
+ // Intentionally spans all CONTENT layers, not `targetPath`: a knowledge note
1041
+ // or an entrypoint route may be the only thing connecting a summary, so scoping
1042
+ // to one layer in isolation would re-introduce the uncited-summary-island bug.
1043
+ const graphRoot = resolve(context.rootPath);
1044
+ let noteFiles;
1045
+ if (typeof check.params?.graph_root === "string" && check.params.graph_root.trim().length > 0) {
1046
+ const narrowed = resolve(graphRoot, check.params.graph_root);
1047
+ // May only NARROW inside the graph root; an override that escapes the root
1048
+ // is rejected so the floor cannot be pointed at a different, passing tree.
1049
+ if (narrowed !== graphRoot && !narrowed.startsWith(`${graphRoot}/`)) {
1050
+ return makeCheckResult(check, false, "params.graph_root resolves outside the Context Graph root; it may only narrow the connectivity floor to a path inside the graph.", { graph_root: check.params.graph_root });
1051
+ }
1052
+ // A narrowed scope scans that subtree's markdown notes directly (the caller
1053
+ // is naming a content path, so no scaffolding filter is applied beyond the
1054
+ // shared output-markdown filter).
1055
+ noteFiles = listMarkdownFiles(narrowed);
1056
+ }
1057
+ else {
1058
+ noteFiles = collectGraphContentNotes(graphRoot);
1059
+ }
1060
+ const web = analyzeNoteWeb(noteFiles, context);
1061
+ if (web.ambiguousLinkNotes.length > 0) {
1062
+ return makeCheckResult(check, false, `${web.ambiguousLinkNotes.length} note(s) link another note by an ambiguous basename two or more notes share; link the full graph path so the web edge can be verified.`, {
1063
+ graph_notes: web.notes,
1064
+ connected: web.connected,
1065
+ islands: web.islands.length,
1066
+ island_notes: web.islands,
1067
+ ambiguous_links: web.ambiguousLinkNotes.length,
1068
+ notes_with_ambiguous_links: web.ambiguousLinkNotes,
1069
+ });
1070
+ }
1071
+ if (web.islands.length === 0) {
1072
+ return makeCheckResult(check, true, web.notes <= 1
1073
+ ? `${web.notes} note(s); too few to form a web.`
1074
+ : `${web.connected} / ${web.notes} notes are link-connected to another note (no islands).`, { graph_notes: web.notes, connected: web.connected, islands: 0 });
1075
+ }
1076
+ return makeCheckResult(check, false, `${web.islands.length} of ${web.notes} note(s) link no other note and are linked by none (disconnected island${web.islands.length === 1 ? "" : "s"}). Every Context Graph note — including every summary — must be reachable through the link web.`, {
1077
+ graph_notes: web.notes,
1078
+ connected: web.connected,
1079
+ islands: web.islands.length,
1080
+ island_notes: web.islands,
1081
+ });
1082
+ },
432
1083
  wikilinks_valid(check, context) {
433
1084
  const target = resolveTargetPath(check, context);
434
1085
  if (!target) {
@@ -447,7 +1098,7 @@ const EVALUATORS = {
447
1098
  }
448
1099
  const phrases = checkPhrases(check);
449
1100
  if (phrases.length === 0) {
450
- return makeCheckResult(check, false, "Artifact diagnostic is missing forbidden text. Use `params.phrases: string[]` or `params.text: string`.");
1101
+ return makeCheckResult(check, false, "Requested output diagnostic is missing forbidden text. Use `params.phrases: string[]` or `params.text: string`.");
451
1102
  }
452
1103
  try {
453
1104
  const stats = statSync(target);
@@ -478,7 +1129,7 @@ const EVALUATORS = {
478
1129
  }
479
1130
  const phrases = checkPhrases(check);
480
1131
  if (phrases.length === 0) {
481
- return makeCheckResult(check, false, "Artifact diagnostic is missing required text. Use `params.phrases: string[]` or `params.text: string`.");
1132
+ return makeCheckResult(check, false, "Requested output diagnostic is missing required text. Use `params.phrases: string[]` or `params.text: string`.");
482
1133
  }
483
1134
  try {
484
1135
  const stats = statSync(target);