@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
@@ -5,13 +5,14 @@
5
5
  * section B: the region, its decisions, their artifacts, the commits, and the
6
6
  * findings. Under each decision come the commits made inside its window, and
7
7
  * when any of them is not the decision's own work, the question #2650 B puts
8
- * to the person about it (#2656).
8
+ * to the person about it (#2656). Without `--kind`, the walk reads every
9
+ * record kind the declaration names (#2680).
9
10
  */
10
11
 
11
12
  import { resolve } from "node:path";
12
13
  import { formatError } from "../cli/format";
13
14
  import type { CommandContext } from "../cli/registry";
14
- import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode } from "./intent";
15
+ import { intentGraph, type ArtifactNode, type CommitNode, type DecisionNode, type IntentDocument, type IntentEdge, type IntentNode, type WorkNode } from "./intent";
15
16
 
16
17
  const USAGE = "chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
17
18
 
@@ -57,6 +58,22 @@ function decisionLine(d: DecisionNode): string {
57
58
  return `decision ${d.record} ${d.state ?? "stateless"}${superseded}: ${d.title ?? d.path}; constrains ${via}${by}; ${reviews}; ${d.provenance.level}${d.valid ? "" : `; invalid: ${d.reasons.map((r) => r.code).join(", ")}`}`;
58
59
  }
59
60
 
61
+ /** A work item, then the commits made inside its window (#2683). */
62
+ function workLines(doc: Result, w: WorkNode): string[] {
63
+ const via = w.constrains.length > 0 ? w.constrains.map((c) => `${c.entry} (${c.granularity})`).join(", ") : "through a link only";
64
+ const implemented = w.implements.length > 0 ? `; implements ${w.implements.map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ")}` : "";
65
+ const readiness = w.ready ? "; ready" : w.blockedBy.length > 0 ? `; blocked by ${w.blockedBy.map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ")}` : "";
66
+ const owner = w.owner ? `, owned by ${w.owner}` : "";
67
+ const from = w.source ? `; from ${w.source.finding} on ${w.source.region}` : "";
68
+ const out = [`work ${w.record} ${w.state ?? "stateless"}${owner}: ${w.title ?? w.path}; constrains ${via}${implemented}${readiness}${from}`];
69
+ for (const e of doc.edges.filter((x) => x.kind === "within" && x.to === w.id)) {
70
+ const c = doc.nodes.find((n): n is CommitNode => n.kind === "commit" && n.id === e.from);
71
+ if (c) out.push(` worked ${short(c.sha)} ${c.subject}; in ${w.record}'s window`);
72
+ }
73
+ for (const x of w.warnings) out.push(` warning ${x.code}: ${x.message}`);
74
+ return out;
75
+ }
76
+
60
77
  function artifactLine(doc: Result, a: ArtifactNode): string {
61
78
  const by = doc.edges
62
79
  .filter((e): e is Extract<IntentEdge, { kind: "pins" }> => e.kind === "pins" && e.to === a.id)
@@ -88,16 +105,21 @@ export function formatIntent(doc: Result): string {
88
105
  const files = ofKind(doc, "file");
89
106
  if (files.length > 0) out.push(`files ${files.length} under the region, ${files.filter((f) => f.generated).length} generated`);
90
107
  for (const d of ofKind(doc, "decision")) out.push(decisionLine(d), ...withinLines(doc, d));
108
+ for (const w of ofKind(doc, "work")) out.push(...workLines(doc, w));
91
109
  for (const a of ofKind(doc, "artifact")) out.push(artifactLine(doc, a));
92
110
  for (const c of ofKind(doc, "commit")) out.push(...commitLine(doc, c));
93
111
  for (const l of ofKind(doc, "link")) {
94
112
  const r = l.row;
95
113
  out.push(`link ${r.consumer} reads ${"producer" in r ? `${r.producer} ${r.output}` : r.input} (${r.status})`);
96
114
  }
97
- for (const f of ofKind(doc, "finding")) out.push(`finding ${f.code}: ${f.message}`);
115
+ for (const f of ofKind(doc, "finding")) {
116
+ const by = f.addressedBy && f.addressedBy.length > 0 ? `; addressed by ${f.addressedBy.map((w) => `${w.id} (${w.state ?? "unknown"})`).join(", ")}` : "";
117
+ out.push(`finding ${f.code}: ${f.message}${by}`);
118
+ }
98
119
  for (const r of doc.reasons) out.push(`reason ${r.code}: ${r.message}`);
99
120
  const kinds = doc.kinds.length === 0 ? "; no --kind, so no decisions were read" : "";
100
- out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
121
+ const work = ofKind(doc, "work").length;
122
+ out.push(`${doc.summary.commits} commits, ${doc.summary.decisions} decisions, ${work > 0 ? `${work} work ${work === 1 ? "item" : "items"}, ` : ""}${doc.summary.artifacts} artifacts, ${doc.summary.findings} findings${kinds}`);
101
123
  return out.join("\n");
102
124
  }
103
125
 
@@ -107,8 +129,9 @@ export async function runWorkspaceIntent(ctx: CommandContext, cwd: string): Prom
107
129
  console.error(formatError({ message: "--intent needs a region: a path, path:line or path:start-end", hint: USAGE }));
108
130
  return 1;
109
131
  }
110
- const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : []);
111
- const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds.map((k) => resolve(k)) });
132
+ // No --kind: undefined, so the walk reads the kinds the declaration names (#2680).
133
+ const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : undefined);
134
+ const { doc, failed } = await intentGraph({ cwd, region: args.intent, at: args.at, kinds: kinds?.map((k) => resolve(k)) });
112
135
  if (args.json) console.log(JSON.stringify(doc, null, 2));
