@intentius/chant 0.87.0 → 0.89.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 (79) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/handlers/serve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +10 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  7. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  8. package/dist/cli/registry.d.ts +6 -1
  9. package/dist/cli/registry.d.ts.map +1 -1
  10. package/dist/cli/version.d.ts +8 -0
  11. package/dist/cli/version.d.ts.map +1 -0
  12. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  13. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  14. package/dist/workspace/composites.d.ts +13 -0
  15. package/dist/workspace/composites.d.ts.map +1 -1
  16. package/dist/workspace/environments.d.ts +80 -0
  17. package/dist/workspace/environments.d.ts.map +1 -0
  18. package/dist/workspace/reason-codes.d.ts +14 -5
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/records-cli.d.ts +6 -2
  21. package/dist/workspace/records-cli.d.ts.map +1 -1
  22. package/dist/workspace/records-close.d.ts +41 -0
  23. package/dist/workspace/records-close.d.ts.map +1 -0
  24. package/dist/workspace/records-since.d.ts +40 -1
  25. package/dist/workspace/records-since.d.ts.map +1 -1
  26. package/dist/workspace/records-write.d.ts +109 -11
  27. package/dist/workspace/records-write.d.ts.map +1 -1
  28. package/dist/workspace/records.d.ts +40 -12
  29. package/dist/workspace/records.d.ts.map +1 -1
  30. package/dist/workspace/runtimes.d.ts +6 -0
  31. package/dist/workspace/runtimes.d.ts.map +1 -1
  32. package/dist/workspace/session-kinds.d.ts +28 -0
  33. package/dist/workspace/session-kinds.d.ts.map +1 -0
  34. package/dist/workspace/status.d.ts +2 -0
  35. package/dist/workspace/status.d.ts.map +1 -1
  36. package/dist/workspace/trust/seal.d.ts +42 -0
  37. package/dist/workspace/trust/seal.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/handlers/misc.ts +1 -9
  40. package/src/cli/handlers/serve.ts +10 -1
  41. package/src/cli/main.test.ts +7 -0
  42. package/src/cli/main.ts +60 -9
  43. package/src/cli/mcp/server.test.ts +14 -1
  44. package/src/cli/mcp/server.ts +13 -2
  45. package/src/cli/mcp/workspace-plugins.ts +123 -0
  46. package/src/cli/registry.ts +6 -1
  47. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  48. package/src/cli/version.ts +15 -0
  49. package/src/workspace/__fixtures__/sessions.ts +41 -0
  50. package/src/workspace/composites.schema.json +68 -3
  51. package/src/workspace/composites.test.ts +119 -5
  52. package/src/workspace/composites.ts +26 -7
  53. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  54. package/src/workspace/environments.ts +165 -0
  55. package/src/workspace/read-contract.test.ts +3 -0
  56. package/src/workspace/reason-codes.test.ts +8 -3
  57. package/src/workspace/reason-codes.ts +18 -6
  58. package/src/workspace/record-assets.test.ts +4 -3
  59. package/src/workspace/records-amend.schema.json +30 -1
  60. package/src/workspace/records-cli.ts +44 -7
  61. package/src/workspace/records-close.schema.json +192 -0
  62. package/src/workspace/records-close.ts +129 -0
  63. package/src/workspace/records-contract.test.ts +4 -3
  64. package/src/workspace/records-new.schema.json +25 -0
  65. package/src/workspace/records-review.schema.json +55 -2
  66. package/src/workspace/records-sessions-write.test.ts +274 -0
  67. package/src/workspace/records-since.schema.json +27 -2
  68. package/src/workspace/records-since.ts +120 -6
  69. package/src/workspace/records-write-contract.test.ts +5 -1
  70. package/src/workspace/records-write.test.ts +4 -2
  71. package/src/workspace/records-write.ts +307 -43
  72. package/src/workspace/records.schema.json +22 -3
  73. package/src/workspace/records.ts +73 -22
  74. package/src/workspace/runtimes.ts +12 -3
  75. package/src/workspace/session-kinds.ts +79 -0
  76. package/src/workspace/status.ts +1 -1
  77. package/src/workspace/trust/record-seal.test.ts +315 -0
  78. package/src/workspace/trust/seal.test.ts +4 -14
  79. package/src/workspace/trust/seal.ts +119 -25
@@ -14,11 +14,17 @@
14
14
  *
15
15
  * Every command writes one file or none, never commits, and prints one JSON
16
16
  * document with a closed error code on refusal. `--dry-run` prints the
