@intentius/chant 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +5 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/cli/version.d.ts +8 -0
  7. package/dist/cli/version.d.ts.map +1 -0
  8. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  9. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  10. package/dist/workspace/composites.d.ts +13 -0
  11. package/dist/workspace/composites.d.ts.map +1 -1
  12. package/dist/workspace/environments.d.ts +80 -0
  13. package/dist/workspace/environments.d.ts.map +1 -0
  14. package/dist/workspace/intent.d.ts +6 -1
  15. package/dist/workspace/intent.d.ts.map +1 -1
  16. package/dist/workspace/reason-codes.d.ts +20 -4
  17. package/dist/workspace/reason-codes.d.ts.map +1 -1
  18. package/dist/workspace/records-cli.d.ts +12 -2
  19. package/dist/workspace/records-cli.d.ts.map +1 -1
  20. package/dist/workspace/records-close.d.ts +41 -0
  21. package/dist/workspace/records-close.d.ts.map +1 -0
  22. package/dist/workspace/records-since.d.ts +40 -1
  23. package/dist/workspace/records-since.d.ts.map +1 -1
  24. package/dist/workspace/records-write.d.ts +117 -12
  25. package/dist/workspace/records-write.d.ts.map +1 -1
  26. package/dist/workspace/records.d.ts +84 -14
  27. package/dist/workspace/records.d.ts.map +1 -1
  28. package/dist/workspace/runtimes.d.ts +6 -0
  29. package/dist/workspace/runtimes.d.ts.map +1 -1
  30. package/dist/workspace/session-kinds.d.ts +28 -0
  31. package/dist/workspace/session-kinds.d.ts.map +1 -0
  32. package/dist/workspace/status.d.ts +2 -0
  33. package/dist/workspace/status.d.ts.map +1 -1
  34. package/dist/workspace/trust/seal.d.ts +127 -0
  35. package/dist/workspace/trust/seal.d.ts.map +1 -0
  36. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  37. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  38. package/dist/workspace/work.d.ts +3 -3
  39. package/package.json +1 -1
  40. package/src/cli/handlers/misc.ts +1 -9
  41. package/src/cli/main.ts +29 -10
  42. package/src/cli/mcp/server.test.ts +14 -1
  43. package/src/cli/mcp/server.ts +3 -1
  44. package/src/cli/registry.ts +5 -0
  45. package/src/cli/version.ts +15 -0
  46. package/src/workspace/__fixtures__/sessions.ts +41 -0
  47. package/src/workspace/composites.schema.json +68 -3
  48. package/src/workspace/composites.test.ts +119 -5
  49. package/src/workspace/composites.ts +26 -7
  50. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
  51. package/src/workspace/environments.ts +165 -0
  52. package/src/workspace/intent-gaps.test.ts +217 -0
  53. package/src/workspace/intent.schema.json +23 -1
  54. package/src/workspace/intent.test.ts +2 -0
  55. package/src/workspace/intent.ts +35 -3
  56. package/src/workspace/read-contract.test.ts +3 -0
  57. package/src/workspace/reason-codes.test.ts +9 -3
  58. package/src/workspace/reason-codes.ts +24 -4
  59. package/src/workspace/record-assets.test.ts +4 -3
  60. package/src/workspace/records-amend.schema.json +30 -1
  61. package/src/workspace/records-cli.ts +113 -14
  62. package/src/workspace/records-close.schema.json +192 -0
  63. package/src/workspace/records-close.ts +129 -0
  64. package/src/workspace/records-contract.test.ts +4 -3
  65. package/src/workspace/records-new.schema.json +25 -0
  66. package/src/workspace/records-review.schema.json +81 -3
  67. package/src/workspace/records-sessions-write.test.ts +274 -0
  68. package/src/workspace/records-since.schema.json +27 -2
  69. package/src/workspace/records-since.ts +120 -6
  70. package/src/workspace/records-write-contract.test.ts +5 -1
  71. package/src/workspace/records-write.test.ts +4 -2
  72. package/src/workspace/records-write.ts +336 -43
  73. package/src/workspace/records.schema.json +37 -3
  74. package/src/workspace/records.ts +145 -25
  75. package/src/workspace/runtimes.ts +12 -3
  76. package/src/workspace/session-kinds.ts +79 -0
  77. package/src/workspace/status.ts +1 -1
  78. package/src/workspace/trust/record-seal.test.ts +315 -0
  79. package/src/workspace/trust/seal.test.ts +222 -0
  80. package/src/workspace/trust/seal.ts +289 -0
  81. package/src/workspace/trust/ssh-commit.ts +2 -2
  82. package/src/workspace/work.test.ts +3 -1
  83. package/src/workspace/work.ts +4 -4
@@ -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 member and one comma beside it (#2664). A review verdict names it in its digest field, and stops counting once the record's digest moves on. For a kind with no reviews list, the digest of the whole text.",
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"],
@@ -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
- /** An attestation policy is active at base, and the verdict carries no seal. */
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, the line
563
- * that starts, at column 0, with the key `field` (bare, or in single or
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 no such key, is hashed after step 1 alone.
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 kept = field === null ? lf : format === "json" ? withoutMember(lf, field) : withoutBlock(lf, field);
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 `field` block removed from its front matter, by the rule {@link recordTextDigest} states. */
628
- function withoutBlock(text: string, field: string): string {
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 key = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
634
- const starts = new RegExp(`^(?:${key}|"${key}"|'${key}')[ \\t]*:(?:[ \\t]|$)`);
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 carries no seal
717
- * under an active attestation policy, in that order. Of the rest, the latest
718
- * verdict per principal counts and each earlier one is a duplicate. No
719
- * verdict carries a seal yet (#2546), so under an active policy none counts.
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">, options: QuorumOptions): Quorum | null {
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: typeof r.digest === "string" ? r.digest : null,
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 the verdict by ${v.reviewer} carries no seal` };
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 one (#2672). */
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.reviews?.field ?? null, kind.format),
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
- names = lexiconNames((await loadChantConfig(dir)).config.lexicons ?? []);
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
+ }
@@ -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;