113
136
  if ("error" in doc) {
114
137
  console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The commit-join hook's data form and its export checks, with no git
3
+ * (#2651, #2663).
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { joinByData, readCommitJoins, trailerValue, trailerValues, type CommitJoinContext, type IntentCommit } from "./intent-joins";
8
+
9
+ const commit = (trailers: Record<string, string[]>): IntentCommit => ({ sha: "a".repeat(40), subject: "s", body: "", author: { name: "t", email: "t@example.com" }, date: "2026-09-24T00:00:00Z", trailers });
10
+
11
+ const files: Record<string, string> = {
12
+ "evidence/E-1.json": JSON.stringify({ id: "ignored", kind: "test-run" }),
13
+ "evidence/E-3.json": JSON.stringify({ kind: "review" }),
14
+ "units/U-1.json": JSON.stringify({ role: "implement" }),
15
+ };
16
+ const context: CommitJoinContext = { read: (p) => files[p], list: () => undefined, at: null };
17
+
18
+ describe("the data form reads every trailer value (#2663)", () => {
19
+ test("each value of the evidence trailer, under any case of its key, is one piece of evidence", () => {
20
+ const data = { trailers: { unit: "Unit", evidence: "Evidence" }, records: { unit: "units/{id}.json", evidence: "evidence/{id}.json" } };
21
+ const out = joinByData(data, commit({ Unit: ["U-1"], Evidence: ["E-1", " E-2 ", "E-1"], evidence: ["E-3", ""] }), context);
22
+ expect(out).toEqual({
23
+ unit: { id: "U-1", role: "implement" },
24
+ evidence: [
25
+ { id: "E-1", kind: "test-run" },
26
+ { id: "E-2" },
27
+ { id: "E-3", kind: "review" },
28
+ ],
29
+ });
30
+ });
31
+
32
+ test("a unit or contract trailer gives its first value, since a commit has one of each", () => {
33
+ const out = joinByData({ trailers: { unit: "Unit", contract: "Contract" } }, commit({ Unit: ["U-1", "U-2"], contract: ["C-1"], Contract: ["C-2"] }), context);
34
+ expect(out.unit).toEqual({ id: "U-1" });
35
+ expect(out.contract?.id).toBe(trailerValue({ contract: ["C-1"], Contract: ["C-2"] }, "Contract"));
36
+ });
37
+
38
+ test("trailerValues keeps git's order, trims, and drops empty values and repeats", () => {
39
+ expect(trailerValues({ "Chud-Evidence": ["h1", "h2"], "chud-evidence": ["h2", " h3", " "] }, "CHUD-EVIDENCE")).toEqual(["h1", "h2", "h3"]);
40
+ expect(trailerValues({}, "X")).toEqual([]);
41
+ expect(trailerValue({ X: [" "] }, "x")).toBeUndefined();
42
+ });
43
+ });
44
+
45
+ describe("commitJoinsName (#2663)", () => {
46
+ test("names the findings of either form, and leaves the data form's own keys alone", () => {
47
+ const join = () => undefined;
48
+ expect(readCommitJoins({ commitJoins: join, commitJoinsName: "chud" })).toEqual({ form: "function", join, name: "chud" });
49
+ expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "chud" })).toEqual({ form: "data", data: { trailers: { unit: "Unit" } }, name: "chud" });
50
+ expect(readCommitJoins({ commitJoins: join })).toEqual({ form: "function", join });
51
+ // A name inside the data form is still a key the data form does not have.
52
+ expect(readCommitJoins({ commitJoins: { name: "chud", trailers: {} } })).toMatch(/Unrecognized key/);
53
+ });
54
+
55
+ test("a name that can't be a plugin:<name>: segment, or one with no joins, is refused", () => {
56
+ for (const bad of ["a:b", "a b", "", 7]) expect(readCommitJoins({ commitJoins: () => undefined, commitJoinsName: bad }), String(bad)).toMatch(/^commitJoinsName:/);
57
+ expect(readCommitJoins({ commitJoinsName: "chud" })).toMatch(/no commitJoins/);
58
+ expect(readCommitJoins({})).toBeUndefined();
59
+ });
60
+ });
@@ -9,11 +9,16 @@
9
9
  *
