@intentius/chant 0.85.0 → 0.87.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 (129) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +13 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  7. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  8. package/dist/workspace/checks/records.d.ts +1 -0
  9. package/dist/workspace/checks/records.d.ts.map +1 -1
  10. package/dist/workspace/checks.d.ts +4 -0
  11. package/dist/workspace/checks.d.ts.map +1 -1
  12. package/dist/workspace/composites.d.ts +14 -1
  13. package/dist/workspace/composites.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +211 -0
  15. package/dist/workspace/conformance/index.d.ts.map +1 -0
  16. package/dist/workspace/conformance/vitest.d.ts +11 -0
  17. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  18. package/dist/workspace/declaration.d.ts +28 -0
  19. package/dist/workspace/declaration.d.ts.map +1 -1
  20. package/dist/workspace/declaration.schema.json +40 -0
  21. package/dist/workspace/declared-kinds.d.ts +43 -0
  22. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  23. package/dist/workspace/graph-cli.d.ts +11 -0
  24. package/dist/workspace/graph-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-cli.d.ts +2 -1
  26. package/dist/workspace/intent-cli.d.ts.map +1 -1
  27. package/dist/workspace/intent-joins.d.ts +45 -8
  28. package/dist/workspace/intent-joins.d.ts.map +1 -1
  29. package/dist/workspace/intent.d.ts +71 -7
  30. package/dist/workspace/intent.d.ts.map +1 -1
  31. package/dist/workspace/ls.d.ts +31 -1
  32. package/dist/workspace/ls.d.ts.map +1 -1
  33. package/dist/workspace/reason-codes.d.ts +47 -4
  34. package/dist/workspace/reason-codes.d.ts.map +1 -1
  35. package/dist/workspace/record-sessions.d.ts +51 -0
  36. package/dist/workspace/record-sessions.d.ts.map +1 -0
  37. package/dist/workspace/record-source.d.ts +2 -0
  38. package/dist/workspace/record-source.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +71 -4
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-since.d.ts +90 -0
  42. package/dist/workspace/records-since.d.ts.map +1 -0
  43. package/dist/workspace/records-write.d.ts +171 -0
  44. package/dist/workspace/records-write.d.ts.map +1 -0
  45. package/dist/workspace/records.d.ts +244 -15
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/runtimes.d.ts +60 -0
  48. package/dist/workspace/runtimes.d.ts.map +1 -0
  49. package/dist/workspace/status-gates.d.ts +90 -0
  50. package/dist/workspace/status-gates.d.ts.map +1 -0
  51. package/dist/workspace/status.d.ts +17 -0
  52. package/dist/workspace/status.d.ts.map +1 -1
  53. package/dist/workspace/trust/seal.d.ts +85 -0
  54. package/dist/workspace/trust/seal.d.ts.map +1 -0
  55. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  56. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  57. package/dist/workspace/work.d.ts +56 -0
  58. package/dist/workspace/work.d.ts.map +1 -0
  59. package/package.json +19 -1
  60. package/src/cli/main.ts +55 -3
  61. package/src/cli/registry.ts +13 -1
  62. package/src/lifecycle/gate-ledger.ts +14 -0
  63. package/src/workspace/__fixtures__/sessions.ts +66 -0
  64. package/src/workspace/checks/records.ts +19 -0
  65. package/src/workspace/checks.test.ts +2 -0
  66. package/src/workspace/checks.ts +7 -1
  67. package/src/workspace/composites.schema.json +65 -3
  68. package/src/workspace/composites.test.ts +95 -5
  69. package/src/workspace/composites.ts +28 -7
  70. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  71. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  72. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  73. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
  74. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  75. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  76. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  77. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  78. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  79. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  80. package/src/workspace/conformance/conformance.test.ts +149 -0
  81. package/src/workspace/conformance/index.mjs +31 -0
  82. package/src/workspace/conformance/index.ts +453 -0
  83. package/src/workspace/conformance/vitest.ts +62 -0
  84. package/src/workspace/declaration.schema.json +40 -0
  85. package/src/workspace/declaration.ts +62 -0
  86. package/src/workspace/declared-kinds.test.ts +321 -0
  87. package/src/workspace/declared-kinds.ts +76 -0
  88. package/src/workspace/graph-cli.ts +8 -0
  89. package/src/workspace/intent-cli.ts +29 -6
  90. package/src/workspace/intent-gaps.test.ts +217 -0
  91. package/src/workspace/intent-joins.test.ts +60 -0
  92. package/src/workspace/intent-joins.ts +71 -19
  93. package/src/workspace/intent.schema.json +304 -7
  94. package/src/workspace/intent.test.ts +99 -0
  95. package/src/workspace/intent.ts +365 -46
  96. package/src/workspace/ls.schema.json +34 -0
  97. package/src/workspace/ls.ts +69 -4
  98. package/src/workspace/read-contract.test.ts +30 -9
  99. package/src/workspace/reason-codes.test.ts +16 -4
  100. package/src/workspace/reason-codes.ts +55 -4
  101. package/src/workspace/record-assets.test.ts +3 -1
  102. package/src/workspace/record-sessions.ts +105 -0
  103. package/src/workspace/record-source.ts +14 -5
  104. package/src/workspace/records-amend.schema.json +167 -0
  105. package/src/workspace/records-cli.ts +308 -19
  106. package/src/workspace/records-contract.test.ts +57 -2
  107. package/src/workspace/records-formats.test.ts +640 -0
  108. package/src/workspace/records-new.schema.json +158 -0
  109. package/src/workspace/records-quorum.test.ts +196 -0
  110. package/src/workspace/records-review.schema.json +227 -0
  111. package/src/workspace/records-sessions.test.ts +108 -0
  112. package/src/workspace/records-since.schema.json +193 -0
  113. package/src/workspace/records-since.test.ts +174 -0
  114. package/src/workspace/records-since.ts +259 -0
  115. package/src/workspace/records-write-contract.test.ts +125 -0
  116. package/src/workspace/records-write.test.ts +373 -0
  117. package/src/workspace/records-write.ts +765 -0
  118. package/src/workspace/records.schema.json +202 -9
  119. package/src/workspace/records.ts +700 -41
  120. package/src/workspace/runtimes.ts +107 -0
  121. package/src/workspace/status-contract.test.ts +163 -0
  122. package/src/workspace/status-gates.ts +215 -0
  123. package/src/workspace/status.schema.json +69 -3
  124. package/src/workspace/status.ts +35 -2
  125. package/src/workspace/trust/seal.test.ts +232 -0
  126. package/src/workspace/trust/seal.ts +195 -0
  127. package/src/workspace/trust/ssh-commit.ts +2 -2
  128. package/src/workspace/work.test.ts +390 -0
  129. package/src/workspace/work.ts +163 -0
