@intentius/chant 0.87.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 (71) 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 +4 -1
  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/reason-codes.d.ts +14 -5
  15. package/dist/workspace/reason-codes.d.ts.map +1 -1
  16. package/dist/workspace/records-cli.d.ts +6 -2
  17. package/dist/workspace/records-cli.d.ts.map +1 -1
  18. package/dist/workspace/records-close.d.ts +41 -0
  19. package/dist/workspace/records-close.d.ts.map +1 -0
  20. package/dist/workspace/records-since.d.ts +40 -1
  21. package/dist/workspace/records-since.d.ts.map +1 -1
  22. package/dist/workspace/records-write.d.ts +109 -11
  23. package/dist/workspace/records-write.d.ts.map +1 -1
  24. package/dist/workspace/records.d.ts +40 -12
  25. package/dist/workspace/records.d.ts.map +1 -1
  26. package/dist/workspace/runtimes.d.ts +6 -0
  27. package/dist/workspace/runtimes.d.ts.map +1 -1
  28. package/dist/workspace/session-kinds.d.ts +28 -0
  29. package/dist/workspace/session-kinds.d.ts.map +1 -0
  30. package/dist/workspace/status.d.ts +2 -0
  31. package/dist/workspace/status.d.ts.map +1 -1
  32. package/dist/workspace/trust/seal.d.ts +42 -0
  33. package/dist/workspace/trust/seal.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cli/handlers/misc.ts +1 -9
  36. package/src/cli/main.ts +20 -8
  37. package/src/cli/mcp/server.test.ts +14 -1
  38. package/src/cli/mcp/server.ts +3 -1
  39. package/src/cli/registry.ts +4 -1
  40. package/src/cli/version.ts +15 -0
  41. package/src/workspace/__fixtures__/sessions.ts +41 -0
  42. package/src/workspace/composites.schema.json +68 -3
  43. package/src/workspace/composites.test.ts +119 -5
  44. package/src/workspace/composites.ts +26 -7
  45. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +11 -0
  46. package/src/workspace/environments.ts +165 -0
  47. package/src/workspace/read-contract.test.ts +3 -0
  48. package/src/workspace/reason-codes.test.ts +8 -3
  49. package/src/workspace/reason-codes.ts +18 -6
  50. package/src/workspace/record-assets.test.ts +4 -3
  51. package/src/workspace/records-amend.schema.json +30 -1
  52. package/src/workspace/records-cli.ts +44 -7
  53. package/src/workspace/records-close.schema.json +192 -0
  54. package/src/workspace/records-close.ts +129 -0
  55. package/src/workspace/records-contract.test.ts +4 -3
  56. package/src/workspace/records-new.schema.json +25 -0
  57. package/src/workspace/records-review.schema.json +55 -2
  58. package/src/workspace/records-sessions-write.test.ts +274 -0
  59. package/src/workspace/records-since.schema.json +27 -2
  60. package/src/workspace/records-since.ts +120 -6
  61. package/src/workspace/records-write-contract.test.ts +5 -1
  62. package/src/workspace/records-write.test.ts +4 -2
  63. package/src/workspace/records-write.ts +307 -43
  64. package/src/workspace/records.schema.json +22 -3
  65. package/src/workspace/records.ts +73 -22
  66. package/src/workspace/runtimes.ts +12 -3
  67. package/src/workspace/session-kinds.ts +79 -0
  68. package/src/workspace/status.ts +1 -1
  69. package/src/workspace/trust/record-seal.test.ts +315 -0
  70. package/src/workspace/trust/seal.test.ts +4 -14
  71. package/src/workspace/trust/seal.ts +119 -25
package/src/cli/main.ts CHANGED
@@ -389,6 +389,7 @@ export function parseArgs(args: string[]): ParsedArgs {
389
389
  if (!result.by || result.by.startsWith("-")) throw new Error("--by needs the reviewer: --by <principal>");
390
390
  } else if (arg === "--sign") {
391
391
  // `chant workspace records review <id> --sign [<key file>]` (#2687): seal the verdict.
392
+ // `records new` and `records amend` take it too, to seal the record's author (#2688).
392
393
  // With no key file, git's user.signingkey, as `git commit -S` reads it.
393
394
  const next = args[i + 1];
394
395
  result.sign = next !== undefined && !next.startsWith("-") ? args[++i] : true;
@@ -757,34 +758,45 @@ Workspace (level 1, #2524):
757
758
  --require attested exits 2 if any record is not
758
759
  attested. A pinned file that changed is a warning,
759
760
  asset-drift or asset-missing
760
- workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json]
761
+ workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json]
761
762
  What changed in the records between <rev> and --at
762
763
  (default: the working tree): new and removed records,
763
764
  state transitions, new verdicts, new supersessions
764
- and changed pins
765
+ and changed pins. A session id compares the commits
766
+ the session opened and closed at
765
767
  workspace records pin <path>
