@intentius/chant 0.86.0 → 0.88.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 (83) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +5 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/cli/version.d.ts +8 -0
  7. package/dist/cli/version.d.ts.map +1 -0
  8. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  9. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  10. package/dist/workspace/composites.d.ts +13 -0
  11. package/dist/workspace/composites.d.ts.map +1 -1
  12. package/dist/workspace/environments.d.ts +80 -0
  13. package/dist/workspace/environments.d.ts.map +1 -0
  14. package/dist/workspace/intent.d.ts +6 -1
  15. package/dist/workspace/intent.d.ts.map +1 -1
  16. package/dist/workspace/reason-codes.d.ts +20 -4
  17. package/dist/workspace/reason-codes.d.ts.map +1 -1
  18. package/dist/workspace/records-cli.d.ts +12 -2
  19. package/dist/workspace/records-cli.d.ts.map +1 -1
  20. package/dist/workspace/records-close.d.ts +41 -0
  21. package/dist/workspace/records-close.d.ts.map +1 -0
  22. package/dist/workspace/records-since.d.ts +40 -1
  23. package/dist/workspace/records-since.d.ts.map +1 -1
  24. package/dist/workspace/records-write.d.ts +117 -12
  25. package/dist/workspace/records-write.d.ts.map +1 -1
  26. package/dist/workspace/records.d.ts +84 -14
  27. package/dist/workspace/records.d.ts.map +1 -1
  28. package/dist/workspace/runtimes.d.ts +6 -0
  29. package/dist/workspace/runtimes.d.ts.map +1 -1
  30. package/dist/workspace/session-kinds.d.ts +28 -0
  31. package/dist/workspace/session-kinds.d.ts.map +1 -0
  32. package/dist/workspace/status.d.ts +2 -0
  33. package/dist/workspace/status.d.ts.map +1 -1
  34. package/dist/workspace/trust/seal.d.ts +127 -0
  35. package/dist/workspace/trust/seal.d.ts.map +1 -0
  36. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  37. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  38. package/dist/workspace/work.d.ts +3 -3
  39. package/package.json +1 -1
  40. package/src/cli/handlers/misc.ts +1 -9
  41. package/src/cli/main.ts +29 -10
  42. package/src/cli/mcp/server.test.ts +14 -1
  43. package/src/cli/mcp/server.ts +3 -1
  44. package/src/cli/registry.ts +5 -0
  45. package/src/cli/version.ts +15 -0
  46. package/src/workspace/__fixtures__/sessions.ts +41 -0
  47. package/src/workspace/composites.schema.json +68 -3
  48. package/src/workspace/composites.test.ts +119 -5
  49. package/src/workspace/composites.ts +26 -7
  50. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
  51. package/src/workspace/environments.ts +165 -0
  52. package/src/workspace/intent-gaps.test.ts +217 -0
  53. package/src/workspace/intent.schema.json +23 -1
  54. package/src/workspace/intent.test.ts +2 -0
  55. package/src/workspace/intent.ts +35 -3
  56. package/src/workspace/read-contract.test.ts +3 -0
  57. package/src/workspace/reason-codes.test.ts +9 -3
  58. package/src/workspace/reason-codes.ts +24 -4
  59. package/src/workspace/record-assets.test.ts +4 -3
  60. package/src/workspace/records-amend.schema.json +30 -1
  61. package/src/workspace/records-cli.ts +113 -14
  62. package/src/workspace/records-close.schema.json +192 -0
  63. package/src/workspace/records-close.ts +129 -0
  64. package/src/workspace/records-contract.test.ts +4 -3
  65. package/src/workspace/records-new.schema.json +25 -0
  66. package/src/workspace/records-review.schema.json +81 -3
  67. package/src/workspace/records-sessions-write.test.ts +274 -0
  68. package/src/workspace/records-since.schema.json +27 -2
  69. package/src/workspace/records-since.ts +120 -6
  70. package/src/workspace/records-write-contract.test.ts +5 -1
  71. package/src/workspace/records-write.test.ts +4 -2
  72. package/src/workspace/records-write.ts +336 -43
  73. package/src/workspace/records.schema.json +37 -3
  74. package/src/workspace/records.ts +145 -25
  75. package/src/workspace/runtimes.ts +12 -3
  76. package/src/workspace/session-kinds.ts +79 -0
  77. package/src/workspace/status.ts +1 -1
  78. package/src/workspace/trust/record-seal.test.ts +315 -0
  79. package/src/workspace/trust/seal.test.ts +222 -0
  80. package/src/workspace/trust/seal.ts +289 -0
  81. package/src/workspace/trust/ssh-commit.ts +2 -2
  82. package/src/workspace/work.test.ts +3 -1
  83. package/src/workspace/work.ts +4 -4
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json",
4
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.",
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, or with --session the record and the session (#2693), 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
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -75,7 +75,7 @@
75
75
  "reviewer": {
76
76
  "type": "string",
77
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)."
78
+ "description": "The principal given with --by, as given. The write does not check who it is. With --sign, the seal lets chant workspace records check it against the signers file at base (#2687)."
79
79
  },