10
10
  * - A function `commitJoins(commit, context)` that returns the unit, contract
11
11
  * and evidence for one commit, or nothing. It is given the commit's sha,
12
- * subject, body, author, date and trailers, and a `read` function for files
13
- * at the revision read.
12
+ * subject, body, author, date and trailers, and a context whose `read(path)`
13
+ * returns a file and whose `list(dir)` lists a directory, both in the tree
14
+ * read (#2663). With `list` a plugin can find the record that names a
15
+ * commit, such as a unit record whose `result.commit` is the commit's sha,
16
+ * so the commit needs no trailer at all.
14
17
  * - Data, which core interprets with no plugin code: the trailer keys that
15
18
  * name a unit, a contract or evidence, the record paths to read for each
16
- * (`units/{id}.json`), and the trailer keys that claim authorship.
19
+ * (`units/{id}.json`), and the trailer keys that claim authorship. Every
20
+ * value of the evidence trailer is read; a unit or contract trailer gives
21
+ * its first value, since a commit has one unit and one contract.
17
22
  *
18
23
  * Either way core never parses a plugin's own trailer or record format: it
19
24
  * reads the trailers git reports and hands them over, and a key means
@@ -27,6 +32,14 @@
27
32
  * about, which the graph carries as finding nodes. That is how a plugin says
28
33
  * what it knows and core does not, such as a contract whose criteria changed
29
34
  * in a commit that names no decision.
35
+ *
36
+ * `<name>` is the kind's name by default: its record kind's name, or the
37
+ * file's name without `.kind.mjs`. A kind file may name its findings itself
38
+ * with a sibling export, `commitJoinsName` (#2663), so joins that live beside
39
+ * a record kind called `decision` can still report `plugin:chud:<code>`. It is
40
+ * a sibling export rather than a property because the data form is a closed
41
+ * object whose keys are all joins, and a function's `name` property is
42
+ * already its own name.
30
43
  */
31
44
 
32
45
  import { z } from "zod";
@@ -47,6 +60,18 @@ export interface IntentCommit {
47
60
  export interface CommitJoinContext {
48
61
  /** The text of a file from the workspace root, in the tree read; undefined when it is not a file there. */
49
62
  read(path: string): string | undefined;
63
+ /**
64
+ * The entries directly inside directory `dir`, in the same tree as `read`
65
+ * (#2663): paths from the workspace root, sorted, a directory's with a
66
+ * trailing `/`. `""` or `"."` is the workspace root. Undefined when `dir` is
67
+ * not a directory there.
68
+ *
69
+ * The tree is the one the graph reads, the commit `--at` names or the
70
+ * working tree, and not each joined commit's own tree: a record that names
71
+ * a commit's sha was written after that commit, so it is only in a later
72
+ * tree.
73
+ */
74
+ list(dir: string): string[] | undefined;
50
75
  /** The full commit id read with `--at`, or null for the working tree. */
51
76
  at: string | null;
52
77
  }
@@ -63,7 +88,7 @@ export interface JoinedEntity {
63
88
 
64
89
  /** A finding a plugin contributes for one commit (#2656). */
65
90
  export interface PluginFinding {
66
- /** `plugin:<name>:<code>`, where `<name>` is the kind's name. */
91
+ /** `plugin:<name>:<code>`, where `<name>` is the kind file's `commitJoinsName`, or else the kind's name. */
67
92
  code: string;
68
93
  message: string;
69
94
  /** What the finding is about: node ids, commit shas, record ids, unit, contract or evidence ids, or paths. */
@@ -103,24 +128,49 @@ export const commitJoinsDataSchema = z
103
128
 
104
129
  export type CommitJoinsData = z.infer<typeof commitJoinsDataSchema>;
105
130
 
106
- /** A kind file's `commitJoins` export, checked: a function, or data. */
107
- export type CommitJoins = { form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData };
131
+ /**
132
+ * A kind file's `commitJoins` export, checked: a function, or data. `name` is
133
+ * its `commitJoinsName` export, when it has one: the namespace of its findings.
134
+ */
135
+ export type CommitJoins = ({ form: "function"; join: CommitJoinsFunction } | { form: "data"; data: CommitJoinsData }) & { name?: string };
136
+
137
+ /** What a `commitJoinsName` may be: the `<name>` of `plugin:<name>:<code>`. */
138
+ const JOINS_NAME = /^[^:\s]+$/;
108
139
 
109
- /** Read a kind module's `commitJoins` export. Undefined when it has none; a string when it is malformed. */
140
+ /**
141
+ * Read a kind module's `commitJoins` export and its `commitJoinsName`.
142
+ * Undefined when it has no `commitJoins`; a string when either is malformed,
143
+ * or when it names findings it has no joins for.
144
+ */
110
145
  export function readCommitJoins(mod: Record<string, unknown>): CommitJoins | string | undefined {
111
146
  const value = mod.commitJoins;
112
- if (value === undefined) return undefined;
113
- if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction };
147
+ const name = mod.commitJoinsName;
148
+ if (value === undefined) return name === undefined ? undefined : "commitJoinsName: a kind file with no commitJoins has no findings to name";
149
+ if (name !== undefined && (typeof name !== "string" || !JOINS_NAME.test(name))) return "commitJoinsName: a string with no colon or space, the <name> of plugin:<name>:<code>";
150
+ const named = name === undefined ? {} : { name: name as string };
151
+ if (typeof value === "function") return { form: "function", join: value as CommitJoinsFunction, ...named };
114
152
  const parsed = commitJoinsDataSchema.safeParse(value);
115
153
  if (!parsed.success) return parsed.error.issues.map((i) => `commitJoins${i.path.length ? `.${i.path.join(".")}` : ""}: ${i.message}`).join("; ");
116
- return { form: "data", data: parsed.data };
154
+ return { form: "data", data: parsed.data, ...named };
155
+ }
156
+
157
+ /** Every value of trailer `key`, trimmed, without empty ones or repeats, compared without case as git does. */
158
+ export function trailerValues(trailers: Record<string, string[]>, key: string): string[] {
159
+ const want = key.toLowerCase();
160
+ const out: string[] = [];
161
+ for (const [k, values] of Object.entries(trailers)) {
162
+ if (k.toLowerCase() !== want) continue;
163
+ for (const v of values) {
164
+ const t = v.trim();
165
+ if (t && !out.includes(t)) out.push(t);
166
+ }
167
+ }
168
+ return out;
117
169
  }
118
170
 
119
171
  /** The first value of trailer `key`, compared without case as git does. */
120
172
  export function trailerValue(trailers: Record<string, string[]>, key: string): string | undefined {
121
- const want = key.toLowerCase();
122
- for (const [k, values] of Object.entries(trailers)) if (k.toLowerCase() === want && values.length > 0) return values[0].trim() || undefined;
123
- return undefined;
173
+ return trailerValues(trailers, key)[0];
124
174
  }
125
175
 
126
176
  /** Whether the commit carries trailer `key`. */
@@ -147,14 +197,15 @@ function recordFields(template: string | undefined, id: string, context: CommitJ
147
197
  /** Interpret the data form for one commit. Throws when a named record can't be read as JSON. */
148
198
  export function joinByData(data: CommitJoinsData, commit: IntentCommit, context: CommitJoinContext): CommitJoin {
149
199
  const out: CommitJoin = {};
150
- for (const part of ["unit", "contract", "evidence"] as const) {
200
+ const entity = (part: "unit" | "contract" | "evidence", id: string): JoinedEntity => ({ ...recordFields(data.records?.[part], id, context), id });
201
+ for (const part of ["unit", "contract"] as const) {
151
202
  const key = data.trailers[part];
152
203
  const id = key ? trailerValue(commit.trailers, key) : undefined;
153
- if (!id) continue;
154
- const entity: JoinedEntity = { ...recordFields(data.records?.[part], id, context), id };
155
- if (part === "evidence") out.evidence = entity;
156
- else out[part] = entity;
204
+ if (id) out[part] = entity(part, id);
157
205
  }
206
+ // Each value of the evidence trailer is one piece of evidence (#2663).
207
+ const evidence = data.trailers.evidence ? trailerValues(commit.trailers, data.trailers.evidence) : [];
208
+ if (evidence.length > 0) out.evidence = evidence.map((id) => entity("evidence", id));
158
209
  const claimed = (data.authorship ?? []).filter((k) => hasTrailer(commit.trailers, k));
159
210
  if (claimed.length > 0) out.authorship = claimed;
160
211
  return out;
@@ -168,7 +219,8 @@ export function entityDecisions(entity: JoinedEntity | undefined): string[] {
168
219
 
169
220
  /**
170
221
  * Run a kind's joins for one commit, checking what a function returns.
171
- * `name` is the kind's name, the namespace its findings' codes must use.
222
+ * `name` is the namespace its findings' codes must use: the kind file's
223
+ * `commitJoinsName`, or else the kind's name.
172
224
  */
173
225
  export async function runCommitJoins(joins: CommitJoins, commit: IntentCommit, context: CommitJoinContext, name: string): Promise<CommitJoin> {
174
226
  if (joins.form === "data") return joinByData(joins.data, commit, context);
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/intent/v1/intent.schema.json",
4
4
  "title": "chant workspace graph --intent output",
5
- "description": "What `chant workspace graph --intent <region> --json` prints: the intent graph over one region of a workspace (#2651, #2650 section C1, #2524 D8 and D15). The region, the commits that touched it, the decisions whose constrains cover it and their supersession chains, the artifacts those decisions pin, the units, contracts and evidence a plugin joins to the commits, the member links of the region's member, and findings as nodes with closed codes. chant emits it and a reader such as hud draws it. Readers ignore fields and node or edge kinds they do not know; a field is only ever added within a version. The finding, reason and error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2651; a chant without --intent refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
5
+ "description": "What `chant workspace graph --intent <region> --json` prints: the intent graph over one region of a workspace (#2651, #2650 section C1, #2524 D8 and D15). The region, the commits that touched it, the decisions whose constrains cover it and their supersession chains, the work items whose constrains cover it (#2683), the artifacts those decisions pin, the units, contracts and evidence a plugin joins to the commits, the member links of the region's member, and findings as nodes with closed codes. chant emits it and a reader such as hud draws it. Readers ignore fields and node or edge kinds they do not know; a field is only ever added within a version. The finding, reason and error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2651; a chant without --intent refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -116,7 +116,7 @@
116
116
  },
117
117
  "name": {
118
118
  "type": "string",
119
- "description": "The kind's name: the record kind's name, or the file's name without .kind.mjs for a plugin with no records. A plugin's findings use it as their namespace, plugin:<name>:<code> (#2656). Added within version 1, so an older document may lack it."
119
+ "description": "The kind's name: the file's commitJoinsName export when it has one (#2663), else the record kind's name, or the file's name without .kind.mjs for a plugin with no records. A plugin's findings use it as their namespace, plugin:<name>:<code> (#2656). Added within version 1, so an older document may lack it."
120
120
  },
121
121
  "records": {
122
122
  "type": [
@@ -126,6 +126,7 @@
126
126
  "description": "The record kind's name, or null for a plugin with no records."
127
127
  },
128
128
  "joins": {
129
+ "description": "The form of the file's commitJoins export: a function commitJoins(commit, context), whose context has read(path) for a file and list(dir) for a directory's entries, both from the workspace root in the tree read (#2663); data, naming the trailers and record paths core reads, with every value of the evidence trailer read; or null when it has none.",
129
130
  "enum": [
130
131
  "function",
131
132
  "data",
@@ -204,6 +205,9 @@
204
205
  {
205
206
  "$ref": "#/$defs/decision"
206
207
  },
208
+ {
209
+ "$ref": "#/$defs/work"
210
+ },
207
211
  {
208
212
  "$ref": "#/$defs/artifact"
209
213
  },
@@ -713,6 +717,240 @@
713
717
  }
714
718
  }
715
719
  },
720
+ "work": {
721
+ "description": "A work item (#2683), a record of a kind with a work block: in the graph when its constrains cover the region as a decision's would, or when a work item there implements, needs or addresses it. Read only when a work kind is passed with --kind.",
722
+ "type": "object",
723
+ "required": [
724
+ "id",
725
+ "kind",
726
+ "recordKind",
727
+ "record",
728
+ "path",
729
+ "title",
730
+ "state",
731
+ "closed",
732
+ "valid",
733
+ "reasons",
734
+ "provenance",
735
+ "owner",
736
+ "ready",
737
+ "blockedBy",
738
+ "implements",
739
+ "needs",
740
+ "source",
741
+ "supersededBy",
742
+ "constrains",
743
+ "warnings"
744
+ ],
745
+ "properties": {
746
+ "id": {
747
+ "type": "string",
748
+ "pattern": "^record:"
749
+ },
750
+ "kind": {
751
+ "const": "work"
752
+ },
753
+ "recordKind": {
754
+ "type": "string"
755
+ },
756
+ "record": {
757
+ "type": "string"
758
+ },
759
+ "path": {
760
+ "type": "string",
761
+ "description": "The record file, from the repository root."
762
+ },
763
+ "title": {
764
+ "type": [
765
+ "string",
766
+ "null"
767
+ ]
768
+ },
769
+ "state": {
770
+ "type": [
771
+ "string",
772
+ "null"
773
+ ]
774
+ },
775
+ "closed": {
776
+ "type": "boolean",
777
+ "description": "Whether the kind counts the state as closed, such as done or dropped."
778
+ },
779
+ "valid": {
780
+ "type": "boolean"
781
+ },
782
+ "reasons": {
783
+ "type": "array",
784
+ "items": {
785
+ "type": "object",
786
+ "required": [
787
+ "code",
788
+ "message"
789
+ ],
790
+ "properties": {
791
+ "code": {
792
+ "enum": [
793
+ "record-unparseable",
794
+ "record-schema-invalid",
795
+ "record-id-duplicate",
796
+ "record-supersedes-unknown",
797
+ "record-supersedes-conflict"
798
+ ]
799
+ },
800
+ "message": {
801
+ "type": "string"
802
+ }
803
+ }
804
+ }
805
+ },
806
+ "provenance": {
807
+ "type": "object",
808
+ "required": [
809
+ "level",
810
+ "commit",
811
+ "reason"
812
+ ],
813
+ "properties": {
814
+ "level": {
815
+ "enum": [
816
+ "attested",
817
+ "attested-unverifiable-here",
818
+ "adopted",
819
+ "unattested"
820
+ ]
821
+ },
822
+ "commit": {
823
+ "type": [
824
+ "string",
825
+ "null"
826
+ ],
827
+ "pattern": "^[0-9a-f]{40,64}$"
828
+ },
829
+ "reason": {
830
+ "type": "string"
831
+ }
832
+ }
833
+ },
834
+ "owner": {
835
+ "type": [
836
+ "string",
837
+ "null"
838
+ ],
839
+ "description": "The principal the record names as its owner, or null."
840
+ },
841
+ "ready": {
842
+ "type": "boolean",
843
+ "description": "As records reports it: valid, not superseded, in the kind's open state, and every need done."
844
+ },
845
+ "blockedBy": {
846
+ "type": "array",
847
+ "items": {
848
+ "$ref": "#/$defs/workLink"
849
+ },
850
+ "description": "Each need that is not done, with its state."
851
+ },
852
+ "implements": {
853
+ "type": "array",
854
+ "items": {
855
+ "$ref": "#/$defs/workLink"
856
+ },
857
+ "description": "Each decision the item implements, with its state."
858
+ },
859
+ "needs": {
860
+ "type": "array",
861
+ "items": {
862
+ "type": "string"
863
+ },
864
+ "description": "The work ids the item's needs list names."
865
+ },
866
+ "source": {
867
+ "description": "The gap the item came from, when its source names one: a finding code, the region it fired on, and optionally the decision and artifact it concerned. Null for an item whose source is an issue or the workspace.",
868
+ "oneOf": [
869
+ {
870
+ "type": "null"
871
+ },
872
+ {
873
+ "type": "object",
874
+ "required": [
875
+ "finding",
876
+ "region"
877
+ ],
878
+ "properties": {
879
+ "finding": {
880
+ "type": "string"
881
+ },
882
+ "region": {
883
+ "type": "string"
884
+ },
885
+ "decision": {
886
+ "type": "string"
887
+ },
888
+ "artifact": {
889
+ "type": "string"
890
+ }
891
+ }
892
+ }
893
+ ]
894
+ },
895
+ "supersededBy": {
896
+ "type": [
897
+ "string",
898
+ "null"
899
+ ]
900
+ },
901
+ "constrains": {
902
+ "type": "array",
903
+ "description": "The entries that cover the region, with their granularity, matched as a decision's are. Empty for an item in the graph only through a link.",
904
+ "items": {
905
+ "type": "object",
906
+ "required": [
907
+ "entry",
908
+ "granularity"
909
+ ],
910
+ "properties": {
911
+ "entry": {
912
+ "type": "string"
913
+ },
914
+ "granularity": {
915
+ "enum": [
916
+ "path",
917
+ "member",
918
+ "contract",
919
+ "issue"
920
+ ]
921
+ }
922
+ }
923
+ }
924
+ },
925
+ "warnings": {
926
+ "type": "array",
927
+ "description": "The item's work warnings as records reports them, and work-done-gap-open, which only this walk raises: the item is done and the finding in its source still fires on its region.",
928
+ "items": {
929
+ "type": "object",
930
+ "required": [
931
+ "code",
932
+ "message"
933
+ ],
934
+ "properties": {
935
+ "code": {
936
+ "enum": [
937
+ "work-needs-unknown",
938
+ "work-implements-unknown",
939
+ "work-needs-cycle",
940
+ "work-implements-undecided",
941
+ "work-done-unpinned",
942
+ "work-closed-without-date",
943
+ "work-done-gap-open"
944
+ ]
945
+ },
946
+ "message": {
947
+ "type": "string"
948
+ }
949
+ }
950
+ }
951
+ }
952
+ }
953
+ },
716
954
  "artifact": {
717
955
  "description": "A workspace file a decision in the graph pins. Artifacts reach the region only through decisions (#2549).",
718
956
  "type": "object",
@@ -857,7 +1095,10 @@
857
1095
  "intent-constraint-lost",
858
1096
  "intent-evidence-unpinned",
859
1097
  "intent-trailer-unverified",
860
- "intent-region-unconstrained"
1098
+ "intent-region-unconstrained",
1099
+ "intent-decision-unimplemented",
1100
+ "intent-work-blocked",
1101
+ "intent-work-open-decided-code"
861
1102
  ]
862
1103
  },
863
1104
  {
@@ -887,11 +1128,22 @@
887
1128
  "type": "string"
888
1129
  },
889
1130
  "description": "For a plugin's finding: the refs as the plugin gave them. Those that name a node in the graph are resolved to its id in concerns, after the commit the finding was returned for."
1131
+ },
1132
+ "addressed": {
1133
+ "type": "boolean",
1134
+ "description": "Added by #2683, present when a work kind was read: whether a work item addresses the finding, because the item's source names this finding code on a region that contains or is contained by this one, or because the item implements a decision the finding concerns. A finding with addressed false is work nobody has taken yet."
1135
+ },
1136
+ "addressedBy": {
1137
+ "type": "array",
1138
+ "items": {
1139
+ "$ref": "#/$defs/workLink"
1140
+ },
1141
+ "description": "Added by #2683, present when a work kind was read: the work items addressing the finding, each with its state. Each has an addressed-by edge from the finding."
890
1142
  }
891
1143
  }
892
1144
  },
893
1145
  "edge": {
894
- "description": "constrains: decision to region, file or contract. pins: decision to artifact. touched-by: region to commit. produced-by: commit to unit. serves: unit to contract. supersedes: the newer decision to the one it replaces. cites-evidence: unit, contract or decision to evidence. links: consumer member to producer member. within: commit to a decision whose path window it falls in.",
1146
+ "description": "constrains: decision or work item to region, file or contract. pins: decision to artifact. touched-by: region to commit. produced-by: commit to unit. serves: unit to contract. supersedes: the newer decision to the one it replaces. cites-evidence: unit, contract or decision to evidence. links: consumer member to producer member. within: commit to a decision whose path window it falls in, or to a work item whose window it falls in. implements: work item to the decision it carries out. needs: work item to a work item it waits on. addressed-by: finding to a work item that addresses it.",
895
1147
  "oneOf": [
896
1148
  {
897
1149
  "$ref": "#/$defs/constrainsEdge"
@@ -1105,13 +1357,16 @@
1105
1357
  "serves",
1106
1358
  "supersedes",
1107
1359
  "cites-evidence",
1108
- "links"
1360
+ "links",
1361
+ "implements",
1362
+ "needs",
1363
+ "addressed-by"
1109
1364
  ]
1110
1365
  }
1111
1366
  }
1112
1367
  },
1113
1368
  "withinEdge": {
1114
- "description": "A commit made inside a decision's window, from the commit that added the decision's record until the one that added its successor (#2656). state is decided when the decision's own unit made the commit, and decided-by-window otherwise.",
1369
+ "description": "A commit made inside a decision's window, from the commit that added the decision's record until the one that added its successor (#2656). state is decided when the decision's own unit made the commit, and decided-by-window otherwise. Added by #2683: a commit made inside a work item's window, from the commit that added the record to the commit that closed it, that commit included, or to the revision read while it is open, has state worked. A worked edge leaves the commit's own state as the decisions set it.",
1115
1370
  "type": "object",
1116
1371
  "required": [
1117
1372
  "kind",
@@ -1132,10 +1387,30 @@
1132
1387
  "state": {
1133
1388
  "enum": [
1134
1389
  "decided",
1135
- "decided-by-window"
1390
+ "decided-by-window",
1391
+ "worked"
1136
1392
  ]
1137
1393
  }
1138
1394
  }
1395
+ },
1396
+ "workLink": {
1397
+ "type": "object",
1398
+ "required": [
1399
+ "id",
1400
+ "state"
1401
+ ],
1402
+ "properties": {
1403
+ "id": {
1404
+ "type": "string"
1405
+ },
1406
+ "state": {
1407
+ "type": [
1408
+ "string",
1409
+ "null"
1410
+ ],
1411
+ "description": "The linked record's state, or null when no record has the id."
1412
+ }
1413
+ }
1139
1414
  }
1140
1415
  }
1141
1416
  }