766
768
  Print the path from the workspace root and the
767
769
  sha256 of a file, for a decision's evidence pin
768
- workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--dry-run]
770
+ workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--sign [<key file>]] [--dry-run]
769
771
  Write one new record in the kind's directory from
770
772
  the JSON fields given, after validating them as
771
773
  records would read them. Without a kind file, the one
772
774
  kind the declaration names. Allocates the next id when
773
- the fields hold none. Prints {path, id} as JSON and
774
- never commits
775
- workspace records amend <id> [--kind <kind file>] --set <file|-> [--dry-run]
775
+ the fields hold none. --sign seals the record's author
776
+ (decided_by for decisions) with an ssh key. Prints
777
+ {path, id} as JSON and never commits
778
+ workspace records amend <id> [--kind <kind file>] --set <file|-> [--sign [<key file>]] [--dry-run]
776
779
  Set top-level fields of one record. A closed record
777
780
  never changes, and an approved one changes only its
778
781
  state (upward), pins and reviews; anything else is
779
- refused with amend-supersede-instead. Prints
782
+ refused with amend-supersede-instead. --sign seals
783
+ the author again; without it an amendment removes
784
+ the author seal and says so. Prints
780
785
  {path, id, changed}
781
786
  workspace records review <id> [--kind <kind file>] --verdict agree|dissent|abstain --by <principal> [--note <text>] [--session <id>] [--sign [<key file>]] [--dry-run]
782
787
  Append a review to one record, dated and bound to
783
788
  the digest of the record text. A dissent needs
784
789
  --note. --sign seals it with an ssh key (git's
785
790
  user.signingkey without a file); under a signers
786
- file at base only a sealed verdict counts. Prints
791
+ file at base only a sealed verdict counts. With
792
+ --session, the session must be open, and the verdict
793
+ is appended to its verdicts too. Prints
787
794
  {path, id, review}
795
+ workspace records close <session id> [--kind <session kind file>] [--dry-run]
796
+ Close an open review session: its state, close time,
797
+ closing commit and seal, in one write. Without
798
+ --kind, the one session kind the declaration names.
799
+ Prints {path, id, changed, seal, closedRev}
788
800
  workspace verify [--base <rev>] [--head <rev>] [--require attested]
789
801
  Check the commits in base..head against the signers
790
802
  and roles read from base. A change to the signers file
@@ -1,11 +1,15 @@
1
1
  import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
2
  import { McpServer } from "./server";
3
+ import { readFileSync } from "node:fs";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { join } from "node:path";
5
6
  import { tmpdir } from "node:os";
6
7
  import type { LexiconPlugin } from "../../lexicon";
7
8
  import type { Serializer } from "../../serializer";
8
9
 
10
+ /** The version in `packages/core/package.json`, read here apart from the code under test. */
11
+ const CORE_VERSION: string = JSON.parse(readFileSync(join(import.meta.dirname, "..", "..", "..", "package.json"), "utf-8")).version;
12
+
9
13
  function createMockPlugin(overrides?: Partial<LexiconPlugin>): LexiconPlugin {
10
14
  return {
11
15
  name: "mock",
@@ -51,7 +55,16 @@ describe("McpServer", () => {
51
55
  expect(result.protocolVersion).toBe("2026-07-28");
52
56
  expect(result.capabilities).toBeDefined();
53
57
  expect((result.serverInfo as Record<string, unknown>).name).toBe("chant");
54
- expect((result.serverInfo as Record<string, unknown>).version).toBe("0.1.0");
58
+ expect((result.serverInfo as Record<string, unknown>).version).toBe(CORE_VERSION);
59
+ });
60
+
61
+ test("server info names the installed chant's version, from core's package.json (#2689)", async () => {
62
+ expect(CORE_VERSION).toMatch(/^\d+\.\d+\.\d+/);
63
+ expect(CORE_VERSION).not.toBe("0.1.0");
64
+ for (const params of [{}, { protocolVersion: "2024-11-05" }]) {
65
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "initialize", params });
66
+ expect(((response.result as Record<string, unknown>).serverInfo as Record<string, unknown>).version).toBe(CORE_VERSION);
67
+ }
55
68
  });
56
69
 
