@intentius/chant 0.84.0 → 0.86.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 (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
@@ -22,28 +22,36 @@
22
22
  * the graph pins it.
23
23
  *
24
24
  * Every gap the walk finds is a `finding` node with a closed code, never
25
- * prose, so a reader can draw it and a test can assert it. chant emits the
25
+ * prose, so a reader can draw it and a test can assert it. A commit made
26
+ * inside a decision's window is not taken as that decision's work: unless
27
+ * the decision's own unit made it, it is `decided-by-window`, shown for the
28
+ * person to judge (#2656). A plugin's `commitJoins` may add findings of its
29
+ * own, in its own code namespace. Without `kinds`, the walk reads every
30
+ * record kind the declaration names (#2680), and each one's `commitJoins`
31
+ * runs for every commit in that order. chant emits the
26
32
  * graph and hud renders it (#2524 D8, D15). Git is read through a local
27
33
  * `git` subprocess only: no fetch, no network.
28
34
  */
29
35
 
30
36
  import { execFileSync } from "node:child_process";
31
37
  import { realpathSync } from "node:fs";
32
- import { relative, resolve, sep } from "node:path";
33
- import { readDeclaration, readerVersion, resolveGroups, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
38
+ import { basename, dirname, relative, resolve, sep } from "node:path";
39
+ import { declaredRecordKinds, readDeclaration, readerVersion, resolveGroups, WORKSPACE_ERROR_CODES, WorkspaceReadError, type Declaration } from "./declaration";
40
+ import { declaredKindFile } from "./declared-kinds";
34
41
  import { classifyFile, declaredFilesUnder } from "./generated-files";
35
- import { hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity } from "./intent-joins";
42
+ import { entityDecisions, hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity, type PluginFinding } from "./intent-joins";
36
43
  import { loadKindRegistry } from "./kinds";
37
44
  import { resolveLinks, type LinkTableRow } from "./links";
38
45
  import { sourceMemberHandles } from "./member-handles";
39
46
  import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
40
- import { importKindModule, loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
47
+ import { importKindModule, loadRecordKind, parseFrontMatter, RecordReadError, supersedesTargets, type LoadedRecordKind } from "./records";
41
48
  import { queryRecords, type RecordView } from "./records-cli";
42
- import type { ReasonCode } from "./reason-codes";
49
+ import { isPluginCode, type PluginCode, type ReasonCode } from "./reason-codes";
43
50
  import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
44
51
  import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
45
52
  import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
46
53
  import { locateWorkspace, type LocatedWorkspace } from "./which-chant";
54
+ import { idList, isDecided, WORK_WARNING_CODES, type WorkLink, type WorkWarningCode } from "./work";
47
55
 
48
56
  /** The version of the `intent` document this chant writes. */
49
57
  export const INTENT_CONTRACT_VERSION = 1;
@@ -79,6 +87,12 @@ export const INTENT_FINDING_CODES = [
79
87
  "intent-trailer-unverified",
80
88
  /** No decision constrains the region at any granularity. */
81
89
  "intent-region-unconstrained",
90
+ /** A decided decision constrains the region, no work item that is not dropped implements it, and no commit falls in its window (#2683). Only with a work kind read. */
91
+ "intent-decision-unimplemented",
92
+ /** A work item constraining the region has commits in its window while its blockedBy is not empty (#2683). */
93
+ "intent-work-blocked",
94
+ /** Commits in the region are a decision's own work while the work item implementing it is still open: the code says done and the queue says not (#2683). */
95
+ "intent-work-open-decided-code",
82
96
  ] as const satisfies readonly ReasonCode[];
83
97
  export type IntentFindingCode = (typeof INTENT_FINDING_CODES)[number];
84
98
 
@@ -167,8 +181,18 @@ export interface CommitNode {
167
181
  signature: { level: ProvenanceLevel; reason: string; principal?: string };
168
182
  /** The line ranges the commit changed in the region, in the commit's own version of the file; null for a file or directory region. */
169
183
  lines: LineRange[] | null;
184
+ /**
185
+ * How the decisions constraining the region by path relate to the commit
186
+ * (#2656): `decided` when it falls inside a decision's window and that
187
+ * decision's own unit made it, `decided-by-window` when it only falls
188
+ * inside a window, `undecided` when it falls outside every window, and
189
+ * null when no record kind was read.
190
+ */
191
+ state: CommitState | null;
170
192
  }
171
193
 
194
+ export type CommitState = "decided" | "decided-by-window" | "undecided";
195
+
172
196
  export interface JoinedNode {
173
197
  id: string;
174
198
  kind: "unit" | "contract" | "evidence";
@@ -237,12 +261,61 @@ export interface LinkNode {
237
261
  export interface FindingNode {
238
262
  id: string;
239
263
  kind: "finding";
240
- code: IntentFindingCode;
264
+ /** A closed code, or a plugin's own `plugin:<name>:<code>` (#2656). */
265
+ code: IntentFindingCode | PluginCode;
241
266
  message: string;
242
267
  concerns: string[];
268
+ /** For a plugin's finding: the kind file that returned it. */
269
+ plugin?: string;
270
+ /** For a plugin's finding: the refs as the plugin gave them. The ones that name a node in the graph are in concerns. */
271
+ refs?: string[];
272
+ /** With a work kind read (#2683): whether a work item addresses the finding. */
273
+ addressed?: boolean;
274
+ /** With a work kind read: the work items addressing the finding, each with its state. */
275
+ addressedBy?: WorkLink[];
243
276
  }
244
277
 
245
- export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | ArtifactNode | LinkNode | FindingNode;
278
+ /** Where a work item says it came from: the gap the intent graph reported (#2683). */
279
+ export interface WorkGapSource {
280
+ finding: string;
281
+ region: string;
282
+ decision?: string;
283
+ artifact?: string;
284
+ }
285
+
286
+ export interface WorkNode {
287
+ id: string;
288
+ kind: "work";
289
+ recordKind: string;
290
+ record: string;
291
+ /** The record file, from the repository root. */
292
+ path: string;
293
+ title: string | null;
294
+ state: string | null;
295
+ /** Whether the kind counts the state as closed, such as done or dropped. */
296
+ closed: boolean;
297
+ valid: boolean;
298
+ reasons: RecordView["reasons"];
299
+ provenance: { level: ProvenanceLevel; commit: string | null; reason: string };
300
+ owner: string | null;
301
+ /** As records reports it: open, and every need done. */
302
+ ready: boolean;
303
+ /** Each need that is not done, with its state. */
304
+ blockedBy: WorkLink[];
305
+ /** Each decision the item implements, with its state. */
306
+ implements: WorkLink[];
307
+ /** The ids the item's needs list names. */
308
+ needs: string[];
309
+ /** The gap the item came from, when its source names one. */
310
+ source: WorkGapSource | null;
311
+ supersededBy: string | null;
312
+ /** The constrains entries that cover the region, with their granularity. Empty for an item in the graph only through a link. */
313
+ constrains: { entry: string; granularity: Granularity }[];
314
+ /** The item's work warnings, as records reports them, and work-done-gap-open. */
315
+ warnings: { code: WorkWarningCode; message: string }[];
316
+ }
317
+
318
+ export type IntentNode = RegionNode | FileNode | MemberNode | CommitNode | JoinedNode | EvidenceEntryNode | DecisionNode | WorkNode | ArtifactNode | LinkNode | FindingNode;
246
319
 
247
320
  export type Granularity = "path" | "member" | "contract" | "issue";
248
321
 
@@ -250,7 +323,8 @@ export type IntentEdge =
250
323
  | { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
251
324
  | { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
252
325
  | { kind: "touched-by"; from: string; to: string; lines: LineRange[] | null }
253
- | { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links"; from: string; to: string };
326
+ | { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" | "worked" }
327
+ | { kind: "produced-by" | "serves" | "cites-evidence" | "supersedes" | "links" | "implements" | "needs" | "addressed-by"; from: string; to: string };
254
328
 
255
329
  export interface IntentReason {
256
330
  code: IntentReasonCode;
@@ -269,7 +343,7 @@ export type IntentDocument =
269
343
  workspace: { name: string; root: string };
270
344
  region: string;
271
345
  history: { rev: string | null; follows: "line-range" | "file" | "directory"; shallow: boolean };
272
- kinds: { file: string; records: string | null; joins: "function" | "data" | null }[];
346
+ kinds: { file: string; name: string; records: string | null; joins: "function" | "data" | null }[];
273
347
  nodes: IntentNode[];
274
348
  edges: IntentEdge[];
275
349
  reasons: IntentReason[];
@@ -283,7 +357,11 @@ export interface IntentQuery {
283
357
  /** `path`, `path:line` or `path:start-end`, or a graph node id `<member>/<id>`. */
284
358
  region: string;
285
359
  at?: string;
286
- /** Kind files: record kinds, plugins with `commitJoins`, or both. Relative to `cwd`. */
360
+ /**
361
+ * Kind files: record kinds, plugins with `commitJoins`, or both. Relative to
362
+ * `cwd`. Left out, every record kind the declaration names, in its order
363
+ * (#2680); an empty list reads none.
364
+ */
287
365
  kinds?: string[];
288
366
  /**
289
367
  * Resolve a graph node id to its source location, for a region given as a
@@ -383,6 +461,25 @@ function addingCommit(top: string, rev: string, path: string): string | null {
383
461
  return out?.trim() || null;
384
462
  }
385
463
 
464
+ /**
465
+ * The commit reachable from `rev` that moved the record at `path` into a
466
+ * closed state and kept it there, or null when the record is not closed at
467
+ * `rev` (#2683). A record added in a closed state closes in the commit that
468
+ * added it.
469
+ */
470
+ function closingCommit(top: string, rev: string, path: string, stateField: string, closed: readonly string[]): string | null {
471
+ const out = tryGit(top, ["log", "--format=%H", rev, "--", path]);
472
+ let closing: string | null = null;
473
+ for (const sha of (out ?? "").split("\n").map((l) => l.trim()).filter(Boolean)) {
474
+ const text = tryGit(top, ["show", `${sha}:${path}`]);
475
+ const fm = text === undefined ? undefined : parseFrontMatter(text);
476
+ const state = fm?.ok ? fm.value[stateField] : undefined;
477
+ if (typeof state !== "string" || !closed.includes(state)) break;
478
+ closing = sha;
479
+ }
480
+ return closing;
481
+ }
482
+
386
483
  /** `commit` and every commit between it and `rev` that has it as an ancestor. */
387
484
  function descendants(top: string, commit: string, rev: string): Set<string> {
388
485
  const out = new Set<string>([commit]);
@@ -449,6 +546,20 @@ function stringOr(v: unknown): string | null {
449
546
  return typeof v === "string" ? v : null;
450
547
  }
451
548
 
549
+ /** A work record's `source` when it names the gap it came from (#2683), or null. */
550
+ function gapSource(data: Record<string, unknown> | null): WorkGapSource | null {
551
+ const src = data?.source;
552
+ if (src === null || typeof src !== "object" || Array.isArray(src)) return null;
553
+ const s = src as Record<string, unknown>;
554
+ if (typeof s.finding !== "string" || typeof s.region !== "string") return null;
555
+ return {
556
+ finding: s.finding,
557
+ region: s.region,
558
+ ...(typeof s.decision === "string" ? { decision: s.decision } : {}),
559
+ ...(typeof s.artifact === "string" ? { artifact: s.artifact } : {}),
560
+ };
561
+ }
562
+
452
563
  function reviewSummary(data: Record<string, unknown> | null): DecisionNode["reviews"] {
453
564
  const out = { agree: 0, dissent: 0, abstain: 0, openConcerns: 0 };
454
565
  const list = data?.reviews;
@@ -486,6 +597,8 @@ interface LoadedKind {
486
597
  file: string;
487
598
  /** Relative to the repository root, for the document. */
488
599
  display: string;
600
+ /** The namespace of its plugin findings: its `commitJoinsName`, the record kind's name, or the file's name without `.kind.mjs`. */
601
+ name: string;
489
602
  records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
490
603
  joins?: CommitJoins;
491
604
  }
@@ -503,12 +616,13 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
503
616
  }
504
617
  const joins = readCommitJoins(mod);
505
618
  if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
506
- const kind: LoadedKind = { file, display, ...(joins ? { joins } : {}) };
619
+ const kind: LoadedKind = { file, display, name: joins?.name ?? basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
507
620
  if (mod.recordKind !== undefined) {
508
621
  const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
509
622
  if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
510
623
  try {
511
624
  kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
625
+ kind.name = joins?.name ?? kind.records.loaded.kind.name;
512
626
  } catch (err) {
513
627
  if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
514
628
  throw err;
@@ -568,7 +682,9 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
568
682
  const count = located.tree.read(region.path).replace(/\n$/, "").split("\n").length;
569
683
  if (region.lines.end > count) throw new IntentError("intent-region-invalid", `${region.path} has ${count} lines${located.tree.label}, so ${region.lines.start}-${region.lines.end} is not in it`);
570
684
  }
571
- const kinds = await loadKinds(query, top);
685
+ // Without --kind, the declared record kinds, read from the working tree as a --kind file is (#2680).
686
+ const kindFiles = query.kinds ?? declaredRecordKinds(declaration).map((d) => declaredKindFile(d, located.rootOnDisk));
687
+ const kinds = await loadKinds({ ...query, kinds: kindFiles }, top);
572
688
 
573
689
  const nodes = new Map<string, IntentNode>();
574
690
  const edges: IntentEdge[] = [];
@@ -611,7 +727,22 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
611
727
  if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
612
728
  return located.tree.read(path);
613
729
  };
614
- const joined = new Map<string, { unit?: string; contracts: string[]; authorship: string[] }>();
730
+ // The same tree as readAt (#2663): entries directly inside dir, from the workspace root, a directory's with a trailing slash.
731
+ const listAt = (dir: string): string[] | undefined => {
732
+ const d = dir === "." ? "" : dir.replace(/\/+$/, "");
733
+ if (d !== "" && (!isWorkspacePath(d) || located.tree.stat(d) !== "dir")) return undefined;
734
+ const entries = located.tree.list(d);
735
+ return entries?.map((e) => `${joinPath(d, e.name)}${e.type === "dir" ? "/" : ""}`).sort();
736
+ };
737
+ interface Joined {
738
+ unit?: string;
739
+ contracts: string[];
740
+ authorship: string[];
741
+ /** The record ids the commit's units and contracts say they carry out. */
742
+ decisions: string[];
743
+ }
744
+ const joined = new Map<string, Joined>();
745
+ const pluginFindings: { commit: string; plugin: string; finding: PluginFinding }[] = [];
615
746
  const failedPlugins = new Set<string>();
616
747
  for (const t of touched) {
617
748
  const c = details.get(t.sha);
@@ -624,17 +755,17 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
624
755
  : { level: "unattested" as const, reason: policy.problems.length ? policy.problems.join("; ") : `no signers file (${policy.signersPath}) at base; attestation is off` };
625
756
  const pr = c.subject.match(/\(#([0-9]+)\)\s*$/);
626
757
  const cid = `commit:${c.sha}`;
627
- add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines });
758
+ add<CommitNode>({ id: cid, kind: "commit", sha: c.sha, subject: c.subject, author: c.author, date: c.date, trailers: c.trailers, pullRequest: pr ? Number(pr[1]) : null, signature, lines: t.lines, state: null });
628
759
  edges.push({ kind: "touched-by", from: rid, to: cid, lines: t.lines });
629
760
 
630
761
  // 2. Each commit's origin, from the plugins.
631
- const entry = { contracts: [] as string[], authorship: [] as string[] } as { unit?: string; contracts: string[]; authorship: string[] };
762
+ const entry: Joined = { contracts: [], authorship: [], decisions: [] };
632
763
  joined.set(c.sha, entry);
633
764
  for (const k of kinds) {
634
765
  if (!k.joins) continue;
635
766
  let result;
636
767
  try {
637
- result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at });
768
+ result = await runCommitJoins(k.joins, c, { read: readAt, list: listAt, at: located.at }, k.name);
638
769
  } catch (err) {
639
770
  const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
640
771
  if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
@@ -653,6 +784,8 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
653
784
  if (contractId) edges.push({ kind: "serves", from: unitId, to: contractId });
654
785
  }
655
786
  if (contractId) entry.contracts.push(result.contract!.id);
787
+ if (unitId) entry.decisions.push(...entityDecisions(result.unit), ...entityDecisions(result.contract));
788
+ for (const f of result.findings ?? []) pluginFindings.push({ commit: cid, plugin: k.display, finding: f });
656
789
  const evidence = result.evidence === undefined ? [] : Array.isArray(result.evidence) ? result.evidence : [result.evidence];
657
790
  for (const e of evidence) edges.push({ kind: "cites-evidence", from: unitId ?? contractId ?? cid, to: joinedNode("evidence", e) });
658
791
  entry.authorship.push(...(result.authorship ?? []));
@@ -675,10 +808,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
675
808
  const decisionId = (k: LoadedKind, id: string) => `record:${k.records!.loaded.kind.name}/${id}`;
676
809
  const decisionNode = (k: LoadedKind, v: RecordView): DecisionNode => {
677
810
  const kind = k.records!.loaded.kind;
678
- const links = v.data?.[kind.supersedes.field];
679
- const supersedes = Array.isArray(links)
680
- ? links.map((l) => (l && typeof l === "object" ? (l as Record<string, unknown>)[kind.supersedes.key] : undefined)).filter((x): x is string => typeof x === "string")
681
- : [];
811
+ const supersedes = supersedesTargets(kind, v.data);
682
812
  return add<DecisionNode>({
683
813
  id: decisionId(k, v.id!),
684
814
  kind: "decision",
@@ -687,7 +817,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
687
817
  path: v.path,
688
818
  title: stringOr(v.data?.title),
689
819
  state: v.state,
690
- closed: v.state !== null && kind.closedStates.includes(v.state),
820
+ closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
691
821
  valid: v.valid,
692
822
  reasons: v.reasons,
693
823
  provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
@@ -700,36 +830,41 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
700
830
  });
701
831
  };
702
832
  const fileNodesUnder = (full: string) => files.filter((f) => constraintCovers(full, joinPath(workspacePrefix, f)));
833
+ /** The constrains entries of a record that cover the region or a file under it. Decisions and work items match the same way (#2683). */
834
+ const matchConstrains = (k: LoadedKind, v: RecordView): { entry: string; granularity: Granularity; to: string }[] => {
835
+ const field = k.records!.loaded.kind.constrains?.field;
836
+ const kindPrefix = k.records!.workspaceRoot === "." ? "" : k.records!.workspaceRoot;
837
+ const list = field ? v.data?.[field] : undefined;
838
+ const matched: { entry: string; granularity: Granularity; to: string }[] = [];
839
+ if (!Array.isArray(list)) return matched;
840
+ for (const entry of list) {
841
+ if (typeof entry !== "string") continue;
842
+ if (entry.startsWith("path:")) {
843
+ const p = entry.slice("path:".length);
844
+ if (!isWorkspacePath(p)) continue;
845
+ const full = joinPath(kindPrefix, p);
846
+ if (regionFull !== "" && constraintCovers(full, regionFull)) {
847
+ matched.push({ entry, granularity: "path", to: rid });
848
+ } else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
849
+ // A path below a directory region constrains the files under it, not the whole region.
850
+ for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
851
+ }
852
+ } else if (entry.startsWith("member:")) {
853
+ if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
854
+ } else {
855
+ const issue = entry.match(ISSUE);
856
+ if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
857
+ else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
858
+ }
859
+ }
860
+ return matched;
861
+ };
703
862
  for (const k of kinds) {
704
- if (!k.records) continue;
705
- const field = k.records.loaded.kind.constrains?.field;
706
- const kindPrefix = k.records.workspaceRoot === "." ? "" : k.records.workspaceRoot;
863
+ if (!k.records || k.records.loaded.kind.work) continue;
707
864
  for (const v of k.records.views) {
708
865
  if (v.id === null) continue;
709
866
  decisionById.set(`${k.records.loaded.kind.name}/${v.id}`, { view: v, kind: k });
710
- const list = field ? v.data?.[field] : undefined;
711
- if (!Array.isArray(list)) continue;
712
- const matched: { entry: string; granularity: Granularity; to: string }[] = [];
713
- for (const entry of list) {
714
- if (typeof entry !== "string") continue;
715
- if (entry.startsWith("path:")) {
716
- const p = entry.slice("path:".length);
717
- if (!isWorkspacePath(p)) continue;
718
- const full = joinPath(kindPrefix, p);
719
- if (regionFull !== "" && constraintCovers(full, regionFull)) {
720
- matched.push({ entry, granularity: "path", to: rid });
721
- } else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
722
- // A path below a directory region constrains the files under it, not the whole region.
723
- for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
724
- }
725
- } else if (entry.startsWith("member:")) {
726
- if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
727
- } else {
728
- const issue = entry.match(ISSUE);
729
- if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
730
- else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
731
- }
732
- }
867
+ const matched = matchConstrains(k, v);
733
868
  if (matched.length === 0) continue;
734
869
  const node = decisionNode(k, v);
735
870
  for (const m of matched) {
@@ -852,6 +987,116 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
852
987
  windows.set(c.node.id, { from: added ? descendants(top, added, rev) : new Set(), until: replaced ? descendants(top, replaced, rev) : null });
853
988
  }
854
989
  }
990
+ // Work items (#2683): the records of each work kind whose constrains cover
991
+ // the region, the decisions they implement and the items they need, one hop
992
+ // each, and each covering item's window: from the commit that added the
993
+ // record to the commit that closed it, that commit included, or to the
994
+ // revision read while it is open.
995
+ interface WorkHit {
996
+ view: RecordView;
997
+ kind: LoadedKind;
998
+ node?: WorkNode;
999
+ }
1000
+ const workKinds = kinds.filter((k) => k.records?.loaded.kind.work);
1001
+ const readsWork = workKinds.length > 0;
1002
+ const workAll: WorkHit[] = [];
1003
+ const workByKey = new Map<string, WorkHit>();
1004
+ for (const k of workKinds) {
1005
+ for (const v of k.records!.views) {
1006
+ if (v.id === null) continue;
1007
+ const hit: WorkHit = { view: v, kind: k };
1008
+ workAll.push(hit);
1009
+ const key = `${k.records!.loaded.kind.name}/${v.id}`;
1010
+ if (!workByKey.has(key)) workByKey.set(key, hit);
1011
+ }
1012
+ }
1013
+ const workSpec = (h: WorkHit) => h.kind.records!.loaded.kind.work!;
1014
+ const workNode = (h: WorkHit): WorkNode => {
1015
+ if (h.node) return h.node;
1016
+ const kind = h.kind.records!.loaded.kind;
1017
+ const v = h.view;
1018
+ h.node = add<WorkNode>({
1019
+ id: `record:${kind.name}/${v.id!}`,
1020
+ kind: "work",
1021
+ recordKind: kind.name,
1022
+ record: v.id!,
1023
+ path: v.path,
1024
+ title: stringOr(v.data?.title),
1025
+ state: v.state,
1026
+ closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
1027
+ valid: v.valid,
1028
+ reasons: v.reasons,
1029
+ provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
1030
+ owner: stringOr(v.data?.owner),
1031
+ ready: v.ready ?? false,
1032
+ blockedBy: v.blockedBy ?? [],
1033
+ implements: v.implements ?? [],
1034
+ needs: idList(v.data, kind.work!.needs),
1035
+ source: gapSource(v.data),
1036
+ supersededBy: v.supersededBy,
1037
+ constrains: [],
1038
+ warnings: v.warnings.filter((w): w is { code: WorkWarningCode; message: string } => (WORK_WARNING_CODES as readonly string[]).includes(w.code)),
1039
+ });
1040
+ return h.node;
1041
+ };
1042
+ /** The decision kind a work kind names, when it is among the kinds read. */
1043
+ const decisionKindOf = (k: LoadedKind): LoadedKind | undefined => {
1044
+ const file = realpathOr(resolve(dirname(k.records!.loaded.file), k.records!.loaded.kind.work!.decisions));
1045
+ return kinds.find((x) => x.records && !x.records.loaded.kind.work && realpathOr(x.records.loaded.file) === file);
1046
+ };
1047
+ /** The node ids of the decisions a work item implements, among the decisions read. */
1048
+ const implementedIds = (h: WorkHit): string[] => {
1049
+ const dk = decisionKindOf(h.kind);
1050
+ if (!dk) return [];
1051
+ return idList(h.view.data, workSpec(h).implements)
1052
+ .map((d) => decisionById.get(`${dk.records!.loaded.kind.name}/${d}`))
1053
+ .filter((x): x is { view: RecordView; kind: LoadedKind } => x !== undefined)
1054
+ .map((x) => decisionId(x.kind, x.view.id!));
1055
+ };
1056
+ /** The work items implementing a decision, leaving out dropped ones: a closed state other than done. */
1057
+ const implementersOf = (d: DecisionNode): WorkHit[] =>
1058
+ workAll.filter((h) => {
1059
+ const kind = h.kind.records!.loaded.kind;
1060
+ const dropped = h.view.state !== null && h.view.state !== kind.work!.done && (kind.closedStates ?? []).includes(h.view.state);
1061
+ return !dropped && implementedIds(h).includes(d.id);
1062
+ });
1063
+ const workCovering: WorkHit[] = [];
1064
+ for (const h of workAll) {
1065
+ const matched = matchConstrains(h.kind, h.view);
1066
+ if (matched.length === 0) continue;
1067
+ const node = workNode(h);
1068
+ for (const m of matched) {
1069
+ edges.push({ kind: "constrains", from: node.id, to: m.to, granularity: m.granularity, entry: m.entry });
1070
+ if (m.to === rid || m.granularity === "contract") node.constrains.push({ entry: m.entry, granularity: m.granularity });
1071
+ }
1072
+ if (node.constrains.length > 0) workCovering.push(h);
1073
+ }
1074
+ for (const h of workAll.filter((x) => x.node)) {
1075
+ for (const id of implementedIds(h)) {
1076
+ const hit = decisionById.get(id.slice("record:".length))!;
1077
+ edges.push({ kind: "implements", from: h.node!.id, to: decisionNode(hit.kind, hit.view).id });
1078
+ }
1079
+ for (const n of idList(h.view.data, workSpec(h).needs)) {
1080
+ const hit = workByKey.get(`${h.kind.records!.loaded.kind.name}/${n}`);
1081
+ if (hit) edges.push({ kind: "needs", from: h.node!.id, to: workNode(hit).id });
1082
+ }
1083
+ }
1084
+ const workWindows = new Map<string, { from: Set<string>; until: Set<string> | null }>();
1085
+ if (rev) {
1086
+ for (const h of workCovering) {
1087
+ const kind = h.kind.records!.loaded.kind;
1088
+ const added = addingCommit(top, rev, h.view.path);
1089
+ const closing = kind.stateField ? closingCommit(top, rev, h.view.path, kind.stateField, kind.closedStates ?? []) : null;
1090
+ const until = closing ? descendants(top, closing, rev) : null;
1091
+ if (closing) until!.delete(closing);
1092
+ workWindows.set(h.node!.id, { from: added ? descendants(top, added, rev) : new Set(), until });
1093
+ }
1094
+ }
1095
+ const inWorkWindow = (h: WorkHit, sha: string) => {
1096
+ const w = workWindows.get(h.node!.id);
1097
+ return !!w && w.from.has(sha) && !w.until?.has(sha);
1098
+ };
1099
+
855
1100
  const coveredAt = (sha: string, granularities: Granularity[]) =>
856
1101
  covering.filter((c) => {
857
1102
  if (!c.node.constrains.some((x) => granularities.includes(x.granularity))) return false;
@@ -859,11 +1104,24 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
859
1104
  return !!w && w.from.has(sha) && !w.until?.has(sha);
860
1105
  });
861
1106
  const readsRecords = kinds.some((k) => k.records);
1107
+ // A decision's own work: a commit whose unit, or that unit's contract, names
1108
+ // the decision, or whose unit serves a contract the decision constrains.
1109
+ const ownWork = (j: Joined, d: DecisionNode) =>
1110
+ !!j.unit && (j.decisions.some((x) => x === d.record || x === `${d.recordKind}/${d.record}`) || d.constrains.some((x) => x.granularity === "contract" && j.contracts.includes(x.entry)));
862
1111
  for (const t of touched) {
863
1112
  const c = nodes.get(`commit:${t.sha}`) as CommitNode | undefined;
864
1113
  if (!c) continue;
865
1114
  const j = joined.get(t.sha)!;
866
- if (readsRecords && coveredAt(t.sha, ["path"]).length === 0) {
1115
+ if (readsRecords) {
1116
+ const inWindow = coveredAt(t.sha, ["path"]);
1117
+ for (const w of inWindow) edges.push({ kind: "within", from: c.id, to: w.node.id, state: ownWork(j, w.node) ? "decided" : "decided-by-window" });
1118
+ c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
1119
+ }
1120
+ // A commit in a work item's window is worked; its own state still comes from decisions (#2683).
1121
+ for (const h of workCovering) {
1122
+ if (h.node!.constrains.some((x) => x.granularity === "path") && inWorkWindow(h, t.sha)) edges.push({ kind: "within", from: c.id, to: h.node!.id, state: "worked" });
1123
+ }
1124
+ if (readsRecords && c.state === "undecided") {
867
1125
  find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
868
1126
  }
869
1127
  if (readsRecords && !j.unit && c.pullRequest === null && coveredAt(t.sha, ["path", "member", "contract", "issue"]).length === 0) {
@@ -891,6 +1149,37 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
891
1149
  }
892
1150
  }
893
1151
 
1152
+ // Findings about work items (#2683).
1153
+ if (readsWork) {
1154
+ for (const c of covering) {
1155
+ if (c.node.supersededBy !== null || !isDecided(c.kind.records!.loaded.kind, c.node.state)) continue;
1156
+ if (implementersOf(c.node).length > 0) continue;
1157
+ const w = windows.get(c.node.id);
1158
+ if (w && touched.some((t) => w.from.has(t.sha) && !w.until?.has(t.sha))) continue;
1159
+ find("intent-decision-unimplemented", `${c.node.record} is ${c.node.state} and constrains ${region.path}, and no work item that is not dropped implements it, and no commit changed the region in its window`, [c.node.id, rid]);
1160
+ }
1161
+ for (const h of workCovering) {
1162
+ const node = h.node!;
1163
+ if (node.blockedBy.length === 0) continue;
1164
+ const worked = touched.filter((t) => inWorkWindow(h, t.sha));
1165
+ if (worked.length === 0) continue;
1166
+ const waiting = node.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
1167
+ const blockers = node.blockedBy.map((b) => workByKey.get(`${node.recordKind}/${b.id}`)).filter((x): x is WorkHit => x !== undefined).map((x) => workNode(x).id);
1168
+ find("intent-work-blocked", `${node.record} has ${worked.length} ${worked.length === 1 ? "commit" : "commits"} in its window while it waits on ${waiting}`, [node.id, ...worked.map((t) => `commit:${t.sha}`), ...blockers]);
1169
+ }
1170
+ for (const d of [...nodes.values()].filter((n): n is DecisionNode => n.kind === "decision")) {
1171
+ const own = edges.filter((e) => e.kind === "within" && e.to === d.id && e.state === "decided").map((e) => e.from);
1172
+ if (own.length === 0) continue;
1173
+ const impl = implementersOf(d);
1174
+ if (impl.length === 0 || impl.some((h) => h.view.state === workSpec(h).done)) continue;
1175
+ find(
1176
+ "intent-work-open-decided-code",
1177
+ `${own.length} ${own.length === 1 ? "commit" : "commits"} in ${region.path} ${own.length === 1 ? "is" : "are"} ${d.record}'s own work, and ${impl.map((h) => `${h.view.id} is ${h.view.state ?? "stateless"}`).join(", ")}: the code says done and the queue says not`,
1178
+ [d.id, ...own, ...impl.map((h) => workNode(h).id)],
1179
+ );
1180
+ }
1181
+ }
1182
+
894
1183
  // 5. Links: the declared member links that touch the region's member, read in source.
895
1184
  if (member) {
896
1185
  let rows: LinkTableRow[] = [];
@@ -915,7 +1204,63 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
915
1204
  }
916
1205
 
917
1206
  // Findings last, in the order of the code list, so the same walk always prints the same way.
918
- findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code) - INTENT_FINDING_CODES.indexOf(y.code));
1207
+ findings.sort((x, y) => INTENT_FINDING_CODES.indexOf(x.code as IntentFindingCode) - INTENT_FINDING_CODES.indexOf(y.code as IntentFindingCode));
1208
+ // Then the plugins' findings, in commit order, each about its commit and the nodes its refs name.
1209
+ const resolveRef = (ref: string): string | undefined => {
1210
+ if (nodes.has(ref)) return ref;
1211
+ for (const prefix of ["commit", "unit", "contract", "evidence", "artifact", "file", "member"]) if (nodes.has(`${prefix}:${ref}`)) return `${prefix}:${ref}`;
1212
+ for (const n of nodes.values()) {
1213
+ if (n.kind === "decision" && (ref === n.record || ref === `${n.recordKind}/${n.record}`)) return n.id;
1214
+ if (n.kind === "commit" && /^[0-9a-f]{7,}$/.test(ref) && n.sha.startsWith(ref)) return n.id;
1215
+ }
1216
+ return undefined;
1217
+ };
1218
+ for (const { commit, plugin, finding } of pluginFindings) {
1219
+ const code = finding.code as PluginCode;
1220
+ const refs = finding.refs ?? [];
1221
+ const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
1222
+ findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
1223
+ }
1224
+ // A finding a work item addresses (#2683): the item came from that gap on
1225
+ // this region, or it implements the decision a drifted or missing pin, or a
1226
+ // plugin's finding, is about. A done item whose gap still fires here gets
1227
+ // work-done-gap-open.
1228
+ if (readsWork) {
1229
+ // Implementing a decision closes the gaps between it and the code or its
1230
+ // artifacts, not gaps in the decision record itself (its review, its
1231
+ // granularity, its evidence), so only these findings, and a plugin's,
1232
+ // count as addressed through implements.
1233
+ const CLOSED_BY_IMPLEMENTING = new Set<string>(["intent-pin-drifted", "intent-pin-missing"]);
1234
+ const fromGap = (h: WorkHit, code: string): boolean => {
1235
+ const src = gapSource(h.view.data);
1236
+ if (src === null || src.finding !== code) return false;
1237
+ const parsed = parseRegion(src.region);
1238
+ if ("error" in parsed) return false;
1239
+ const kindPrefix = h.kind.records!.workspaceRoot === "." ? "" : h.kind.records!.workspaceRoot;
1240
+ const full = joinPath(kindPrefix, parsed.path);
1241
+ const contains = (a: string, b: string) => a === b || a === "" || b.startsWith(`${a}/`);
1242
+ if (!contains(full, regionFull) && !contains(regionFull, full)) return false;
1243
+ if (full === regionFull && parsed.lines && region.lines) return parsed.lines.start <= region.lines.end && region.lines.start <= parsed.lines.end;
1244
+ return true;
1245
+ };
1246
+ for (const f of findings) {
1247
+ const by: WorkHit[] = [];
1248
+ for (const h of workAll) {
1249
+ const gap = fromGap(h, f.code);
1250
+ const viaDecision = (CLOSED_BY_IMPLEMENTING.has(f.code) || isPluginCode(f.code)) && implementedIds(h).some((id) => f.concerns.includes(id));
1251
+ if (gap || viaDecision) by.push(h);
1252
+ if (gap && h.view.state === workSpec(h).done) {
1253
+ const node = workNode(h);
1254
+ if (!node.warnings.some((w) => w.code === "work-done-gap-open")) {
1255
+ node.warnings.push({ code: "work-done-gap-open", message: `${node.record} is ${h.view.state}, and ${f.code}, the gap it came from, still fires on ${region.path}` });
1256
+ }
1257
+ }
1258
+ }
1259
+ f.addressed = by.length > 0;
1260
+ f.addressedBy = by.map((h) => ({ id: h.view.id!, state: h.view.state }));
1261
+ for (const h of by) edges.push({ kind: "addressed-by", from: f.id, to: workNode(h).id });
1262
+ }
1263
+ }
919
1264
  for (const f of findings) nodes.set(f.id, f);
920
1265
  const all = [...nodes.values()];
921
1266
  return {
@@ -925,7 +1270,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
925
1270
  workspace: { name: declaration.name, root: located.root },
926
1271
  region: rid,
927
1272
  history: { rev, follows: region.lines ? "line-range" : type === "file" ? "file" : "directory", shallow },
928
- kinds: kinds.map((k) => ({ file: k.display, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
1273
+ kinds: kinds.map((k) => ({ file: k.display, name: k.name, records: k.records?.loaded.kind.name ?? null, joins: k.joins?.form ?? null })),
929
1274
  nodes: all,
930
1275
  edges,
931
1276
  reasons,