@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.
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +5 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/version.d.ts +8 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +13 -0
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/environments.d.ts +80 -0
- package/dist/workspace/environments.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +6 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +20 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +41 -0
- package/dist/workspace/records-close.d.ts.map +1 -0
- package/dist/workspace/records-since.d.ts +40 -1
- package/dist/workspace/records-since.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +117 -12
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +84 -14
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +6 -0
- package/dist/workspace/runtimes.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +28 -0
- package/dist/workspace/session-kinds.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +2 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +127 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +3 -3
- package/package.json +1 -1
- package/src/cli/handlers/misc.ts +1 -9
- package/src/cli/main.ts +29 -10
- package/src/cli/mcp/server.test.ts +14 -1
- package/src/cli/mcp/server.ts +3 -1
- package/src/cli/registry.ts +5 -0
- package/src/cli/version.ts +15 -0
- package/src/workspace/__fixtures__/sessions.ts +41 -0
- package/src/workspace/composites.schema.json +68 -3
- package/src/workspace/composites.test.ts +119 -5
- package/src/workspace/composites.ts +26 -7
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
- package/src/workspace/environments.ts +165 -0
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent.schema.json +23 -1
- package/src/workspace/intent.test.ts +2 -0
- package/src/workspace/intent.ts +35 -3
- package/src/workspace/read-contract.test.ts +3 -0
- package/src/workspace/reason-codes.test.ts +9 -3
- package/src/workspace/reason-codes.ts +24 -4
- package/src/workspace/record-assets.test.ts +4 -3
- package/src/workspace/records-amend.schema.json +30 -1
- package/src/workspace/records-cli.ts +113 -14
- package/src/workspace/records-close.schema.json +192 -0
- package/src/workspace/records-close.ts +129 -0
- package/src/workspace/records-contract.test.ts +4 -3
- package/src/workspace/records-new.schema.json +25 -0
- package/src/workspace/records-review.schema.json +81 -3
- package/src/workspace/records-sessions-write.test.ts +274 -0
- package/src/workspace/records-since.schema.json +27 -2
- package/src/workspace/records-since.ts +120 -6
- package/src/workspace/records-write-contract.test.ts +5 -1
- package/src/workspace/records-write.test.ts +4 -2
- package/src/workspace/records-write.ts +336 -43
- package/src/workspace/records.schema.json +37 -3
- package/src/workspace/records.ts +145 -25
- package/src/workspace/runtimes.ts +12 -3
- package/src/workspace/session-kinds.ts +79 -0
- package/src/workspace/status.ts +1 -1
- package/src/workspace/trust/record-seal.test.ts +315 -0
- package/src/workspace/trust/seal.test.ts +222 -0
- package/src/workspace/trust/seal.ts +289 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +3 -1
- 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
|
-
|
|
133
|
-
export
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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;
|
|
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,
|
|
818
|
+
digest: recordTextDigest(current, digestFields(kind), kind.format),
|
|
610
819
|
...(opts.session !== undefined ? { session: opts.session } : {}),
|
|
611
820
|
};
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
}
|