@intentius/chant 0.87.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 (71) 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 +4 -1
  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/reason-codes.d.ts +14 -5
  15. package/dist/workspace/reason-codes.d.ts.map +1 -1
  16. package/dist/workspace/records-cli.d.ts +6 -2
  17. package/dist/workspace/records-cli.d.ts.map +1 -1
  18. package/dist/workspace/records-close.d.ts +41 -0
  19. package/dist/workspace/records-close.d.ts.map +1 -0
  20. package/dist/workspace/records-since.d.ts +40 -1
  21. package/dist/workspace/records-since.d.ts.map +1 -1
  22. package/dist/workspace/records-write.d.ts +109 -11
  23. package/dist/workspace/records-write.d.ts.map +1 -1
  24. package/dist/workspace/records.d.ts +40 -12
  25. package/dist/workspace/records.d.ts.map +1 -1
  26. package/dist/workspace/runtimes.d.ts +6 -0
  27. package/dist/workspace/runtimes.d.ts.map +1 -1
  28. package/dist/workspace/session-kinds.d.ts +28 -0
  29. package/dist/workspace/session-kinds.d.ts.map +1 -0
  30. package/dist/workspace/status.d.ts +2 -0
  31. package/dist/workspace/status.d.ts.map +1 -1
  32. package/dist/workspace/trust/seal.d.ts +42 -0
  33. package/dist/workspace/trust/seal.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/handlers/misc.ts +1 -9
  36. package/src/cli/main.ts +20 -8
  37. package/src/cli/mcp/server.test.ts +14 -1
  38. package/src/cli/mcp/server.ts +3 -1
  39. package/src/cli/registry.ts +4 -1
  40. package/src/cli/version.ts +15 -0
  41. package/src/workspace/__fixtures__/sessions.ts +41 -0
  42. package/src/workspace/composites.schema.json +68 -3
  43. package/src/workspace/composites.test.ts +119 -5
  44. package/src/workspace/composites.ts +26 -7
  45. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  46. package/src/workspace/environments.ts +165 -0
  47. package/src/workspace/read-contract.test.ts +3 -0
  48. package/src/workspace/reason-codes.test.ts +8 -3
  49. package/src/workspace/reason-codes.ts +18 -6
  50. package/src/workspace/record-assets.test.ts +4 -3
  51. package/src/workspace/records-amend.schema.json +30 -1
  52. package/src/workspace/records-cli.ts +44 -7
  53. package/src/workspace/records-close.schema.json +192 -0
  54. package/src/workspace/records-close.ts +129 -0
  55. package/src/workspace/records-contract.test.ts +4 -3
  56. package/src/workspace/records-new.schema.json +25 -0
  57. package/src/workspace/records-review.schema.json +55 -2
  58. package/src/workspace/records-sessions-write.test.ts +274 -0
  59. package/src/workspace/records-since.schema.json +27 -2
  60. package/src/workspace/records-since.ts +120 -6
  61. package/src/workspace/records-write-contract.test.ts +5 -1
  62. package/src/workspace/records-write.test.ts +4 -2
  63. package/src/workspace/records-write.ts +307 -43
  64. package/src/workspace/records.schema.json +22 -3
  65. package/src/workspace/records.ts +73 -22
  66. package/src/workspace/runtimes.ts +12 -3
  67. package/src/workspace/session-kinds.ts +79 -0
  68. package/src/workspace/status.ts +1 -1
  69. package/src/workspace/trust/record-seal.test.ts +315 -0
  70. package/src/workspace/trust/seal.test.ts +4 -14
  71. package/src/workspace/trust/seal.ts +119 -25
@@ -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
  );
@@ -13,6 +13,7 @@ import { afterAll, describe, expect, test } from "vitest";
13
13
  import { RECORD_WARNING_CODES } from "./records";
14
14
  import amendSchema from "./records-amend.schema.json";
15
15
  import newSchema from "./records-new.schema.json";
16
+ import closeSchema from "./records-close.schema.json";
16
17
  import reviewSchema from "./records-review.schema.json";
17
18
  import {
18
19
  AMEND_ERROR_CODES,
@@ -26,6 +27,7 @@ import {
26
27
  REVIEW_ERROR_CODES,
27
28
  reviewRecord,
28
29
  } from "./records-write";
30
+ import { CLOSE_ERROR_CODES, RECORDS_CLOSE_SCHEMA_ID } from "./records-close";
29
31
 
30
32
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
31
33
  const KIND = "docs/design/decisions/decision.kind.mjs";
@@ -50,6 +52,7 @@ const SCHEMAS = [
50
52
  { name: "records-new", schema: newSchema, id: RECORDS_NEW_SCHEMA_ID, codes: NEW_ERROR_CODES },
51
53
  { name: "records-amend", schema: amendSchema, id: RECORDS_AMEND_SCHEMA_ID, codes: AMEND_ERROR_CODES },
52
54
  { name: "records-review", schema: reviewSchema, id: RECORDS_REVIEW_SCHEMA_ID, codes: REVIEW_ERROR_CODES },
55
+ { name: "records-close", schema: closeSchema, id: RECORDS_CLOSE_SCHEMA_ID, codes: CLOSE_ERROR_CODES },
53
56
  ] as const;
54
57
 
55
58
  function expectValid(schema: object, doc: unknown): void {
@@ -118,8 +121,9 @@ describe("real output validates", () => {
118
121
  expectValid(amendSchema, await amendRecord({ kind: KIND, id: "ws-003", fields: JSON.stringify({ title: "x" }), cwd: root }));
119
122
 
120
123
  for (const dryRun of [true, false]) {
121
- expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", note: "Not yet.", session: "S-0001", dryRun, cwd: root }));
124
+ expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", note: "Not yet.", dryRun, cwd: root }));
122
125
  }
123
126
  expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "dissent", by: "alice", cwd: root }));
127
+ expectValid(reviewSchema, await reviewRecord({ kind: KIND, id: made.id, verdict: "agree", by: "alice", session: "S-0001", cwd: root }));
124
128
  });
125
129
  });
@@ -311,8 +311,10 @@ describe("records review", () => {
311
311
  expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", cwd: dir }))).toBe("review-note-required");
312
312
  expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: " ", cwd: dir }))).toBe("review-note-required");
313
313
  expect(touched(before, snapshot())).toEqual([]);
314
- const doc = await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: "It leaves out X.", session: "S-0001", cwd: dir });
315
- expect(doc).toMatchObject({ review: { reviewer: "bob", verdict: "dissent", note: "It leaves out X.", session: "S-0001" } });
314
+ const doc = await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "bob", note: "It leaves out X.", cwd: dir });
315
+ expect(doc).toMatchObject({ review: { reviewer: "bob", verdict: "dissent", note: "It leaves out X." } });
316
+ // No session kind names these decisions as its subjects, so no session exists to give a verdict in (#2693).
317
+ expect(code(await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by: "carol", session: "S-0001", cwd: dir }))).toBe("session-unknown");
316
318
  });
317
319
 
318
320
  test("reviews accumulate on a proposal too", async () => {