@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
@@ -0,0 +1,158 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/workspace/records-new/v1/records-new.schema.json",
4
+ "title": "chant workspace records new output",
5
+ "description": "What `chant workspace records new <kind file> --from <file|->` prints (#2670): the path and id of the record it wrote, or would write with --dry-run, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
6
+ "oneOf": [
7
+ {
8
+ "$ref": "#/$defs/result"
9
+ },
10
+ {
11
+ "$ref": "#/$defs/failure"
12
+ }
13
+ ],
14
+ "$defs": {
15
+ "result": {
16
+ "description": "The record was written, or would be with --dry-run. Exit code 0.",
17
+ "type": "object",
18
+ "required": [
19
+ "$schema",
20
+ "contract",
21
+ "kind",
22
+ "path",
23
+ "id",
24
+ "dryRun",
25
+ "warnings"
26
+ ],
27
+ "properties": {
28
+ "$schema": {
29
+ "const": "https://intentius.io/chant/schemas/workspace/records-new/v1/records-new.schema.json"
30
+ },
31
+ "contract": {
32
+ "const": 1
33
+ },
34
+ "kind": {
35
+ "type": "object",
36
+ "required": [
37
+ "name",
38
+ "schema",
39
+ "file"
40
+ ],
41
+ "properties": {
42
+ "name": {
43
+ "type": "string",
44
+ "description": "The kind's name, such as \"decision\"."
45
+ },
46
+ "schema": {
47
+ "type": "string",
48
+ "description": "The `$id` of the schema the record was validated against."
49
+ },
50
+ "file": {
51
+ "type": "string",
52
+ "description": "The kind file, relative to the repository root, with / separators."
53
+ }
54
+ }
55
+ },
56
+ "path": {
57
+ "type": "string",
58
+ "description": "The record file, relative to the repository root (the working directory outside git), with / separators."
59
+ },
60
+ "id": {
61
+ "type": "string",
62
+ "description": "The record's id."
63
+ },
64
+ "dryRun": {
65
+ "type": "boolean",
66
+ "description": "True when --dry-run was given and nothing was written."
67
+ },
68
+ "warnings": {
69
+ "type": "array",
70
+ "description": "The written record's warnings, as chant workspace records reports them. A warning never refuses a write.",
71
+ "items": {
72
+ "$ref": "#/$defs/warning"
73
+ }
74
+ },
75
+ "text": {
76
+ "type": "string",
77
+ "description": "With --dry-run only: the whole text of the file the command would write."
78
+ },
79
+ "error": false
80
+ }
81
+ },
82
+ "warning": {
83
+ "type": "object",
84
+ "required": [
85
+ "code",
86
+ "message"
87
+ ],
88
+ "properties": {
89
+ "code": {
90
+ "enum": [
91
+ "asset-drift",
92
+ "asset-missing",
93
+ "asset-stale",
94
+ "record-supersedes-pending",
95
+ "record-no-evidence",
96
+ "review-undigested"
97
+ ]
98
+ },
99
+ "message": {
100
+ "type": "string"
101
+ }
102
+ }
103
+ },
104
+ "failure": {
105
+ "description": "Nothing was written. Exit code 1.",
106
+ "type": "object",
107
+ "required": [
108
+ "$schema",
109
+ "contract",
110
+ "error"
111
+ ],
112
+ "properties": {
113
+ "$schema": {
114
+ "const": "https://intentius.io/chant/schemas/workspace/records-new/v1/records-new.schema.json"
115
+ },
116
+ "contract": {
117
+ "const": 1
118
+ },
119
+ "error": {
120
+ "type": "object",
121
+ "required": [
122
+ "code",
123
+ "message"
124
+ ],
125
+ "properties": {
126
+ "code": {
127
+ "enum": [
128
+ "kind-unreadable",
129
+ "kind-invalid",
130
+ "schema-unreadable",
131
+ "schema-id-mismatch",
132
+ "schema-invalid",
133
+ "location-missing",
134
+ "write-usage-invalid",
135
+ "write-input-invalid",
136
+ "record-id-taken",
137
+ "record-id-unallocatable",
138
+ "record-path-unmatched",
139
+ "record-unparseable",
140
+ "record-schema-invalid",
141
+ "record-id-duplicate",
142
+ "record-supersedes-unknown",
143
+ "record-supersedes-conflict",
144
+ "session-seal-mismatch",
145
+ "session-verdict-unknown-record"
146
+ ]
147
+ },
148
+ "message": {
149
+ "type": "string",
150
+ "description": "What was wrong, and what to do instead when there is something, such as writing a new record that supersedes this one."
151
+ }
152
+ }
153
+ },
154
+ "path": false
155
+ }
156
+ }
157
+ }
158
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A record's digest and its quorum (#2671, #2672).
3
+ *
4
+ * `recordTextDigest` hashes a record's text without its reviews block, so a
5
+ * verdict can name the text it judged and stop counting after an amendment.
6
+ * `computeQuorum` lists which verdicts count toward the quorum and why the
7
+ * others don't. The end-to-end reads through `records --json` are in
8
+ * records-contract.test.ts.
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
16
+ import { workingTreeSource } from "./record-source";
17
+ import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest, type QuorumOptions, type RecordEntry } from "./records";
18
+
19
+ const REPO = join(import.meta.dirname, "..", "..", "..", "..");
20
+ const DECISIONS = join(REPO, "docs", "design", "decisions");
21
+ const SAMPLE = readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8");
22
+
23
+ /** ws-003 with its reviews list replaced. `reviews` is the YAML under `reviews:`, or null for `reviews: []`. */
24
+ function withReviews(reviews: string | null, text = SAMPLE): string {
25
+ return text.replace(/^reviews: \[\]$/m, reviews === null ? "reviews: []" : `reviews:\n${reviews.replace(/\n$/, "")}`);
26
+ }
27
+
28
+ /** One review entry as YAML. */
29
+ function review(reviewer: string, verdict: string, extra: Record<string, string> = {}): string {
30
+ const note = verdict === "dissent" && !extra.note ? { note: "it misses a case" } : {};
31
+ const fields = { reviewer, verdict, on: "2026-09-24", ...note, ...extra };
32
+ return Object.entries(fields)
33
+ .map(([k, v], i) => `${i === 0 ? " - " : " "}${k}: ${JSON.stringify(v)}`)
34
+ .join("\n");
35
+ }
36
+
37
+ const BARE = recordTextDigest(SAMPLE);
38
+
39
+ describe("recordTextDigest", () => {
40
+ test("is the sha256 hex of the text with the reviews block taken out", () => {
41
+ expect(BARE).toMatch(/^[0-9a-f]{64}$/);
42
+ const lines = SAMPLE.split("\n");
43
+ const without = lines.filter((l) => l !== "reviews: []").join("\n");
44
+ expect(recordTextDigest(SAMPLE)).toBe(recordTextDigest(without, null));
45
+ });
46
+
47
+ test("does not move when a verdict is added, changed or removed", () => {
48
+ const one = withReviews(review("alice", "agree"));
49
+ const two = withReviews(`${review("alice", "agree", { digest: BARE })}\n${review("bob", "dissent", { note: "no" })}`);
50
+ expect(recordTextDigest(one)).toBe(BARE);
51
+ expect(recordTextDigest(two)).toBe(BARE);
52
+ // The compact form, with the list at column 0, and comments and blank lines inside the block.
53
+ const compact = SAMPLE.replace(/^reviews: \[\]$/m, `reviews:\n# first review\n- reviewer: "alice"\n verdict: "agree"\n\n on: "2026-09-24"`);
54
+ expect(recordTextDigest(compact)).toBe(BARE);
55
+ // A quoted key is the same key.
56
+ expect(recordTextDigest(SAMPLE.replace(/^reviews: \[\]$/m, `"reviews": []`))).toBe(BARE);
57
+ });
58
+
59
+ test("moves with any other edit, in the front matter or the body", () => {
60
+ expect(recordTextDigest(SAMPLE.replace(/^title: .*$/m, 'title: "Seal scope, amended"'))).not.toBe(BARE);
61
+ expect(recordTextDigest(`${SAMPLE}\nOne more line.\n`)).not.toBe(BARE);
62
+ // The line after the block is kept: removal stops at the next key.
63
+ expect(recordTextDigest(SAMPLE.replace(/^constrains:$/m, "constrains: # the scope"))).not.toBe(BARE);
64
+ });
65
+
66
+ test("reads CRLF and LF alike", () => {
67
+ expect(recordTextDigest(SAMPLE.replace(/\n/g, "\r\n"))).toBe(BARE);
68
+ });
69
+
70
+ test("hashes text with no front matter as it is", () => {
71
+ expect(recordTextDigest("reviews: []\nbody\n")).toBe(recordTextDigest("reviews: []\nbody\n", null));
72
+ expect(recordTextDigest("---\nreviews: []\nno closing line\n")).toBe(recordTextDigest("---\nreviews: []\nno closing line\n", null));
73
+ });
74
+
75
+ test("a hand-editor gets the same digest with awk and sha256sum", () => {
76
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-digest-")));
77
+ try {
78
+ const file = join(dir, "ws-003.md");
79
+ writeFileSync(file, withReviews(`${review("alice", "agree")}\n${review("bob", "dissent")}`));
80
+ // The recipe in docs/src/content/docs/cli/workspace-records.mdx.
81
+ const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^reviews[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
82
+ const out = execFileSync("sh", ["-c", awk], { encoding: "utf-8" });
83
+ expect(out.split(" ")[0]).toBe(BARE);
84
+ } finally {
85
+ rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ });
88
+ });
89
+
90
+ describe("normalisePrincipal", () => {
91
+ test("trims and case-folds", () => {
92
+ expect(normalisePrincipal("Alice ")).toBe("alice");
93
+ expect(normalisePrincipal(" ALICE")).toBe(normalisePrincipal("alice"));
94
+ expect(normalisePrincipal("alice")).toBe("alice");
95
+ });
96
+ });
97
+
98
+ describe("computeQuorum", () => {
99
+ let dir: string;
100
+ beforeEach(() => {
101
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-quorum-")));
102
+ mkdirSync(join(dir, "decisions"));
103
+ cpSync(join(DECISIONS, "decision.kind.mjs"), join(dir, "decisions", "decision.kind.mjs"));
104
+ cpSync(join(DECISIONS, "decision.schema.json"), join(dir, "decisions", "decision.schema.json"));
105
+ });
106
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
107
+
108
+ const OPTIONS: QuorumOptions = { need: DEFAULT_QUORUM, needFrom: "default", agents: new Set(), attestation: false };
109
+
110
+ async function quorumOf(text: string, options: Partial<QuorumOptions> = {}) {
111
+ writeFileSync(join(dir, "decisions", "ws-003-seal-scope.md"), text);
112
+ const loaded = await loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
113
+ const { records } = await readRecords(loaded, { root: dir, source: workingTreeSource(dir) });
114
+ const record = records[0] as RecordEntry;
115
+ expect(record.reasons).toEqual([]);
116
+ return { record, quorum: computeQuorum(loaded.kind, record, { ...OPTIONS, ...options })! };
117
+ }
118
+
119
+ const principals = (list: Array<{ principal: string; reason?: { code: string } }>) => list.map((v) => (v.reason ? `${v.principal}:${v.reason.code}` : v.principal));
120
+
121
+ test("alice and 'Alice ' count once: the later verdict stands, the earlier is a duplicate", async () => {
122
+ const { quorum } = await quorumOf(withReviews(`${review("alice", "agree", { digest: BARE })}\n${review("Alice ", "agree", { digest: BARE })}`));
123
+ expect(principals(quorum.counted)).toEqual(["alice"]);
124
+ expect(quorum.counted[0].reviewer).toBe("Alice ");
125
+ expect(principals(quorum.notCounted)).toEqual(["alice:review-duplicate"]);
126
+ expect(quorum).toMatchObject({ need: 2, agreed: 1, met: false, metWithObjections: false });
127
+ });
128
+
129
+ test("a review by the decider is listed as not counted", async () => {
130
+ const { quorum } = await quorumOf(withReviews(`${review("Lex00", "agree", { digest: BARE })}\n${review("bob", "agree", { digest: BARE })}`));
131
+ expect(principals(quorum.notCounted)).toEqual(["lex00:review-decider"]);
132
+ expect(principals(quorum.counted)).toEqual(["bob"]);
133
+ });
134
+
135
+ test("amending the record after one agree drops the count from 1 to 0, with review-older-digest", async () => {
136
+ const reviewed = withReviews(review("alice", "agree", { digest: BARE }));
137
+ const before = await quorumOf(reviewed);
138
+ expect(before.quorum.agreed).toBe(1);
139
+ const after = await quorumOf(reviewed.replace(/^question: .*$/m, 'question: "What does a seal cover, now?"'));
140
+ expect(after.record.digest).not.toBe(BARE);
141
+ expect(after.quorum.counted).toEqual([]);
142
+ expect(after.quorum.agreed).toBe(0);
143
+ expect(principals(after.quorum.notCounted)).toEqual(["alice:review-older-digest"]);
144
+ expect(after.quorum.notCounted[0].reason!.message).toContain(BARE.slice(0, 12));
145
+ });
146
+
147
+ test("a verdict with no digest counts, and the record carries review-undigested", async () => {
148
+ const { record, quorum } = await quorumOf(withReviews(`${review("alice", "agree")}\n${review("bob", "agree", { digest: BARE })}`));
149
+ expect(principals(quorum.counted)).toEqual(["alice", "bob"]);
150
+ expect(quorum.counted[0].digest).toBeNull();
151
+ expect(quorum).toMatchObject({ agreed: 2, met: true, metWithObjections: false });
152
+ expect(record.warnings.map((w) => w.code)).toEqual(["review-undigested"]);
153
+ expect(record.warnings[0].message).toContain("alice");
154
+ expect(record.warnings[0].message).not.toContain("bob");
155
+ });
156
+
157
+ test("a met count with an open concern is met with objections, never consensus", async () => {
158
+ const reviews = [review("alice", "agree", { digest: BARE }), review("bob", "agree", { digest: BARE }), review("carol", "dissent", { digest: BARE, proposes: "ws-900" })];
159
+ const { quorum } = await quorumOf(withReviews(reviews.join("\n")));
160
+ expect(quorum).toMatchObject({ agreed: 2, met: true, metWithObjections: true });
161
+ expect(quorum.openConcerns).toEqual([{ index: 2, principal: "carol", reviewer: "carol", note: "it misses a case", proposes: "ws-900" }]);
162
+ });
163
+
164
+ test("an addressed or withdrawn dissent is no open concern", async () => {
165
+ const reviews = [
166
+ review("alice", "agree", { digest: BARE }),
167
+ review("bob", "agree", { digest: BARE }),
168
+ review("carol", "dissent", { digest: BARE, addressed_by: "ws-001" }),
169
+ review("dan", "dissent", { digest: BARE, withdrawn_on: "2026-09-25" }),
170
+ ];
171
+ const { quorum } = await quorumOf(withReviews(reviews.join("\n")));
172
+ expect(quorum.openConcerns).toEqual([]);
173
+ expect(quorum).toMatchObject({ met: true, metWithObjections: false });
174
+ });
175
+
176
+ test("an agent's verdict is not counted, and under an attestation policy an unsealed one is not either", async () => {
177
+ const text = withReviews(`${review("Bot-1", "agree", { digest: BARE })}\n${review("alice", "agree", { digest: BARE })}`);
178
+ const agents = await quorumOf(text, { agents: new Set(["bot-1"]) });
179
+ expect(principals(agents.quorum.notCounted)).toEqual(["bot-1:review-agent"]);
180
+ const attested = await quorumOf(text, { attestation: true });
181
+ expect(principals(attested.quorum.notCounted)).toEqual(["bot-1:review-unattested", "alice:review-unattested"]);
182
+ expect(attested.quorum.counted).toEqual([]);
183
+ });
184
+
185
+ test("the declared need applies, and a need of 0 is met with no verdict", async () => {
186
+ const { quorum } = await quorumOf(SAMPLE, { need: 0, needFrom: "declaration" });
187
+ expect(quorum).toMatchObject({ need: 0, needFrom: "declaration", agreed: 0, met: true, counted: [], notCounted: [], openConcerns: [] });
188
+ });
189
+
190
+ test("a kind with no reviews list has no quorum", async () => {
191
+ const loaded = await loadRecordKind(join(DECISIONS, "decision.kind.mjs"));
192
+ const { reviews: _drop, ...kind } = loaded.kind;
193
+ expect(computeQuorum(kind, { data: {}, digest: BARE }, OPTIONS)).toBeNull();
194
+ expect(computeQuorum(loaded.kind, { data: null, digest: BARE }, OPTIONS)).toBeNull();
195
+ });
196
+ });
@@ -0,0 +1,202 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json",
4
+ "title": "chant workspace records review output",
5
+ "description": "What `chant workspace records review <id> --kind <kind file> --verdict <verdict> --by <principal>` prints (#2670): the record's path and id and the review entry appended, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.86.0 and newer.",
6
+ "oneOf": [
7
+ {
8
+ "$ref": "#/$defs/result"
9
+ },
10
+ {
11
+ "$ref": "#/$defs/failure"
12
+ }
13
+ ],
14
+ "$defs": {
15
+ "result": {
16
+ "description": "The record was written, or would be with --dry-run. Exit code 0.",
17
+ "type": "object",
18
+ "required": [
19
+ "$schema",
20
+ "contract",
21
+ "kind",
22
+ "path",
23
+ "id",
24
+ "review",
25
+ "dryRun",
26
+ "warnings"
27
+ ],
28
+ "properties": {
29
+ "$schema": {
30
+ "const": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json"
31
+ },
32
+ "contract": {
33
+ "const": 1
34
+ },
35
+ "kind": {
36
+ "type": "object",
37
+ "required": [
38
+ "name",
39
+ "schema",
40
+ "file"
41
+ ],
42
+ "properties": {
43
+ "name": {
44
+ "type": "string",
45
+ "description": "The kind's name, such as \"decision\"."
46
+ },
47
+ "schema": {
48
+ "type": "string",
49
+ "description": "The `$id` of the schema the record was validated against."
50
+ },
51
+ "file": {
52
+ "type": "string",
53
+ "description": "The kind file, relative to the repository root, with / separators."
54
+ }
55
+ }
56
+ },
57
+ "path": {
58
+ "type": "string",
59
+ "description": "The record file, relative to the repository root (the working directory outside git), with / separators."
60
+ },
61
+ "id": {
62
+ "type": "string",
63
+ "description": "The record's id."
64
+ },
65
+ "review": {
66
+ "type": "object",
67
+ "description": "The entry appended to the record's reviews, as written.",
68
+ "required": [
69
+ "reviewer",
70
+ "verdict",
71
+ "on",
72
+ "digest"
73
+ ],
74
+ "properties": {
75
+ "reviewer": {
76
+ "type": "string",
77
+ "minLength": 1,
78
+ "description": "The principal given with --by, as given. chant does not check who it is; attestation of the commit that carries the review does (#2547)."
79
+ },
80
+ "verdict": {
81
+ "enum": [
82
+ "agree",
83
+ "dissent",
84
+ "abstain"
85
+ ]
86
+ },
87
+ "note": {
88
+ "type": "string",
89
+ "description": "The note given with --note. Absent when none was given; a dissent always has one."
90
+ },
91
+ "on": {
92
+ "type": "string",
93
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$",
94
+ "description": "The date of the verdict, in UTC."
95
+ },
96
+ "digest": {
97
+ "type": "string",
98
+ "pattern": "^[0-9a-f]{64}$",
99
+ "description": "The digest of the record text the verdict judged: the lowercase hex SHA-256 of the file with LF line endings and without its reviews block (#2672), as records prints it for the record. The command changes only the reviews block, so the record keeps this digest."
100
+ },
101
+ "session": {
102
+ "type": "string",
103
+ "minLength": 1,
104
+ "description": "The session given with --session. Absent when none was given."
105
+ }
106
+ }
107
+ },
108
+ "dryRun": {
109
+ "type": "boolean",
110
+ "description": "True when --dry-run was given and nothing was written."
111
+ },
112
+ "warnings": {
113
+ "type": "array",
114
+ "description": "The written record's warnings, as chant workspace records reports them. A warning never refuses a write.",
115
+ "items": {
116
+ "$ref": "#/$defs/warning"
117
+ }
118
+ },
119
+ "text": {
120
+ "type": "string",
121
+ "description": "With --dry-run only: the whole text of the file the command would write."
122
+ },
123
+ "error": false
124
+ }
125
+ },
126
+ "warning": {
127
+ "type": "object",
128
+ "required": [
129
+ "code",
130
+ "message"
131
+ ],
132
+ "properties": {
133
+ "code": {
134
+ "enum": [
135
+ "asset-drift",
136
+ "asset-missing",
137
+ "asset-stale",
138
+ "record-supersedes-pending",
139
+ "record-no-evidence",
140
+ "review-undigested"
141
+ ]
142
+ },
143
+ "message": {
144
+ "type": "string"
145
+ }
146
+ }
147
+ },
148
+ "failure": {
149
+ "description": "Nothing was written. Exit code 1.",
150
+ "type": "object",
151
+ "required": [
152
+ "$schema",
153
+ "contract",
154
+ "error"
155
+ ],
156
+ "properties": {
157
+ "$schema": {
158
+ "const": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json"
159
+ },
160
+ "contract": {
161
+ "const": 1
162
+ },
163
+ "error": {
164
+ "type": "object",
165
+ "required": [
166
+ "code",
167
+ "message"
168
+ ],
169
+ "properties": {
170
+ "code": {
171
+ "enum": [
172
+ "kind-unreadable",
173
+ "kind-invalid",
174
+ "schema-unreadable",
175
+ "schema-id-mismatch",
176
+ "schema-invalid",
177
+ "location-missing",
178
+ "write-usage-invalid",
179
+ "record-not-found",
180
+ "review-unsupported",
181
+ "record-closed",
182
+ "review-note-required",
183
+ "record-unparseable",
184
+ "record-schema-invalid",
185
+ "record-id-duplicate",
186
+ "record-supersedes-unknown",
187
+ "record-supersedes-conflict",
188
+ "session-seal-mismatch",
189
+ "session-verdict-unknown-record"
190
+ ]
191
+ },
192
+ "message": {
193
+ "type": "string",
194
+ "description": "What was wrong, and what to do instead when there is something, such as writing a new record that supersedes this one."
195
+ }
196
+ }
197
+ },
198
+ "path": false
199
+ }
200
+ }
201
+ }
202
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Review sessions as a record kind (#2673, #2650 C10), read through the
3
+ * reference workspace's session kind: a closed session's seal is checked on
4
+ * read, each verdict names a decision that exists, and each session lists the
5
+ * decision reviews that cite it.
6
+ */
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { cleanScratch, commitAll, contract, REPO } from "./__fixtures__/contract-repo";
13
+ import { reviewed, sessionText, SESSIONS_KIND as SESSIONS, sessionsRepo as fixture } from "./__fixtures__/sessions";
14
+ import { sessionSeal } from "./record-sessions";
15
+ import { queryRecords, type RecordsDocument } from "./records-cli";
16
+ import recordsSchema from "./records.schema.json";
17
+
18
+ afterAll(cleanScratch);
19
+
20
+ const { expectValid } = contract(recordsSchema);
21
+
22
+ async function read(root: string, at?: string): Promise<Extract<RecordsDocument, { records: unknown }>> {
23
+ const doc = await queryRecords({ kind: SESSIONS, cwd: root, ...(at ? { at } : {}) });
24
+ expectValid(doc);
25
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
26
+ return doc;
27
+ }
28
+
29
+ describe("the session kind (#2673)", () => {
30
+ test("the reference workspace's session is closed, sealed and valid, and cited by nothing", async () => {
31
+ const doc = await read(join(REPO, "reference-workspace"));
32
+ expect(doc.kind.name).toBe("session");
33
+ expect(doc.records.map((r) => [r.id, r.state, r.valid])).toEqual([["S-0001", "closed", true]]);
34
+ expect(doc.records[0].citedBy).toEqual([]);
35
+ });
36
+
37
+ test("the seal is the awk recipe the docs give", () => {
38
+ const file = join(REPO, "reference-workspace", "design", "sessions", "S-0001-first-walk-of-the-reference-decisions.md");
39
+ const awk = execFileSync("sh", ["-c", `awk 'NR==1&&/^---$/{f=1;print;next} f&&/^---$/{f=0} f&&/^closed_digest:/{next} {print}' "$1" | shasum -a 256`, "sh", file], { encoding: "utf-8" });
40
+ const text = readFileSync(file, "utf-8");
41
+ expect(awk.split(" ")[0]).toBe(sessionSeal(text, "closed_digest"));
42
+ expect(text).toContain(`closed_digest: "${sessionSeal(text, "closed_digest")}"`);
43
+ // CRLF line endings hash as LF.
44
+ expect(sessionSeal(text.replace(/\n/g, "\r\n"), "closed_digest")).toBe(sessionSeal(text, "closed_digest"));
45
+ });
46
+
47
+ test("a closed session edited after it closed is session-seal-mismatch", async () => {
48
+ const root = fixture();
49
+ const file = join(root, "design", "sessions", "S-0001-first-walk-of-the-reference-decisions.md");
50
+ writeFileSync(file, readFileSync(file, "utf-8").replace("First walk", "Second walk"));
51
+ const s1 = (await read(root)).records[0];
52
+ expect(s1.valid).toBe(false);
53
+ expect(s1.reasons.map((r) => r.code)).toEqual(["session-seal-mismatch"]);
54
+ });
55
+
56
+ test("an open session with a seal, or a closed one without, is schema-invalid", async () => {
57
+ const root = fixture();
58
+ const dir = join(root, "design", "sessions");
59
+ writeFileSync(join(dir, "S-0002-open.md"), sessionText({ id: "S-0002", state: "open" }).replace("verdicts: []", 'verdicts: []\nclosed_digest: "' + "0".repeat(64) + '"'));
60
+ writeFileSync(join(dir, "S-0003-closed.md"), sessionText({ id: "S-0003", state: "closed" }).replace(/^closed_digest: .*\n/m, ""));
61
+ const doc = await read(root);
62
+ expect(doc.records.filter((r) => r.id !== "S-0001").map((r) => [r.id, r.reasons.map((x) => x.code)])).toEqual([
63
+ ["S-0002", ["record-schema-invalid"]],
64
+ ["S-0003", ["record-schema-invalid"]],
65
+ ]);
66
+ });
67
+
68
+ test("a verdict naming a record no decision has is session-verdict-unknown-record", async () => {
69
+ const root = fixture();
70
+ writeFileSync(
71
+ join(root, "design", "sessions", "S-0002-walk.md"),
72
+ sessionText({ id: "S-0002", state: "closed", verdicts: [{ record: "ref-001", principal: "alice", verdict: "agree" }, { record: "ref-999", principal: "alice", verdict: "agree" }] }),
73
+ );
74
+ const s2 = (await read(root)).records.find((r) => r.id === "S-0002")!;
75
+ expect(s2.valid).toBe(false);
76
+ expect(s2.reasons).toEqual([{ code: "session-verdict-unknown-record", message: expect.stringContaining("verdicts[1] names ref-999") }]);
77
+ });
78
+
79
+ test("each session lists the decision reviews that name it, and --at reads both kinds at the revision", async () => {
80
+ const root = fixture();
81
+ writeFileSync(join(root, "design", "sessions", "S-0002-walk.md"), sessionText({ id: "S-0002", state: "open" }));
82
+ const open = commitAll(root, "open S-0002");
83
+ reviewed(root, ["alice", "bob"], "S-0002");
84
+ writeFileSync(
85
+ join(root, "design", "sessions", "S-0002-walk.md"),
86
+ sessionText({ id: "S-0002", state: "closed", verdicts: [{ record: "ref-001", principal: "alice", verdict: "agree" }, { record: "ref-001", principal: "bob", verdict: "agree" }] }),
87
+ );
88
+ const doc = await read(root);
89
+ expect(doc.summary.invalid).toBe(0);
90
+ const s2 = doc.records.find((r) => r.id === "S-0002")!;
91
+ expect(s2.citedBy).toEqual([
92
+ { id: "ref-001", path: "decisions/ref-001-how-the-app-is-deployed.md", index: 0, reviewer: "alice", verdict: "agree" },
93
+ { id: "ref-001", path: "decisions/ref-001-how-the-app-is-deployed.md", index: 1, reviewer: "bob", verdict: "agree" },
94
+ ]);
95
+ expect(doc.records.find((r) => r.id === "S-0001")!.citedBy).toEqual([]);
96
+ const then = await read(root, open);
97
+ expect(then.records.find((r) => r.id === "S-0002")).toMatchObject({ state: "open", valid: true, citedBy: [] });
98
+ });
99
+
100
+ test("the decision kind accepts a review entry naming its session", async () => {
101
+ const root = fixture();
102
+ reviewed(root, ["alice"], "S-0001");
103
+ const doc = await queryRecords({ kind: "decisions/decision.kind.mjs", cwd: root });
104
+ if ("error" in doc) throw new Error(doc.error.message);
105
+ expect(doc.records.find((r) => r.id === "ref-001")).toMatchObject({ valid: true });
106
+ expect((await read(root)).records[0].citedBy).toEqual([expect.objectContaining({ id: "ref-001", reviewer: "alice" })]);
107
+ });
108
+ });