57
70
  test("capabilities include tools and resources", async () => {
@@ -13,6 +13,7 @@ import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
13
13
  import { setGateOrigin } from "../../lifecycle/gate-origin";
14
14
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
15
15
  import { buildResourcesList, handleResourcesRead } from "./resource-handlers";
16
+ import { CHANT_VERSION } from "../version";
16
17
 
17
18
  /**
18
19
  * Protocol versions this server understands, newest first. `initialize` and
@@ -223,7 +224,8 @@ export class McpServer {
223
224
  return {
224
225
  protocolVersion: negotiateProtocolVersion(protocolVersion),
225
226
  capabilities: { tools: {}, resources: {} },
226
- serverInfo: { name: "chant", version: "0.1.0" },
227
+ // The installed chant's version, so a client can tell which chant it talks to (#2689).
228
+ serverInfo: { name: "chant", version: CHANT_VERSION },
227
229
  };
228
230
  }
229
231
 
@@ -297,7 +297,10 @@ export interface ParsedArgs {
297
297
  verdict?: string;
298
298
  /** `chant workspace records review <id> --by <principal>` (#2670): the reviewer, as the caller names them. */
299
299
  by?: string;
300
- /** `chant workspace records review <id> --sign [<key file>]` (#2687): the key that seals the verdict, or true for git's user.signingkey. */
300
+ /**
301
+ * `chant workspace records review <id> --sign [<key file>]` (#2687): the key that seals the verdict, or true for git's user.signingkey.
302
+ * On `records new` and `records amend`, the key that seals the record's author (#2688).
303
+ */
301
304
  sign?: string | true;
302
305
  /** `chant workspace records review <id> --session <id>` (#2670): the review session the verdict was given in. */
303
306
  session?: string;
@@ -0,0 +1,15 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ /**
4
+ * The installed chant's version, read from `@intentius/chant`'s own
5
+ * package.json, or "0.0.0" when it can't be read. The path is the same from
6
+ * `src/cli/` and `dist/cli/`, so it holds whether chant runs from source or
7
+ * from its build.
8
+ */
9
+ export const CHANT_VERSION: string = (() => {
10
+ try {
11
+ return (createRequire(import.meta.url)("../../package.json") as { version?: string }).version ?? "0.0.0";
12
+ } catch {
13
+ return "0.0.0";
14
+ }
15
+ })();
@@ -64,3 +64,44 @@ export function reviewed(root: string, reviewers: string[], session: string, sta
64
64
  .replace(/^reviews: .*$/m, reviewers.length ? `reviews:\n${reviews}` : "reviews: []"),
65
65
  );
66
66
  }
67
+
68
+ /**
69
+ * Declare the fixture's two kinds in a chant.workspace.json (#2693): the
70
+ * decisions at the root and the session kind in the design member, as the
71
+ * reference workspace declares them, so review --session, close and
72
+ * --since <session id> find the session kind.
73
+ */
74
+ export function declareSessions(root: string): void {
75
+ writeFileSync(
76
+ join(root, "chant.workspace.json"),
77
+ `${JSON.stringify(
78
+ {
79
+ name: "sessions",
80
+ schema: 1,
81
+ members: [{ name: "design", dir: "design", kind: "other", because: "the session fixture", records: [{ kind: "sessions/session.kind.mjs" }] }],
82
+ records: [{ kind: DECISIONS_KIND }],
83
+ pins: [],
84
+ },
85
+ null,
86
+ 2,
87
+ )}\n`,
88
+ );
89
+ }
90
+
91
+ /** The fields of a new open session, as a UI sends them to records new. */
92
+ export function newSessionFields(over: Record<string, unknown> = {}): string {
93
+ return JSON.stringify({
94
+ schema: 1,
95
+ title: "Second walk",
96
+ state: "open",
97
+ agenda: [{ record: "ref-001" }, { record: "ref-002" }],
98
+ attendance: [
99
+ { principal: "lex00", class: "person" },
100
+ { principal: "alice", class: "person" },
101
+ ],
102
+ opened: "2026-09-25T09:00:00Z",
103
+ closed: null,
104
+ verdicts: [],
105
+ ...over,
106
+ });
107
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/composites/v1/composites.schema.json",
4
4
  "title": "chant workspace graph --composites output",
5
- "description": "What `chant workspace graph --composites` prints: each composite instance the workspace's members declare, joined to the components whose contract can deploy it (#2662). Each member of kind chant is read through its own `chant graph --format ir` and `chant graph --components --format ir`, under its own toolchain. An instance with no component is listed with an empty `components` array. chant supplies the rows and never picks one: each match says how it was made and how it crosses members. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2662; a chant without --composites refuses the flag. Each component lists the runtimes it can deploy on, read from its member's chant.config.ts (#2674). Every code is in the one closed list of `reason-codes.ts`.",
5
+ "description": "What `chant workspace graph --composites` prints: each composite instance the workspace's members declare, joined to the components whose contract can deploy it (#2662). Each member of kind chant is read through its own `chant graph --format ir` and `chant graph --components --format ir`, under its own toolchain. An instance with no component is listed with an empty `components` array. chant supplies the rows and never picks one: each match says how it was made and how it crosses members. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists. Contract version 1 is written by chant 0.81.0 and newer, and this document was added to it by #2662; a chant without --composites refuses the flag. Each component lists the runtimes it can deploy on, read from its member's chant.config.ts (#2674), and the environments it may deploy to, from that config and the member's release ledger on chant/lifecycle (#2695). Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -131,7 +131,8 @@
131
131
  "status",
132
132
  "reason",
133
133
  "chant",
134
- "runtimeReasons"
134
+ "runtimeReasons",
135
+ "environmentReasons"
135
136
  ],
136
137
  "properties": {
137
138
  "name": {
@@ -210,6 +211,30 @@
210
211
  }
211
212
  }
212
213
  }
