@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
|
@@ -138,12 +138,12 @@
|
|
|
138
138
|
"items": { "$ref": "#/$defs/asset" }
|
|
139
139
|
},
|
|
140
140
|
"warnings": {
|
|
141
|
-
"description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, a supersedes link that has no effect yet, (added by #2654) an evidence list that is empty, (added by #2672) a verdict that names no digest, or (added by #2683) a work record's links and dates. A work record never carries record-no-evidence; a done one with no evidence carries work-done-unpinned. valid and --current do not look at them.",
|
|
141
|
+
"description": "Added in contract 1 by #2549. Findings that leave the record valid: a pinned file that changed, went missing or did not follow a superseding decision, a supersedes link that has no effect yet, (added by #2654) an evidence list that is empty, (added by #2672) a verdict that names no digest, or (added by #2683) a work record's links and dates, or (added by #2688) a record whose author seal is not attested under a signers file at base. A work record never carries record-no-evidence; a done one with no evidence carries work-done-unpinned. valid and --current do not look at them.",
|
|
142
142
|
"type": "array",
|
|
143
143
|
"items": { "$ref": "#/$defs/warning" }
|
|
144
144
|
},
|
|
145
145
|
"digest": {
|
|
146
|
-
"description": "Added in contract 1 by #2672. The lowercase hex SHA-256 of the record file's text, with LF line endings and without the kind's reviews block in the front matter, or for a kind of format json without the top-level reviews
|
|
146
|
+
"description": "Added in contract 1 by #2672. The lowercase hex SHA-256 of the record file's text, with LF line endings and without the kind's reviews block and its top-level seal block (#2688) in the front matter, or for a kind of format json without the top-level reviews and seal members, each with one comma beside it (#2664). A record with no seal hashes as it did before #2688. A review verdict names it in its digest field, and stops counting once the record's digest moves on; a record's author seal signs it. For a kind with no reviews list, the digest of the whole text.",
|
|
147
147
|
"type": "string",
|
|
148
148
|
"pattern": "^[0-9a-f]{64}$"
|
|
149
149
|
},
|
|
@@ -151,6 +151,14 @@
|
|
|
151
151
|
"description": "Added in contract 1 by #2671, on every record of a kind with a reviews list. Null when the front matter could not be parsed.",
|
|
152
152
|
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/quorum" }]
|
|
153
153
|
},
|
|
154
|
+
"attested": {
|
|
155
|
+
"description": "Added in contract 1 by #2688, on every parsed record of a kind with a reviews list. true when the record's author seal (its top-level seal field) verifies, against the signers file at base, for the author the kind's reviews.decider field names (decided_by for decisions). false when a signers file is active at base and the record names an author and carries no seal, or when a seal fails. null when nothing here can say: no seal and no signers file, a seal checked with no signers file, no ssh-keygen, or a record that names no author and carries no seal. Independent of provenance, which judges the commit that last changed the file.",
|
|
156
|
+
"type": ["boolean", "null"]
|
|
157
|
+
},
|
|
158
|
+
"attestation": {
|
|
159
|
+
"description": "Added in contract 1 by #2688, beside attested. What the record's author seal establishes, and why when it is not attested. Under an active signers file, a record that names an author and is not attested also carries the warning record-unattested; it is still read and still valid.",
|
|
160
|
+
"$ref": "#/$defs/attestation"
|
|
161
|
+
},
|
|
154
162
|
"citedBy": {
|
|
155
163
|
"description": "Added in contract 1 by #2673, on every record of a session kind and on no other. The review entries of the records the session kind names as its subjects (decisions, for the reference session kind) whose session field names this record's id, in path order and then list order.",
|
|
156
164
|
"type": "array",
|
|
@@ -206,7 +214,9 @@
|
|
|
206
214
|
"work-needs-cycle",
|
|
207
215
|
"work-implements-undecided",
|
|
208
216
|
"work-done-unpinned",
|
|
209
|
-
"work-closed-without-date"
|
|
217
|
+
"work-closed-without-date",
|
|
218
|
+
"work-done-gap-open",
|
|
219
|
+
"record-unattested"
|
|
210
220
|
]
|
|
211
221
|
},
|
|
212
222
|
"message": { "type": "string" }
|
|
@@ -248,6 +258,20 @@
|
|
|
248
258
|
"reviewer": { "type": "string", "description": "The reviewer as the record writes it." },
|
|
249
259
|
"verdict": { "enum": ["agree", "dissent", "abstain"] },
|
|
250
260
|
"digest": { "type": ["string", "null"], "description": "The digest the verdict names, or null when it names none (a warning review-undigested on the record)." },
|
|
261
|
+
"attested": {
|
|
262
|
+
"type": ["boolean", "null"],
|
|
263
|
+
"description": "Added in contract 1 by #2687. true when the verdict's seal verifies for its reviewer against the signers file at base. false when a signers file is active at base and the verdict has no seal, or when a seal fails, with or without one. null when nothing here can say: no seal and no signers file, a seal checked with no signers file (its signature is intact, and nothing says whose key it is), or no ssh-keygen. Under an active signers file only true counts."
|
|
264
|
+
},
|
|
265
|
+
"attestation": {
|
|
266
|
+
"description": "Added in contract 1 by #2687. What the verdict's seal establishes, and why when it is not attested.",
|
|
267
|
+
"type": "object",
|
|
268
|
+
"required": ["message"],
|
|
269
|
+
"properties": {
|
|
270
|
+
"code": { "enum": ["seal-missing", "seal-signer-unlisted", "seal-signature-invalid", "seal-unverifiable"], "description": "Absent exactly when attested is true." },
|
|
271
|
+
"message": { "type": "string" },
|
|
272
|
+
"key": { "type": "string", "description": "The fingerprint of the key that made the signature, when the signature was checked." }
|
|
273
|
+
}
|
|
274
|
+
},
|
|
251
275
|
"reason": {
|
|
252
276
|
"description": "Why the verdict does not count. Present exactly on the entries in notCounted.",
|
|
253
277
|
"type": "object",
|
|
@@ -259,6 +283,16 @@
|
|
|
259
283
|
}
|
|
260
284
|
}
|
|
261
285
|
},
|
|
286
|
+
"attestation": {
|
|
287
|
+
"description": "Added in contract 1 by #2688. What a record's author seal establishes, and why when it is not attested: the same shape as a verdict's attestation (#2687), which is kept inline in verdict.",
|
|
288
|
+
"type": "object",
|
|
289
|
+
"required": ["message"],
|
|
290
|
+
"properties": {
|
|
291
|
+
"code": { "enum": ["seal-missing", "seal-signer-unlisted", "seal-signature-invalid", "seal-unverifiable"], "description": "Absent exactly when attested is true." },
|
|
292
|
+
"message": { "type": "string" },
|
|
293
|
+
"key": { "type": "string", "description": "The fingerprint of the key that made the signature, when the signature was checked." }
|
|
294
|
+
}
|
|
295
|
+
},
|
|
262
296
|
"concern": {
|
|
263
297
|
"type": "object",
|
|
264
298
|
"required": ["index", "principal", "reviewer", "note"],
|
package/src/workspace/records.ts
CHANGED
|
@@ -100,14 +100,76 @@ export const REVIEW_REASON_CODES = [
|
|
|
100
100
|
"review-duplicate",
|
|
101
101
|
/** The verdict's digest is not the digest of the record's text now: the record changed after the verdict (#2672). */
|
|
102
102
|
"review-older-digest",
|
|
103
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* An attestation policy is active at base, and the verdict's seal does not
|
|
105
|
+
* verify for its reviewer: it has none, the reviewer has no key in the
|
|
106
|
+
* signers file, or the signature fails (#2687).
|
|
107
|
+
*/
|
|
104
108
|
"review-unattested",
|
|
105
109
|
] as const satisfies readonly ReasonCode[];
|
|
106
110
|
export type ReviewReasonCode = (typeof REVIEW_REASON_CODES)[number];
|
|
107
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Why a seal is not attested: a verdict's (#2687), or a record's author seal
|
|
114
|
+
* (#2688). Closed, like the reason codes. Every verdict in the quorum, and
|
|
115
|
+
* every parsed record of a kind with a reviews list, carries one of these in
|
|
116
|
+
* its `attestation`, unless its seal verified.
|
|
117
|
+
*/
|
|
118
|
+
export const SEAL_REASON_CODES = [
|
|
119
|
+
/** The verdict or record carries no seal. */
|
|
120
|
+
"seal-missing",
|
|
121
|
+
/** The reviewer or author has no key in the signers file at base. */
|
|
122
|
+
"seal-signer-unlisted",
|
|
123
|
+
/** The seal is malformed, names another signer, or its signature does not verify over the verdict or record. */
|
|
124
|
+
"seal-signature-invalid",
|
|
125
|
+
/** Nothing here can say whose seal it is: there is no signers file at base, or ssh-keygen is not installed. */
|
|
126
|
+
"seal-unverifiable",
|
|
127
|
+
] as const satisfies readonly ReasonCode[];
|
|
128
|
+
export type SealCode = (typeof SEAL_REASON_CODES)[number];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A warning about a record's own seal (#2688), raised by `records` with the
|
|
132
|
+
* policy at base, never by a write. Closed, like the reason codes.
|
|
133
|
+
*/
|
|
134
|
+
export const SEAL_WARNING_CODES = [
|
|
135
|
+
/**
|
|
136
|
+
* A signers file is active at base, the record names its author (the
|
|
137
|
+
* kind's `reviews.decider` field), and its seal does not verify for that
|
|
138
|
+
* author: it has none, the author has no key in the file, or the signature
|
|
139
|
+
* fails. The record is still read, and still valid.
|
|
140
|
+
*/
|
|
141
|
+
"record-unattested",
|
|
142
|
+
] as const satisfies readonly ReasonCode[];
|
|
143
|
+
export type SealWarningCode = (typeof SEAL_WARNING_CODES)[number];
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The top-level field that holds a record's author seal (#2688), on a kind
|
|
147
|
+
* with a reviews list. Like the reviews block, the record's digest leaves it
|
|
148
|
+
* out, so sealing a record never moves its digest.
|
|
149
|
+
*/
|
|
150
|
+
export const RECORD_SEAL_FIELD = "seal";
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The top-level fields {@link recordTextDigest} leaves out for a kind: its
|
|
154
|
+
* reviews list and {@link RECORD_SEAL_FIELD} when it has a reviews list, and
|
|
155
|
+
* nothing when it has none (#2672, #2688).
|
|
156
|
+
*/
|
|
157
|
+
export function digestFields(kind: Pick<RecordKind, "reviews">): string[] | null {
|
|
158
|
+
return kind.reviews ? [kind.reviews.field, RECORD_SEAL_FIELD] : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
|
|
162
|
+
export interface VerdictAttestation {
|
|
163
|
+
/** Why the verdict is not attested. Absent when its seal verified. */
|
|
164
|
+
code?: SealCode;
|
|
165
|
+
message: string;
|
|
166
|
+
/** The fingerprint of the key that made the signature, when it was checked. */
|
|
167
|
+
key?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
108
170
|
export interface RecordWarning {
|
|
109
|
-
/** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683). */
|
|
110
|
-
code: RecordWarningCode | WorkWarningCode;
|
|
171
|
+
/** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), and `records` adds `SEAL_WARNING_CODES` (#2688). */
|
|
172
|
+
code: RecordWarningCode | WorkWarningCode | SealWarningCode;
|
|
111
173
|
message: string;
|
|
112
174
|
}
|
|
113
175
|
|
|
@@ -244,12 +306,21 @@ export const recordKindSchema = z
|
|
|
244
306
|
* (relative to this kind file's directory). The entries of that kind's
|
|
245
307
|
* reviews list (its `reviews.field`, or `reviews`) name a session in
|
|
246
308
|
* `session`. Optional.
|
|
309
|
+
*
|
|
310
|
+
* `openedRev`, `closedRev` and `closedOn` (#2693) name the fields that
|
|
311
|
+
* hold the commit a session opened at, the commit it closed at, and when
|
|
312
|
+
* it closed. `records new` writes the first, `records close` the other
|
|
313
|
+
* two, and `records --since <session id>` reads the revisions. Each is
|
|
314
|
+
* optional: without it, nothing writes that field.
|
|
247
315
|
*/
|
|
248
316
|
session: z
|
|
249
317
|
.object({
|
|
250
318
|
verdicts: z.string().min(1),
|
|
251
319
|
seal: z.string().min(1),
|
|
252
320
|
subjects: z.object({ kind: z.string().min(1) }).strict(),
|
|
321
|
+
openedRev: z.string().min(1).optional(),
|
|
322
|
+
closedRev: z.string().min(1).optional(),
|
|
323
|
+
closedOn: z.string().min(1).optional(),
|
|
253
324
|
})
|
|
254
325
|
.strict()
|
|
255
326
|
.optional(),
|
|
@@ -554,34 +625,43 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
|
|
|
554
625
|
* to the file changes it, so a verdict given before an amendment stops
|
|
555
626
|
* counting.
|
|
556
627
|
*
|
|
628
|
+
* `field` names the top-level fields left out: one name, or a list. For a
|
|
629
|
+
* kind with a reviews list the list is the reviews field and `seal`, the
|
|
630
|
+
* record's author seal (#2688; {@link digestFields}), so sealing a record
|
|
631
|
+
* leaves its digest where it was too. A record with no `seal` hashes exactly
|
|
632
|
+
* as it did before author seals existed.
|
|
633
|
+
*
|
|
557
634
|
* The rule, which a hand-editor can follow with a text editor and
|
|
558
635
|
* `sha256sum`:
|
|
559
636
|
*
|
|
560
637
|
* 1. Line endings become LF (CRLF and a lone CR each become one LF).
|
|
561
638
|
* 2. When the text starts with a `---` line and a later line is exactly
|
|
562
|
-
* `---`, the lines between them are the front matter. In it,
|
|
563
|
-
* that starts, at column 0, with the
|
|
639
|
+
* `---`, the lines between them are the front matter. In it, each line
|
|
640
|
+
* that starts, at column 0, with one of the keys (bare, or in single or
|
|
564
641
|
* double quotes), optional spaces or tabs and a `:`, is removed, and so
|
|
565
642
|
* is every line after it, up to the closing `---`, that is empty or
|
|
566
643
|
* starts with a space, a tab, `#` or `-`. Removal stops at the first
|
|
567
644
|
* other line. Everything else, the `---` lines and the body included, is
|
|
568
|
-
* kept byte for byte.
|
|
645
|
+
* kept byte for byte. Which key is removed first makes no difference.
|
|
569
646
|
* 3. The digest is the SHA-256 of the result's UTF-8 bytes.
|
|
570
647
|
*
|
|
571
|
-
* Text with no front matter, or
|
|
572
|
-
* A writer that adds a verdict must change only the reviews block: a
|
|
648
|
+
* Text with no front matter, or none of the keys, is hashed after step 1
|
|
649
|
+
* alone. A writer that adds a verdict must change only the reviews block: a
|
|
573
650
|
* reformatted front matter is a new digest, and every earlier verdict stops
|
|
574
651
|
* counting.
|
|
575
652
|
*/
|
|
576
|
-
export function recordTextDigest(text: string, field: string | null = "reviews", format: RecordFormat = "markdown-front-matter"): string {
|
|
653
|
+
export function recordTextDigest(text: string, field: string | readonly string[] | null = "reviews", format: RecordFormat = "markdown-front-matter"): string {
|
|
577
654
|
const lf = text.replace(/\r\n?/g, "\n");
|
|
578
|
-
const
|
|
655
|
+
const fields = field === null ? [] : typeof field === "string" ? [field] : field;
|
|
656
|
+
const kept = format === "json" ? fields.reduce(withoutMember, lf) : withoutBlocks(lf, fields);
|
|
579
657
|
return createHash("sha256").update(kept, "utf8").digest("hex");
|
|
580
658
|
}
|
|
581
659
|
|
|
582
660
|
/**
|
|
583
661
|
* `text` with the top-level member `field` removed from a JSON record, by the
|
|
584
|
-
* rule a hand-editor follows for a JSON record (ws-053)
|
|
662
|
+
* rule a hand-editor follows for a JSON record (ws-053). With several fields
|
|
663
|
+
* (the reviews member, then `seal`, #2688) the rule is applied to each in
|
|
664
|
+
* turn, to the text the previous one left:
|
|
585
665
|
*
|
|
586
666
|
* 1. Line endings become LF, as for Markdown.
|
|
587
667
|
* 2. When the text is a JSON record (one object, no repeated member name) and
|
|
@@ -624,14 +704,15 @@ function withoutMember(text: string, field: string): string {
|
|
|
624
704
|
return text.slice(0, start) + text.slice(end);
|
|
625
705
|
}
|
|
626
706
|
|
|
627
|
-
/** `text` with the top-level `
|
|
628
|
-
function
|
|
707
|
+
/** `text` with the top-level blocks of `fields` removed from its front matter, by the rule {@link recordTextDigest} states. */
|
|
708
|
+
function withoutBlocks(text: string, fields: readonly string[]): string {
|
|
709
|
+
if (fields.length === 0) return text;
|
|
629
710
|
const lines = text.split("\n");
|
|
630
711
|
if (lines[0] !== "---") return text;
|
|
631
712
|
const close = lines.indexOf("---", 1);
|
|
632
713
|
if (close < 0) return text;
|
|
633
|
-
const
|
|
634
|
-
const starts = new RegExp(`^(?:${
|
|
714
|
+
const keys = fields.map((f) => f.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).map((k) => `${k}|"${k}"|'${k}'`);
|
|
715
|
+
const starts = new RegExp(`^(?:${keys.join("|")})[ \\t]*:(?:[ \\t]|$)`);
|
|
635
716
|
const out: string[] = [];
|
|
636
717
|
for (let i = 0; i < lines.length; i++) {
|
|
637
718
|
if (i > 0 && i < close && starts.test(lines[i])) {
|
|
@@ -664,6 +745,13 @@ export interface QuorumVerdict {
|
|
|
664
745
|
verdict: "agree" | "dissent" | "abstain";
|
|
665
746
|
/** The digest the verdict names, or null when it names none. */
|
|
666
747
|
digest: string | null;
|
|
748
|
+
/**
|
|
749
|
+
* true when its seal verifies for the reviewer against the signers at base;
|
|
750
|
+
* false when a seal is missing under an active policy, or fails; null when
|
|
751
|
+
* nothing here can say (#2687).
|
|
752
|
+
*/
|
|
753
|
+
attested: boolean | null;
|
|
754
|
+
attestation: VerdictAttestation;
|
|
667
755
|
/** Why it does not count. Absent on a counted verdict. */
|
|
668
756
|
reason?: { code: ReviewReasonCode; message: string };
|
|
669
757
|
}
|
|
@@ -703,6 +791,30 @@ export interface QuorumOptions {
|
|
|
703
791
|
agents: ReadonlySet<string>;
|
|
704
792
|
/** Whether an attestation policy is active, so a verdict needs a seal to count. */
|
|
705
793
|
attestation: boolean;
|
|
794
|
+
/**
|
|
795
|
+
* Checks one verdict's seal (#2687), as `trust/seal.ts` does against the
|
|
796
|
+
* policy at base. Without it no seal is checked: a sealed verdict is
|
|
797
|
+
* `seal-unverifiable`, so under an active policy none counts.
|
|
798
|
+
*/
|
|
799
|
+
verifySeal?: (v: SealInput) => { attested: boolean | null } & VerdictAttestation;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** What a seal check reads of one verdict. */
|
|
803
|
+
export interface SealInput {
|
|
804
|
+
record: string | null;
|
|
805
|
+
reviewer: string;
|
|
806
|
+
verdict: string;
|
|
807
|
+
on: unknown;
|
|
808
|
+
digest: string | null;
|
|
809
|
+
seal: unknown;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The check used when {@link QuorumOptions.verifySeal} is not given. */
|
|
813
|
+
function uncheckedSeal(attestation: boolean, v: SealInput): { attested: boolean | null } & VerdictAttestation {
|
|
814
|
+
if (v.seal === undefined || v.seal === null) {
|
|
815
|
+
return { attested: attestation ? false : null, code: "seal-missing", message: `the verdict by ${v.reviewer} carries no seal` };
|
|
816
|
+
}
|
|
817
|
+
return { attested: null, code: "seal-unverifiable", message: `the seal by ${v.reviewer} was not checked` };
|
|
706
818
|
}
|
|
707
819
|
|
|
708
820
|
const VERDICTS = new Set(["agree", "dissent", "abstain"]);
|
|
@@ -713,28 +825,36 @@ const VERDICTS = new Set(["agree", "dissent", "abstain"]);
|
|
|
713
825
|
* Malformed entries are skipped; the schema reports them.
|
|
714
826
|
*
|
|
715
827
|
* A verdict is not counted when its reviewer is the decider, holds the agent
|
|
716
|
-
* role, names a digest other than the record's own now, or
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
828
|
+
* role, names a digest other than the record's own now, or, under an active
|
|
829
|
+
* attestation policy, carries no seal that verifies for its reviewer (#2687),
|
|
830
|
+
* in that order. Of the rest, the latest verdict per principal counts and
|
|
831
|
+
* each earlier one is a duplicate. Every verdict reports its seal's check in
|
|
832
|
+
* `attested` and `attestation`, whether it counts or not.
|
|
720
833
|
*/
|
|
721
|
-
export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data" | "digest"
|
|
834
|
+
export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data" | "digest"> & { id?: string | null }, options: QuorumOptions): Quorum | null {
|
|
722
835
|
if (!kind.reviews || entry.data === null) return null;
|
|
723
836
|
const list = entry.data[kind.reviews.field];
|
|
724
837
|
const decidedBy = entry.data[kind.reviews.decider];
|
|
725
838
|
const decider = typeof decidedBy === "string" ? normalisePrincipal(decidedBy) : null;
|
|
839
|
+
const idValue = entry.id !== undefined ? entry.id : kind.idField !== undefined ? entry.data[kind.idField] : null;
|
|
840
|
+
const record = typeof idValue === "string" ? idValue : null;
|
|
726
841
|
const verdicts: QuorumVerdict[] = [];
|
|
727
842
|
const openConcerns: OpenConcern[] = [];
|
|
728
843
|
(Array.isArray(list) ? list : []).forEach((raw, index) => {
|
|
729
844
|
if (raw === null || typeof raw !== "object") return;
|
|
730
845
|
const r = raw as Record<string, unknown>;
|
|
731
846
|
if (typeof r.reviewer !== "string" || typeof r.verdict !== "string" || !VERDICTS.has(r.verdict)) return;
|
|
847
|
+
const digest = typeof r.digest === "string" ? r.digest : null;
|
|
848
|
+
const input: SealInput = { record, reviewer: r.reviewer, verdict: r.verdict, on: r.on, digest, seal: r.seal };
|
|
849
|
+
const { attested, ...attestation } = options.verifySeal ? options.verifySeal(input) : uncheckedSeal(options.attestation, input);
|
|
732
850
|
const v: QuorumVerdict = {
|
|
733
851
|
index,
|
|
734
852
|
principal: normalisePrincipal(r.reviewer),
|
|
735
853
|
reviewer: r.reviewer,
|
|
736
854
|
verdict: r.verdict as QuorumVerdict["verdict"],
|
|
737
|
-
digest
|
|
855
|
+
digest,
|
|
856
|
+
attested,
|
|
857
|
+
attestation,
|
|
738
858
|
};
|
|
739
859
|
if (v.principal === decider) {
|
|
740
860
|
v.reason = { code: "review-decider", message: `${v.reviewer} decided this record, and the quorum counts verdicts besides the decider's` };
|
|
@@ -742,8 +862,8 @@ export function computeQuorum(kind: RecordKind, entry: Pick<RecordEntry, "data"
|
|
|
742
862
|
v.reason = { code: "review-agent", message: `${v.reviewer} holds the agent role in the trust policy at base, and an agent's verdict does not count` };
|
|
743
863
|
} else if (v.digest !== null && v.digest !== entry.digest) {
|
|
744
864
|
v.reason = { code: "review-older-digest", message: `${v.reviewer} judged the text at digest ${v.digest.slice(0, 12)}, and the record's text is now at ${entry.digest.slice(0, 12)}` };
|
|
745
|
-
} else if (options.attestation) {
|
|
746
|
-
v.reason = { code: "review-unattested", message: `an attestation policy is active at base, and
|
|
865
|
+
} else if (options.attestation && attested !== true) {
|
|
866
|
+
v.reason = { code: "review-unattested", message: `an attestation policy is active at base, and ${attestation.message}` };
|
|
747
867
|
}
|
|
748
868
|
verdicts.push(v);
|
|
749
869
|
if (v.verdict === "dissent" && r.addressed_by == null && r.withdrawn_on == null) {
|
|
@@ -807,7 +927,7 @@ export interface RecordEntry {
|
|
|
807
927
|
assets: AssetPin[];
|
|
808
928
|
/** Findings that leave the record valid, such as a pinned file that changed (#2549). */
|
|
809
929
|
warnings: RecordWarning[];
|
|
810
|
-
/** {@link recordTextDigest} of the file's text, without the kind's reviews list when it has
|
|
930
|
+
/** {@link recordTextDigest} of the file's text, without the kind's reviews list and author seal when it has a reviews list (#2672, #2688). */
|
|
811
931
|
digest: string;
|
|
812
932
|
/** For a session kind only: the subject records' review entries that name this session (#2673). */
|
|
813
933
|
citedBy?: SessionCitation[];
|
|
@@ -972,7 +1092,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
972
1092
|
data: null,
|
|
973
1093
|
assets: [],
|
|
974
1094
|
warnings: [],
|
|
975
|
-
digest: recordTextDigest(text, kind
|
|
1095
|
+
digest: recordTextDigest(text, digestFields(kind), kind.format),
|
|
976
1096
|
};
|
|
977
1097
|
entries.push(entry);
|
|
978
1098
|
if (kind.session) texts.set(path, text);
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
+
import type { EnvironmentDeclaration } from "../config";
|
|
18
19
|
import { lexiconNames } from "../lexicon-module";
|
|
19
20
|
import type { ReasonCode } from "./reason-codes";
|
|
20
21
|
|
|
@@ -54,6 +55,11 @@ export interface MemberRuntimes {
|
|
|
54
55
|
/** The default runtime's name. */
|
|
55
56
|
default: string;
|
|
56
57
|
reasons: RuntimeReason[];
|
|
58
|
+
/**
|
|
59
|
+
* The config's `environments`, read in the same load (#2695): `[]` when it
|
|
60
|
+
* declares none, null when the config couldn't be read.
|
|
61
|
+
*/
|
|
62
|
+
environments: EnvironmentDeclaration[] | null;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/** Loads a lexicon plugin by name; `loadPlugin` from the CLI unless a test swaps it. */
|
|
@@ -65,11 +71,14 @@ const firstLine = (err: unknown): string => (err instanceof Error ? err.message
|
|
|
65
71
|
export async function readMemberRuntimes(dir: string, load?: PluginLoader): Promise<MemberRuntimes> {
|
|
66
72
|
const reasons: RuntimeReason[] = [];
|
|
67
73
|
let names: string[];
|
|
74
|
+
let environments: EnvironmentDeclaration[];
|
|
68
75
|
try {
|
|
69
76
|
const { loadChantConfig } = await import("../config");
|
|
70
|
-
|
|
77
|
+
const { config } = await loadChantConfig(dir);
|
|
78
|
+
names = lexiconNames(config.lexicons ?? []);
|
|
79
|
+
environments = config.environments ?? [];
|
|
71
80
|
} catch (err) {
|
|
72
|
-
return { lexicons: [], default: LOCAL_RUNTIME, reasons: [{ code: "runtimes-config-unreadable", message: `chant.config.ts: ${firstLine(err)}` }] };
|
|
81
|
+
return { lexicons: [], default: LOCAL_RUNTIME, reasons: [{ code: "runtimes-config-unreadable", message: `chant.config.ts: ${firstLine(err)}` }], environments: null };
|
|
73
82
|
}
|
|
74
83
|
const loader = load ?? (await import("../cli/plugins")).loadPlugin;
|
|
75
84
|
const lexicons: string[] = [];
|
|
@@ -81,7 +90,7 @@ export async function readMemberRuntimes(dir: string, load?: PluginLoader): Prom
|
|
|
81
90
|
reasons.push({ code: "runtimes-lexicon-unreadable", message: `lexicon "${name}": ${firstLine(err)}` });
|
|
82
91
|
}
|
|
83
92
|
}
|
|
84
|
-
return { lexicons, default: LOCAL_RUNTIME, reasons };
|
|
93
|
+
return { lexicons, default: LOCAL_RUNTIME, reasons, environments };
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
/** The runtimes one component can deploy on, `local` first, each with its command line. */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding review-session kinds and the commits a session names (#2693), for
|
|
3
|
+
* the commands that act on a session by its id: `records review --session`,
|
|
4
|
+
* `records close` and `records --since <session id>`.
|
|
5
|
+
*
|
|
6
|
+
* A session kind is found through the workspace declaration nearest above
|
|
7
|
+
* the working directory, or named directly. Nothing here writes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { dirname, resolve } from "node:path";
|
|
12
|
+
import { WorkspaceReadError } from "./declaration";
|
|
13
|
+
import { gitRoot, resolveRevision } from "./record-source";
|
|
14
|
+
import { loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
|
|
15
|
+
import { declaredKindFiles, realpathOr } from "./records-cli";
|
|
16
|
+
|
|
17
|
+
/** The shape of an id `records --since` looks up as a session before reading it as a revision: `S-0002`, `ws-052`. */
|
|
18
|
+
export const SESSION_ID = /^[A-Za-z][A-Za-z0-9]*-[0-9]+$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Every session kind among `extra` (kind files, resolved against `cwd`) and
|
|
22
|
+
* the kinds the declaration nearest above `cwd` names, each once, in that
|
|
23
|
+
* order. A kind that can't be loaded, or a declaration that can't be read,
|
|
24
|
+
* adds nothing: the read of that kind reports it.
|
|
25
|
+
*/
|
|
26
|
+
export async function findSessionKinds(cwd: string, extra: string[] = []): Promise<LoadedRecordKind[]> {
|
|
27
|
+
const files = [...extra.map((f) => resolve(cwd, f))];
|
|
28
|
+
try {
|
|
29
|
+
files.push(...declaredKindFiles(cwd).map((k) => k.file));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
if (!(err instanceof WorkspaceReadError)) throw err;
|
|
32
|
+
}
|
|
33
|
+
const seen = new Set<string>();
|
|
34
|
+
const out: LoadedRecordKind[] = [];
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
const real = realpathOr(f);
|
|
37
|
+
if (seen.has(real)) continue;
|
|
38
|
+
seen.add(real);
|
|
39
|
+
try {
|
|
40
|
+
const loaded = await loadRecordKind(f, cwd);
|
|
41
|
+
if (loaded.kind.session) out.push(loaded);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (!(err instanceof RecordReadError)) throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The session kinds whose `session.subjects.kind` is the kind file `subjectFile`. */
|
|
50
|
+
export async function sessionKindsFor(subjectFile: string, cwd: string): Promise<LoadedRecordKind[]> {
|
|
51
|
+
const target = realpathOr(resolve(cwd, subjectFile));
|
|
52
|
+
return (await findSessionKinds(cwd)).filter((k) => realpathOr(resolve(dirname(k.file), k.kind.session!.subjects.kind)) === target);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The full commit id HEAD names in the repository holding `dir`, or null outside git or before the first commit. */
|
|
56
|
+
export function headCommit(dir: string): string | null {
|
|
57
|
+
const top = gitRoot(realpathOr(dir));
|
|
58
|
+
if (!top) return null;
|
|
59
|
+
try {
|
|
60
|
+
return resolveRevision(top, "HEAD");
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err instanceof RecordReadError) return null;
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Commit ids from `git log <args> --format=%H -- <path>` in the repository at
|
|
69
|
+
* `top`, newest first; empty when git knows nothing of the path.
|
|
70
|
+
*/
|
|
71
|
+
export function commitsTouching(top: string, path: string, args: string[]): string[] {
|
|
72
|
+
try {
|
|
73
|
+
return execFileSync("git", ["log", ...args, "--format=%H", "--", path], { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] })
|
|
74
|
+
.split("\n")
|
|
75
|
+
.filter(Boolean);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/workspace/status.ts
CHANGED
|
@@ -91,7 +91,7 @@ export const STATUS_ERROR_CODES = [
|
|
|
91
91
|
export type StatusErrorCode = (typeof STATUS_ERROR_CODES)[number];
|
|
92
92
|
|
|
93
93
|
/** Environment names a ledger directory can have: no `/`, no leading `_` or `.` (those are chant's own directories). */
|
|
94
|
-
const ENV_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
94
|
+
export const ENV_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
95
95
|
|
|
96
96
|
export interface StatusRelease {
|
|
97
97
|
component: string;
|