80
80
  "verdict": {
81
81
  "enum": [
@@ -101,7 +101,31 @@
101
101
  "session": {
102
102
  "type": "string",
103
103
  "minLength": 1,
104
- "description": "The session given with --session. Absent when none was given."
104
+ "description": "The session given with --session. Absent when none was given. Since #2693 it names an open session of a session kind whose subjects are this kind, found through the workspace declaration (session-unknown, session-not-open)."
105
+ },
106
+ "seal": {
107
+ "type": "object",
108
+ "description": "Added in contract 1 by #2687. With --sign only: an ssh signature by the key given (or git's user.signingkey) over <id>\\n<digest>\\n<verdict>\\n<reviewer>\\n<on>, in the ssh-keygen namespace chant-review. The write does not check the key against the signers file; chant workspace records reports whether the seal counts.",
109
+ "required": [
110
+ "signer",
111
+ "key",
112
+ "signature"
113
+ ],
114
+ "properties": {
115
+ "signer": {
116
+ "type": "string",
117
+ "description": "The reviewer given with --by."
118
+ },
119
+ "key": {
120
+ "type": "string",
121
+ "pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
122
+ "description": "The fingerprint of the key that signed, read back from the signature."
123
+ },
124
+ "signature": {
125
+ "type": "string",
126
+ "description": "The armored ssh signature."
127
+ }
128
+ }
105
129
  }
106
130
  }
107
131
  },
@@ -120,6 +144,57 @@
120
144
  "type": "string",
121
145
  "description": "With --dry-run only: the whole text of the file the command would write."
122
146
  },
147
+ "session": {
148
+ "type": "object",
149
+ "description": "Added in contract 1 by #2693, with --session only: the session record the verdict was also appended to, in the same command, so the session's verdicts list and the reviews that name the session never differ. Both files are validated before either is written.",
150
+ "required": [
151
+ "id",
152
+ "path",
153
+ "verdict"
154
+ ],
155
+ "properties": {
156
+ "id": {
157
+ "type": "string"
158
+ },
159
+ "path": {
160
+ "type": "string",
161
+ "description": "The session file, relative to the repository root, with / separators."
162
+ },
163
+ "verdict": {
164
+ "type": "object",
165
+ "description": "The entry appended to the session's verdicts list (the field the session kind's session.verdicts names): the record's id, the principal given with --by, the verdict and the same digest as the review.",
166
+ "required": [
167
+ "record",
168
+ "principal",
169
+ "verdict",
170
+ "digest"
171
+ ],
172
+ "properties": {
173
+ "record": {
174
+ "type": "string"
175
+ },
176
+ "principal": {
177
+ "type": "string"
178
+ },
179
+ "verdict": {
180
+ "enum": [
181
+ "agree",
182
+ "dissent",
183
+ "abstain"
184
+ ]
185
+ },
186
+ "digest": {
187
+ "type": "string",
188
+ "pattern": "^[0-9a-f]{64}$"
189
+ }
190
+ }
191
+ },
192
+ "text": {
193
+ "type": "string",
194
+ "description": "With --dry-run only: the whole text the session file would hold."
195
+ }
196
+ }
197
+ },
123
198
  "error": false
124
199
  }
125
200
  },
@@ -180,6 +255,9 @@
180
255
  "review-unsupported",
181
256
  "record-closed",
182
257
  "review-note-required",
258
+ "review-sign-failed",
259
+ "session-unknown",
260
+ "session-not-open",
183
261
  "record-unparseable",
184
262
  "record-schema-invalid",