214
+ },
215
+ "environmentReasons": {
216
+ "description": "Why the environments of the member's components are only local, or leave out one its ledger has: its chant.config.ts could not be read (the same read and code as runtimeReasons), declares no environments, or doesn't cover an environment the ledger has, or the ledger couldn't be listed. Empty for a member not of kind chant, and when nothing needs saying.",
217
+ "type": "array",
218
+ "items": {
219
+ "type": "object",
220
+ "required": [
221
+ "code",
222
+ "message"
223
+ ],
224
+ "properties": {
225
+ "code": {
226
+ "enum": [
227
+ "runtimes-config-unreadable",
228
+ "environments-none-declared",
229
+ "environments-ledger-undeclared",
230
+ "environments-ledger-unreadable"
231
+ ]
232
+ },
233
+ "message": {
234
+ "type": "string"
235
+ }
236
+ }
237
+ }
213
238
  }
214
239
  },
215
240
  "if": {
@@ -351,7 +376,8 @@
351
376
  "archetype",
352
377
  "composites",
353
378
  "file",
354
- "runtimes"
379
+ "runtimes",
380
+ "environments"
355
381
  ],
356
382
  "properties": {
357
383
  "id": {
@@ -400,6 +426,14 @@
400
426
  "items": {
401
427
  "$ref": "#/$defs/runtime"
402
428
  }
429
+ },
430
+ "environments": {
431
+ "description": "The environments the component may deploy to with chant run --components --env in its member: local first, the default, then each name the member's chant.config.ts declares in environments (patterns left out), in config order, then each environment with a release ledger for the member on chant/lifecycle that the config covers, sorted. The component contract declares no environments.",
432
+ "type": "array",
433
+ "minItems": 1,
434
+ "items": {
435
+ "$ref": "#/$defs/environment"
436
+ }
403
437
  }
404
438
  }
405
439
  },
@@ -528,6 +562,37 @@
528
562
  "description": "The command that deploys the component on this runtime, run in the member's directory: chant run --components <name>, with --on <runtime> for any runtime but local."
529
563
  }
530
564
  }
565
+ },
566
+ "environment": {
567
+ "type": "object",
568
+ "required": [
569
+ "name",
570
+ "default",
571
+ "source",
572
+ "command"
573
+ ],
574
+ "properties": {
575
+ "name": {
576
+ "type": "string",
577
+ "description": "What --env takes."
578
+ },
579
+ "default": {
580
+ "type": "boolean",
581
+ "description": "True for the environment chant run --components deploys to without --env. That is local, since chant.config.ts names no default environment."
582
+ },
583
+ "source": {
584
+ "enum": [
585
+ "config",
586
+ "ledger",
587
+ "builtin"
588
+ ],
589
+ "description": "Where the name came from, the first of these that names it: config for the member's chant.config.ts environments, ledger for a release ledger on chant/lifecycle, builtin for local when neither names it."
590
+ },
591
+ "command": {
592
+ "type": "string",
593
+ "description": "The command that deploys the component to this environment on its default runtime, run in the member's directory: chant run --components <name>, with --on <runtime> when the default runtime isn't local, and --env <env> for any environment but the default."
594
+ }
595
+ }
531
596
  }
532
597
  }
533
598
  }
@@ -7,7 +7,8 @@
7
7
  * (`read-contract.test.ts`).
8
8
  */
9
9
 