17
- * document and the text it would write, and writes nothing.
17
+ * document and the text it would write, and writes nothing. The exception is
18
+ * `review --session` (#2693), which appends the verdict to the open session
19
+ * as well, after checking both files, so the two lists never differ.
20
+ * `records close` is in `records-close.ts`.
21
+ *
22
+ * `new --sign` and `amend --sign` seal the record's author (#2688), and
23
+ * `review --sign` seals a verdict (#2687): see `trust/seal.ts`.
18
24
  */
19
25
 
20
26
  import { readFileSync, statSync, writeFileSync } from "node:fs";
21
- import { join, posix, relative, resolve } from "node:path";
27
+ import { dirname, join, posix, relative, resolve } from "node:path";
22
28
  import type { CommandContext } from "../cli/registry";
23
29
  import type { ReasonCode } from "./reason-codes";
24
30
  import { gitRoot, workingTreeSource, type RecordSource } from "./record-source";
@@ -28,6 +34,8 @@ import {
28
34
  readRecords,
29
35
  RECORD_REASON_CODES,
30
36
  RecordReadError,
37
+ RECORD_SEAL_FIELD,
38
+ digestFields,
31
39
  recordTextDigest,
32
40
  type LoadedRecordKind,
33
41
  type RecordEntry,
@@ -35,6 +43,7 @@ import {
35
43
  } from "./records";
36
44
  import { declaredKindFiles, pinRoot, realpathOr } from "./records-cli";
37
45
  import { WorkspaceReadError } from "./declaration";
46
+ import { findSessionKinds, headCommit, sessionKindsFor } from "./session-kinds";
38
47
  import { workingTree } from "./tree";
39
48
 
40
49
  // ── Contract ─────────────────────────────────────────────────────────────────
@@ -47,7 +56,7 @@ export const RECORDS_AMEND_SCHEMA_ID = "https://intentius.io/chant/schemas/works
47
56
  export const RECORDS_REVIEW_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json";
48
57
 
49
58
  /** Loading the kind and reading its records, as `records` reads them. */
50
- const LOAD_ERROR_CODES = ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch", "schema-invalid", "location-missing"] as const;
59
+ export const LOAD_ERROR_CODES = ["kind-unreadable", "kind-invalid", "schema-unreadable", "schema-id-mismatch", "schema-invalid", "location-missing"] as const;
51
60
 
52
61
  /** Why `records new` wrote nothing. Closed: a reader may switch on it. */
53
62
  export const NEW_ERROR_CODES = [
@@ -57,6 +66,7 @@ export const NEW_ERROR_CODES = [
57
66
  "record-id-taken",
58
67
  "record-id-unallocatable",
59
68
  "record-path-unmatched",
69
+ "record-sign-failed",
60
70
  ...RECORD_REASON_CODES,
61
71
  ] as const satisfies readonly ReasonCode[];
62
72
 
@@ -69,6 +79,7 @@ export const AMEND_ERROR_CODES = [
69
79
  "amend-id-immutable",
70
80
  "record-closed",
71
81
  "amend-supersede-instead",
82
+ "record-sign-failed",
72
83
  ...RECORD_REASON_CODES,
73
84
  ] as const satisfies readonly ReasonCode[];
74
85
 
@@ -81,18 +92,20 @@ export const REVIEW_ERROR_CODES = [
81
92
  "record-closed",
82
93
  "review-note-required",
83
94
  "review-sign-failed",
95
+ "session-unknown",
96
+ "session-not-open",
84
97
  ...RECORD_REASON_CODES,
85
98
  ] as const satisfies readonly ReasonCode[];
86
99
 
87
100
  export type NewErrorCode = (typeof NEW_ERROR_CODES)[number];
88
101
  export type AmendErrorCode = (typeof AMEND_ERROR_CODES)[number];
89
102
  export type ReviewErrorCode = (typeof REVIEW_ERROR_CODES)[number];
90
- type WriteErrorCode = NewErrorCode | AmendErrorCode | ReviewErrorCode;
103
+ type WriteErrorCode = NewErrorCode | AmendErrorCode | ReviewErrorCode | import("./records-close").CloseErrorCode;
91
104
 
92
105
  export const VERDICTS = ["agree", "dissent", "abstain"] as const;
93
106
  export type Verdict = (typeof VERDICTS)[number];
94
107
 
95
- class RecordWriteError extends Error {
108
+ export class RecordWriteError extends Error {
96
109
  constructor(
97
110
  readonly code: WriteErrorCode,
98
111
  message: string,
@@ -102,14 +115,14 @@ class RecordWriteError extends Error {
102
115
  }
103
116
  }
104
117
 
105
- interface KindView {
118
+ export interface KindView {
106
119
  name: string;
107
120
  schema: string;
108
121
  file: string;
109
122
  }
110
123
 
111
124
  /** What every write result carries. */
112
- interface WriteResult {
125
+ export interface WriteResult {
113
126
  $schema: string;
114
127
  contract: number;
115
128
  kind: KindView;
@@ -124,15 +137,32 @@ interface WriteResult {
124
137
  text?: string;
125
138
  }
126
139
 
127
- interface WriteFailure<C> {
140
+ export interface WriteFailure<C> {
128
141
  $schema: string;
129
142
  contract: number;
130
143
  error: { code: C; message: string };
131
144
  }
132
145
 
133
- export type NewDocument = WriteResult | WriteFailure<NewErrorCode>;
134
- export type AmendDocument = (WriteResult & { changed: string[] }) | WriteFailure<AmendErrorCode>;
135
- export type ReviewDocument = (WriteResult & { review: Record<string, unknown> }) | WriteFailure<ReviewErrorCode>;
146
+ /** A record's author seal as `new` and `amend` write it with `--sign` (#2688). */
147
+ export interface AuthorSeal {
148
+ signer: string;
149
+ key: string;
150
+ signature: string;
151
+ }
152
+
153
+ export type NewDocument = (WriteResult & { seal?: AuthorSeal }) | WriteFailure<NewErrorCode>;
154
+ export type AmendDocument = (WriteResult & { changed: string[]; seal?: AuthorSeal; sealDropped?: string }) | WriteFailure<AmendErrorCode>;
155
+ /** With --session (#2693): the session the verdict was also appended to. */
156
+ export interface ReviewSession {
157
+ id: string;
158
+ path: string;
159
+ /** The entry appended to the session's verdicts list. */
160
+ verdict: Record<string, unknown>;
161
+ /** With --dry-run, the whole text the session file would hold. */
162
+ text?: string;
163
+ }
164
+
165
+ export type ReviewDocument = (WriteResult & { review: Record<string, unknown>; session?: ReviewSession }) | WriteFailure<ReviewErrorCode>;
136
166
 
137
167
  // ── Rendering ────────────────────────────────────────────────────────────────
138
168
 
@@ -196,7 +226,7 @@ function bodyOf(text: string): string {
196
226
  }
197
227
 
198
228
  /** JSON with object keys sorted, for comparing two values whatever their key order. */
199
- function stableJson(value: unknown): string {
229
+ export function stableJson(value: unknown): string {
200
230
  if (value === null || typeof value !== "object") return JSON.stringify(value);
201
231
  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
202
232
  const obj = value as Record<string, unknown>;
@@ -242,6 +272,31 @@ export function replaceFields(text: string, set: Record<string, unknown>, expect
242
272
  return back.ok && stableJson(back.value) === stableJson(expected) ? out : undefined;
243
273
  }
244
274
 
275
+ /**
276
+ * `text` with the top-level field `key` removed: its key line and the lines
277
+ * after it that {@link replaceFields} counts as its block, less the blank
278
+ * lines ending it, which stay. Returns undefined when the result does not
279
+ * read back as `expected`.
280
+ */
281
+ export function removeField(text: string, key: string, expected: Record<string, unknown>): string | undefined {
282
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
283
+ if (lines[0] !== "---") return undefined;
284
+ const close = lines.indexOf("---", 1);
285
+ if (close < 0) return undefined;
286
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
287
+ const starts = new RegExp(`^(?:${k}|"${k}"|'${k}')[ \\t]*:(?:[ \\t]|$)`);
288
+ const at = lines.findIndex((l, i) => i > 0 && i < close && starts.test(l));
289
+ if (at >= 0) {
290
+ let end = at;
291
+ while (end + 1 < close && /^(?:$|[ \t#-])/.test(lines[end + 1])) end++;
292
+ while (end > at && lines[end] === "") end--;
293
+ lines.splice(at, end - at + 1);
294
+ }
295
+ const out = lines.join("\n");
296
+ const back = parseFrontMatter(out);
297
+ return back.ok && stableJson(back.value) === stableJson(expected) ? out : undefined;
298
+ }
299
+
245
300
  /** A file-name slug from a title, as `scripts/import-decisions.mjs` makes one. */
246
301
  export function slug(title: string): string {
247
302
  return title
@@ -255,7 +310,7 @@ export function slug(title: string): string {
255
310
 
256
311
  // ── Shared steps ─────────────────────────────────────────────────────────────
257
312
 
258
- interface Opened {
313
+ export interface Opened {
259
314
  loaded: LoadedRecordKind;
260
315
  root: string;
261
316
  /** Where pinned paths resolve, from `root`. */
@@ -266,7 +321,7 @@ interface Opened {
266
321
  dirRel: string;
267
322
  }
268
323
 
269
- async function open(kind: string, cwd: string): Promise<Opened> {
324
+ export async function open(kind: string, cwd: string): Promise<Opened> {
270
325
  const real = realpathOr(cwd);
271
326
  const root = gitRoot(real) ?? real;
272
327
  const loaded = await loadRecordKind(kind, real);
@@ -287,13 +342,23 @@ async function open(kind: string, cwd: string): Promise<Opened> {
287
342
  };
288
343
  }
289
344
 
290
- async function readAll(o: Opened, source: RecordSource): Promise<RecordEntry[]> {
345
+ /**
346
+ * The kind's records as `records` reads them from `source`. A session kind's
347
+ * subject records are read from the same source (#2693), so a write is
348
+ * refused for a verdict naming a record that does not exist.
349
+ */
350
+ export async function readAll(o: Opened, source: RecordSource): Promise<RecordEntry[]> {
291
351
  const assets = workingTree(o.workspaceRoot === "." ? o.root : join(o.root, ...o.workspaceRoot.split("/")));
292
- return (await readRecords(o.loaded, { root: o.root, source, assets })).records;
352
+ let subjects: { records: RecordEntry[]; reviews: string } | undefined;
353
+ if (o.loaded.kind.session) {
354
+ const subjectKind = await loadRecordKind(resolve(dirname(o.loaded.file), o.loaded.kind.session.subjects.kind), o.root);
355
+ subjects = { records: (await readRecords(subjectKind, { root: o.root, source })).records, reviews: subjectKind.kind.reviews?.field ?? "reviews" };
356
+ }
357
+ return (await readRecords(o.loaded, { root: o.root, source, assets, ...(subjects ? { subjects } : {}) })).records;
293
358
  }
294
359
 
295
360
  /** `base` with the file at `path` holding `text`, added to its directory when new. */
296
- function overlay(base: RecordSource, path: string, text: string): RecordSource {
361
+ export function overlay(base: RecordSource, path: string, text: string): RecordSource {
297
362
  const dir = posix.dirname(path);
298
363
  const name = posix.basename(path);
299
364
  return {
@@ -313,12 +378,13 @@ function overlay(base: RecordSource, path: string, text: string): RecordSource {
313
378
  }
314
379
 
315
380
  /**
316
- * Read the records again with `text` at `path`. The written record must come
381
+ * Read the records again with `text` at `path`, over `base` (the working
382
+ * tree unless another write goes with this one). The written record must come
317
383
  * back with no reason, and no other record may gain one. Returns the written
318
384
  * record's warnings.
319
385
  */
320
- async function validateWrite(o: Opened, before: RecordEntry[], path: string, text: string): Promise<RecordWarning[]> {
321
- const after = await readAll(o, overlay(o.source, path, text));
386
+ export async function validateWrite(o: Opened, before: RecordEntry[], path: string, text: string, base: RecordSource = o.source): Promise<RecordWarning[]> {
387
+ const after = await readAll(o, overlay(base, path, text));
322
388
  const written = after.find((e) => e.path === path);
323
389
  if (!written) throw new RecordWriteError("record-path-unmatched", `${path} is not a file the kind ${o.view.name} reads`);
324
390
  if (written.reasons.length > 0) {
@@ -333,7 +399,7 @@ async function validateWrite(o: Opened, before: RecordEntry[], path: string, tex
333
399
  return written.warnings;
334
400
  }
335
401
 
336
- function findRecord(entries: RecordEntry[], id: string, kind: string): RecordEntry & { data: Record<string, unknown> } {
402
+ export function findRecord(entries: RecordEntry[], id: string, kind: string): RecordEntry & { data: Record<string, unknown> } {
337
403
  const hits = entries.filter((e) => e.id === id);
338
404
  if (hits.length === 0) throw new RecordWriteError("record-not-found", `no ${kind} record has id ${id}`);
339
405
  if (hits.length > 1) {
@@ -355,8 +421,97 @@ function parseFields(text: string, flag: string): Record<string, unknown> {
355
421
  return value as Record<string, unknown>;
356
422
  }
357
423
 
424
+ /** Refuse fields that set the author seal by hand: only --sign writes it (#2688). */
425
+ function refuseSealField(o: Opened, fields: Record<string, unknown>, flag: string): void {
426
+ if (o.loaded.kind.reviews && RECORD_SEAL_FIELD in fields) {
427
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${RECORD_SEAL_FIELD}, and only --sign writes a record's seal`);
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Refuse fields that set a session's opening or closing revision by hand
433
+ * (#2693): `new` writes the first from HEAD and `close` the second. A field
434
+ * given with the value the record already holds is not a change, so an
435
+ * amendment that sends the whole record back is taken.
436
+ */
437
+ function refuseRevisionFields(kind: LoadedRecordKind["kind"], fields: Record<string, unknown>, old: Record<string, unknown>, flag: string): void {
438
+ for (const f of [kind.session?.openedRev, kind.session?.closedRev]) {
439
+ if (f === undefined || !(f in fields) || (f in old && stableJson(old[f]) === stableJson(fields[f]))) continue;
440
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} set ${f}, and chant writes it: records new writes the commit a session opened at, and records close the one it closed at`);
441
+ }
442
+ }
443
+
444
+ /**
445
+ * `text`, the record `data` holds, with its author sealed (#2688): an ssh
446
+ * signature by the key `sign` names over the record id, the digest of
447
+ * `text`, the author (the kind's `reviews.decider` field) and the state, in
448
+ * the `chant-record` namespace. The seal goes in the top-level `seal` field,
449
+ * replacing one already there, or added at the end of the front matter. The
450
+ * digest leaves that field out, so it is the same before and after.
451
+ */
452
+ async function sealAuthor(o: Opened, text: string, data: Record<string, unknown>, id: string, sign: string | true, cwd: string): Promise<{ text: string; seal: AuthorSeal }> {
453
+ const { kind } = o.loaded;
454
+ if (!kind.reviews) {
455
+ throw new RecordWriteError(
456
+ "write-usage-invalid",
457
+ `--sign seals a record's author, named by the kind's reviews.decider field, and the ${kind.name} kind declares no reviews`,
458
+ );
459
+ }
460
+ const field = kind.reviews.decider;
461
+ const author = data[field];
462
+ if (typeof author !== "string" || author.trim() === "") {
463
+ throw new RecordWriteError("record-sign-failed", `${id} names no ${field}, so there is no author to seal: set ${field}, then seal it with records amend ${id} --sign`);
464
+ }
465
+ const state = kind.stateField !== undefined && typeof data[kind.stateField] === "string" ? (data[kind.stateField] as string) : null;
466
+ const digest = recordTextDigest(text, digestFields(kind), kind.format);
467
+ const { resolveSigningKey, sealRecord, SealError } = await import("./trust/seal");
468
+ let seal: AuthorSeal;
469
+ try {
470
+ const key = resolveSigningKey(sign, cwd);
471
+ try {
472
+ seal = { ...sealRecord(key.file, { record: id, digest, author, state }) };
473
+ } finally {
474
+ key.cleanup();
475
+ }
476
+ } catch (err) {
477
+ if (err instanceof SealError) throw new RecordWriteError("record-sign-failed", err.message);
478
+ throw err;
479
+ }
480
+ const sealed = replaceFields(text, { [RECORD_SEAL_FIELD]: seal }, { ...data, [RECORD_SEAL_FIELD]: seal });
481
+ if (sealed === undefined) throw new RecordWriteError("record-unparseable", `the ${RECORD_SEAL_FIELD} field can't be written into ${id} without changing the rest of the file`);
482
+ if (recordTextDigest(sealed, digestFields(kind), kind.format) !== digest) {
483
+ throw new Error(`sealing ${id} moved its digest; the seal block and the digest rule disagree`);
484
+ }
485
+ return { text: sealed, seal };
486
+ }
487
+
488
+ /**
489
+ * What to do instead of changing a closed record. A supersedes link is
490
+ * advice only when the kind's schema has the field: the session schema has
491
+ * none, so a closed session is followed by a new session (#2693).
492
+ */
493
+ function closedAdvice(o: Opened, id: string, supersede: string): string {
494
+ const { kind, schema } = o.loaded;
495
+ if (kind.session) return `A closed session is sealed and stays as it is: open a new session for what follows, with chant workspace records new ${o.view.file}`;
496
+ const props = schema.properties !== null && typeof schema.properties === "object" ? (schema.properties as Record<string, unknown>) : {};
497
+ if (kind.supersedes && (schema.additionalProperties !== false || kind.supersedes.field in props)) return `Write a new record that supersedes it: ${supersede}`;
498
+ return `Write a new record in its place with chant workspace records new ${o.view.file}; the ${kind.name} schema has no field that links it to ${id}`;
499
+ }
500
+
501
+ /**
502
+ * The opening revision a session lacks (#2693): when the kind names one and
503
+ * the record holds null there, HEAD, once the repository has a commit.
504
+ * Every write to the session fills it.
505
+ */
506
+ export function openedRevFill(kind: LoadedRecordKind["kind"], data: Record<string, unknown>, root: string): Record<string, unknown> {
507
+ const f = kind.session?.openedRev;
508
+ if (f === undefined || data[f] !== null) return {};
509
+ const head = headCommit(root);
510
+ return head === null ? {} : { [f]: head };
511
+ }
512
+
358
513
  /** Keys in the schema's `required` order, then its `properties` order, then the rest as given. */
359
- function schemaOrder(data: Record<string, unknown>, schema: Record<string, unknown>): string[] {
514
+ export function schemaOrder(data: Record<string, unknown>, schema: Record<string, unknown>): string[] {
360
515
  const required = Array.isArray(schema.required) ? (schema.required as unknown[]).filter((k): k is string => typeof k === "string") : [];
361
516
  const props = schema.properties !== null && typeof schema.properties === "object" ? Object.keys(schema.properties as object) : [];
362
517
  const order = [...new Set([...required, ...props])];
@@ -364,11 +519,11 @@ function schemaOrder(data: Record<string, unknown>, schema: Record<string, unkno
364
519
  return [...order.filter((k) => keys.includes(k)), ...keys.filter((k) => !order.includes(k))];
365
520
  }
366
521
 
367
- function pick(data: Record<string, unknown>, keys: string[]): Record<string, unknown> {
522
+ export function pick(data: Record<string, unknown>, keys: string[]): Record<string, unknown> {
368
523
  return Object.fromEntries(keys.map((k) => [k, data[k]]));
369
524
  }
370
525
 
371
- function abs(o: Opened, path: string): string {
526
+ export function abs(o: Opened, path: string): string {
372
527
  return join(o.root, ...path.split("/"));
373
528
  }
374
529
 
@@ -376,7 +531,7 @@ function today(): string {
376
531
  return new Date().toISOString().slice(0, 10);
377
532
  }
378
533
 
379
- function failure<C>(schema: string, err: unknown): WriteFailure<C> {
534
+ export function failure<C>(schema: string, err: unknown): WriteFailure<C> {
380
535
  if (err instanceof RecordWriteError || err instanceof RecordReadError) {
381
536
  return { $schema: schema, contract: RECORDS_WRITE_CONTRACT_VERSION, error: { code: err.code as C, message: err.message } };
382
537
  }
@@ -431,9 +586,15 @@ export interface NewRecordOptions {
431
586
  prefix?: string;
432
587
  dryRun?: boolean;
433
588
  cwd: string;
589
+ /**
590
+ * Seal the record's author (#2688): a key file, resolved against `cwd`, or
591
+ * true for git's `user.signingkey`. The author is the kind's
592
+ * `reviews.decider` field, which the fields must set.
593
+ */
594
+ sign?: string | true;
434
595
  }
435
596
 
436
- /** `records new`: write one new record from validated fields. */
597
+ /** `records new`: write one new record from validated fields, sealed by its author with `sign`. */
437
598
  export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
438
599
  try {
439
600
  if (opts.prefix !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(opts.prefix)) {
@@ -442,6 +603,8 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
442
603
  const fields = parseFields(opts.fields, "--from");
443
604
  const o = await open(opts.kind, opts.cwd);
444
605
  const { kind, schema } = o.loaded;
606
+ refuseSealField(o, fields, "--from");
607
+ refuseRevisionFields(kind, fields, {}, "--from");
445
608
  const idField = kind.idField!;
446
609
  const before = await readAll(o, o.source);
447
610
  const given = fields[idField];
@@ -454,7 +617,10 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
454
617
  if (taken) throw new RecordWriteError("record-id-taken", `id ${given} is already used by ${taken.path}; ids are never reused, so leave ${idField} out to have the next one allocated`);
455
618
  id = given;
456
619
  }
457
- const data = pick({ ...fields, [idField]: id }, schemaOrder({ ...fields, [idField]: id }, schema));
620
+ // A session records the commit it opened at (#2693): HEAD now, or null before the first commit.
621
+ const opened = kind.session?.openedRev ? { [kind.session.openedRev]: headCommit(o.root) } : {};
622
+ const full = { ...fields, ...opened, [idField]: id };
623
+ const data = pick(full, schemaOrder(full, schema));
458
624
  const title = typeof data.title === "string" ? data.title : "";
459
625
  const match = new RegExp(kind.location.match);
460
626
  const names = [slug(title) ? `${id}-${slug(title)}.md` : null, `${id}.md`].filter((n): n is string => n !== null);
@@ -462,7 +628,9 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
462
628
  if (!name) throw new RecordWriteError("record-path-unmatched", `the kind's location.match ${kind.location.match} matches none of ${names.join(", ")}`);
463
629
  const path = o.dirRel === "." ? name : `${o.dirRel}/${name}`;
464
630
  if (o.source.list(o.dirRel)?.includes(name)) throw new RecordWriteError("record-id-taken", `${path} already exists`);
465
- const text = renderRecord(data, title ? `\n# ${title}\n` : "");
631
+ let text = renderRecord(data, title ? `\n# ${title}\n` : "");
632
+ let seal: AuthorSeal | undefined;
633
+ if (opts.sign !== undefined) ({ text, seal } = await sealAuthor(o, text, data, id, opts.sign, opts.cwd));
466
634
  const warnings = await validateWrite(o, before, path, text);
467
635
  if (!opts.dryRun) writeFileSync(abs(o, path), text, { flag: "wx" });
468
636
  return {
@@ -471,6 +639,7 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
471
639
  kind: o.view,
472
640
  path,
473
641
  id,
642
+ ...(seal ? { seal } : {}),
474
643
  dryRun: !!opts.dryRun,
475
644
  warnings,
476
645
  ...(opts.dryRun ? { text } : {}),
@@ -489,6 +658,8 @@ export interface AmendRecordOptions {
489
658
  fields: string;
490
659
  dryRun?: boolean;
491
660
  cwd: string;
661
+ /** Seal the amended record's author, as `records new` does (#2688). */
662
+ sign?: string | true;
492
663
  }
493
664
 
494
665
  /**
@@ -497,15 +668,26 @@ export interface AmendRecordOptions {
497
668
  * a decided decision) changes in place only in its state, to one ranked at
498
669
  * least as high, its pins field and its reviews: anything else is a new
499
670
  * decision, written as a record that supersedes it (#2524 D4).
671
+ *
672
+ * An amendment moves the record's digest, so an author seal no longer holds
673
+ * (#2688). With `sign` the record is sealed again over the new text; without
674
+ * it, an amendment that moves the digest removes the seal and says so in
675
+ * `sealDropped`, so a record never carries a seal that fails. One that
676
+ * changes only the reviews leaves the digest, and the seal, as they were.
677
+ * `--sign` with nothing to change seals the record as it is.
500
678
  */
501
679
  export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocument> {
502
680
  try {
503
- const patch = parseFields(opts.fields, "--set");
681
+ const given = parseFields(opts.fields, "--set");
504
682
  const o = await open(opts.kind, opts.cwd);
505
683
  const { kind } = o.loaded;
684
+ refuseSealField(o, given, "--set");
506
685
  const before = await readAll(o, o.source);
507
686
  const target = findRecord(before, opts.id, kind.name);
508
687
  const old = target.data;
688
+ refuseRevisionFields(kind, given, old, "--set");
689
+ const isClosed = target.state !== null && (kind.closedStates ?? []).includes(target.state);
690
+ const patch = { ...(isClosed ? {} : openedRevFill(kind, old, o.root)), ...given };
509
691
  const added = Object.keys(patch).filter((k) => !(k in old));
510
692
  const merged = pick({ ...old, ...patch }, [...Object.keys(old), ...schemaOrder(pick(patch, added), o.loaded.schema)]);
511
693
  const changed = Object.keys(merged).filter((k) => stableJson(old[k]) !== stableJson(merged[k]));
@@ -515,8 +697,8 @@ export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocume
515
697
  const state = target.state;
516
698
  const link = kind.supersedes?.key === undefined ? JSON.stringify(opts.id) : `[{"${kind.supersedes.key}": "${opts.id}"}]`;
517
699
  const supersede = kind.supersedes ? `chant workspace records new with ${kind.supersedes.field}: ${link}` : `chant workspace records new`;
518
- if (changed.length > 0 && state !== null && (kind.closedStates ?? []).includes(state)) {
519
- throw new RecordWriteError("record-closed", `${opts.id} is ${state}, a closed state, so nothing in it changes. Write a new record that supersedes it: ${supersede}`);
700
+ if ((changed.length > 0 || opts.sign !== undefined) && state !== null && (kind.closedStates ?? []).includes(state)) {
701
+ throw new RecordWriteError("record-closed", `${opts.id} is ${state}, a closed state, so nothing in it changes. ${closedAdvice(o, opts.id, supersede)}`);
520
702
  }
521
703
  const rank = (s: unknown): number => (typeof s === "string" ? (kind.approval?.[s] ?? 0) : 0);
522
704
  if (changed.length > 0 && kind.approval && rank(state) > 0) {
@@ -538,7 +720,23 @@ export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocume
538
720
  }
539
721
  // Only the changed fields' blocks are rewritten, so the rest of the file keeps its bytes.
540
722
  const current = o.source.read(target.path);
541
- const text = replaceFields(current, pick(merged, changed), merged) ?? renderRecord(merged, bodyOf(current));
723
+ let text = changed.length === 0 ? current : (replaceFields(current, pick(merged, changed), merged) ?? renderRecord(merged, bodyOf(current)));
724
+ // The author seal (#2688): signed again over the new text, or dropped once the text moves.
725
+ let seal: AuthorSeal | undefined;
726
+ let sealDropped: string | undefined;
727
+ const sealable = kind.reviews !== undefined && RECORD_SEAL_FIELD in old;
728
+ if (opts.sign !== undefined) {
729
+ ({ text, seal } = await sealAuthor(o, text, merged, opts.id, opts.sign, opts.cwd));
730
+ merged[RECORD_SEAL_FIELD] = seal;
731
+ } else if (sealable && recordTextDigest(text, digestFields(kind), kind.format) !== recordTextDigest(current, digestFields(kind), kind.format)) {
732
+ const signer = (old[RECORD_SEAL_FIELD] as { signer?: unknown } | null)?.signer;
733
+ delete merged[RECORD_SEAL_FIELD];
734
+ const dropped = removeField(text, RECORD_SEAL_FIELD, merged);
735
+ if (dropped === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: the ${RECORD_SEAL_FIELD} block can't be removed without changing the rest of the file`);
736
+ text = dropped;
737
+ sealDropped = `${opts.id} was sealed${typeof signer === "string" ? ` by ${signer}` : ""}, and the amendment moves its digest, so the seal was removed: seal it again with records amend ${opts.id} --sign`;
738
+ }
739
+ if (stableJson(old[RECORD_SEAL_FIELD]) !== stableJson(merged[RECORD_SEAL_FIELD])) changed.push(RECORD_SEAL_FIELD);
542
740
  const warnings = changed.length === 0 ? target.warnings : await validateWrite(o, before, target.path, text);
543
741
  if (!opts.dryRun && changed.length > 0) writeFileSync(abs(o, target.path), text);
544
742
  return {
@@ -548,6 +746,8 @@ export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocume
548
746
  path: target.path,
549
747
  id: opts.id,
550
748
  changed,
749
+ ...(seal ? { seal } : {}),
750
+ ...(sealDropped ? { sealDropped } : {}),
551
751
  dryRun: !!opts.dryRun,
552
752
  warnings,
553
753
  ...(opts.dryRun ? { text } : {}),
@@ -606,6 +806,7 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
606
806
  if (opts.verdict === "dissent" && !(opts.note ?? "").trim()) {
607
807
  throw new RecordWriteError("review-note-required", `a dissent needs a reason: pass --note <text> with the concern`);
608
808
  }
809
+ const inSession = opts.session !== undefined ? await openSession(o, opts.session, opts.cwd) : undefined;
609
810
  const reviews = target.data[field] ?? [];
610
811
  if (!Array.isArray(reviews)) throw new RecordWriteError("record-schema-invalid", `${target.path}: ${field} is not a list`);
611
812
  const current = o.source.read(target.path);
@@ -614,16 +815,34 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
614
815
  verdict: opts.verdict,
615
816
  ...(opts.note !== undefined ? { note: opts.note } : {}),
616
817
  on: opts.on ?? today(),
617
- digest: recordTextDigest(current, field),
818
+ digest: recordTextDigest(current, digestFields(kind), kind.format),
618
819
  ...(opts.session !== undefined ? { session: opts.session } : {}),
619
820
  };
620
821
  if (opts.sign !== undefined) review.seal = await seal(opts.sign, opts.cwd, { record: opts.id, digest: review.digest as string, verdict: opts.verdict, reviewer: opts.by, on: review.on as string });
621
- // Only the reviews block changes, so the digest the verdict names stays the record's digest (#2672).
822
+ // Only the reviews block changes, so the digest the verdict names stays the record's digest (#2672),
823
+ // and a record's author seal, which the digest leaves out too, still holds (#2688).
622
824
  const list = [...reviews, review];
623
825
  const text = replaceFields(current, { [field]: list }, { ...target.data, [field]: list });
624
826
  if (text === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: the ${field} block can't be rewritten in place without changing the rest of the file`);
625
827
  const warnings = await validateWrite(o, before, target.path, text);
626
- if (!opts.dryRun) writeFileSync(abs(o, target.path), text);
828
+ // The same verdict on the session's own list (#2693), validated with the review in place, so the two lists never differ.
829
+ let session: { path: string; text: string; out: ReviewSession } | undefined;
830
+ if (inSession) {
831
+ const { so, before: sessions, target: st } = inSession;
832
+ const decl = so.loaded.kind.session!;
833
+ const verdicts = st.data[decl.verdicts] ?? [];
834
+ if (!Array.isArray(verdicts)) throw new RecordWriteError("record-schema-invalid", `${st.path}: ${decl.verdicts} is not a list`);
835
+ const verdict = { record: opts.id, principal: opts.by, verdict: opts.verdict, digest: review.digest };
836
+ const set = { ...openedRevFill(so.loaded.kind, st.data, so.root), [decl.verdicts]: [...verdicts, verdict] };
837
+ const sText = replaceFields(so.source.read(st.path), set, { ...st.data, ...set });
838
+ if (sText === undefined) throw new RecordWriteError("record-unparseable", `${st.path}: the ${decl.verdicts} block can't be rewritten in place without changing the rest of the file`);
839
+ await validateWrite(so, sessions, st.path, sText, overlay(o.source, target.path, text));
840
+ session = { path: st.path, text: sText, out: { id: opts.session!, path: st.path, verdict, ...(opts.dryRun ? { text: sText } : {}) } };
841
+ }
842
+ if (!opts.dryRun) {
843
+ writeFileSync(abs(o, target.path), text);
844
+ if (session) writeFileSync(abs(o, session.path), session.text);
845
+ }
627
846
  return {
628
847
  $schema: RECORDS_REVIEW_SCHEMA_ID,
629
848
  contract: RECORDS_WRITE_CONTRACT_VERSION,
@@ -631,6 +850,7 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
631
850
  path: target.path,
632
851
  id: opts.id,
633
852
  review,
853
+ ...(session ? { session: session.out } : {}),
634
854
  dryRun: !!opts.dryRun,
635
855
  warnings,
636
856
  ...(opts.dryRun ? { text } : {}),
@@ -640,6 +860,33 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
640
860
  }
641
861
  }
642
862
 
863
+ /**
864
+ * The open session `id` of a session kind whose subjects are the kind `o`
865
+ * writes (#2693), found through the workspace declaration. Refused with
866
+ * session-unknown when no such session exists, and session-not-open when it
867
+ * is in a closed state.
868
+ */
869
+ async function openSession(o: Opened, id: string, cwd: string): Promise<{ so: Opened; before: RecordEntry[]; target: RecordEntry & { data: Record<string, unknown> } }> {
870
+ const kinds = await sessionKindsFor(o.loaded.file, cwd);
871
+ for (const k of kinds) {
872
+ const so = await open(k.file, cwd);
873
+ const before = await readAll(so, so.source);
874
+ if (!before.some((e) => e.id === id)) continue;
875
+ const target = findRecord(before, id, so.view.name);
876
+ if (target.data === null) throw new RecordWriteError("record-unparseable", `session ${id} (${target.path}) can't be read, so the verdict can't be added to it`);
877
+ if (target.state !== null && (so.loaded.kind.closedStates ?? []).includes(target.state)) {
878
+ throw new RecordWriteError("session-not-open", `session ${id} is ${target.state}, so it takes no more verdicts: give the verdict in an open session, or without --session`);
879
+ }
880
+ return { so, before, target };
881
+ }
882
+ throw new RecordWriteError(
883
+ "session-unknown",
884
+ kinds.length === 0
885
+ ? `no session kind the workspace declaration names has ${o.view.file} as its subjects, so there is no session ${id} to give the verdict in`
886
+ : `no ${kinds.map((k) => k.kind.name).join(" or ")} record has id ${id}`,
887
+ );
888
+ }
889
+
643
890
  /** A seal over one verdict, or a refusal with review-sign-failed. Loaded only when --sign is given. */
644
891
  async function seal(sign: string | true, cwd: string, v: { record: string; digest: string; verdict: string; reviewer: string; on: string }): Promise<Record<string, unknown>> {
645
892
  const { resolveSigningKey, sealVerdict, SealError } = await import("./trust/seal");
@@ -659,9 +906,10 @@ async function seal(sign: string | true, cwd: string, v: { record: string; diges
659
906
  // ── The command ──────────────────────────────────────────────────────────────
660
907
 
661
908
  export const WRITE_USAGE = [
662
- "chant workspace records new [<kind file or declared kind>] --from <file|-> [--prefix <prefix>] [--dry-run]",
663
- "chant workspace records amend <id> [--kind <kind file>] --set <file|-> [--dry-run]",
909
+ "chant workspace records new [<kind file or declared kind>] --from <file|-> [--prefix <prefix>] [--sign [<key file>]] [--dry-run]",
910
+ "chant workspace records amend <id> [--kind <kind file>] --set <file|-> [--sign [<key file>]] [--dry-run]",
664
911
  "chant workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]",
912
+ "chant workspace records close <session id> [--kind <session kind file>] [--dry-run]",
665
913
  ].join("\n");
666
914
 
667
915
  function usage(schema: string, message: string): WriteFailure<"write-usage-invalid"> {
@@ -726,7 +974,18 @@ function readInput(schema: string, flag: string, value: string | undefined, cwd:
726
974
  }
727
975
  }
728
976
 
729
- /** `chant workspace records new|amend|review`. Prints one JSON document; exits 0 when it wrote, or would have with --dry-run. */
977
+ /**
978
+ * The session kind `records close` goes through when none is named (#2693):
979
+ * the one session kind the declaration names.
980
+ */
981
+ async function declaredSessionKind(schema: string, cwd: string): Promise<string | WriteFailure<"write-usage-invalid">> {
982
+ const kinds = (await findSessionKinds(cwd)).map((k) => k.file);
983
+ if (kinds.length === 1) return kinds[0];
984
+ if (kinds.length === 0) return usage(schema, "--kind <session kind file> is required: the declaration names no session kind");
985
+ return usage(schema, `the declaration names ${kinds.length} session kinds (${kinds.join(", ")}), so name the one to close with --kind`);
986
+ }
987
+
988
+ /** `chant workspace records new|amend|review|close`. Prints one JSON document; exits 0 when it wrote, or would have with --dry-run. */
730
989
  export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
731
990
  const { args } = ctx;
732
991
  const cwd = process.cwd();
@@ -735,9 +994,14 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
735
994
  console.log(JSON.stringify(doc, null, 2));
736
995
  return "error" in doc ? 1 : 0;
737
996
  };
738
- if (args.sign !== undefined && verb !== "review") {
739
- const schema = verb === "new" ? RECORDS_NEW_SCHEMA_ID : RECORDS_AMEND_SCHEMA_ID;
740
- return print(usage(schema, `--sign seals a review verdict; sealing a record's author on ${verb} is not supported yet (#2688)`));
997
+ if (verb === "close") {
998
+ const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("./records-close");
999
+ if (args.sign !== undefined) return print(usage(RECORDS_CLOSE_SCHEMA_ID, "--sign is not taken by close: the seal close writes is the digest of the session text"));
1000
+ const id = args.extraPositional2;
1001
+ if (!id) return print(usage(RECORDS_CLOSE_SCHEMA_ID, "close needs the session's id"));
1002
+ const kind = args.kind !== undefined ? resolveWriteKind(args.kind, cwd) : await declaredSessionKind(RECORDS_CLOSE_SCHEMA_ID, cwd);
1003
+ if (typeof kind !== "string") return print(kind);
1004
+ return print(await closeRecord({ kind, id, dryRun: args.dryRun, cwd }));
741
1005
  }
742
1006
  if (verb === "new") {
743
1007
  const named = args.extraPositional2 ?? args.kind;
@@ -745,7 +1009,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
745
1009
  if (typeof kind !== "string") return print(kind);
746
1010
  const input = readInput(RECORDS_NEW_SCHEMA_ID, "--from", args.migrateFrom, cwd);
747
1011
  if (typeof input !== "string") return print(input);
748
- return print(await newRecord({ kind, fields: input, prefix: args.prefix, dryRun: args.dryRun, cwd }));
1012
+ return print(await newRecord({ kind, fields: input, prefix: args.prefix, sign: args.sign, dryRun: args.dryRun, cwd }));
749
1013
  }
750
1014
  const schema = verb === "amend" ? RECORDS_AMEND_SCHEMA_ID : RECORDS_REVIEW_SCHEMA_ID;
751
1015
  const id = args.extraPositional2;
@@ -755,7 +1019,7 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
755
1019
  if (verb === "amend") {
756
1020
  const input = readInput(schema, "--set", args.set, cwd);
757
1021
  if (typeof input !== "string") return print(input);
758
- return print(await amendRecord({ kind, id, fields: input, dryRun: args.dryRun, cwd }));
1022
+ return print(await amendRecord({ kind, id, fields: input, sign: args.sign, dryRun: args.dryRun, cwd }));
759
1023
  }
760
1024
  if (args.verdict === undefined) return print(usage(schema, "--verdict agree|dissent|abstain is required"));
761
1025
  if (args.by === undefined) return print(usage(schema, "--by <principal> is required"));