@intentius/chant 0.85.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 (121) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +11 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  7. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  8. package/dist/workspace/checks/records.d.ts +1 -0
  9. package/dist/workspace/checks/records.d.ts.map +1 -1
  10. package/dist/workspace/checks.d.ts +4 -0
  11. package/dist/workspace/checks.d.ts.map +1 -1
  12. package/dist/workspace/composites.d.ts +14 -1
  13. package/dist/workspace/composites.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +211 -0
  15. package/dist/workspace/conformance/index.d.ts.map +1 -0
  16. package/dist/workspace/conformance/vitest.d.ts +11 -0
  17. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  18. package/dist/workspace/declaration.d.ts +28 -0
  19. package/dist/workspace/declaration.d.ts.map +1 -1
  20. package/dist/workspace/declaration.schema.json +40 -0
  21. package/dist/workspace/declared-kinds.d.ts +43 -0
  22. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  23. package/dist/workspace/graph-cli.d.ts +11 -0
  24. package/dist/workspace/graph-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-cli.d.ts +2 -1
  26. package/dist/workspace/intent-cli.d.ts.map +1 -1
  27. package/dist/workspace/intent-joins.d.ts +45 -8
  28. package/dist/workspace/intent-joins.d.ts.map +1 -1
  29. package/dist/workspace/intent.d.ts +66 -7
  30. package/dist/workspace/intent.d.ts.map +1 -1
  31. package/dist/workspace/ls.d.ts +31 -1
  32. package/dist/workspace/ls.d.ts.map +1 -1
  33. package/dist/workspace/reason-codes.d.ts +40 -4
  34. package/dist/workspace/reason-codes.d.ts.map +1 -1
  35. package/dist/workspace/record-sessions.d.ts +51 -0
  36. package/dist/workspace/record-sessions.d.ts.map +1 -0
  37. package/dist/workspace/record-source.d.ts +2 -0
  38. package/dist/workspace/record-source.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +65 -4
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-since.d.ts +90 -0
  42. package/dist/workspace/records-since.d.ts.map +1 -0
  43. package/dist/workspace/records-write.d.ts +164 -0
  44. package/dist/workspace/records-write.d.ts.map +1 -0
  45. package/dist/workspace/records.d.ts +202 -15
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/runtimes.d.ts +60 -0
  48. package/dist/workspace/runtimes.d.ts.map +1 -0
  49. package/dist/workspace/status-gates.d.ts +90 -0
  50. package/dist/workspace/status-gates.d.ts.map +1 -0
  51. package/dist/workspace/status.d.ts +17 -0
  52. package/dist/workspace/status.d.ts.map +1 -1
  53. package/dist/workspace/work.d.ts +56 -0
  54. package/dist/workspace/work.d.ts.map +1 -0
  55. package/package.json +19 -1
  56. package/src/cli/main.ts +48 -3
  57. package/src/cli/registry.ts +11 -1
  58. package/src/lifecycle/gate-ledger.ts +14 -0
  59. package/src/workspace/__fixtures__/sessions.ts +66 -0
  60. package/src/workspace/checks/records.ts +19 -0
  61. package/src/workspace/checks.test.ts +2 -0
  62. package/src/workspace/checks.ts +7 -1
  63. package/src/workspace/composites.schema.json +65 -3
  64. package/src/workspace/composites.test.ts +95 -5
  65. package/src/workspace/composites.ts +28 -7
  66. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  67. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  68. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  69. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  70. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  71. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  72. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  73. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  74. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  75. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  76. package/src/workspace/conformance/conformance.test.ts +149 -0
  77. package/src/workspace/conformance/index.mjs +31 -0
  78. package/src/workspace/conformance/index.ts +453 -0
  79. package/src/workspace/conformance/vitest.ts +62 -0
  80. package/src/workspace/declaration.schema.json +40 -0
  81. package/src/workspace/declaration.ts +62 -0
  82. package/src/workspace/declared-kinds.test.ts +321 -0
  83. package/src/workspace/declared-kinds.ts +76 -0
  84. package/src/workspace/graph-cli.ts +8 -0
  85. package/src/workspace/intent-cli.ts +29 -6
  86. package/src/workspace/intent-joins.test.ts +60 -0
  87. package/src/workspace/intent-joins.ts +71 -19
  88. package/src/workspace/intent.schema.json +282 -7
  89. package/src/workspace/intent.test.ts +97 -0
  90. package/src/workspace/intent.ts +332 -45
  91. package/src/workspace/ls.schema.json +34 -0
  92. package/src/workspace/ls.ts +69 -4
  93. package/src/workspace/read-contract.test.ts +30 -9
  94. package/src/workspace/reason-codes.test.ts +15 -4
  95. package/src/workspace/reason-codes.ts +47 -4
  96. package/src/workspace/record-assets.test.ts +3 -1
  97. package/src/workspace/record-sessions.ts +105 -0
  98. package/src/workspace/record-source.ts +14 -5
  99. package/src/workspace/records-amend.schema.json +167 -0
  100. package/src/workspace/records-cli.ts +246 -19
  101. package/src/workspace/records-contract.test.ts +57 -2
  102. package/src/workspace/records-formats.test.ts +640 -0
  103. package/src/workspace/records-new.schema.json +158 -0
  104. package/src/workspace/records-quorum.test.ts +196 -0
  105. package/src/workspace/records-review.schema.json +202 -0
  106. package/src/workspace/records-sessions.test.ts +108 -0
  107. package/src/workspace/records-since.schema.json +193 -0
  108. package/src/workspace/records-since.test.ts +174 -0
  109. package/src/workspace/records-since.ts +259 -0
  110. package/src/workspace/records-write-contract.test.ts +125 -0
  111. package/src/workspace/records-write.test.ts +373 -0
  112. package/src/workspace/records-write.ts +736 -0
  113. package/src/workspace/records.schema.json +187 -9
  114. package/src/workspace/records.ts +631 -41
  115. package/src/workspace/runtimes.ts +107 -0
  116. package/src/workspace/status-contract.test.ts +163 -0
  117. package/src/workspace/status-gates.ts +215 -0
  118. package/src/workspace/status.schema.json +69 -3
  119. package/src/workspace/status.ts +35 -2
  120. package/src/workspace/work.test.ts +388 -0
  121. package/src/workspace/work.ts +163 -0