10
- import { mkdirSync, writeFileSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
11
12
  import { join } from "node:path";
12
13
  import { afterAll, describe, expect, test } from "vitest";
13
14
  import type { GraphIR, IRNode } from "../graph-ir";
@@ -15,6 +16,7 @@ import { cleanScratch, commitAll, contract, declaration, FAKE_FILE_GRAPH_CHANT,
15
16
  import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
16
17
  import {
17
18
  COMPOSITES_CONTRACT_VERSION,
19
+ COMPOSITES_ENVIRONMENT_REASON_CODES,
18
20
  COMPOSITES_ERROR_CODES,
19
21
  COMPOSITES_OUTPUT_SCHEMA_ID,
20
22
  COMPOSITES_REASON_CODES,
@@ -58,6 +60,9 @@ const componentIr = (components: { name: string; composites?: string[]; archetyp
58
60
  /** The built-in runtime every component has. */
59
61
  const local = (name: string) => ({ name: "local", lexicon: null, default: true, command: `chant run --components ${name}` });
60
62
 
63
+ /** The default environment, named by nothing but chant itself. */
64
+ const localEnv = (name: string) => ({ name: "local", default: true, source: "builtin", command: `chant run --components ${name}` });
65
+
61
66
  /**
62
67
  * app declares three instances and exports ImageUri; delivery links to it and
63
68
  * declares two components; jobs declares an instance and a component of its
@@ -111,6 +116,7 @@ describe("composites output schema", () => {
111
116
  expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
112
117
  expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
113
118
  expect(schema.$defs.member.properties.runtimeReasons.items.properties.code.enum).toEqual([...COMPOSITES_RUNTIME_REASON_CODES]);
119
+ expect(schema.$defs.member.properties.environmentReasons.items.properties.code.enum).toEqual([...COMPOSITES_ENVIRONMENT_REASON_CODES]);
114
120
  });
115
121
  });
116
122
 
@@ -122,7 +128,7 @@ describe("the join", () => {
122
128
  { ...composite("a/billingTable", "Table", "a/billing"), member: "a" },
123
129
  ],
124
130
  });
125
- const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null, runtimes: [] });
131
+ const component = (member: string, name: string, composites: string[] | null = null): ComponentEntry => ({ id: `${member}/${name}`, name, member, archetype: null, composites, file: null, runtimes: [], environments: [] });
126
132
 
127
133
  test("an instance carries every kind and lexicon of its nodes", () => {
128
134
  expect(instances.map((i) => [i.id, i.instance, i.kinds, i.lexicons])).toEqual([
@@ -165,9 +171,9 @@ describe("chant workspace graph --composites on a built workspace", () => {
165
171
  ["docs", "skipped", "kind-not-run"],
166
172
  ]);
167
173
  expect(g.components).toEqual([
168
- { id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts", runtimes: [local("edge")] },
169
- { id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts", runtimes: [local("loom-backend")] },
170
- { id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts", runtimes: [local("queue-runner")] },
174
+ { id: "delivery/edge", name: "edge", member: "delivery", archetype: "infra", composites: ["StaticSite"], file: "delivery/src/edge.component.ts", runtimes: [local("edge")], environments: [localEnv("edge")] },
175
+ { id: "delivery/loom-backend", name: "loom-backend", member: "delivery", archetype: "service", composites: null, file: "delivery/src/loom-backend.component.ts", runtimes: [local("loom-backend")], environments: [localEnv("loom-backend")] },
176
+ { id: "jobs/queue-runner", name: "queue-runner", member: "jobs", archetype: null, composites: ["WorkQueue", "CacheCluster"], file: "jobs/src/queue-runner.component.ts", runtimes: [local("queue-runner")], environments: [localEnv("queue-runner")] },
171
177
  ]);
172
178
  const rows = Object.fromEntries(g.composites.map((c) => [c.id, c]));
173
179
  expect(Object.keys(rows)).toEqual(["app/backend", "app/cache", "app/site", "jobs/queue"]);
@@ -332,3 +338,111 @@ describe("the runtimes each component can deploy on (#2674)", () => {
332
338
  ]);
333
339
  });
334
340
  });
341
+
342
+ /**
343
+ * Write `files` as the whole tree of a `chant/lifecycle` commit, the way the
344
+ * lifecycle code stores ledgers, without touching the working tree or index.
345
+ */
346
+ function lifecycle(root: string, files: Record<string, string>): void {
347
+ const index = join(root, ".git", "composites-test-index");
348
+ const run = (args: string[], input?: string) =>
349
+ execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", ...args], {
350
+ cwd: root,
351
+ encoding: "utf-8",
352
+ input,
353
+ env: { ...process.env, GIT_INDEX_FILE: index },
354
+ }).trim();
355
+ rmSync(index, { force: true });
356
+ for (const [path, text] of Object.entries(files)) run(["update-index", "--add", "--cacheinfo", `100644,${run(["hash-object", "-w", "--stdin"], text)},${path}`]);
357
+ run(["update-ref", "refs/heads/chant/lifecycle", run(["commit-tree", run(["write-tree"]), "-m", "ledger"])]);
358
+ rmSync(index, { force: true });
359
+ }
360
+
361
+ /** One release line; the environment list reads only which ledgers exist. */
362
+ const release = (component: string, env: string) => `${JSON.stringify({ version: 1, component, env, digest: `sha256:${"a".repeat(64)}`, gitSha: "b".repeat(40), runId: "r1", timestamp: "2026-09-24T00:00:00.000Z", actor: "t" })}\n`;
363
+
364
+ describe("the environments each component may deploy to (#2695)", () => {
365
+ /** jobs declares staging, prod and the pattern pr-*; its ledger has releases in staging, pr-42 and retired. */
366
+ function declared(): string {
367
+ const root = fixture();
368
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["staging", { name: "prod", endpoint: "https://prod.example" }, "pr-*"] };\n');
369
+ lifecycle(root, {
370
+ "_members/jobs/staging/releases.jsonl": release("queue-runner", "staging"),
371
+ "_members/jobs/pr-42/releases.jsonl": release("queue-runner", "pr-42"),
372
+ "_members/jobs/retired/releases.jsonl": release("queue-runner", "retired"),
373
+ "_members/jobs/_gates/queue-runner.jsonl": "",
374
+ "_members/jobs/notes/README.md": "not a ledger\n",
375
+ "staging/releases.jsonl": release("edge", "staging"),
376
+ });
377
+ return root;
378
+ }
379
+
380
+ test("local is the default, then the config's names in order, then the ledger's, each with its --env line", async () => {
381
+ const g = result((await workspaceComposites({ cwd: declared() })).doc);
382
+ expectValid(g);
383
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments).toEqual([
384
+ { name: "local", default: true, source: "builtin", command: "chant run --components queue-runner" },
385
+ { name: "staging", default: false, source: "config", command: "chant run --components queue-runner --env staging" },
386
+ { name: "prod", default: false, source: "config", command: "chant run --components queue-runner --env prod" },
387
+ // The pattern pr-* is no environment by itself, and it covers the ledger's pr-42.
388
+ { name: "pr-42", default: false, source: "ledger", command: "chant run --components queue-runner --env pr-42" },
389
+ ]);
390
+ const jobs = g.members.find((m) => m.name === "jobs")!;
391
+ expect(jobs.environmentReasons).toEqual([{ code: "environments-ledger-undeclared", message: expect.stringContaining("retired") }]);
392
+ expect(jobs.runtimeReasons).toEqual([]);
393
+ });
394
+
395
+ test("a member with no _members directory reads the flat ledger, and a config that declares none says so", async () => {
396
+ const g = result((await workspaceComposites({ cwd: declared() })).doc);
397
+ // delivery has no _members/delivery, so the flat staging ledger is its; its config declares nothing, so anything goes.
398
+ expect(g.components.find((c) => c.id === "delivery/edge")!.environments).toEqual([
399
+ localEnv("edge"),
400
+ { name: "staging", default: false, source: "ledger", command: "chant run --components edge --env staging" },
401
+ ]);
402
+ expect(g.members.find((m) => m.name === "delivery")!.environmentReasons.map((r) => r.code)).toEqual(["environments-none-declared"]);
403
+ expect(g.members.find((m) => m.name === "docs")!.environmentReasons).toEqual([]);
404
+ });
405
+
406
+ test("with no chant/lifecycle branch, the config's names are the list", async () => {
407
+ const root = fixture();
408
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["local", "prod"] };\n');
409
+ const g = result((await workspaceComposites({ cwd: root })).doc);
410
+ expectValid(g);
411
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments).toEqual([
412
+ { name: "local", default: true, source: "config", command: "chant run --components queue-runner" },
413
+ { name: "prod", default: false, source: "config", command: "chant run --components queue-runner --env prod" },
414
+ ]);
415
+ expect(g.members.find((m) => m.name === "jobs")!.environmentReasons).toEqual([]);
416
+ });
417
+
418
+ test("a config that can't be read lists local and the ledger's, with the runtimes' code", async () => {
419
+ const root = declared();
420
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'throw new Error("no config here");\nexport default {};\n');
421
+ const g = result((await workspaceComposites({ cwd: root })).doc);
422
+ expectValid(g);
423
+ const jobs = g.members.find((m) => m.name === "jobs")!;
424
+ expect(jobs.environmentReasons).toEqual([{ code: "runtimes-config-unreadable", message: expect.stringContaining("no config here") }]);
425
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments.map((e) => [e.name, e.source])).toEqual([
426
+ ["local", "builtin"],
427
+ ["pr-42", "ledger"],
428
+ ["retired", "ledger"],
429
+ ["staging", "ledger"],
430
+ ]);
431
+ });
432
+
433
+ test("a ledger that can't be listed says so, and the config's names are still listed", async () => {
434
+ const root = fixture();
435
+ writeFileSync(join(root, "jobs", "chant.config.ts"), 'export default { environments: ["prod"] };\n');
436
+ const g = result(
437
+ (
438
+ await workspaceComposites({
439
+ cwd: root,
440
+ readLedgerEnvironments: (m) => (m.name === "jobs" ? { envs: [], reason: { code: "environments-ledger-unreadable", message: "boom" } } : { envs: [], reason: null }),
441
+ })
442
+ ).doc,
443
+ );
444
+ expectValid(g);
445
+ expect(g.members.find((m) => m.name === "jobs")!.environmentReasons).toEqual([{ code: "environments-ledger-unreadable", message: "boom" }]);
446
+ expect(g.components.find((c) => c.id === "jobs/queue-runner")!.environments.map((e) => e.name)).toEqual(["local", "prod"]);
447
+ });
448
+ });
@@ -32,6 +32,11 @@
32
32
  * Each component also lists the runtimes it can deploy on (#2674,
33
33
  * `runtimes.ts`): `local`, and each lexicon its member configures that hosts
