@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
@@ -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
 
@@ -80,18 +91,21 @@ export const REVIEW_ERROR_CODES = [
80
91
  "review-unsupported",
81
92
  "record-closed",
82
93
  "review-note-required",
94
+ "review-sign-failed",
95
+ "session-unknown",
96
+ "session-not-open",
83
97
  ...RECORD_REASON_CODES,
84
98
  ] as const satisfies readonly ReasonCode[];
85
99
 
86
100
  export type NewErrorCode = (typeof NEW_ERROR_CODES)[number];
87
101
  export type AmendErrorCode = (typeof AMEND_ERROR_CODES)[number];
88
102
  export type ReviewErrorCode = (typeof REVIEW_ERROR_CODES)[number];
89
- type WriteErrorCode = NewErrorCode | AmendErrorCode | ReviewErrorCode;
103
+ type WriteErrorCode = NewErrorCode | AmendErrorCode | ReviewErrorCode | import("./records-close").CloseErrorCode;
90
104
 
91
105
  export const VERDICTS = ["agree", "dissent", "abstain"] as const;
92
106
  export type Verdict = (typeof VERDICTS)[number];
93
107
 
94
- class RecordWriteError extends Error {
108
+ export class RecordWriteError extends Error {
95
109
  constructor(
96
110
  readonly code: WriteErrorCode,
97
111
  message: string,
@@ -101,14 +115,14 @@ class RecordWriteError extends Error {
101
115
  }
102
116
  }
103
117
 
104
- interface KindView {
118
+ export interface KindView {
105
119
  name: string;
106
120
  schema: string;
107
121
  file: string;
108
122
  }
109
123
 
110
124
  /** What every write result carries. */
111
- interface WriteResult {
125
+ export interface WriteResult {
112
126
  $schema: string;
113
127
  contract: number;
114
128
  kind: KindView;
@@ -123,15 +137,32 @@ interface WriteResult {
123
137
  text?: string;
124
138
  }
125
139
 
126
- interface WriteFailure<C> {
140
+ export interface WriteFailure<C> {
127
141
  $schema: string;
128
142
  contract: number;
129
143
  error: { code: C; message: string };
130
144
  }
131
145
 
132
- export type NewDocument = WriteResult | WriteFailure<NewErrorCode>;
133
- export type AmendDocument = (WriteResult & { changed: string[] }) | WriteFailure<AmendErrorCode>;
134
- 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>;
135
166
 
136
167
  // ── Rendering ────────────────────────────────────────────────────────────────
137
168
 
@@ -195,7 +226,7 @@ function bodyOf(text: string): string {
195
226
  }
196
227
 
197
228
  /** JSON with object keys sorted, for comparing two values whatever their key order. */
198
- function stableJson(value: unknown): string {
229
+ export function stableJson(value: unknown): string {
199
230
  if (value === null || typeof value !== "object") return JSON.stringify(value);
200
231
  if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
201
232
  const obj = value as Record<string, unknown>;
@@ -241,6 +272,31 @@ export function replaceFields(text: string, set: Record<string, unknown>, expect
241
272
  return back.ok && stableJson(back.value) === stableJson(expected) ? out : undefined;
242
273
  }
243
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
+
244
300
  /** A file-name slug from a title, as `scripts/import-decisions.mjs` makes one. */
245
301
  export function slug(title: string): string {
246
302
  return title
@@ -254,7 +310,7 @@ export function slug(title: string): string {
254
310
 
255
311
  // ── Shared steps ─────────────────────────────────────────────────────────────
256
312
 
257
- interface Opened {
313
+ export interface Opened {
258
314
  loaded: LoadedRecordKind;
259
315
  root: string;
260
316
  /** Where pinned paths resolve, from `root`. */
@@ -265,7 +321,7 @@ interface Opened {
265
321
  dirRel: string;
266
322
  }
267
323
 
268
- async function open(kind: string, cwd: string): Promise<Opened> {
324
+ export async function open(kind: string, cwd: string): Promise<Opened> {
269
325
  const real = realpathOr(cwd);
270
326
  const root = gitRoot(real) ?? real;
271
327
  const loaded = await loadRecordKind(kind, real);
@@ -286,13 +342,23 @@ async function open(kind: string, cwd: string): Promise<Opened> {
286
342
  };
287
343
  }
288
344
 
289
- 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[]> {
290
351
  const assets = workingTree(o.workspaceRoot === "." ? o.root : join(o.root, ...o.workspaceRoot.split("/")));
291
- 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;
292
358
  }
293
359
 
294
360
  /** `base` with the file at `path` holding `text`, added to its directory when new. */
295
- function overlay(base: RecordSource, path: string, text: string): RecordSource {
361
+ export function overlay(base: RecordSource, path: string, text: string): RecordSource {
296
362
  const dir = posix.dirname(path);
297
363
  const name = posix.basename(path);
298
364
  return {
@@ -312,12 +378,13 @@ function overlay(base: RecordSource, path: string, text: string): RecordSource {
312
378
  }
313
379
 
314
380
  /**
315
- * 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
316
383
  * back with no reason, and no other record may gain one. Returns the written
317
384
  * record's warnings.
318
385
  */
319
- async function validateWrite(o: Opened, before: RecordEntry[], path: string, text: string): Promise<RecordWarning[]> {
320
- 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));
321
388
  const written = after.find((e) => e.path === path);
322
389
  if (!written) throw new RecordWriteError("record-path-unmatched", `${path} is not a file the kind ${o.view.name} reads`);
323
390
  if (written.reasons.length > 0) {
@@ -332,7 +399,7 @@ async function validateWrite(o: Opened, before: RecordEntry[], path: string, tex
332
399
  return written.warnings;
333
400
  }
334
401
 
335
- 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> } {
336
403
  const hits = entries.filter((e) => e.id === id);
337
404
  if (hits.length === 0) throw new RecordWriteError("record-not-found", `no ${kind} record has id ${id}`);
338
405
  if (hits.length > 1) {
@@ -354,8 +421,97 @@ function parseFields(text: string, flag: string): Record<string, unknown> {
354
421
  return value as Record<string, unknown>;
355
422
  }
356
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
+
357
513
  /** Keys in the schema's `required` order, then its `properties` order, then the rest as given. */
358
- function schemaOrder(data: Record<string, unknown>, schema: Record<string, unknown>): string[] {
514
+ export function schemaOrder(data: Record<string, unknown>, schema: Record<string, unknown>): string[] {
359
515
  const required = Array.isArray(schema.required) ? (schema.required as unknown[]).filter((k): k is string => typeof k === "string") : [];
360
516
  const props = schema.properties !== null && typeof schema.properties === "object" ? Object.keys(schema.properties as object) : [];
361
517
  const order = [...new Set([...required, ...props])];
@@ -363,11 +519,11 @@ function schemaOrder(data: Record<string, unknown>, schema: Record<string, unkno
363
519
  return [...order.filter((k) => keys.includes(k)), ...keys.filter((k) => !order.includes(k))];
364
520
  }
365
521
 
366
- function pick(data: Record<string, unknown>, keys: string[]): Record<string, unknown> {
522
+ export function pick(data: Record<string, unknown>, keys: string[]): Record<string, unknown> {
367
523
  return Object.fromEntries(keys.map((k) => [k, data[k]]));
368
524
  }
369
525
 
370
- function abs(o: Opened, path: string): string {
526
+ export function abs(o: Opened, path: string): string {
371
527
  return join(o.root, ...path.split("/"));
372
528
  }
373
529
 
@@ -375,7 +531,7 @@ function today(): string {
375
531
  return new Date().toISOString().slice(0, 10);
376
532
  }
377
533
 
378
- function failure<C>(schema: string, err: unknown): WriteFailure<C> {
534
+ export function failure<C>(schema: string, err: unknown): WriteFailure<C> {
379
535
  if (err instanceof RecordWriteError || err instanceof RecordReadError) {
380
536
  return { $schema: schema, contract: RECORDS_WRITE_CONTRACT_VERSION, error: { code: err.code as C, message: err.message } };
381
537
  }
@@ -430,9 +586,15 @@ export interface NewRecordOptions {
430
586
  prefix?: string;
431
587
  dryRun?: boolean;
432
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;
433
595
  }
434
596
 
435
- /** `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`. */
436
598
  export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
437
599
  try {
438
600
  if (opts.prefix !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(opts.prefix)) {
@@ -441,6 +603,8 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
441
603
  const fields = parseFields(opts.fields, "--from");
442
604
  const o = await open(opts.kind, opts.cwd);
443
605
  const { kind, schema } = o.loaded;
606
+ refuseSealField(o, fields, "--from");
607
+ refuseRevisionFields(kind, fields, {}, "--from");
444
608
  const idField = kind.idField!;
445
609
  const before = await readAll(o, o.source);
446
610
  const given = fields[idField];
@@ -453,7 +617,10 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
453
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`);
454
618
  id = given;
455
619
  }
456
- 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));
457
624
  const title = typeof data.title === "string" ? data.title : "";
458
625
  const match = new RegExp(kind.location.match);
459
626
  const names = [slug(title) ? `${id}-${slug(title)}.md` : null, `${id}.md`].filter((n): n is string => n !== null);
@@ -461,7 +628,9 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
461
628
  if (!name) throw new RecordWriteError("record-path-unmatched", `the kind's location.match ${kind.location.match} matches none of ${names.join(", ")}`);
462
629
  const path = o.dirRel === "." ? name : `${o.dirRel}/${name}`;
463
630
  if (o.source.list(o.dirRel)?.includes(name)) throw new RecordWriteError("record-id-taken", `${path} already exists`);
464
- 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));
465
634
  const warnings = await validateWrite(o, before, path, text);
466
635
  if (!opts.dryRun) writeFileSync(abs(o, path), text, { flag: "wx" });
467
636
  return {
@@ -470,6 +639,7 @@ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
470
639
  kind: o.view,
471
640
  path,
472
641
  id,
642
+ ...(seal ? { seal } : {}),
473
643
  dryRun: !!opts.dryRun,
474
644
  warnings,
475
645
  ...(opts.dryRun ? { text } : {}),
@@ -488,6 +658,8 @@ export interface AmendRecordOptions {
488
658
  fields: string;
489
659
  dryRun?: boolean;
490
660
  cwd: string;
661
+ /** Seal the amended record's author, as `records new` does (#2688). */
662
+ sign?: string | true;
491
663
  }
492
664
 
493
665
  /**
@@ -496,15 +668,26 @@ export interface AmendRecordOptions {
496
668
  * a decided decision) changes in place only in its state, to one ranked at
497
669
  * least as high, its pins field and its reviews: anything else is a new
498
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.
499
678
  */
500
679
  export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocument> {
501
680
  try {
502
- const patch = parseFields(opts.fields, "--set");
681
+ const given = parseFields(opts.fields, "--set");
503
682
  const o = await open(opts.kind, opts.cwd);
504
683
  const { kind } = o.loaded;
684
+ refuseSealField(o, given, "--set");
505
685
  const before = await readAll(o, o.source);
506
686
  const target = findRecord(before, opts.id, kind.name);
507
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 };
508
691
  const added = Object.keys(patch).filter((k) => !(k in old));
509
692
  const merged = pick({ ...old, ...patch }, [...Object.keys(old), ...schemaOrder(pick(patch, added), o.loaded.schema)]);
510
693
  const changed = Object.keys(merged).filter((k) => stableJson(old[k]) !== stableJson(merged[k]));
@@ -514,8 +697,8 @@ export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocume
514
697
  const state = target.state;
515
698
  const link = kind.supersedes?.key === undefined ? JSON.stringify(opts.id) : `[{"${kind.supersedes.key}": "${opts.id}"}]`;
516
699
  const supersede = kind.supersedes ? `chant workspace records new with ${kind.supersedes.field}: ${link}` : `chant workspace records new`;
517
- if (changed.length > 0 && state !== null && (kind.closedStates ?? []).includes(state)) {
518
- 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)}`);
519
702
  }
520
703
  const rank = (s: unknown): number => (typeof s === "string" ? (kind.approval?.[s] ?? 0) : 0);
521
704
  if (changed.length > 0 && kind.approval && rank(state) > 0) {
@@ -537,7 +720,23 @@ export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocume
537
720
  }
538
721
  // Only the changed fields' blocks are rewritten, so the rest of the file keeps its bytes.
539
722
  const current = o.source.read(target.path);
540
- 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);
541
740
  const warnings = changed.length === 0 ? target.warnings : await validateWrite(o, before, target.path, text);
542
741
  if (!opts.dryRun && changed.length > 0) writeFileSync(abs(o, target.path), text);
543
742
  return {
@@ -547,6 +746,8 @@ export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocume
547
746
  path: target.path,
548
747
  id: opts.id,
549
748
  changed,
749
+ ...(seal ? { seal } : {}),
750
+ ...(sealDropped ? { sealDropped } : {}),
550
751
  dryRun: !!opts.dryRun,
551
752
  warnings,
552
753
  ...(opts.dryRun ? { text } : {}),
@@ -562,7 +763,7 @@ export interface ReviewRecordOptions {
562
763
  kind: string;
563
764
  id: string;
564
765
  verdict: string;
565
- /** The reviewer, as the caller names them. chant does not check who it is; attestation does (#2547). */
766
+ /** The reviewer, as the caller names them. chant does not check who it is; a seal does, on read (#2687). */
566
767
  by: string;
567
768
  note?: string;
568
769
  /** The review session the verdict was given in. */
@@ -571,11 +772,18 @@ export interface ReviewRecordOptions {
571
772
  cwd: string;
572
773
  /** The date written as `on`, YYYY-MM-DD. Defaults to today, in UTC. */
573
774
  on?: string;
775
+ /**
776
+ * Seal the verdict (#2687): a key file, resolved against `cwd`, or true for
777
+ * git's `user.signingkey`. Without it the verdict is written unsealed.
778
+ */
779
+ sign?: string | true;
574
780
  }
575
781
 
576
782
  /**
577
783
  * `records review`: append one verdict to a record's reviews, with the date
578
784
  * and the digest of the record text it judged ({@link recordTextDigest}).
785
+ * With `sign`, the verdict carries a seal: an ssh signature over the record
786
+ * id, the digest, the verdict, the reviewer and the date (`trust/seal.ts`).
579
787
  */
580
788
  export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDocument> {
581
789
  try {
@@ -598,6 +806,7 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
598
806
  if (opts.verdict === "dissent" && !(opts.note ?? "").trim()) {
599
807
  throw new RecordWriteError("review-note-required", `a dissent needs a reason: pass --note <text> with the concern`);
600
808
  }
809
+ const inSession = opts.session !== undefined ? await openSession(o, opts.session, opts.cwd) : undefined;
601
810
  const reviews = target.data[field] ?? [];
602
811
  if (!Array.isArray(reviews)) throw new RecordWriteError("record-schema-invalid", `${target.path}: ${field} is not a list`);
603
812
  const current = o.source.read(target.path);
@@ -606,15 +815,34 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
606
815
  verdict: opts.verdict,
607
816
  ...(opts.note !== undefined ? { note: opts.note } : {}),
608
817
  on: opts.on ?? today(),
609
- digest: recordTextDigest(current, field),
818
+ digest: recordTextDigest(current, digestFields(kind), kind.format),
610
819
  ...(opts.session !== undefined ? { session: opts.session } : {}),
611
820
  };
612
- // Only the reviews block changes, so the digest the verdict names stays the record's digest (#2672).
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 });
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).
613
824
  const list = [...reviews, review];
614
825
  const text = replaceFields(current, { [field]: list }, { ...target.data, [field]: list });
615
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`);
616
827
  const warnings = await validateWrite(o, before, target.path, text);
617
- 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
+ }
618
846
  return {
619
847
  $schema: RECORDS_REVIEW_SCHEMA_ID,
620
848
  contract: RECORDS_WRITE_CONTRACT_VERSION,
@@ -622,6 +850,7 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
622
850
  path: target.path,
623
851
  id: opts.id,
624
852
  review,
853
+ ...(session ? { session: session.out } : {}),
625
854
  dryRun: !!opts.dryRun,
626
855
  warnings,
627
856
  ...(opts.dryRun ? { text } : {}),
@@ -631,12 +860,56 @@ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDoc
631
860
  }
632
861
  }
633
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
+
890
+ /** A seal over one verdict, or a refusal with review-sign-failed. Loaded only when --sign is given. */
891
+ async function seal(sign: string | true, cwd: string, v: { record: string; digest: string; verdict: string; reviewer: string; on: string }): Promise<Record<string, unknown>> {
892
+ const { resolveSigningKey, sealVerdict, SealError } = await import("./trust/seal");
893
+ try {
894
+ const key = resolveSigningKey(sign, cwd);
895
+ try {
896
+ return { ...sealVerdict(key.file, v) };
897
+ } finally {
898
+ key.cleanup();
899
+ }
900
+ } catch (err) {
901
+ if (err instanceof SealError) throw new RecordWriteError("review-sign-failed", err.message);
902
+ throw err;
903
+ }
904
+ }
905
+
634
906
  // ── The command ──────────────────────────────────────────────────────────────
635
907
 
636
908
  export const WRITE_USAGE = [
637
- "chant workspace records new [<kind file or declared kind>] --from <file|-> [--prefix <prefix>] [--dry-run]",
638
- "chant workspace records amend <id> [--kind <kind file>] --set <file|-> [--dry-run]",
639
- "chant workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--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]",
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]",
640
913
  ].join("\n");
641
914
 
642
915
  function usage(schema: string, message: string): WriteFailure<"write-usage-invalid"> {
@@ -701,7 +974,18 @@ function readInput(schema: string, flag: string, value: string | undefined, cwd:
701
974
  }
702
975
  }
703
976
 
704
- /** `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. */
705
989
  export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
706
990
  const { args } = ctx;
707
991
  const cwd = process.cwd();
@@ -710,13 +994,22 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
710
994
  console.log(JSON.stringify(doc, null, 2));
711
995
  return "error" in doc ? 1 : 0;
712
996
  };
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 }));
1005
+ }
713
1006
  if (verb === "new") {
714
1007
  const named = args.extraPositional2 ?? args.kind;
715
1008
  const kind = named !== undefined ? resolveWriteKind(named, cwd) : declaredWriteKind(RECORDS_NEW_SCHEMA_ID, cwd, "new needs the kind file");
716
1009
  if (typeof kind !== "string") return print(kind);
717
1010
  const input = readInput(RECORDS_NEW_SCHEMA_ID, "--from", args.migrateFrom, cwd);
718
1011
  if (typeof input !== "string") return print(input);
719
- 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 }));
720
1013
  }
721
1014
  const schema = verb === "amend" ? RECORDS_AMEND_SCHEMA_ID : RECORDS_REVIEW_SCHEMA_ID;
722
1015
  const id = args.extraPositional2;
@@ -726,11 +1019,11 @@ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
726
1019
  if (verb === "amend") {
727
1020
  const input = readInput(schema, "--set", args.set, cwd);
728
1021
  if (typeof input !== "string") return print(input);
729
- 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 }));
730
1023
  }
731
1024
  if (args.verdict === undefined) return print(usage(schema, "--verdict agree|dissent|abstain is required"));
732
1025
  if (args.by === undefined) return print(usage(schema, "--by <principal> is required"));
733
1026
  return print(
734
- await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, dryRun: args.dryRun, cwd }),
1027
+ await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, sign: args.sign, dryRun: args.dryRun, cwd }),
735
1028
  );
736
1029
  }