@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.
Files changed (79) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/handlers/serve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +10 -1
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/workspace-plugins.d.ts +40 -0
  7. package/dist/cli/mcp/workspace-plugins.d.ts.map +1 -0
  8. package/dist/cli/registry.d.ts +6 -1
  9. package/dist/cli/registry.d.ts.map +1 -1
  10. package/dist/cli/version.d.ts +8 -0
  11. package/dist/cli/version.d.ts.map +1 -0
  12. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  13. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  14. package/dist/workspace/composites.d.ts +13 -0
  15. package/dist/workspace/composites.d.ts.map +1 -1
  16. package/dist/workspace/environments.d.ts +80 -0
  17. package/dist/workspace/environments.d.ts.map +1 -0
  18. package/dist/workspace/reason-codes.d.ts +14 -5
  19. package/dist/workspace/reason-codes.d.ts.map +1 -1
  20. package/dist/workspace/records-cli.d.ts +6 -2
  21. package/dist/workspace/records-cli.d.ts.map +1 -1
  22. package/dist/workspace/records-close.d.ts +41 -0
  23. package/dist/workspace/records-close.d.ts.map +1 -0
  24. package/dist/workspace/records-since.d.ts +40 -1
  25. package/dist/workspace/records-since.d.ts.map +1 -1
  26. package/dist/workspace/records-write.d.ts +109 -11
  27. package/dist/workspace/records-write.d.ts.map +1 -1
  28. package/dist/workspace/records.d.ts +40 -12
  29. package/dist/workspace/records.d.ts.map +1 -1
  30. package/dist/workspace/runtimes.d.ts +6 -0
  31. package/dist/workspace/runtimes.d.ts.map +1 -1
  32. package/dist/workspace/session-kinds.d.ts +28 -0
  33. package/dist/workspace/session-kinds.d.ts.map +1 -0
  34. package/dist/workspace/status.d.ts +2 -0
  35. package/dist/workspace/status.d.ts.map +1 -1
  36. package/dist/workspace/trust/seal.d.ts +42 -0
  37. package/dist/workspace/trust/seal.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/handlers/misc.ts +1 -9
  40. package/src/cli/handlers/serve.ts +10 -1
  41. package/src/cli/main.test.ts +7 -0
  42. package/src/cli/main.ts +60 -9
  43. package/src/cli/mcp/server.test.ts +14 -1
  44. package/src/cli/mcp/server.ts +13 -2
  45. package/src/cli/mcp/workspace-plugins.ts +123 -0
  46. package/src/cli/registry.ts +6 -1
  47. package/src/cli/serve-mcp-workspace.test.ts +142 -0
  48. package/src/cli/version.ts +15 -0
  49. package/src/workspace/__fixtures__/sessions.ts +41 -0
  50. package/src/workspace/composites.schema.json +68 -3
  51. package/src/workspace/composites.test.ts +119 -5
  52. package/src/workspace/composites.ts +26 -7
  53. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  54. package/src/workspace/environments.ts +165 -0
  55. package/src/workspace/read-contract.test.ts +3 -0
  56. package/src/workspace/reason-codes.test.ts +8 -3
  57. package/src/workspace/reason-codes.ts +18 -6
  58. package/src/workspace/record-assets.test.ts +4 -3
  59. package/src/workspace/records-amend.schema.json +30 -1
  60. package/src/workspace/records-cli.ts +44 -7
  61. package/src/workspace/records-close.schema.json +192 -0
  62. package/src/workspace/records-close.ts +129 -0
  63. package/src/workspace/records-contract.test.ts +4 -3
  64. package/src/workspace/records-new.schema.json +25 -0
  65. package/src/workspace/records-review.schema.json +55 -2
  66. package/src/workspace/records-sessions-write.test.ts +274 -0
  67. package/src/workspace/records-since.schema.json +27 -2
  68. package/src/workspace/records-since.ts +120 -6
  69. package/src/workspace/records-write-contract.test.ts +5 -1
  70. package/src/workspace/records-write.test.ts +4 -2
  71. package/src/workspace/records-write.ts +307 -43
  72. package/src/workspace/records.schema.json +22 -3
  73. package/src/workspace/records.ts +73 -22
  74. package/src/workspace/runtimes.ts +12 -3
  75. package/src/workspace/session-kinds.ts +79 -0
  76. package/src/workspace/status.ts +1 -1
  77. package/src/workspace/trust/record-seal.test.ts +315 -0
  78. package/src/workspace/trust/seal.test.ts +4 -14
  79. package/src/workspace/trust/seal.ts +119 -25
@@ -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",
@@ -207,7 +215,8 @@
207
215
  "work-implements-undecided",
208
216
  "work-done-unpinned",
209
217
  "work-closed-without-date",
210
- "work-done-gap-open"
218
+ "work-done-gap-open",
219
+ "record-unattested"
211
220
  ]
212
221
  },
213
222
  "message": { "type": "string" }
@@ -274,6 +283,16 @@
274
283
  }
275
284
  }