34
34
  * `chant run --components`, with the command line for each.
35
+ *
36
+ * And each component lists the environments it may deploy to (#2695,
37
+ * `environments.ts`): `local`, the environments its member's config declares,
38
+ * and those with a release in the member's ledger on `chant/lifecycle`, with
39
+ * the command line for each.
35
40
  */
36
41
 
37
42
  import { formatError } from "../cli/format";
@@ -44,6 +49,7 @@ import type { LinkRow } from "./links";
44
49
  import { emitDocument, type UnitResult } from "./member-commands";
45
50
  import { readerVersion, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
46
51
  import type { ReasonCode } from "./reason-codes";
52
+ import { componentEnvironments, ENVIRONMENT_REASON_CODES, memberEnvironments, readLedgerEnvironments, type ComponentEnvironment, type EnvironmentReason, type LedgerEnvironmentReader, type MemberEnvironments } from "./environments";
47
53
  import { componentRuntimes, readRuntimesIn, RUNTIME_REASON_CODES, type ComponentRuntime, type MemberRuntimes, type PluginLoader, type RuntimeReason } from "./runtimes";
48
54
 
49
55
  /** `$id` of the JSON Schema for the document, shipped beside this file. */
@@ -69,6 +75,9 @@ export type CompositesReasonCode = (typeof COMPOSITES_REASON_CODES)[number];
69
75
  /** Why a member's runtimes are only `local`, or leave out a lexicon its config lists (#2674). Closed. */
70
76
  export const COMPOSITES_RUNTIME_REASON_CODES = RUNTIME_REASON_CODES;
71
77
 
78
+ /** Why a member's environments are only `local`, or leave out one its ledger has (#2695). Closed. */
79
+ export const COMPOSITES_ENVIRONMENT_REASON_CODES = ENVIRONMENT_REASON_CODES;
80
+
72
81
  export interface CompositesMember {
73
82
  name: string;
74
83
  dir: string;
@@ -79,6 +88,8 @@ export interface CompositesMember {
79
88
  chant: string | null;
80
89
  /** Why its components' runtimes leave something out: its config, or a lexicon, couldn't be read. Empty for a member not of kind chant. */
81
90
  runtimeReasons: RuntimeReason[];
91
+ /** Why its components' environments are only `local`, or leave one out. Empty for a member not of kind chant. */
92
+ environmentReasons: EnvironmentReason[];
82
93
  }
83
94
 
84
95
  export interface ComponentEntry {
@@ -94,6 +105,8 @@ export interface ComponentEntry {
94
105
  file: string | null;
95
106
  /** The runtimes it can deploy on: `local` first, then each hosting lexicon its member configures. */
96
107
  runtimes: ComponentRuntime[];
108
+ /** The environments it may deploy to: `local` first, then the config's, then the ledger's. */
109
+ environments: ComponentEnvironment[];
97
110
  }
98
111
 
99
112
  export interface ComponentMatch {
@@ -145,7 +158,7 @@ export interface CompositesResult {
145
158
  }
146
159
 
147
160
  /** Read one member's component graph run into components, or the reason it can't be read. */
148
- function readComponents(member: string, dir: string, run: UnitResult, runtimes: MemberRuntimes | undefined): { components: ComponentEntry[] } | { reason: MemberReason } {
161
+ function readComponents(member: string, dir: string, run: UnitResult, runtimes: MemberRuntimes | undefined, environments: MemberEnvironments | undefined): { components: ComponentEntry[] } | { reason: MemberReason } {
149
162
  if (run.exitCode !== 0) {
150
163
  const tail = run.stderr.trim().split("\n").slice(-5).join("\n");
151
164
  return { reason: { code: "command-failed", message: `chant graph --components exited ${run.exitCode}${tail ? `: ${tail}` : ""}` } };
@@ -166,6 +179,7 @@ function readComponents(member: string, dir: string, run: UnitResult, runtimes:
166
179
  composites: composites && composites.length > 0 ? composites : null,
167
180
  file: file ? (dir === "." ? file : `${dir}/${file}`) : null,
168
181
  runtimes: componentRuntimes(n.id, runtimes),
182
+ environments: componentEnvironments(n.id, environments, runtimes?.default),
169
183
  });
170
184
  }
171
185
  return { components };
@@ -233,17 +247,19 @@ export function joinComponents(instances: CompositeInstanceRow[], components: Co
233
247
  });
234
248
  }
235
249
 
236
- function memberEntry(m: ComposedMember, componentReason: MemberReason | null, runtimes: MemberRuntimes | undefined): CompositesMember {
250
+ function memberEntry(m: ComposedMember, componentReason: MemberReason | null, runtimes: MemberRuntimes | undefined, environments: MemberEnvironments | undefined): CompositesMember {
237
251
  const reason = m.reason ?? componentReason;
238
252
  const status = m.status === "composed" ? (componentReason ? "failed" : "read") : m.status;
239
- return { name: m.name, dir: m.dir, kind: m.kind, status, reason, chant: m.chant, runtimeReasons: runtimes?.reasons ?? [] };
253
+ return { name: m.name, dir: m.dir, kind: m.kind, status, reason, chant: m.chant, runtimeReasons: runtimes?.reasons ?? [], environmentReasons: environments?.reasons ?? [] };
240
254
  }
241
255
 
242
256
  /** Read the workspace's composites and components, and build the document. Never throws a `WorkspaceReadError`. */
243
- export async function workspaceComposites(query: Omit<GraphQuery, "kind" | "components" | "inTree"> & { loadPlugin?: PluginLoader }): Promise<CompositesResult> {
257
+ export async function workspaceComposites(
258
+ query: Omit<GraphQuery, "kind" | "components" | "inTree"> & { loadPlugin?: PluginLoader; readLedgerEnvironments?: LedgerEnvironmentReader },
259
+ ): Promise<CompositesResult> {
244
260
  const head: Head = { $schema: COMPOSITES_OUTPUT_SCHEMA_ID, contract: COMPOSITES_CONTRACT_VERSION, chant: readerVersion() };
245
261
  let runtimes = new Map<string, MemberRuntimes>();
246
- const { loadPlugin, ...graphQuery } = query;
262
+ const { loadPlugin, readLedgerEnvironments: readLedger = readLedgerEnvironments, ...graphQuery } = query;
247
263
  const { doc: graph, failed, components: runs } = await workspaceGraph({
248
264
  ...graphQuery,
249
265
  components: true,
@@ -259,15 +275,18 @@ export async function workspaceComposites(query: Omit<GraphQuery, "kind" | "comp
259
275
  let componentsFailed = false;
260
276
  for (const m of graph.members) {
261
277
  const run = m.status === "composed" ? byMember.get(m.name) : undefined;
278
+ // The ledger is the local chant/lifecycle branch, read from the working tree even with --at, as workspace status reads it.
279
+ const memberRuntimes = runtimes.get(m.name);
280
+ const environments = memberRuntimes ? memberEnvironments(memberRuntimes, readLedger(m, query.cwd)) : undefined;
262
281
  let reason: MemberReason | null = null;
263
282
  if (run) {
264
- const read = readComponents(m.name, m.dir, run, runtimes.get(m.name));
283
+ const read = readComponents(m.name, m.dir, run, memberRuntimes, environments);
265
284
  if ("reason" in read) {
266
285
  reason = read.reason;
267
286
  componentsFailed = true;
268
287
  } else components.push(...read.components);
269
288
  }
270
- members.push(memberEntry(m, reason, runtimes.get(m.name)));
289
+ members.push(memberEntry(m, reason, memberRuntimes, environments));
271
290
  }
272
291
  components.sort((a, b) => a.id.localeCompare(b.id));
273
292
 
@@ -199,6 +199,17 @@
199
199
  }
200
200
  }
201
201
  },
202
+ "seal": {
203
+ "description": "The author's ssh signature over the record (#2688): the bytes <id>\\n<digest>\\n<decided_by>\\n<state>, in the ssh-keygen namespace chant-record, where digest is the record's digest, which leaves this field and reviews out. chant workspace records new --sign and amend --sign write it, and an amendment without --sign removes it. Optional: under a signers file at base, a record with decided_by and no seal that verifies is read with the warning record-unattested.",
204
+ "type": "object",
205
+ "additionalProperties": false,
206
+ "required": ["signer", "key", "signature"],
207
+ "properties": {
208
+ "signer": { "description": "Who signed: decided_by, as the signers file names them.", "type": "string", "minLength": 1 },
209
+ "key": { "description": "The signing key's fingerprint. Reported, never trusted: the signature is the proof.", "type": "string", "pattern": "^SHA256:[A-Za-z0-9+/]+=*$" },
210
+ "signature": { "description": "The armored ssh signature, as ssh-keygen -Y sign prints it.", "type": "string", "pattern": "^-----BEGIN SSH SIGNATURE-----\\n" }
211
+ }
212
+ },
202
213
  "constrains": {
203
214
  "description": "What this decision governs: issues (owner/repo#n), other decisions (their ids), workspace members (member:<name>) or files and directories in the workspace (path:<path>, the grammar of an evidence path, #2549). At least one entry: a decision that governs nothing is refused.",
204
215
  "type": "array",