@@ -564,3 +564,100 @@ describe("reads that fail, and parts that can't be read (#2651)", () => {
564
564
  expect(parseRegion("a/b.ts:0")).toHaveProperty("error");
565
565
  });
566
566
  });
567
+
568
+ describe("commitJoins lists records and names its findings (#2663)", () => {
569
+ test("a plugin joins commits by listing its records, with no trailer at all (#2663)", async () => {
570
+ const SCAN = `
571
+ export function commitJoins(commit, context) {
572
+ const walk = (dir) => (context.list(dir) ?? []).flatMap((p) => (p.endsWith("/") ? walk(p) : [p]));
573
+ for (const path of walk("unit-records")) {
574
+ const id = path.split("/").at(-1).replace(/\\.(json|md)$/, "");
575
+ const text = context.read(path);
576
+ if (path.endsWith(".json")) {
577
+ const record = JSON.parse(text);
578
+ if (record.result?.commit !== commit.sha) continue;
579
+ const probe = { missing: context.list("nope"), escape: context.list(".."), file: context.list(path), root: context.list(".").includes("unit-records/") };
580
+ return { unit: { id, path, probe }, contract: { id: record.contract } };
581
+ }
582
+ if (text.includes(commit.sha)) return { unit: { id, path } };
583
+ }
584
+ return undefined;
585
+ }
586
+ `;
587
+ writeFiles(root, {
588
+ "plugins/scan.kind.mjs": SCAN,
589
+ "unit-records/U-0100.json": JSON.stringify({ contract: "C-100", result: { commit: sha.c2 } }),
590
+ "unit-records/archive/U-0104.md": `---\nid: U-0104\n---\n\nLanded as ${sha.c4}.\n`,
591
+ });
592
+ commit(["record the units after their commits"]);
593
+ try {
594
+ const doc = await walk("app/server.mjs", { kinds: [KIND, "plugins/scan.kind.mjs"] });
595
+ // c4 carries no trailer; the markdown record in a subdirectory names it.
596
+ expect(node(doc, `commit:${sha.c4}`)).toMatchObject({ trailers: {} });
597
+ expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c4}`, to: "unit:U-0104" });
598
+ expect(node(doc, "unit:U-0104")).toMatchObject({ data: { path: "unit-records/archive/U-0104.md" } });
599
+ expect(doc.edges).toContainEqual({ kind: "produced-by", from: `commit:${sha.c2}`, to: "unit:U-0100" });
600
+ expect(doc.edges).toContainEqual({ kind: "serves", from: "unit:U-0100", to: "contract:C-100" });
601
+ expect(node(doc, "unit:U-0100")).toMatchObject({ data: { path: "unit-records/U-0100.json", probe: { root: true } } });
602
+ // Not a directory there, or outside the workspace: undefined.
603
+ expect((node(doc, "unit:U-0100") as { data: unknown }).data).toMatchObject({ probe: { missing: undefined, escape: undefined, file: undefined, root: true } });
604
+ expect(node(doc, `commit:${sha.c2b}`) && doc.edges.some((e) => e.kind === "produced-by" && e.from === `commit:${sha.c2b}`)).toBe(false);
605
+ expect(doc.reasons).toEqual([]);
606
+
607
+ // At c4 the records were not written yet: list answers from the tree read, so nothing joins.
608
+ const before = await walk("app/server.mjs", { kinds: [KIND, "plugins/scan.kind.mjs"], at: sha.c4 });
609
+ expect(before.nodes.filter((n) => n.kind === "unit")).toEqual([]);
610
+ expect(before.reasons).toEqual([]);
611
+ } finally {
612
+ git(root, "reset", "-q", "--hard", sha.c4);
613
+ }
614
+ });
615
+
616
+ test("commitJoinsName names a plugin's findings apart from its record kind's name (#2663)", async () => {
617
+ const NAMED = (name: string) => `
618
+ export { recordKind } from "./decision.kind.mjs";
619
+ export const commitJoinsName = ${JSON.stringify(name)};
620
+ export function commitJoins(commit) {
621
+ if (commit.trailers["Unit"]?.[0] !== "U-0002") return undefined;
622
+ return { findings: [{ code: "plugin:studio:contract-moved", message: "moved", refs: [commit.sha] }] };
623
+ }
624
+ `;
625
+ writeFiles(root, { "decisions/studio.kind.mjs": NAMED("studio"), "decisions/other.kind.mjs": NAMED("other") });
626
+ commit(["name the findings"]);
627
+ try {
628
+ const doc = await walk("app/server.mjs", { kinds: ["decisions/studio.kind.mjs"] });
629
+ expect(doc.kinds).toEqual([{ file: "decisions/studio.kind.mjs", name: "studio", records: "decision", joins: "function" }]);
630
+ expect(doc.reasons).toEqual([]);
631
+ expect(doc.nodes).toContainEqual(expect.objectContaining({ kind: "finding", code: "plugin:studio:contract-moved", concerns: [`commit:${sha.c2b}`] }));
632
+ // The decisions still come from the record kind called decision.
633
+ expect(node(doc, "record:decision/dec-001")).toBeDefined();
634
+
635
+ // Under another name, the same code is outside the namespace, and the record kind's name is no longer it either.
636
+ const { doc: other, failed } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, "decisions/other.kind.mjs")] });
637
+ expectValid(other);
638
+ if ("error" in other) throw new Error(other.error.message);
639
+ expect(failed).toBe(true);
640
+ expect(other.reasons).toEqual([{ code: "intent-plugin-failed", message: expect.stringContaining("plugin:other:<code>") }]);
641
+ } finally {
642
+ git(root, "reset", "-q", "--hard", sha.c4);
643
+ }
644
+ });
645
+
646
+ test("a malformed commitJoinsName is kind-invalid (#2663)", async () => {
647
+ writeFiles(root, { "plugins/badname.kind.mjs": `export const commitJoinsName = "a:b";\nexport function commitJoins() {}\n`, "plugins/nameonly.kind.mjs": `export const commitJoinsName = "x";\n` });
648
+ commit(["malformed names"]);
649
+ try {
650
+ for (const [file, text] of [
651
+ ["plugins/badname.kind.mjs", "no colon or space"],
652
+ ["plugins/nameonly.kind.mjs", "no commitJoins"],
653
+ ]) {
654
+ const { doc } = await intentGraph({ cwd: root, region: "app/server.mjs", kinds: [join(root, file)] });
655
+ expectValid(doc);
656
+ expect("error" in doc && doc.error.code).toBe("kind-invalid");
657
+ expect("error" in doc && doc.error.message).toContain(text);
658
+ }
659
+ } finally {
660
+ git(root, "reset", "-q", "--hard", sha.c4);
661
+ }
662
+ });
663
+ });
@@ -26,28 +26,32 @@
26
26
  * inside a decision's window is not taken as that decision's work: unless
27
27
  * the decision's own unit made it, it is `decided-by-window`, shown for the
28
28
  * person to judge (#2656). A plugin's `commitJoins` may add findings of its
29
- * own, in its own code namespace. chant emits the
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
30
32
  * graph and hud renders it (#2524 D8, D15). Git is read through a local
31
33
  * `git` subprocess only: no fetch, no network.
32
34
  */
33
35
 
34
36
  import { execFileSync } from "node:child_process";
35
37
  import { realpathSync } from "node:fs";
36
- import { basename, relative, resolve, sep } from "node:path";
37
- 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";
38
41
  import { classifyFile, declaredFilesUnder } from "./generated-files";
39
42
  import { entityDecisions, hasTrailer, readCommitJoins, runCommitJoins, type CommitJoins, type IntentCommit, type JoinedEntity, type PluginFinding } from "./intent-joins";
40
43
  import { loadKindRegistry } from "./kinds";
41
44
  import { resolveLinks, type LinkTableRow } from "./links";
42
45
  import { sourceMemberHandles } from "./member-handles";
43
46
  import { constraintCovers, isWorkspacePath, memberHolding } from "./record-assets";
44
- import { importKindModule, loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
47
+ import { importKindModule, loadRecordKind, parseFrontMatter, RecordReadError, supersedesTargets, type LoadedRecordKind } from "./records";
45
48
  import { queryRecords, type RecordView } from "./records-cli";
46
- import type { PluginCode, ReasonCode } from "./reason-codes";
49
+ import { isPluginCode, type PluginCode, type ReasonCode } from "./reason-codes";
47
50
  import { joinPath, skippedDir, type WorkspaceTree } from "./tree";
48
51
  import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
49
52
  import { commitProvenance, policyAtBase, resolveBase } from "./trust/provenance";
50
53
  import { locateWorkspace, type LocatedWorkspace } from "./which-chant";
54
+ import { idList, isDecided, WORK_WARNING_CODES, type WorkLink, type WorkWarningCode } from "./work";
51
55
 
52
56
  /** The version of the `intent` document this chant writes. */
53
57
  export const INTENT_CONTRACT_VERSION = 1;
@@ -83,6 +87,12 @@ export const INTENT_FINDING_CODES = [
83
87
  "intent-trailer-unverified",
84
88
  /** No decision constrains the region at any granularity. */
85
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",
86
96
  ] as const satisfies readonly ReasonCode[];
87
97
  export type IntentFindingCode = (typeof INTENT_FINDING_CODES)[number];
88
98
 
@@ -259,9 +269,53 @@ export interface FindingNode {
259
269
  plugin?: string;
260
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. */
261
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[];
262
276
  }
263
277
 
264
- 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;
265
319
 
266
320
  export type Granularity = "path" | "member" | "contract" | "issue";
267
321
 
@@ -269,8 +323,8 @@ export type IntentEdge =
269
323
  | { kind: "constrains"; from: string; to: string; granularity: Granularity; entry: string }
270
324
  | { kind: "pins"; from: string; to: string; pinnedSha256: string | null; pinState: PinState }
271
325
  | { kind: "touched-by"; from: string; to: string; lines: LineRange[] | null }
272
- | { kind: "within"; from: string; to: string; state: "decided" | "decided-by-window" }
273
- | { 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 };
274
328
 
275
329
  export interface IntentReason {
276
330
  code: IntentReasonCode;
@@ -303,7 +357,11 @@ export interface IntentQuery {
303
357
  /** `path`, `path:line` or `path:start-end`, or a graph node id `<member>/<id>`. */
304
358
  region: string;
305
359
  at?: string;
306
- /** 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
+ */
307
365
  kinds?: string[];
308
366
  /**
309
367
  * Resolve a graph node id to its source location, for a region given as a
@@ -403,6 +461,25 @@ function addingCommit(top: string, rev: string, path: string): string | null {
403
461
  return out?.trim() || null;
404
462
  }
405
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
+
406
483
  /** `commit` and every commit between it and `rev` that has it as an ancestor. */
407
484
  function descendants(top: string, commit: string, rev: string): Set<string> {
408
485
  const out = new Set<string>([commit]);
@@ -469,6 +546,20 @@ function stringOr(v: unknown): string | null {
469
546
  return typeof v === "string" ? v : null;
470
547
  }
471
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
+
472
563
  function reviewSummary(data: Record<string, unknown> | null): DecisionNode["reviews"] {
473
564
  const out = { agree: 0, dissent: 0, abstain: 0, openConcerns: 0 };
474
565
  const list = data?.reviews;
@@ -506,7 +597,7 @@ interface LoadedKind {
506
597
  file: string;
507
598
  /** Relative to the repository root, for the document. */
508
599
  display: string;
509
- /** The record kind's name, or the file's name without `.kind.mjs`: the namespace of its plugin findings. */
600
+ /** The namespace of its plugin findings: its `commitJoinsName`, the record kind's name, or the file's name without `.kind.mjs`. */
510
601
  name: string;
511
602
  records?: { loaded: LoadedRecordKind; views: RecordView[]; workspaceRoot: string };
512
603
  joins?: CommitJoins;
@@ -525,13 +616,13 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
525
616
  }
526
617
  const joins = readCommitJoins(mod);
527
618
  if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
528
- const kind: LoadedKind = { file, display, name: basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
619
+ const kind: LoadedKind = { file, display, name: joins?.name ?? basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
529
620
  if (mod.recordKind !== undefined) {
530
621
  const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
531
622
  if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
532
623
  try {
533
624
  kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
534
- kind.name = kind.records.loaded.kind.name;
625
+ kind.name = joins?.name ?? kind.records.loaded.kind.name;
535
626
  } catch (err) {
536
627
  if (err instanceof RecordReadError) throw new IntentError(err.code as IntentErrorCode, err.message);
537
628
  throw err;
@@ -591,7 +682,9 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
591
682
  const count = located.tree.read(region.path).replace(/\n$/, "").split("\n").length;
592
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`);
593
684
  }
594
- 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);
595
688
 
596
689
  const nodes = new Map<string, IntentNode>();
597
690
  const edges: IntentEdge[] = [];
@@ -634,6 +727,13 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
634
727
  if (!isWorkspacePath(path) || located.tree.stat(path) !== "file") return undefined;
635
728
  return located.tree.read(path);
636
729
  };
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
+ };
637
737
  interface Joined {
638
738
  unit?: string;
639
739
  contracts: string[];
@@ -665,7 +765,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
665
765
  if (!k.joins) continue;
666
766
  let result;
667
767
  try {
668
- result = await runCommitJoins(k.joins, c, { read: readAt, at: located.at }, k.name);
768
+ result = await runCommitJoins(k.joins, c, { read: readAt, list: listAt, at: located.at }, k.name);
669
769
  } catch (err) {
670
770
  const message = `${k.display}: commitJoins failed for ${c.sha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`;
671
771
  if (!failedPlugins.has(message)) reasons.push({ code: "intent-plugin-failed", message });
@@ -708,10 +808,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
708
808
  const decisionId = (k: LoadedKind, id: string) => `record:${k.records!.loaded.kind.name}/${id}`;
709
809
  const decisionNode = (k: LoadedKind, v: RecordView): DecisionNode => {
710
810
  const kind = k.records!.loaded.kind;
711
- const links = v.data?.[kind.supersedes.field];
712
- const supersedes = Array.isArray(links)
713
- ? links.map((l) => (l && typeof l === "object" ? (l as Record<string, unknown>)[kind.supersedes.key] : undefined)).filter((x): x is string => typeof x === "string")
714
- : [];
811
+ const supersedes = supersedesTargets(kind, v.data);
715
812
  return add<DecisionNode>({
716
813
  id: decisionId(k, v.id!),
717
814
  kind: "decision",
@@ -720,7 +817,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
720
817
  path: v.path,
721
818
  title: stringOr(v.data?.title),
722
819
  state: v.state,
723
- closed: v.state !== null && kind.closedStates.includes(v.state),
820
+ closed: v.state !== null && (kind.closedStates ?? []).includes(v.state),
724
821
  valid: v.valid,
725
822
  reasons: v.reasons,
726
823
  provenance: { level: v.provenance.level, commit: v.provenance.commit, reason: v.provenance.reason },
@@ -733,36 +830,41 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
733
830
  });
734
831
  };
735
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
+ };
736
862
  for (const k of kinds) {
737
- if (!k.records) continue;
738
- const field = k.records.loaded.kind.constrains?.field;
739
- const kindPrefix = k.records.workspaceRoot === "." ? "" : k.records.workspaceRoot;
863
+ if (!k.records || k.records.loaded.kind.work) continue;
740
864
  for (const v of k.records.views) {
741
865
  if (v.id === null) continue;
742
866
  decisionById.set(`${k.records.loaded.kind.name}/${v.id}`, { view: v, kind: k });
743
- const list = field ? v.data?.[field] : undefined;
744
- if (!Array.isArray(list)) continue;
745
- const matched: { entry: string; granularity: Granularity; to: string }[] = [];
746
- for (const entry of list) {
747
- if (typeof entry !== "string") continue;
748
- if (entry.startsWith("path:")) {
749
- const p = entry.slice("path:".length);
750
- if (!isWorkspacePath(p)) continue;
751
- const full = joinPath(kindPrefix, p);
752
- if (regionFull !== "" && constraintCovers(full, regionFull)) {
753
- matched.push({ entry, granularity: "path", to: rid });
754
- } else if (type === "dir" && full !== regionFull && (regionFull === "" || full.startsWith(`${regionFull}/`))) {
755
- // A path below a directory region constrains the files under it, not the whole region.
756
- for (const f of fileNodesUnder(full)) matched.push({ entry, granularity: "path", to: `file:${f}` });
757
- }
758
- } else if (entry.startsWith("member:")) {
759
- if (member !== null && entry.slice("member:".length) === member) matched.push({ entry, granularity: "member", to: rid });
760
- } else {
761
- const issue = entry.match(ISSUE);
762
- if (issue && origin !== null && issue[1].toLowerCase() === origin && commitRefs.has(issue[2])) matched.push({ entry, granularity: "issue", to: rid });
763
- else if (contractIds.has(entry)) matched.push({ entry, granularity: "contract", to: `contract:${entry}` });
764
- }
765
- }
867
+ const matched = matchConstrains(k, v);
766
868
  if (matched.length === 0) continue;
767
869
  const node = decisionNode(k, v);
768
870
  for (const m of matched) {
@@ -885,6 +987,116 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
885
987
  windows.set(c.node.id, { from: added ? descendants(top, added, rev) : new Set(), until: replaced ? descendants(top, replaced, rev) : null });
886
988
  }
887
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
+
888
1100
  const coveredAt = (sha: string, granularities: Granularity[]) =>
889
1101
  covering.filter((c) => {
890
1102
  if (!c.node.constrains.some((x) => granularities.includes(x.granularity))) return false;
@@ -905,6 +1117,10 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
905
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" });
906
1118
  c.state = inWindow.length === 0 ? "undecided" : inWindow.some((w) => ownWork(j, w.node)) ? "decided" : "decided-by-window";
907
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
+ }
908
1124
  if (readsRecords && c.state === "undecided") {
909
1125
  find("intent-commit-undecided", `${t.sha.slice(0, 8)} changed the region when no decision constrained ${region.path} by path`, [c.id, rid]);
910
1126
  }
@@ -933,6 +1149,37 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
933
1149
  }
934
1150
  }
935
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
+
936
1183
  // 5. Links: the declared member links that touch the region's member, read in source.
937
1184
  if (member) {
938
1185
  let rows: LinkTableRow[] = [];
@@ -974,6 +1221,46 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
974
1221
  const concerns = [...new Set([commit, ...refs.map(resolveRef).filter((x): x is string => x !== undefined)])];
975
1222
  findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
976
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
+ }
977
1264
  for (const f of findings) nodes.set(f.id, f);
978
1265
  const all = [...nodes.values()];
979
1266
  return {