185
263
  "record-id-duplicate",
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Sessions from a UI (#2693): `records new` on a session kind records the
3
+ * commit it opened at, `records close` closes and seals a session in one
4
+ * write, `records review --session` refuses a session that does not exist or
5
+ * is not open and appends the verdict to the session too, and
6
+ * `records --since <session id>` compares the session's own revisions. The
7
+ * fixture is a copy of the reference workspace's decisions and design
8
+ * member with a declaration naming both kinds.
9
+ */
10
+
11
+ import { spawnSync } from "node:child_process";
12
+ import { cpSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { afterAll, describe, expect, test } from "vitest";
16
+ import { cleanScratch, commitAll, contract, git, REPO, scratchDir } from "./__fixtures__/contract-repo";
17
+ import { declareSessions, DECISIONS_KIND, newSessionFields, sessionsRepo, SESSIONS_KIND } from "./__fixtures__/sessions";
18
+ import { sessionSeal } from "./record-sessions";
19
+ import { parseFrontMatter, recordTextDigest } from "./records";
20
+ import { queryRecords } from "./records-cli";
21
+ import { closeRecord } from "./records-close";
22
+ import closeSchema from "./records-close.schema.json";
23
+ import { queryRecordsSince, type RecordsSinceDocument } from "./records-since";
24
+ import sinceSchema from "./records-since.schema.json";
25
+ import { amendRecord, newRecord, reviewRecord } from "./records-write";
26
+ import newSchema from "./records-new.schema.json";
27
+ import reviewSchema from "./records-review.schema.json";
28
+
29
+ afterAll(cleanScratch);
30
+
31
+ const close = contract(closeSchema);
32
+ const since = contract(sinceSchema);
33
+ const review = contract(reviewSchema);
34
+ const created = contract(newSchema);
35
+
36
+ const code = (doc: object) => ("error" in doc ? (doc as { error: { code: string } }).error.code : "ok");
37
+ const HEX = /^[0-9a-f]{40}$/;
38
+
39
+ /** The reference fixture with a declaration, committed. */
40
+ function declared(): string {
41
+ const root = sessionsRepo();
42
+ declareSessions(root);
43
+ commitAll(root, "declare");
44
+ return root;
45
+ }
46
+
47
+ function front(root: string, path: string): Record<string, unknown> {
48
+ const fm = parseFrontMatter(readFileSync(join(root, path), "utf-8"));
49
+ if (!fm.ok) throw new Error(fm.message);
50
+ return fm.value;
51
+ }
52
+
53
+ /** Open S-0002 through records new, and commit it. */
54
+ async function opened(root: string): Promise<{ path: string; head: string }> {
55
+ const head = git(root, "rev-parse", "HEAD");
56
+ const doc = await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields(), cwd: root });
57
+ created.expectValid(doc);
58
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
59
+ expect(doc.id).toBe("S-0002");
60
+ commitAll(root, "open S-0002");
61
+ return { path: doc.path, head };
62
+ }
63
+
64
+ async function sessions(root: string) {
65
+ const doc = await queryRecords({ kind: SESSIONS_KIND, cwd: root });
66
+ if ("error" in doc) throw new Error(doc.error.message);
67
+ return doc;
68
+ }
69
+
70
+ describe("opening revisions (#2693)", () => {
71
+ test("records new on a session kind writes opened_rev, the commit HEAD names", async () => {
72
+ const root = declared();
73
+ const { path, head } = await opened(root);
74
+ expect(front(root, path).opened_rev).toBe(head);
75
+ expect((await sessions(root)).records.find((r) => r.id === "S-0002")).toMatchObject({ state: "open", valid: true });
76
+ });
77
+
78
+ test("before the first commit it is null, and the next amend fills it", async () => {
79
+ const root = scratchDir("chant-sessions-nocommit-");
80
+ for (const d of ["decisions", "design"]) cpSync(join(REPO, "reference-workspace", d), join(root, d), { recursive: true });
81
+ declareSessions(root);
82
+ git(root, "init", "-q");
83
+ const doc = await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields(), cwd: root });
84
+ if ("error" in doc) throw new Error(doc.error.message);
85
+ expect(front(root, doc.path).opened_rev).toBeNull();
86
+ const head = commitAll(root, "first");
87
+ const amended = await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: "{}", cwd: root });
88
+ expect(amended).toMatchObject({ changed: ["opened_rev"] });
89
+ expect(front(root, doc.path).opened_rev).toBe(head);
90
+ });
91
+
92
+ test("a caller can't set the opening or closing revision", async () => {
93
+ const root = declared();
94
+ expect(code(await newRecord({ kind: SESSIONS_KIND, fields: newSessionFields({ opened_rev: "a".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
95
+ await opened(root);
96
+ expect(code(await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: JSON.stringify({ opened_rev: "b".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
97
+ expect(code(await amendRecord({ kind: SESSIONS_KIND, id: "S-0002", fields: JSON.stringify({ closed_rev: "b".repeat(40) }), cwd: root }))).toBe("write-input-invalid");
98
+ });
99
+ });
100
+
101
+ describe("records close (#2693)", () => {
102
+ test("sets the state, the close time, the closing revision and the seal in one write, and the session reads back sealed", async () => {
103
+ const root = declared();
104
+ const { path } = await opened(root);
105
+ const head = git(root, "rev-parse", "HEAD");
106
+ const dry = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", dryRun: true, cwd: root, now: new Date("2026-09-25T10:30:00.123Z") });
107
+ close.expectValid(dry);
108
+ expect(dry).toMatchObject({ dryRun: true, closedRev: head, text: expect.stringContaining('state: "closed"') });
109
+ expect(front(root, path).state).toBe("open");
110
+
111
+ const doc = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root, now: new Date("2026-09-25T10:30:00.123Z") });
112
+ close.expectValid(doc);
113
+ if ("error" in doc) throw new Error(doc.error.message);
114
+ expect(doc.changed.sort()).toEqual(["closed", "closed_digest", "closed_rev", "state"]);
115
+ const fm = front(root, path);
116
+ expect(fm).toMatchObject({ state: "closed", closed: "2026-09-25T10:30:00Z", closed_rev: head, opened_rev: expect.stringMatching(HEX) });
117
+ const text = readFileSync(join(root, path), "utf-8");
118
+ expect(fm.closed_digest).toBe(sessionSeal(text, "closed_digest"));
119
+ expect(doc.seal).toEqual({ field: "closed_digest", digest: fm.closed_digest });
120
+ expect((await sessions(root)).records.find((r) => r.id === "S-0002")).toMatchObject({ state: "closed", valid: true, reasons: [] });
121
+ });
122
+
123
+ test("refuses a closed session, an unknown id and a verdict naming a record the subjects lack", async () => {
124
+ const root = declared();
125
+ const s1 = await closeRecord({ kind: SESSIONS_KIND, id: "S-0001", cwd: root });
126
+ close.expectValid(s1);
127
+ expect(code(s1)).toBe("record-closed");
128
+ expect(code(await closeRecord({ kind: SESSIONS_KIND, id: "S-0404", cwd: root }))).toBe("record-not-found");
129
+ expect(code(await closeRecord({ kind: DECISIONS_KIND, id: "ref-001", cwd: root }))).toBe("write-usage-invalid");
130
+ await opened(root);
131
+ const file = join(root, "design", "sessions", "S-0002-second-walk.md");
132
+ writeFileSync(file, readFileSync(file, "utf-8").replace("verdicts: []", 'verdicts:\n - record: "ref-999"\n principal: "alice"\n verdict: "agree"'));
133
+ const before = readFileSync(file, "utf-8");
134
+ const bad = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root });
135
+ close.expectValid(bad);
136
+ expect(code(bad)).toBe("session-verdict-unknown-record");
137
+ expect(readFileSync(file, "utf-8")).toBe(before);
138
+ });
139
+
140
+ test("amend's refusal on a closed session does not advise a supersedes field the session schema lacks", async () => {
141
+ const root = declared();
142
+ const doc = await amendRecord({ kind: SESSIONS_KIND, id: "S-0001", fields: JSON.stringify({ title: "x" }), cwd: root });
143
+ expect(code(doc)).toBe("record-closed");
144
+ const message = "error" in doc ? doc.error.message : "";
145
+ expect(message).not.toContain("supersedes");
146
+ expect(message).toContain("open a new session");
147
+ });
148
+ });
149
+
150
+ describe("records review --session (#2693)", () => {
151
+ test("refuses a session that does not exist and one that is closed, writing nothing", async () => {
152
+ const root = declared();
153
+ const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
154
+ const before = readFileSync(decision, "utf-8");
155
+ const unknown = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-9999", cwd: root });
156
+ review.expectValid(unknown);
157
+ expect(code(unknown)).toBe("session-unknown");
158
+ const closed = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0001", cwd: root });
159
+ review.expectValid(closed);
160
+ expect(code(closed)).toBe("session-not-open");
161
+ expect(readFileSync(decision, "utf-8")).toBe(before);
162
+ });
163
+
164
+ test("a verdict given in a session shows on both the decision and the session, with the same digest", async () => {
165
+ const root = declared();
166
+ const { path } = await opened(root);
167
+ const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
168
+ const digest = recordTextDigest(readFileSync(decision, "utf-8"));
169
+
170
+ const dry = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", dryRun: true, cwd: root });
171
+ review.expectValid(dry);
172
+ expect(dry).toMatchObject({ session: { id: "S-0002", path, text: expect.stringContaining('principal: "alice"') } });
173
+ expect(front(root, path).verdicts).toEqual([]);
174
+
175
+ const doc = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", on: "2026-09-25", cwd: root });
176
+ review.expectValid(doc);
177
+ if ("error" in doc) throw new Error(doc.error.message);
178
+ const verdict = { record: "ref-001", principal: "alice", verdict: "agree", digest };
179
+ expect(doc.session).toEqual({ id: "S-0002", path, verdict });
180
+ expect(front(root, "decisions/ref-001-how-the-app-is-deployed.md").reviews).toEqual([{ reviewer: "alice", verdict: "agree", on: "2026-09-25", digest, session: "S-0002" }]);
181
+ expect(front(root, path).verdicts).toEqual([verdict]);
182
+
183
+ const s2 = (await sessions(root)).records.find((r) => r.id === "S-0002")!;
184
+ expect(s2).toMatchObject({ valid: true, citedBy: [{ id: "ref-001", index: 0, reviewer: "alice", verdict: "agree" }] });
185
+ expect(s2.data?.verdicts).toEqual([verdict]);
186
+
187
+ // The session closes over the verdict, and a later one is refused.
188
+ close.expectValid(await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root }));
189
+ expect(code(await reviewRecord({ kind: DECISIONS_KIND, id: "ref-002", verdict: "agree", by: "bob", session: "S-0002", cwd: root }))).toBe("session-not-open");
190
+ });
191
+ });
192
+
193
+ describe("records --since <session id> (#2693)", () => {
194
+ async function sinceDoc(q: { kind: string; since: string; at?: string; cwd: string }): Promise<Extract<RecordsSinceDocument, { changes: unknown }>> {
195
+ const doc = await queryRecordsSince(q);
196
+ since.expectValid(doc);
197
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
198
+ return doc;
199
+ }
200
+
201
+ test("an open session compares its opening revision with the working tree, and a closed one its opening and closing revisions", async () => {
202
+ const root = declared();
203
+ const { head } = await opened(root);
204
+ await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", on: "2026-09-25", cwd: root });
205
+
206
+ const open = await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root });
207
+ expect(open.since).toBe(head);
208
+ expect(open.at).toBeNull();
209
+ expect(open.session).toMatchObject({ id: "S-0002", state: "open", sinceFrom: "opened-rev", atFrom: "working-tree", reasons: [{ code: "since-session-open" }] });
210
+ expect(open.changes).toEqual([{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" }]);
211
+
212
+ // Closed but not committed: closed_rev is HEAD before the close, and the close is only in the working tree.
213
+ const closing = await closeRecord({ kind: SESSIONS_KIND, id: "S-0002", cwd: root });
214
+ expect(closing).toMatchObject({ closedRev: git(root, "rev-parse", "HEAD") });
215
+ expect((await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root })).session).toMatchObject({ state: "closed", atFrom: "working-tree", reasons: [] });
216
+ const closeCommit = commitAll(root, "the verdict and the close");
217
+ await reviewRecord({ kind: DECISIONS_KIND, id: "ref-002", verdict: "agree", by: "bob", cwd: root });
218
+
219
+ const done = await sinceDoc({ kind: DECISIONS_KIND, since: "S-0002", cwd: root });
220
+ expect(done).toMatchObject({ since: head, at: closeCommit, session: { state: "closed", sinceFrom: "opened-rev", atFrom: "close-commit", reasons: [] } });
221
+ // The later review of ref-002 came after the session closed, so it is not listed.
222
+ expect(done.changes).toEqual([{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" }]);
223
+ // The session was written after its opening commit, so it is new, with the verdict it produced.
224
+ const own = await sinceDoc({ kind: SESSIONS_KIND, since: "S-0002", cwd: root });
225
+ expect(own.changes).toEqual([
226
+ { change: "new", id: "S-0002", path: "design/sessions/S-0002-second-walk.md", state: "closed" },
227
+ { change: "verdict", id: "S-0002", principal: "alice", verdict: "agree", index: 0, record: "ref-001" },
228
+ ]);
229
+ });
230
+
231
+ test("a session without revision fields falls back to its file's history, and an unknown id is since-session-unknown", async () => {
232
+ const root = declared();
233
+ const added = git(root, "log", "--diff-filter=A", "--format=%H", "--", "design/sessions/S-0001-first-walk-of-the-reference-decisions.md");
234
+ const s1 = await sinceDoc({ kind: SESSIONS_KIND, since: "S-0001", cwd: root });
235
+ expect(s1).toMatchObject({ since: added, at: added, session: { sinceFrom: "history", atFrom: "history" }, changes: [] });
236
+ const unknown = await queryRecordsSince({ kind: DECISIONS_KIND, since: "S-9999", cwd: root });
237
+ since.expectValid(unknown);
238
+ expect(code(unknown)).toBe("since-session-unknown");
239
+ // An id-shaped value that names a commit, such as a tag, is still read as the commit.
240
+ git(root, "tag", "v-1");
241
+ expect((await sinceDoc({ kind: DECISIONS_KIND, since: "v-1", cwd: root })).session).toBeUndefined();
242
+ });
243
+
244
+ test(
245
+ "through the CLI: close with the declared session kind, and --since <session id> without --kind",
246
+ async () => {
247
+ const root = declared();
248
+ await opened(root);
249
+ const run = (...args: string[]) =>
250
+ spawnSync(process.execPath, ["--import", pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href, join(REPO, "packages/core/src/cli/main.ts"), "workspace", "records", ...args], {
251
+ cwd: root,
252
+ encoding: "utf-8",
253
+ env: { ...process.env, NO_COLOR: "1" },
254
+ timeout: 60_000,
255
+ });
256
+ const closed = run("close", "S-0002");
257
+ expect(closed.status, closed.stderr).toBe(0);
258
+ close.expectValid(JSON.parse(closed.stdout));
259
+ const again = run("close", "S-0002");
260
+ expect(again.status).toBe(1);
261
+ expect(JSON.parse(again.stdout).error.code).toBe("record-closed");
262
+ commitAll(root, "close");
263
+ const set = run("--since", "S-0002", "--json");
264
+ expect(set.status, set.stderr).toBe(0);
265
+ const kinds = JSON.parse(set.stdout).kinds as RecordsSinceDocument[];
266
+ for (const k of kinds) since.expectValid(k);
267
+ expect(kinds.map((k) => ("session" in k ? k.session?.id : null))).toEqual(["S-0002", "S-0002"]);
268
+ const text = run("--kind", SESSIONS_KIND, "--since", "S-0002");
269
+ expect(text.stdout).toContain("session S-0002 (closed)");
270
+ expect(text.stdout).toContain("S-0002 new, closed");
271
+ },
272
+ 120_000,
273
+ );
274
+ });
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/records-since/v1/records-since.schema.json",
4
4
  "title": "chant workspace records --since output",
5
- "description": "What `chant workspace records --kind <kind file> --since <rev> --json` prints (#2673, #2650 C11): what changed in a kind's records between the commit --since names and the tree --at names, or the working tree without it. Records are matched by id; a record whose id could not be read is not compared. Between a review session's open and close commits, the changes are what the session did. Readers ignore fields they do not know; a field is only ever added within a version. The change kinds and the 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 #2673; a chant without --since on records refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
5
+ "description": "What `chant workspace records --kind <kind file> --since <rev|session id> --json` prints (#2673, #2650 C11): what changed in a kind's records between the commit --since names and the tree --at names, or the working tree without it. Records are matched by id; a record whose id could not be read is not compared. Between a review session's open and close commits, the changes are what the session did. Readers ignore fields they do not know; a field is only ever added within a version. The change kinds and the 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 #2673; a chant without --since on records refuses the flag. Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }, { "$ref": "#/$defs/set" }],
7
7
  "$defs": {
8
8
  "set": {
@@ -59,6 +59,7 @@
59
59
  },
60
60
  "since": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit id --since names." },
61
61
  "at": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40,64}$", "description": "The full commit id --at names, or null for the working tree." },
62
+ "session": { "$ref": "#/$defs/session" },
62
63
  "error": false,
63
64
  "changes": {
64
65
  "description": "In the order new, removed, state, verdict, supersession, pin, and by id within each.",
@@ -80,6 +81,29 @@
80
81
  }
81
82
  }
82
83
  },
84
+ "session": {
85
+ "description": "Added in contract 1 by #2693, with --since <session id> only: the session named, and where since and at came from. since is the commit the session opened at: its opening revision field (the kind's session.openedRev), or, for a session without one, the latest commit that added its file (history). at is the commit that carries the close: the first commit after its closing revision field (session.closedRev, HEAD when records close wrote the close) that changed the session file (close-commit), or, for a session without that field, the last commit that changed its file (history); --at overrides it (at). An open session, or one whose close is not committed yet, is compared with the working tree (working-tree), and an open one carries since-session-open.",
86
+ "type": "object",
87
+ "required": ["id", "path", "state", "sinceFrom", "atFrom", "reasons"],
88
+ "properties": {
89
+ "id": { "type": "string" },
90
+ "path": { "type": "string", "description": "The session file, from the repository root, with / separators." },
91
+ "state": { "type": ["string", "null"] },
92
+ "sinceFrom": { "enum": ["opened-rev", "history"] },
93
+ "atFrom": { "enum": ["close-commit", "history", "at", "working-tree"] },
94
+ "reasons": {
95
+ "type": "array",
96
+ "items": {
97
+ "type": "object",
98
+ "required": ["code", "message"],
99
+ "properties": {
100
+ "code": { "enum": ["since-session-open"] },
101
+ "message": { "type": "string" }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ },
83
107
  "change": {
84
108
  "oneOf": [
85
109
  { "$ref": "#/$defs/new" },
@@ -180,7 +204,8 @@
180
204
  "location-missing",
181
205
  "not-a-git-repository",
182
206
  "revision-unknown",
183
- "since-rev-unknown"
207
+ "since-rev-unknown",
208
+ "since-session-unknown"
184
209
  ]
185
210
  },
186
211
  "message": { "type": "string" }
@@ -9,6 +9,18 @@
9
9
  *
10
10
  * Records are matched by id. A record whose id could not be read is left
11
11
  * out of the comparison, since nothing says which record it was before.
12
+ *
13
+ * `--since <session id>` (#2693) compares the commit a review session
14
+ * opened at with the commit that carries its close. The first is the
15
+ * record's opening revision (the kind's `session.openedRev` field). The
16
+ * record's closing revision (`session.closedRev`) is HEAD when `records
17
+ * close` wrote the close, before the caller committed it, so the close and
18
+ * the verdicts committed with it are in the first commit after it that
19
+ * changed the session file. A session written before chant wrote these
20
+ * fields falls back to its file's history: the commit that added it and the
21
+ * last commit that changed it. An open session, or one whose close is not
22
+ * committed yet, is compared with the working tree, and an open one says so
23
+ * with `since-session-open`.
12
24
  */
13
25
 
14
26
  import { realpathSync } from "node:fs";
@@ -16,8 +28,9 @@ import { relative } from "node:path";
16
28
  import { pinEntries } from "./record-assets";
17
29
  import { gitRoot, resolveRevision } from "./record-source";
18
30
  import { READ_ERROR_CODES, RecordReadError, type LoadedRecordKind, type ReadErrorCode, type RecordEntry, type RecordKind } from "./records";
19
- import { readRecordsFor, RECORDS_CONTRACT_VERSION } from "./records-cli";
31
+ import { readRecordsFor, realpathOr, RECORDS_CONTRACT_VERSION } from "./records-cli";
20
32
  import type { ReasonCode } from "./reason-codes";
33
+ import { commitsTouching, findSessionKinds, SESSION_ID } from "./session-kinds";
21
34
 
22
35
  /** `$id` of the JSON Schema for `records --since --json`, shipped beside this file. */
23
36
  export const RECORDS_SINCE_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-since/v1/records-since.schema.json";
@@ -27,9 +40,38 @@ export const RECORDS_SINCE_ERROR_CODES = [
27
40
  ...READ_ERROR_CODES,
28
41
  /** `--since` names no commit. */
29
42
  "since-rev-unknown",
43
+ /** `--since` has a session id's shape, names no commit, and no session has the id (#2693). */
44
+ "since-session-unknown",
30
45
  ] as const satisfies readonly ReasonCode[];
31
46
  export type RecordsSinceErrorCode = (typeof RECORDS_SINCE_ERROR_CODES)[number];
32
47
 
48
+ /** Why a session's comparison is not the one between its open and close commits (#2693). Closed. */
49
+ export const RECORDS_SINCE_REASON_CODES = [
50
+ /** The session is open, so the comparison runs to the working tree. */
51
+ "since-session-open",
52
+ ] as const satisfies readonly ReasonCode[];
53
+
54
+ /**
55
+ * The session `--since` named (#2693), and where the two revisions came
56
+ * from: the record's own fields, or git's history of its file for a session
57
+ * written before chant wrote them.
58
+ */
59
+ export interface SinceSession {
60
+ id: string;
61
+ /** The session file, from the repository root, with / separators. */
62
+ path: string;
63
+ state: string | null;
64
+ /** Where `since` came from: the session's opening revision field, or the last commit that added its file. */
65
+ sinceFrom: "opened-rev" | "history";
66
+ /**
67
+ * Where `at` came from: the first commit after the closing revision that
68
+ * changed the session file (the commit carrying the close), the last
69
+ * commit that changed the file, --at, or the working tree.
70
+ */
71
+ atFrom: "close-commit" | "history" | "at" | "working-tree";
72
+ reasons: { code: (typeof RECORDS_SINCE_REASON_CODES)[number]; message: string }[];
73
+ }
74
+
33
75
  /** The kinds of change, in the order the output lists them. */
34
76
  export const SINCE_CHANGE_KINDS = ["new", "removed", "state", "verdict", "supersession", "pin"] as const;
35
77
  export type SinceChangeKind = (typeof SINCE_CHANGE_KINDS)[number];
@@ -52,6 +94,8 @@ export type RecordsSinceDocument =
52
94
  kind: { name: string; schema: string; file: string };
53
95
  since: string;
54
96
  at: string | null;
97
+ /** With `--since <session id>` only. */
98
+ session?: SinceSession;
55
99
  changes: SinceChange[];
56
100
  summary: Record<SinceChangeKind, number>;
57
101
  }
@@ -81,8 +125,10 @@ export async function queryRecordsSince(query: RecordsSinceQuery): Promise<Recor
81
125
  error: { code, message },
82
126
  });
83
127
  try {
84
- const since = resolveSince(query.cwd, query.since);
85
- const after = await readRecordsFor({ kind: query.kind, at: query.at, cwd: query.cwd });
128
+ const found = await findSinceSession(query);
129
+ const since = resolveSince(query.cwd, found ? found.since : query.since, found?.session.id);
130
+ const at = found ? (found.at ?? undefined) : query.at;
131
+ const after = await readRecordsFor({ kind: query.kind, at, cwd: query.cwd });
86
132
  let before: RecordEntry[];
87
133
  try {
88
134
  before = (await readRecordsFor({ kind: query.kind, at: since, cwd: query.cwd })).result.records;
@@ -99,6 +145,7 @@ export async function queryRecordsSince(query: RecordsSinceQuery): Promise<Recor
99
145
  kind: kindView(after.loaded, after.root),
100
146
  since,
101
147
  at: after.at,
148
+ ...(found ? { session: found.session } : {}),
102
149
  changes,
103
150
  summary,
104
151
  };
@@ -113,8 +160,71 @@ function kindView(loaded: LoadedRecordKind, root: string): { name: string; schem
113
160
  return { name: loaded.kind.name, schema: loaded.kind.schema.id, file: relative(root, loaded.file).split("\\").join("/") };
114
161
  }
115
162
 
116
- /** The full commit id `rev` names, from the repository holding `cwd`. */
117
- function resolveSince(cwd: string, rev: string): string {
163
+ /**
164
+ * The session `query.since` names, with the revisions to compare, or null
165
+ * when it names none and is to be read as a revision. It is looked up only
166
+ * in a git repository, when it has a session id's shape, in the kind read
167
+ * when that is a session kind and in the session kinds the declaration
168
+ * names. An id-shaped value that is neither a session nor a commit is
169
+ * `since-session-unknown`.
170
+ */
171
+ async function findSinceSession(query: RecordsSinceQuery): Promise<{ since: string; at: string | null; session: SinceSession } | null> {
172
+ const top = gitRoot(realpathOr(query.cwd));
173
+ if (!top || !SESSION_ID.test(query.since)) return null;
174
+ const kinds = await findSessionKinds(query.cwd, [query.kind]);
175
+ for (const k of kinds) {
176
+ let records: RecordEntry[];
177
+ try {
178
+ records = (await readRecordsFor({ kind: k.file, cwd: query.cwd })).result.records;
179
+ } catch (err) {
180
+ if (err instanceof RecordReadError) continue;
181
+ throw err;
182
+ }
183
+ const s = records.find((r) => r.id === query.since);
184
+ if (!s) continue;
185
+ const decl = k.kind.session!;
186
+ const field = (f: string | undefined): string | null => (f !== undefined && typeof s.data?.[f] === "string" ? (s.data[f] as string) : null);
187
+ const closed = s.state !== null && (k.kind.closedStates ?? []).includes(s.state);
188
+ const session: SinceSession = { id: s.id!, path: s.path, state: s.state, sinceFrom: "opened-rev", atFrom: "working-tree", reasons: [] };
189
+ let since = field(decl.openedRev);
190
+ if (since === null) {
191
+ since = commitsTouching(top, s.path, ["--diff-filter=A"])[0] ?? null;
192
+ session.sinceFrom = "history";
193
+ if (since === null) {
194
+ throw new SinceError("since-rev-unknown", `session ${s.id} (${s.path}) names no commit it opened at${decl.openedRev ? ` in ${decl.openedRev}` : ""}, and no commit added its file`);
195
+ }
196
+ }
197
+ let at: string | null = null;
198
+ if (query.at !== undefined) {
199
+ at = query.at;
200
+ session.atFrom = "at";
201
+ } else if (closed) {
202
+ const closedRev = field(decl.closedRev);
203
+ if (closedRev !== null && /^[0-9a-f]{40,64}$/.test(closedRev)) {
204
+ // The oldest of these is the commit that carried the close. None yet means the close is only in the working tree.
205
+ at = commitsTouching(top, s.path, [`${closedRev}..HEAD`]).slice(-1)[0] ?? null;
206
+ session.atFrom = at === null ? "working-tree" : "close-commit";
207
+ } else {
208
+ at = commitsTouching(top, s.path, ["-1"])[0] ?? null;
209
+ session.atFrom = at === null ? "working-tree" : "history";
210
+ }
211
+ } else {
212
+ session.reasons.push({ code: "since-session-open", message: `session ${s.id} is ${s.state ?? "not closed"}, so it has no closing revision, and the comparison runs to the working tree` });
213
+ }
214
+ return { since, at, session };
215
+ }
216
+ try {
217
+ resolveRevision(top, query.since);
218
+ return null;
219
+ } catch (err) {
220
+ if (!(err instanceof RecordReadError)) throw err;
221
+ }
222
+ if (kinds.length === 0) return null;
223
+ throw new SinceError("since-session-unknown", `--since ${query.since} names no commit, and no ${[...new Set(kinds.map((k) => k.kind.name))].join(" or ")} record has that id`);
224
+ }
225
+
226
+ /** The full commit id `rev` names, from the repository holding `cwd`; `session` names the session it came from. */
227
+ function resolveSince(cwd: string, rev: string, session?: string): string {
118
228
  let dir = cwd;
119
229
  try {
120
230
  dir = realpathSync(cwd);
@@ -127,7 +237,7 @@ function resolveSince(cwd: string, rev: string): string {
127
237
  return resolveRevision(top, rev);
128
238
  } catch (err) {
129
239
  if (err instanceof RecordReadError && err.code === "revision-unknown") {
130
- throw new SinceError("since-rev-unknown", `--since ${rev} names no commit in this repository`);
240
+ throw new SinceError("since-rev-unknown", session ? `session ${session} names ${rev}, which is no commit in this repository` : `--since ${rev} names no commit in this repository`);
131
241
  }
132
242
  throw err;
133
243
  }
@@ -252,6 +362,10 @@ export function formatSince(doc: Extract<RecordsSinceDocument, { changes: SinceC
252
362
  }
253
363
  });
254
364
  const s = doc.summary;
365
+ if (doc.session) {
366
+ const x = doc.session;
367
+ lines.unshift(`session ${x.id} (${x.state ?? "no state"}): opened at ${doc.since.slice(0, 8)} (${x.sinceFrom}), compared to ${doc.at ? `${doc.at.slice(0, 8)} (${x.atFrom})` : "the working tree"}`);
368
+ }
255
369
  lines.push(
256
370
  `${doc.changes.length} changes since ${doc.since.slice(0, 8)}${doc.at ? ` to ${doc.at.slice(0, 8)}` : " to the working tree"}: ${s.new} new, ${s.removed} removed, ${s.state} state, ${s.verdict} verdicts, ${s.supersession} supersessions, ${s.pin} pins`,
257
371
  );