276
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
+ },
277
296
  "concern": {
278
297
  "type": "object",
279
298
  "required": ["index", "principal", "reviewer", "note"],
@@ -110,22 +110,54 @@ export const REVIEW_REASON_CODES = [
110
110
  export type ReviewReasonCode = (typeof REVIEW_REASON_CODES)[number];
111
111
 
112
112
  /**
113
- * Why a verdict's seal is not attested (#2687). Closed, like the reason
114
- * codes. Every verdict in the quorum carries one of these in its
115
- * `attestation`, unless its seal verified.
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.
116
117
  */
117
118
  export const SEAL_REASON_CODES = [
118
- /** The verdict carries no seal. */
119
+ /** The verdict or record carries no seal. */
119
120
  "seal-missing",
120
- /** The reviewer has no key in the signers file at base. */
121
+ /** The reviewer or author has no key in the signers file at base. */
121
122
  "seal-signer-unlisted",
122
- /** The seal is malformed, names another signer, or its signature does not verify over the verdict. */
123
+ /** The seal is malformed, names another signer, or its signature does not verify over the verdict or record. */
123
124
  "seal-signature-invalid",
124
125
  /** Nothing here can say whose seal it is: there is no signers file at base, or ssh-keygen is not installed. */
125
126
  "seal-unverifiable",
126
127
  ] as const satisfies readonly ReasonCode[];
127
128
  export type SealCode = (typeof SEAL_REASON_CODES)[number];
128
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
+
129
161
  /** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
130
162
  export interface VerdictAttestation {
131
163
  /** Why the verdict is not attested. Absent when its seal verified. */
@@ -136,8 +168,8 @@ export interface VerdictAttestation {
136
168
  }
137
169
 
138
170
  export interface RecordWarning {
139
- /** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683). */
140
- 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;
141
173
  message: string;
142
174
  }
143
175
 
@@ -274,12 +306,21 @@ export const recordKindSchema = z
274
306
  * (relative to this kind file's directory). The entries of that kind's
275
307
  * reviews list (its `reviews.field`, or `reviews`) name a session in
276
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.
277
315
  */
278
316
  session: z
279
317
  .object({
280
318
  verdicts: z.string().min(1),
281
319
  seal: z.string().min(1),
282
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(),
283
324
  })
284
325
  .strict()
285
326
  .optional(),
@@ -584,34 +625,43 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
584
625
  * to the file changes it, so a verdict given before an amendment stops
585
626
  * counting.
586
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
+ *
587
634
  * The rule, which a hand-editor can follow with a text editor and
588
635
  * `sha256sum`:
589
636
  *
590
637
  * 1. Line endings become LF (CRLF and a lone CR each become one LF).
591
638
  * 2. When the text starts with a `---` line and a later line is exactly
592
- * `---`, the lines between them are the front matter. In it, the line
593
- * 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
594
641
  * double quotes), optional spaces or tabs and a `:`, is removed, and so
595
642
  * is every line after it, up to the closing `---`, that is empty or
596
643
  * starts with a space, a tab, `#` or `-`. Removal stops at the first
597
644
  * other line. Everything else, the `---` lines and the body included, is
598
- * kept byte for byte.
645
+ * kept byte for byte. Which key is removed first makes no difference.
599
646
  * 3. The digest is the SHA-256 of the result's UTF-8 bytes.
600
647
  *
601
- * Text with no front matter, or no such key, is hashed after step 1 alone.
602
- * 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
603
650
  * reformatted front matter is a new digest, and every earlier verdict stops
604
651
  * counting.
605
652
  */
606
- 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 {
607
654
  const lf = text.replace(/\r\n?/g, "\n");
608
- 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);
609
657
  return createHash("sha256").update(kept, "utf8").digest("hex");
610
658
  }
611
659
 
612
660
  /**
613
661
  * `text` with the top-level member `field` removed from a JSON record, by the
614
- * 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:
615
665
  *
616
666
  * 1. Line endings become LF, as for Markdown.
617
667
  * 2. When the text is a JSON record (one object, no repeated member name) and
@@ -654,14 +704,15 @@ function withoutMember(text: string, field: string): string {
654
704
  return text.slice(0, start) + text.slice(end);
655
705
  }
656
706
 
657
- /** `text` with the top-level `field` block removed from its front matter, by the rule {@link recordTextDigest} states. */
658
- 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;
659
710
  const lines = text.split("\n");
660
711
  if (lines[0] !== "---") return text;
661
712
  const close = lines.indexOf("---", 1);
662
713
  if (close < 0) return text;
663
- const key = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
664
- 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]|$)`);
665
716
  const out: string[] = [];
666
717
  for (let i = 0; i < lines.length; i++) {
667
718
  if (i > 0 && i < close && starts.test(lines[i])) {
@@ -876,7 +927,7 @@ export interface RecordEntry {
876
927
  assets: AssetPin[];
877
928
  /** Findings that leave the record valid, such as a pinned file that changed (#2549). */
878
929
  warnings: RecordWarning[];
879
- /** {@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). */
880
931
  digest: string;
881
932
  /** For a session kind only: the subject records' review entries that name this session (#2673). */
882
933
  citedBy?: SessionCitation[];
@@ -1041,7 +1092,7 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
1041
1092
  data: null,
1042
1093
  assets: [],
1043
1094
  warnings: [],
1044
- digest: recordTextDigest(text, kind.reviews?.field ?? null, kind.format),
1095
+ digest: recordTextDigest(text, digestFields(kind), kind.format),
1045
1096
  };
1046
1097
  entries.push(entry);
1047
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;