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