@@ -0,0 +1,765 @@
1
+ /**
2
+ * `chant workspace records new|amend|review` (#2670): the commands a UI
3
+ * writes records through, so it never parses or writes a record file itself
4
+ * (ws-052).
5
+ *
6
+ * Each command loads the kind, reads every record it locates, builds the one
7
+ * file it would write, and reads the records again with that file in place,
8
+ * through the same {@link readRecords} a read uses. The write goes ahead only
9
+ * when the written record comes back valid and no other record gains a
10
+ * reason. So a write is refused for exactly what a later read would report,
11
+ * plus the rules only a write has: ids are allocated and never reused, a
12
+ * closed record never changes, an approved one changes only in place of
13
+ * what the approval rule allows, and a dissent needs a note.
14
+ *
15
+ * Every command writes one file or none, never commits, and prints one JSON
16
+ * document with a closed error code on refusal. `--dry-run` prints the
17
+ * document and the text it would write, and writes nothing.
18
+ */
19
+
20
+ import { readFileSync, statSync, writeFileSync } from "node:fs";
21
+ import { join, posix, relative, resolve } from "node:path";
22
+ import type { CommandContext } from "../cli/registry";
23
+ import type { ReasonCode } from "./reason-codes";
24
+ import { gitRoot, workingTreeSource, type RecordSource } from "./record-source";
25
+ import {
26
+ loadRecordKind,
27
+ parseFrontMatter,
28
+ readRecords,
29
+ RECORD_REASON_CODES,
30
+ RecordReadError,
31
+ recordTextDigest,
32
+ type LoadedRecordKind,
33
+ type RecordEntry,
34
+ type RecordWarning,
35
+ } from "./records";
36
+ import { declaredKindFiles, pinRoot, realpathOr } from "./records-cli";
37
+ import { WorkspaceReadError } from "./declaration";
38
+ import { workingTree } from "./tree";
39
+
40
+ // ── Contract ─────────────────────────────────────────────────────────────────
41
+
42
+ /** The version of the write documents this chant prints. */
43
+ export const RECORDS_WRITE_CONTRACT_VERSION = 1;
44
+
45
+ export const RECORDS_NEW_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-new/v1/records-new.schema.json";
46
+ export const RECORDS_AMEND_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-amend/v1/records-amend.schema.json";
47
+ export const RECORDS_REVIEW_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-review/v1/records-review.schema.json";
48
+
49
+ /** 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;
51
+
52
+ /** Why `records new` wrote nothing. Closed: a reader may switch on it. */
53
+ export const NEW_ERROR_CODES = [
54
+ ...LOAD_ERROR_CODES,
55
+ "write-usage-invalid",
56
+ "write-input-invalid",
57
+ "record-id-taken",
58
+ "record-id-unallocatable",
59
+ "record-path-unmatched",
60
+ ...RECORD_REASON_CODES,
61
+ ] as const satisfies readonly ReasonCode[];
62
+
63
+ /** Why `records amend` wrote nothing. */
64
+ export const AMEND_ERROR_CODES = [
65
+ ...LOAD_ERROR_CODES,
66
+ "write-usage-invalid",
67
+ "write-input-invalid",
68
+ "record-not-found",
69
+ "amend-id-immutable",
70
+ "record-closed",
71
+ "amend-supersede-instead",
72
+ ...RECORD_REASON_CODES,
73
+ ] as const satisfies readonly ReasonCode[];
74
+
75
+ /** Why `records review` wrote nothing. */
76
+ export const REVIEW_ERROR_CODES = [
77
+ ...LOAD_ERROR_CODES,
78
+ "write-usage-invalid",
79
+ "record-not-found",
80
+ "review-unsupported",
81
+ "record-closed",
82
+ "review-note-required",
83
+ "review-sign-failed",
84
+ ...RECORD_REASON_CODES,
85
+ ] as const satisfies readonly ReasonCode[];
86
+
87
+ export type NewErrorCode = (typeof NEW_ERROR_CODES)[number];
88
+ export type AmendErrorCode = (typeof AMEND_ERROR_CODES)[number];
89
+ export type ReviewErrorCode = (typeof REVIEW_ERROR_CODES)[number];
90
+ type WriteErrorCode = NewErrorCode | AmendErrorCode | ReviewErrorCode;
91
+
92
+ export const VERDICTS = ["agree", "dissent", "abstain"] as const;
93
+ export type Verdict = (typeof VERDICTS)[number];
94
+
95
+ class RecordWriteError extends Error {
96
+ constructor(
97
+ readonly code: WriteErrorCode,
98
+ message: string,
99
+ ) {
100
+ super(message);
101
+ this.name = "RecordWriteError";
102
+ }
103
+ }
104
+
105
+ interface KindView {
106
+ name: string;
107
+ schema: string;
108
+ file: string;
109
+ }
110
+
111
+ /** What every write result carries. */
112
+ interface WriteResult {
113
+ $schema: string;
114
+ contract: number;
115
+ kind: KindView;
116
+ /** The record file, from the repository root (the working directory outside git), with / separators. */
117
+ path: string;
118
+ id: string;
119
+ /** True when nothing was written. */
120
+ dryRun: boolean;
121
+ /** The written record's warnings, as `records` would report them. */
122
+ warnings: RecordWarning[];
123
+ /** With --dry-run, the whole text the command would write. */
124
+ text?: string;
125
+ }
126
+
127
+ interface WriteFailure<C> {
128
+ $schema: string;
129
+ contract: number;
130
+ error: { code: C; message: string };
131
+ }
132
+
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>;
136
+
137
+ // ── Rendering ────────────────────────────────────────────────────────────────
138
+
139
+ const PLAIN_KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/;
140
+ const RESERVED_KEY = new Set(["true", "false", "null"]);
141
+
142
+ function yamlKey(k: string): string {
143
+ return PLAIN_KEY.test(k) && !RESERVED_KEY.has(k) ? k : JSON.stringify(k);
144
+ }
145
+
146
+ function isScalar(v: unknown): boolean {
147
+ return v === null || typeof v !== "object";
148
+ }
149
+
150
+ function isEmpty(v: unknown): boolean {
151
+ return Array.isArray(v) ? v.length === 0 : v !== null && typeof v === "object" && Object.keys(v).length === 0;
152
+ }
153
+
154
+ const scalar = (v: unknown): string => (v === null ? "null" : JSON.stringify(v));
155
+ const empty = (v: unknown): string => (Array.isArray(v) ? "[]" : "{}");
156
+
157
+ /**
158
+ * YAML limited to what JSON can say, laid out as the decision README writes a
159
+ * record: every string double-quoted with JSON escapes, block mappings and
160
+ * sequences, and `[]` or `{}` for an empty one. `parseFrontMatter` reads it
161
+ * back to the same value.
162
+ */
163
+ export function toYaml(value: unknown, indent = 0): string {
164
+ const pad = " ".repeat(indent);
165
+ if (Array.isArray(value)) {
166
+ if (value.length === 0) return `${pad}[]`;
167
+ return value
168
+ .map((item) => {
169
+ if (isScalar(item)) return `${pad}- ${scalar(item)}`;
170
+ if (isEmpty(item)) return `${pad}- ${empty(item)}`;
171
+ if (Array.isArray(item)) return `${pad}-\n${toYaml(item, indent + 2)}`;
172
+ return `${pad}- ${toYaml(item, indent + 2).slice(indent + 2)}`;
173
+ })
174
+ .join("\n");
175
+ }
176
+ if (isScalar(value)) return `${pad}${scalar(value)}`;
177
+ const lines: string[] = [];
178
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
179
+ if (isScalar(v)) lines.push(`${pad}${yamlKey(k)}: ${scalar(v)}`);
180
+ else if (isEmpty(v)) lines.push(`${pad}${yamlKey(k)}: ${empty(v)}`);
181
+ else lines.push(`${pad}${yamlKey(k)}:\n${toYaml(v, indent + 2)}`);
182
+ }
183
+ return lines.join("\n");
184
+ }
185
+
186
+ /** A record file: the front matter, then `body` as it is. */
187
+ export function renderRecord(data: Record<string, unknown>, body: string): string {
188
+ return `---\n${toYaml(data)}\n---\n${body}`;
189
+ }
190
+
191
+ /** The text below a file's front matter, line endings normalised. */
192
+ function bodyOf(text: string): string {
193
+ const normalised = text.replace(/\r\n?/g, "\n");
194
+ const m = normalised.match(/^---\n[\s\S]*?\n---(?:\n|$)/);
195
+ return m ? normalised.slice(m[0].length) : "";
196
+ }
197
+
198
+ /** JSON with object keys sorted, for comparing two values whatever their key order. */
199
+ function stableJson(value: unknown): string {
200
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
201
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
202
+ const obj = value as Record<string, unknown>;
203
+ return `{${Object.keys(obj)
204
+ .sort()
205
+ .map((k) => `${JSON.stringify(k)}:${stableJson(obj[k])}`)
206
+ .join(",")}}`;
207
+ }
208
+
209
+ /**
210
+ * `text` with the top-level fields in `set` replaced in place, and every
211
+ * other byte kept. A field's block is its key line at column 0 and the lines
212
+ * after it, up to the closing `---`, that start with a space, a tab, `#` or
213
+ * `-`: the block {@link recordTextDigest} cuts for the reviews field. Blank
214
+ * lines ending a block stay where they are. A field the front matter lacks is
215
+ * added before the closing `---`. Line endings become LF. Returns undefined
216
+ * when the text has no front matter, or when the result does not read back
217
+ * as `expected`, so a caller never writes a file it did not mean to.
218
+ */
219
+ export function replaceFields(text: string, set: Record<string, unknown>, expected: Record<string, unknown>): string | undefined {
220
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
221
+ if (lines[0] !== "---") return undefined;
222
+ let close = lines.indexOf("---", 1);
223
+ if (close < 0) return undefined;
224
+ for (const [key, value] of Object.entries(set)) {
225
+ const rendered = toYaml({ [key]: value }).split("\n");
226
+ const k = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
+ const starts = new RegExp(`^(?:${k}|"${k}"|'${k}')[ \\t]*:(?:[ \\t]|$)`);
228
+ const at = lines.findIndex((l, i) => i > 0 && i < close && starts.test(l));
229
+ if (at < 0) {
230
+ lines.splice(close, 0, ...rendered);
231
+ close += rendered.length;
232
+ continue;
233
+ }
234
+ let end = at;
235
+ while (end + 1 < close && /^(?:$|[ \t#-])/.test(lines[end + 1])) end++;
236
+ while (end > at && lines[end] === "") end--;
237
+ lines.splice(at, end - at + 1, ...rendered);
238
+ close += rendered.length - (end - at + 1);
239
+ }
240
+ const out = lines.join("\n");
241
+ const back = parseFrontMatter(out);
242
+ return back.ok && stableJson(back.value) === stableJson(expected) ? out : undefined;
243
+ }
244
+
245
+ /** A file-name slug from a title, as `scripts/import-decisions.mjs` makes one. */
246
+ export function slug(title: string): string {
247
+ return title
248
+ .toLowerCase()
249
+ .replace(/`/g, "")
250
+ .replace(/[^a-z0-9]+/g, "-")
251
+ .replace(/^-|-$/g, "")
252
+ .slice(0, 48)
253
+ .replace(/-$/, "");
254
+ }
255
+
256
+ // ── Shared steps ─────────────────────────────────────────────────────────────
257
+
258
+ interface Opened {
259
+ loaded: LoadedRecordKind;
260
+ root: string;
261
+ /** Where pinned paths resolve, from `root`. */
262
+ workspaceRoot: string;
263
+ source: RecordSource;
264
+ view: KindView;
265
+ /** The records directory, from `root`. */
266
+ dirRel: string;
267
+ }
268
+
269
+ async function open(kind: string, cwd: string): Promise<Opened> {
270
+ const real = realpathOr(cwd);
271
+ const root = gitRoot(real) ?? real;
272
+ const loaded = await loadRecordKind(kind, real);
273
+ // The writers write Markdown front matter with an id field. A JSON or
274
+ // content-addressed kind (ws-053) is read by records, and written by its own tool.
275
+ if (loaded.kind.format !== "markdown-front-matter" || loaded.kind.idField === undefined) {
276
+ const what = loaded.kind.format !== "markdown-front-matter" ? `format ${loaded.kind.format}` : `ids from ${loaded.kind.idFrom}`;
277
+ throw new RecordWriteError("write-usage-invalid", `the ${loaded.kind.name} kind has ${what}, and records new, amend and review write only Markdown front matter records with an idField`);
278
+ }
279
+ const dirRel = relative(root, loaded.dir).split("\\").join("/") || ".";
280
+ return {
281
+ loaded,
282
+ root,
283
+ workspaceRoot: pinRoot(loaded.file, root),
284
+ source: workingTreeSource(root),
285
+ view: { name: loaded.kind.name, schema: loaded.kind.schema.id, file: relative(root, loaded.file).split("\\").join("/") },
286
+ dirRel,
287
+ };
288
+ }
289
+
290
+ async function readAll(o: Opened, source: RecordSource): Promise<RecordEntry[]> {
291
+ 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;
293
+ }
294
+
295
+ /** `base` with the file at `path` holding `text`, added to its directory when new. */
296
+ function overlay(base: RecordSource, path: string, text: string): RecordSource {
297
+ const dir = posix.dirname(path);
298
+ const name = posix.basename(path);
299
+ return {
300
+ label: base.label,
301
+ list(d) {
302
+ const names = base.list(d);
303
+ if (d !== dir || !names) return names;
304
+ return names.includes(name) ? names : [...names, name];
305
+ },
306
+ read(p) {
307
+ return p === path ? text : base.read(p);
308
+ },
309
+ bytes(p) {
310
+ return p === path ? Buffer.from(text, "utf-8") : base.bytes(p);
311
+ },
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Read the records again with `text` at `path`. The written record must come
317
+ * back with no reason, and no other record may gain one. Returns the written
318
+ * record's warnings.
319
+ */
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));
322
+ const written = after.find((e) => e.path === path);
323
+ if (!written) throw new RecordWriteError("record-path-unmatched", `${path} is not a file the kind ${o.view.name} reads`);
324
+ if (written.reasons.length > 0) {
325
+ throw new RecordWriteError(written.reasons[0].code, written.reasons.map((r) => `${r.code}: ${r.message}`).join("; "));
326
+ }
327
+ const had = new Map(before.map((e) => [e.path, new Set(e.reasons.map((r) => `${r.code}\0${r.message}`))]));
328
+ for (const e of after) {
329
+ if (e.path === path) continue;
330
+ const gained = e.reasons.find((r) => !had.get(e.path)?.has(`${r.code}\0${r.message}`));
331
+ if (gained) throw new RecordWriteError(gained.code, `writing ${path} would make ${e.path} invalid: ${gained.message}`);
332
+ }
333
+ return written.warnings;
334
+ }
335
+
336
+ function findRecord(entries: RecordEntry[], id: string, kind: string): RecordEntry & { data: Record<string, unknown> } {
337
+ const hits = entries.filter((e) => e.id === id);
338
+ if (hits.length === 0) throw new RecordWriteError("record-not-found", `no ${kind} record has id ${id}`);
339
+ if (hits.length > 1) {
340
+ throw new RecordWriteError("record-id-duplicate", `id ${id} is used by ${hits.map((h) => h.path).join(" and ")}; make the ids unique before writing either`);
341
+ }
342
+ return hits[0] as RecordEntry & { data: Record<string, unknown> };
343
+ }
344
+
345
+ function parseFields(text: string, flag: string): Record<string, unknown> {
346
+ let value: unknown;
347
+ try {
348
+ value = JSON.parse(text);
349
+ } catch (err) {
350
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} are not JSON: ${err instanceof Error ? err.message : String(err)}`);
351
+ }
352
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
353
+ throw new RecordWriteError("write-input-invalid", `the fields given with ${flag} must be a JSON object`);
354
+ }
355
+ return value as Record<string, unknown>;
356
+ }
357
+
358
+ /** 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[] {
360
+ const required = Array.isArray(schema.required) ? (schema.required as unknown[]).filter((k): k is string => typeof k === "string") : [];
361
+ const props = schema.properties !== null && typeof schema.properties === "object" ? Object.keys(schema.properties as object) : [];
362
+ const order = [...new Set([...required, ...props])];
363
+ const keys = Object.keys(data);
364
+ return [...order.filter((k) => keys.includes(k)), ...keys.filter((k) => !order.includes(k))];
365
+ }
366
+
367
+ function pick(data: Record<string, unknown>, keys: string[]): Record<string, unknown> {
368
+ return Object.fromEntries(keys.map((k) => [k, data[k]]));
369
+ }
370
+
371
+ function abs(o: Opened, path: string): string {
372
+ return join(o.root, ...path.split("/"));
373
+ }
374
+
375
+ function today(): string {
376
+ return new Date().toISOString().slice(0, 10);
377
+ }
378
+
379
+ function failure<C>(schema: string, err: unknown): WriteFailure<C> {
380
+ if (err instanceof RecordWriteError || err instanceof RecordReadError) {
381
+ return { $schema: schema, contract: RECORDS_WRITE_CONTRACT_VERSION, error: { code: err.code as C, message: err.message } };
382
+ }
383
+ throw err;
384
+ }
385
+
386
+ // ── records new ──────────────────────────────────────────────────────────────
387
+
388
+ const ALLOCATABLE = /^([A-Za-z][A-Za-z0-9]*)-([0-9]+)$/;
389
+
390
+ /**
391
+ * The next id: `<prefix>-<n>`, one above the highest number any record in the
392
+ * directory has with that prefix, padded to at least three digits, or to the
393
+ * widest number those records use. Ids come from the records' own id field
394
+ * and, for a file that can't be read, from its name, so an id in use is never
395
+ * handed out again. The shape is derived from the records, with the prefix's
396
+ * case kept: a work kind's `W-001` and `W-002` give `W-003` (#2683), and a
397
+ * decision's `ws-052` gives `ws-053`. The kind's schema still judges the id
398
+ * written. Without `prefix`, every record must share one prefix.
399
+ */
400
+ export function allocateId(entries: RecordEntry[], prefix: string | undefined, kind: string): string {
401
+ const seen: Array<{ prefix: string; digits: string }> = [];
402
+ for (const e of entries) {
403
+ const stem = e.id ?? posix.basename(e.path).match(/^([A-Za-z][A-Za-z0-9]*-[0-9]+)/)?.[1] ?? null;
404
+ const m = stem?.match(ALLOCATABLE);
405
+ if (m) seen.push({ prefix: m[1], digits: m[2] });
406
+ }
407
+ if (prefix === undefined) {
408
+ const prefixes = [...new Set(seen.map((s) => s.prefix))];
409
+ if (prefixes.length !== 1) {
410
+ throw new RecordWriteError(
411
+ "record-id-unallocatable",
412
+ prefixes.length === 0
413
+ ? `no ${kind} record has an id of the form <prefix>-<number> to follow; pass --prefix <prefix>, or give the id in the fields`
414
+ : `the ${kind} records use the prefixes ${prefixes.join(", ")}; pass --prefix with one of them, or give the id in the fields`,
415
+ );
416
+ }
417
+ prefix = prefixes[0];
418
+ }
419
+ const mine = seen.filter((s) => s.prefix === prefix);
420
+ const max = mine.reduce((m, s) => Math.max(m, Number(s.digits)), 0);
421
+ const width = Math.max(3, ...mine.map((s) => s.digits.length));
422
+ return `${prefix}-${String(max + 1).padStart(width, "0")}`;
423
+ }
424
+
425
+ export interface NewRecordOptions {
426
+ /** The kind file, resolved against `cwd`. */
427
+ kind: string;
428
+ /** The record's fields, as JSON text. */
429
+ fields: string;
430
+ /** The id prefix to allocate under, when the fields hold no id. */
431
+ prefix?: string;
432
+ dryRun?: boolean;
433
+ cwd: string;
434
+ }
435
+
436
+ /** `records new`: write one new record from validated fields. */
437
+ export async function newRecord(opts: NewRecordOptions): Promise<NewDocument> {
438
+ try {
439
+ if (opts.prefix !== undefined && !/^[A-Za-z][A-Za-z0-9]*$/.test(opts.prefix)) {
440
+ throw new RecordWriteError("write-usage-invalid", `--prefix takes letters and digits, starting with a letter, not ${JSON.stringify(opts.prefix)}`);
441
+ }
442
+ const fields = parseFields(opts.fields, "--from");
443
+ const o = await open(opts.kind, opts.cwd);
444
+ const { kind, schema } = o.loaded;
445
+ const idField = kind.idField!;
446
+ const before = await readAll(o, o.source);
447
+ const given = fields[idField];
448
+ let id: string;
449
+ if (given === undefined) {
450
+ id = allocateId(before, opts.prefix, kind.name);
451
+ } else {
452
+ if (typeof given !== "string" || given === "") throw new RecordWriteError("write-input-invalid", `${idField} must be a non-empty string when it is given`);
453
+ const taken = before.find((e) => e.id === given || posix.basename(e.path).startsWith(`${given}-`) || posix.basename(e.path) === `${given}.md`);
454
+ 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
+ id = given;
456
+ }
457
+ const data = pick({ ...fields, [idField]: id }, schemaOrder({ ...fields, [idField]: id }, schema));
458
+ const title = typeof data.title === "string" ? data.title : "";
459
+ const match = new RegExp(kind.location.match);
460
+ const names = [slug(title) ? `${id}-${slug(title)}.md` : null, `${id}.md`].filter((n): n is string => n !== null);
461
+ const name = names.find((n) => match.test(n));
462
+ if (!name) throw new RecordWriteError("record-path-unmatched", `the kind's location.match ${kind.location.match} matches none of ${names.join(", ")}`);
463
+ const path = o.dirRel === "." ? name : `${o.dirRel}/${name}`;
464
+ 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` : "");
466
+ const warnings = await validateWrite(o, before, path, text);
467
+ if (!opts.dryRun) writeFileSync(abs(o, path), text, { flag: "wx" });
468
+ return {
469
+ $schema: RECORDS_NEW_SCHEMA_ID,
470
+ contract: RECORDS_WRITE_CONTRACT_VERSION,
471
+ kind: o.view,
472
+ path,
473
+ id,
474
+ dryRun: !!opts.dryRun,
475
+ warnings,
476
+ ...(opts.dryRun ? { text } : {}),
477
+ };
478
+ } catch (err) {
479
+ return failure<NewErrorCode>(RECORDS_NEW_SCHEMA_ID, err);
480
+ }
481
+ }
482
+
483
+ // ── records amend ────────────────────────────────────────────────────────────
484
+
485
+ export interface AmendRecordOptions {
486
+ kind: string;
487
+ id: string;
488
+ /** The fields to set, as JSON text. Each replaces the whole top-level field. */
489
+ fields: string;
490
+ dryRun?: boolean;
491
+ cwd: string;
492
+ }
493
+
494
+ /**
495
+ * `records amend`: set top-level fields of one record. A record in a closed
496
+ * state never changes. With approval ranks, a record ranked above 0 (such as
497
+ * a decided decision) changes in place only in its state, to one ranked at
498
+ * least as high, its pins field and its reviews: anything else is a new
499
+ * decision, written as a record that supersedes it (#2524 D4).
500
+ */
501
+ export async function amendRecord(opts: AmendRecordOptions): Promise<AmendDocument> {
502
+ try {
503
+ const patch = parseFields(opts.fields, "--set");
504
+ const o = await open(opts.kind, opts.cwd);
505
+ const { kind } = o.loaded;
506
+ const before = await readAll(o, o.source);
507
+ const target = findRecord(before, opts.id, kind.name);
508
+ const old = target.data;
509
+ const added = Object.keys(patch).filter((k) => !(k in old));
510
+ const merged = pick({ ...old, ...patch }, [...Object.keys(old), ...schemaOrder(pick(patch, added), o.loaded.schema)]);
511
+ const changed = Object.keys(merged).filter((k) => stableJson(old[k]) !== stableJson(merged[k]));
512
+ if (changed.includes(kind.idField!)) {
513
+ throw new RecordWriteError("amend-id-immutable", `${kind.idField!} never changes: ids are never renumbered. Write a new record that supersedes ${opts.id} instead`);
514
+ }
515
+ const state = target.state;
516
+ const link = kind.supersedes?.key === undefined ? JSON.stringify(opts.id) : `[{"${kind.supersedes.key}": "${opts.id}"}]`;
517
+ 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}`);
520
+ }
521
+ const rank = (s: unknown): number => (typeof s === "string" ? (kind.approval?.[s] ?? 0) : 0);
522
+ if (changed.length > 0 && kind.approval && rank(state) > 0) {
523
+ const allowed = [kind.stateField, kind.pins?.field, kind.reviews?.field].filter((f): f is string => typeof f === "string");
524
+ const stronger = (kind.states ?? []).filter((s) => rank(s) >= rank(state));
525
+ const bad = changed.filter((k) => !allowed.includes(k));
526
+ if (bad.length > 0) {
527
+ throw new RecordWriteError(
528
+ "amend-supersede-instead",
529
+ `${opts.id} is ${state}, so ${bad.join(", ")} can't change in place: only ${allowed.join(", ")} may. Write the change as a new record that supersedes it (${supersede}); it replaces ${opts.id} once it is ${stronger.filter((s) => rank(s) > 0).join(" or ")}`,
530
+ );
531
+ }
532
+ if (kind.stateField !== undefined && changed.includes(kind.stateField) && rank(merged[kind.stateField]) < rank(state)) {
533
+ throw new RecordWriteError(
534
+ "amend-supersede-instead",
535
+ `${opts.id} is ${state}, and ${JSON.stringify(merged[kind.stateField!])} is approved less strongly: a state only moves to ${stronger.join(", ")}. Write a new record that supersedes it (${supersede})`,
536
+ );
537
+ }
538
+ }
539
+ // Only the changed fields' blocks are rewritten, so the rest of the file keeps its bytes.
540
+ const current = o.source.read(target.path);
541
+ const text = replaceFields(current, pick(merged, changed), merged) ?? renderRecord(merged, bodyOf(current));
542
+ const warnings = changed.length === 0 ? target.warnings : await validateWrite(o, before, target.path, text);
543
+ if (!opts.dryRun && changed.length > 0) writeFileSync(abs(o, target.path), text);
544
+ return {
545
+ $schema: RECORDS_AMEND_SCHEMA_ID,
546
+ contract: RECORDS_WRITE_CONTRACT_VERSION,
547
+ kind: o.view,
548
+ path: target.path,
549
+ id: opts.id,
550
+ changed,
551
+ dryRun: !!opts.dryRun,
552
+ warnings,
553
+ ...(opts.dryRun ? { text } : {}),
554
+ };
555
+ } catch (err) {
556
+ return failure<AmendErrorCode>(RECORDS_AMEND_SCHEMA_ID, err);
557
+ }
558
+ }
559
+
560
+ // ── records review ───────────────────────────────────────────────────────────
561
+
562
+ export interface ReviewRecordOptions {
563
+ kind: string;
564
+ id: string;
565
+ verdict: string;
566
+ /** The reviewer, as the caller names them. chant does not check who it is; a seal does, on read (#2687). */
567
+ by: string;
568
+ note?: string;
569
+ /** The review session the verdict was given in. */
570
+ session?: string;
571
+ dryRun?: boolean;
572
+ cwd: string;
573
+ /** The date written as `on`, YYYY-MM-DD. Defaults to today, in UTC. */
574
+ on?: string;
575
+ /**
576
+ * Seal the verdict (#2687): a key file, resolved against `cwd`, or true for
577
+ * git's `user.signingkey`. Without it the verdict is written unsealed.
578
+ */
579
+ sign?: string | true;
580
+ }
581
+
582
+ /**
583
+ * `records review`: append one verdict to a record's reviews, with the date
584
+ * and the digest of the record text it judged ({@link recordTextDigest}).
585
+ * With `sign`, the verdict carries a seal: an ssh signature over the record
586
+ * id, the digest, the verdict, the reviewer and the date (`trust/seal.ts`).
587
+ */
588
+ export async function reviewRecord(opts: ReviewRecordOptions): Promise<ReviewDocument> {
589
+ try {
590
+ if (!(VERDICTS as readonly string[]).includes(opts.verdict)) {
591
+ throw new RecordWriteError("write-usage-invalid", `--verdict takes ${VERDICTS.join(", ")}, not ${JSON.stringify(opts.verdict)}`);
592
+ }
593
+ if (opts.by.trim() === "") throw new RecordWriteError("write-usage-invalid", "--by needs the reviewer's name");
594
+ if (opts.session !== undefined && opts.session === "") throw new RecordWriteError("write-usage-invalid", "--session needs a session id");
595
+ const o = await open(opts.kind, opts.cwd);
596
+ const { kind } = o.loaded;
597
+ if (!kind.reviews) {
598
+ throw new RecordWriteError("review-unsupported", `the ${kind.name} kind declares no reviews field, so its records take no review`);
599
+ }
600
+ const field = kind.reviews.field;
601
+ const before = await readAll(o, o.source);
602
+ const target = findRecord(before, opts.id, kind.name);
603
+ if (target.state !== null && (kind.closedStates ?? []).includes(target.state)) {
604
+ throw new RecordWriteError("record-closed", `${opts.id} is ${target.state}, a closed state, so it takes no more reviews`);
605
+ }
606
+ if (opts.verdict === "dissent" && !(opts.note ?? "").trim()) {
607
+ throw new RecordWriteError("review-note-required", `a dissent needs a reason: pass --note <text> with the concern`);
608
+ }
609
+ const reviews = target.data[field] ?? [];
610
+ if (!Array.isArray(reviews)) throw new RecordWriteError("record-schema-invalid", `${target.path}: ${field} is not a list`);
611
+ const current = o.source.read(target.path);
612
+ const review: Record<string, unknown> = {
613
+ reviewer: opts.by,
614
+ verdict: opts.verdict,
615
+ ...(opts.note !== undefined ? { note: opts.note } : {}),
616
+ on: opts.on ?? today(),
617
+ digest: recordTextDigest(current, field),
618
+ ...(opts.session !== undefined ? { session: opts.session } : {}),
619
+ };
620
+ 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).
622
+ const list = [...reviews, review];
623
+ const text = replaceFields(current, { [field]: list }, { ...target.data, [field]: list });
624
+ 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
+ const warnings = await validateWrite(o, before, target.path, text);
626
+ if (!opts.dryRun) writeFileSync(abs(o, target.path), text);
627
+ return {
628
+ $schema: RECORDS_REVIEW_SCHEMA_ID,
629
+ contract: RECORDS_WRITE_CONTRACT_VERSION,
630
+ kind: o.view,
631
+ path: target.path,
632
+ id: opts.id,
633
+ review,
634
+ dryRun: !!opts.dryRun,
635
+ warnings,
636
+ ...(opts.dryRun ? { text } : {}),
637
+ };
638
+ } catch (err) {
639
+ return failure<ReviewErrorCode>(RECORDS_REVIEW_SCHEMA_ID, err);
640
+ }
641
+ }
642
+
643
+ /** A seal over one verdict, or a refusal with review-sign-failed. Loaded only when --sign is given. */
644
+ async function seal(sign: string | true, cwd: string, v: { record: string; digest: string; verdict: string; reviewer: string; on: string }): Promise<Record<string, unknown>> {
645
+ const { resolveSigningKey, sealVerdict, SealError } = await import("./trust/seal");
646
+ try {
647
+ const key = resolveSigningKey(sign, cwd);
648
+ try {
649
+ return { ...sealVerdict(key.file, v) };
650
+ } finally {
651
+ key.cleanup();
652
+ }
653
+ } catch (err) {
654
+ if (err instanceof SealError) throw new RecordWriteError("review-sign-failed", err.message);
655
+ throw err;
656
+ }
657
+ }
658
+
659
+ // ── The command ──────────────────────────────────────────────────────────────
660
+
661
+ 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]",
664
+ "chant workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]",
665
+ ].join("\n");
666
+
667
+ function usage(schema: string, message: string): WriteFailure<"write-usage-invalid"> {
668
+ return { $schema: schema, contract: RECORDS_WRITE_CONTRACT_VERSION, error: { code: "write-usage-invalid", message: `${message}\n${WRITE_USAGE}` } };
669
+ }
670
+
671
+ /**
672
+ * The kind a write goes through when none is named (#2680): the one record
673
+ * kind the declaration nearest above `cwd` names. None declared keeps the
674
+ * message the verb has always given; several are refused, since a write
675
+ * never guesses which kind it means.
676
+ */
677
+ function declaredWriteKind(schema: string, cwd: string, missing: string): string | WriteFailure<"write-usage-invalid"> {
678
+ let kinds: ReturnType<typeof declaredKindFiles>;
679
+ try {
680
+ kinds = declaredKindFiles(cwd);
681
+ } catch (err) {
682
+ if (!(err instanceof WorkspaceReadError)) throw err;
683
+ return usage(schema, `${missing}; the declaration can't name one: ${err.code}: ${err.describe()}`);
684
+ }
685
+ if (kinds.length === 0) return usage(schema, missing);
686
+ if (kinds.length > 1) {
687
+ return usage(schema, `the declaration names ${kinds.length} record kinds (${kinds.map((k) => k.declared.path).join(", ")}), so name the one to write with --kind`);
688
+ }
689
+ return kinds[0].file;
690
+ }
691
+
692
+ /**
693
+ * The kind file a write names: `arg` itself when it is a file, or else the
694
+ * declared record kind it names (#2683), by the name the declaration gives it
695
+ * or its file's name without `.kind.mjs`, so `records new work` finds
696
+ * `work/work.kind.mjs`. Anything else is returned as given, and loading it
697
+ * fails with kind-unreadable.
698
+ */
699
+ export function resolveWriteKind(arg: string, cwd: string): string {
700
+ try {
701
+ if (statSync(resolve(cwd, arg)).isFile()) return arg;
702
+ } catch {
703
+ // Not a file: try the declared kinds.
704
+ }
705
+ let kinds: ReturnType<typeof declaredKindFiles>;
706
+ try {
707
+ kinds = declaredKindFiles(cwd);
708
+ } catch {
709
+ return arg;
710
+ }
711
+ const hit = kinds.filter((k) => (k.declared.name ?? posix.basename(k.declared.path).replace(/(?:\.kind)?\.[cm]?[jt]s$/, "")) === arg);
712
+ return hit.length === 1 ? hit[0].file : arg;
713
+ }
714
+
715
+ /** The text of `--from` or `--set`: a file, or standard input for `-`. */
716
+ function readInput(schema: string, flag: string, value: string | undefined, cwd: string): string | WriteFailure<"write-usage-invalid" | "write-input-invalid"> {
717
+ if (value === undefined || value === "") return usage(schema, `${flag} <file|-> is required`);
718
+ try {
719
+ return readFileSync(value === "-" ? 0 : resolve(cwd, value), "utf-8");
720
+ } catch (err) {
721
+ return {
722
+ $schema: schema,
723
+ contract: RECORDS_WRITE_CONTRACT_VERSION,
724
+ error: { code: "write-input-invalid", message: `${flag} ${value} could not be read: ${err instanceof Error ? err.message : String(err)}` },
725
+ };
726
+ }
727
+ }
728
+
729
+ /** `chant workspace records new|amend|review`. Prints one JSON document; exits 0 when it wrote, or would have with --dry-run. */
730
+ export async function runRecordsWrite(ctx: CommandContext): Promise<number> {
731
+ const { args } = ctx;
732
+ const cwd = process.cwd();
733
+ const verb = args.extraPositional;
734
+ const print = (doc: object): number => {
735
+ console.log(JSON.stringify(doc, null, 2));
736
+ return "error" in doc ? 1 : 0;
737
+ };
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)`));
741
+ }
742
+ if (verb === "new") {
743
+ const named = args.extraPositional2 ?? args.kind;
744
+ const kind = named !== undefined ? resolveWriteKind(named, cwd) : declaredWriteKind(RECORDS_NEW_SCHEMA_ID, cwd, "new needs the kind file");
745
+ if (typeof kind !== "string") return print(kind);
746
+ const input = readInput(RECORDS_NEW_SCHEMA_ID, "--from", args.migrateFrom, cwd);
747
+ if (typeof input !== "string") return print(input);
748
+ return print(await newRecord({ kind, fields: input, prefix: args.prefix, dryRun: args.dryRun, cwd }));
749
+ }
750
+ const schema = verb === "amend" ? RECORDS_AMEND_SCHEMA_ID : RECORDS_REVIEW_SCHEMA_ID;
751
+ const id = args.extraPositional2;
752
+ if (!id) return print(usage(schema, `${verb} needs the record's id`));
753
+ const kind = args.kind !== undefined ? resolveWriteKind(args.kind, cwd) : declaredWriteKind(schema, cwd, "--kind <kind file> is required");
754
+ if (typeof kind !== "string") return print(kind);
755
+ if (verb === "amend") {
756
+ const input = readInput(schema, "--set", args.set, cwd);
757
+ if (typeof input !== "string") return print(input);
758
+ return print(await amendRecord({ kind, id, fields: input, dryRun: args.dryRun, cwd }));
759
+ }
760
+ if (args.verdict === undefined) return print(usage(schema, "--verdict agree|dissent|abstain is required"));
761
+ if (args.by === undefined) return print(usage(schema, "--by <principal> is required"));
762
+ return print(
763
+ await reviewRecord({ kind, id, verdict: args.verdict, by: args.by, note: args.note, session: args.session, sign: args.sign, dryRun: args.dryRun, cwd }),
764
+ );
765
+ }