@intentius/chant 0.89.0 → 0.91.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 (194) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/handlers/serve.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/server.d.ts +10 -5
  9. package/dist/cli/mcp/server.d.ts.map +1 -1
  10. package/dist/cli/mcp/types.d.ts +13 -5
  11. package/dist/cli/mcp/types.d.ts.map +1 -1
  12. package/dist/cli/mcp/workspace-tools.d.ts +55 -0
  13. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +31 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/lifecycle/git.d.ts +40 -5
  19. package/dist/lifecycle/git.d.ts.map +1 -1
  20. package/dist/lifecycle/lease.d.ts +72 -16
  21. package/dist/lifecycle/lease.d.ts.map +1 -1
  22. package/dist/lifecycle/member-ledger.d.ts +3 -2
  23. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  25. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/work-lease.d.ts +140 -0
  27. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts +2 -2
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/discover.d.ts +25 -0
  31. package/dist/op/discover.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +9 -3
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  35. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  36. package/dist/op/local-executor.d.ts +19 -0
  37. package/dist/op/local-executor.d.ts.map +1 -1
  38. package/dist/op/local-output.d.ts.map +1 -1
  39. package/dist/op/op-ir.d.ts +10 -1
  40. package/dist/op/op-ir.d.ts.map +1 -1
  41. package/dist/op/op-verb-class.d.ts.map +1 -1
  42. package/dist/op/operator.d.ts +29 -0
  43. package/dist/op/operator.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +9 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/runtimes/local.d.ts.map +1 -1
  47. package/dist/op/step-output-ref.d.ts +2 -2
  48. package/dist/op/step-output-ref.d.ts.map +1 -1
  49. package/dist/op/steward.d.ts +140 -0
  50. package/dist/op/steward.d.ts.map +1 -0
  51. package/dist/op/types.d.ts +51 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/op/work-lease-decl.d.ts +18 -0
  54. package/dist/op/work-lease-decl.d.ts.map +1 -0
  55. package/dist/op/work-lease-run.d.ts +173 -0
  56. package/dist/op/work-lease-run.d.ts.map +1 -0
  57. package/dist/workspace/box-isolation.d.ts +99 -0
  58. package/dist/workspace/box-isolation.d.ts.map +1 -0
  59. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  60. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  61. package/dist/workspace/checks/boxes.d.ts +71 -0
  62. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  63. package/dist/workspace/checks/records.d.ts +1 -0
  64. package/dist/workspace/checks/records.d.ts.map +1 -1
  65. package/dist/workspace/checks.d.ts +10 -1
  66. package/dist/workspace/checks.d.ts.map +1 -1
  67. package/dist/workspace/conformance/index.d.ts +42 -2
  68. package/dist/workspace/conformance/index.d.ts.map +1 -1
  69. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  70. package/dist/workspace/decide.d.ts +184 -0
  71. package/dist/workspace/decide.d.ts.map +1 -0
  72. package/dist/workspace/decision-points.schema.json +137 -0
  73. package/dist/workspace/declaration.d.ts +51 -0
  74. package/dist/workspace/declaration.d.ts.map +1 -1
  75. package/dist/workspace/declaration.schema.json +172 -0
  76. package/dist/workspace/declared-kinds.d.ts +12 -0
  77. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  78. package/dist/workspace/points-cli.d.ts +113 -0
  79. package/dist/workspace/points-cli.d.ts.map +1 -0
  80. package/dist/workspace/points.d.ts +320 -0
  81. package/dist/workspace/points.d.ts.map +1 -0
  82. package/dist/workspace/reason-codes.d.ts +29 -0
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/record-assets.d.ts.map +1 -1
  85. package/dist/workspace/records-cli.d.ts +12 -0
  86. package/dist/workspace/records-cli.d.ts.map +1 -1
  87. package/dist/workspace/records-write.d.ts +35 -3
  88. package/dist/workspace/records-write.d.ts.map +1 -1
  89. package/dist/workspace/records.d.ts +12 -3
  90. package/dist/workspace/records.d.ts.map +1 -1
  91. package/dist/workspace/source-block.d.ts +85 -0
  92. package/dist/workspace/source-block.d.ts.map +1 -0
  93. package/dist/workspace/status-stewards.d.ts +121 -0
  94. package/dist/workspace/status-stewards.d.ts.map +1 -0
  95. package/dist/workspace/status.d.ts +52 -1
  96. package/dist/workspace/status.d.ts.map +1 -1
  97. package/dist/workspace/work-cli.d.ts +78 -0
  98. package/dist/workspace/work-cli.d.ts.map +1 -0
  99. package/package.json +1 -1
  100. package/src/cli/handlers/components.test.ts +93 -0
  101. package/src/cli/handlers/components.ts +44 -3
  102. package/src/cli/handlers/operator.ts +107 -2
  103. package/src/cli/handlers/run.test.ts +19 -0
  104. package/src/cli/handlers/run.ts +53 -1
  105. package/src/cli/handlers/serve.ts +2 -1
  106. package/src/cli/main.test.ts +40 -0
  107. package/src/cli/main.ts +78 -2
  108. package/src/cli/mcp/docs-parity.test.ts +20 -2
  109. package/src/cli/mcp/server.ts +23 -6
  110. package/src/cli/mcp/types.ts +15 -2
  111. package/src/cli/mcp/workspace-tools.test.ts +211 -0
  112. package/src/cli/mcp/workspace-tools.ts +449 -0
  113. package/src/cli/registry.ts +31 -0
  114. package/src/components/verbs/vuln-scan.test.ts +124 -1
  115. package/src/components/verbs/vuln-scan.ts +142 -1
  116. package/src/lifecycle/git.ts +65 -15
  117. package/src/lifecycle/lease.test.ts +22 -0
  118. package/src/lifecycle/lease.ts +133 -29
  119. package/src/lifecycle/member-ledger.ts +3 -2
  120. package/src/lifecycle/plan-ledger.test.ts +148 -0
  121. package/src/lifecycle/plan-ledger.ts +158 -0
  122. package/src/lifecycle/work-lease.test.ts +236 -0
  123. package/src/lifecycle/work-lease.ts +426 -0
  124. package/src/op/builders.ts +5 -0
  125. package/src/op/discover.ts +71 -0
  126. package/src/op/index.ts +16 -3
  127. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  128. package/src/op/lifecycle-receipt-store.ts +61 -0
  129. package/src/op/local-executor.ts +216 -18
  130. package/src/op/local-output.ts +13 -0
  131. package/src/op/op-ir.ts +14 -0
  132. package/src/op/op-verb-class.ts +6 -0
  133. package/src/op/operator.ts +75 -4
  134. package/src/op/runtime.ts +6 -0
  135. package/src/op/runtimes/local.ts +3 -0
  136. package/src/op/step-output-ref.ts +6 -2
  137. package/src/op/steward.test.ts +212 -0
  138. package/src/op/steward.ts +253 -0
  139. package/src/op/types.ts +53 -0
  140. package/src/op/work-lease-decl.ts +80 -0
  141. package/src/op/work-lease-run.test.ts +326 -0
  142. package/src/op/work-lease-run.ts +395 -0
  143. package/src/workspace/box-isolation.test.ts +261 -0
  144. package/src/workspace/box-isolation.ts +205 -0
  145. package/src/workspace/check-contract.test.ts +3 -1
  146. package/src/workspace/check.schema.json +15 -7
  147. package/src/workspace/checks/box-isolation.ts +68 -0
  148. package/src/workspace/checks/boxes.test.ts +197 -0
  149. package/src/workspace/checks/boxes.ts +307 -0
  150. package/src/workspace/checks/records.ts +25 -0
  151. package/src/workspace/checks.test.ts +7 -0
  152. package/src/workspace/checks.ts +19 -2
  153. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  154. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  155. package/src/workspace/conformance/index.mjs +3 -0
  156. package/src/workspace/conformance/index.ts +185 -7
  157. package/src/workspace/conformance/vitest.ts +17 -8
  158. package/src/workspace/decide.test.ts +224 -0
  159. package/src/workspace/decide.ts +576 -0
  160. package/src/workspace/decision-points.schema.json +137 -0
  161. package/src/workspace/declaration.schema.json +172 -0
  162. package/src/workspace/declaration.ts +137 -0
  163. package/src/workspace/declared-kinds.ts +25 -2
  164. package/src/workspace/intent.schema.json +4 -1
  165. package/src/workspace/point-answer.schema.json +95 -0
  166. package/src/workspace/points-cli.ts +273 -0
  167. package/src/workspace/points-write.schema.json +489 -0
  168. package/src/workspace/points.schema.json +710 -0
  169. package/src/workspace/points.test.ts +264 -0
  170. package/src/workspace/points.ts +564 -0
  171. package/src/workspace/read-contract.test.ts +18 -1
  172. package/src/workspace/reason-codes.test.ts +14 -1
  173. package/src/workspace/reason-codes.ts +36 -0
  174. package/src/workspace/record-assets.test.ts +3 -2
  175. package/src/workspace/record-assets.ts +4 -1
  176. package/src/workspace/records-amend.schema.json +2 -1
  177. package/src/workspace/records-cli.ts +15 -2
  178. package/src/workspace/records-close.schema.json +2 -1
  179. package/src/workspace/records-contract.test.ts +3 -2
  180. package/src/workspace/records-new.schema.json +4 -1
  181. package/src/workspace/records-review.schema.json +2 -1
  182. package/src/workspace/records-write.ts +85 -7
  183. package/src/workspace/records.schema.json +18 -0
  184. package/src/workspace/records.ts +61 -3
  185. package/src/workspace/source-block.test.ts +167 -0
  186. package/src/workspace/source-block.ts +129 -0
  187. package/src/workspace/status-contract.test.ts +178 -0
  188. package/src/workspace/status-stewards.ts +225 -0
  189. package/src/workspace/status.schema.json +230 -4
  190. package/src/workspace/status.ts +106 -5
  191. package/src/workspace/work-cli.test.ts +180 -0
  192. package/src/workspace/work-cli.ts +246 -0
  193. package/src/workspace/work-lease.schema.json +233 -0
  194. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A record's stored source block (#2708): the decision schema documents it,
3
+ * records new writes it and records returns it unchanged, a harvested record
4
+ * opens proposed, and records warns source-transcript-drift when the pinned
5
+ * transcript is here and hashes to something else.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
14
+ import { parseFrontMatter, type RecordEntry } from "./records";
15
+ import { queryRecords } from "./records-cli";
16
+ import { newRecord, renderRecord, stableJson } from "./records-write";
17
+ import { sourceBlockProblems, transcriptDrift, transcriptFile } from "./source-block";
18
+
19
+ const REPO = join(import.meta.dirname, "..", "..", "..", "..");
20
+ const DECISIONS = join(REPO, "docs", "design", "decisions");
21
+ const KIND = "decisions/decision.kind.mjs";
22
+
23
+ type Data = Record<string, unknown>;
24
+
25
+ const SAMPLE = (() => {
26
+ const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
27
+ if (!fm.ok) throw new Error(fm.message);
28
+ return fm.value;
29
+ })();
30
+
31
+ /** A proposed decision like ws-003, with `over` laid on top. */
32
+ function proposal(over: Data = {}): Data {
33
+ const d: Data = { ...structuredClone(SAMPLE), state: "proposed", choice: null, decided_by: null, decided_on: null, reviews: [], ...over };
34
+ delete d.id;
35
+ return d;
36
+ }
37
+
38
+ const sha = (text: string) => createHash("sha256").update(text).digest("hex");
39
+ const TRANSCRIPT = '{"turn":1,"role":"user","text":"which way do we deploy?"}\n';
40
+
41
+ const HARNESS = {
42
+ via: "mcp",
43
+ client: { name: "claude-code", version: "2.1.0" },
44
+ harness: "claude-code",
45
+ model: "claude-opus-5-5",
46
+ session: { id: "0f4c2a", record: "S-0002" },
47
+ turns: { from: 12, to: 18 },
48
+ transcript: { path: "transcripts/0f4c2a.jsonl", sha256: sha(TRANSCRIPT) },
49
+ };
50
+
51
+ let dir: string;
52
+ beforeEach(() => {
53
+ dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-source-block-")));
54
+ mkdirSync(join(dir, "decisions"));
55
+ mkdirSync(join(dir, "transcripts"));
56
+ cpSync(join(DECISIONS, "decision.kind.mjs"), join(dir, "decisions", "decision.kind.mjs"));
57
+ cpSync(join(DECISIONS, "decision.schema.json"), join(dir, "decisions", "decision.schema.json"));
58
+ writeFileSync(join(dir, "decisions", "ws-001-one.md"), renderRecord({ ...proposal({ title: "One" }), id: "ws-001" }, "\n# One\n"));
59
+ writeFileSync(join(dir, "transcripts", "0f4c2a.jsonl"), TRANSCRIPT);
60
+ });
61
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
62
+
63
+ const code = (doc: object) => ("error" in doc ? (doc as { error: { code: string } }).error.code : "ok");
64
+
65
+ async function read(): Promise<RecordEntry[]> {
66
+ const doc = await queryRecords({ kind: KIND, cwd: dir });
67
+ if (!("records" in doc)) throw new Error(JSON.stringify(doc));
68
+ return doc.records;
69
+ }
70
+
71
+ async function write(source: unknown, over: Data = {}) {
72
+ return newRecord({ kind: KIND, fields: JSON.stringify(proposal({ title: "Two", source, ...over })), cwd: dir });
73
+ }
74
+
75
+ describe("the decision schema documents the block", () => {
76
+ test("each form takes the proposal fields, and a harness form needs via", async () => {
77
+ for (const source of [
78
+ HARNESS,
79
+ { ...HARNESS, issue: "INTENTIUS/chant#2708" },
80
+ { issue: "INTENTIUS/chant#2708", row: "Source", revision: null, ...HARNESS },
81
+ { kind: "workspace", member: "app", ...HARNESS, session: "S-0001" },
82
+ { via: "cli" },
83
+ ]) {
84
+ const doc = await write(source, { title: `T ${Math.random()}` });
85
+ expect(code(doc), JSON.stringify(source)).toBe("ok");
86
+ }
87
+ const { via: _via, ...noVia } = HARNESS;
88
+ for (const source of [
89
+ noVia,
90
+ { ...HARNESS, via: "email" },
91
+ { ...HARNESS, transcript: { path: "a", uri: "file:///a", sha256: sha("a") } },
92
+ { ...HARNESS, transcript: { path: "a", sha256: "ABC" } },
93
+ { ...HARNESS, turns: { from: 3 } },
94
+ { ...HARNESS, session: {} },
95
+ { ...HARNESS, client: { version: "1" } },
96
+ { ...HARNESS, row: "a row without an issue" },
97
+ ]) {
98
+ const doc = await write(source);
99
+ expect(code(doc), JSON.stringify(source)).toBe("record-schema-invalid");
100
+ }
101
+ });
102
+
103
+ test("chant checks the shapes the schema can't: turns that end before they start", async () => {
104
+ const doc = await write({ ...HARNESS, turns: { from: 18, to: 12 } });
105
+ expect(doc).toMatchObject({ error: { code: "record-schema-invalid", message: expect.stringContaining("/source/turns ends before it starts") } });
106
+ expect(sourceBlockProblems({ ...HARNESS, turns: { from: 18, to: 12 } }, "source")).toEqual(["/source/turns ends before it starts"]);
107
+ expect(sourceBlockProblems({ issue: "o/r#1", row: "anything the kind says" }, "source")).toEqual([]);
108
+ });
109
+ });
110
+
111
+ describe("records new and records", () => {
112
+ test("records new --from with a source block writes it, and records --json returns it unchanged", async () => {
113
+ const doc = await write(HARNESS);
114
+ expect(code(doc)).toBe("ok");
115
+ const written = (await read()).find((r) => r.id === "ws-002");
116
+ expect(stableJson(written?.data?.source)).toBe(stableJson(HARNESS));
117
+ expect(written?.valid).toBe(true);
118
+ expect(written?.warnings.map((w) => w.code)).not.toContain("source-transcript-drift");
119
+ });
120
+
121
+ test("a harvested record opens proposed; any other state is refused with a remedy", async () => {
122
+ const decided = await write(
123
+ { ...HARNESS, via: "harvest" },
124
+ { state: "decided", choice: SAMPLE.choice, decided_by: "alice", decided_on: "2026-09-25" },
125
+ );
126
+ expect(decided).toMatchObject({ error: { code: "source-harvest-not-proposed", message: expect.stringContaining('write it with state "proposed"') } });
127
+ const withdrawn = await write({ ...HARNESS, via: "harvest" }, { state: "withdrawn" });
128
+ expect(code(withdrawn)).toBe("source-harvest-not-proposed");
129
+ expect(code(await write({ ...HARNESS, via: "harvest" }))).toBe("ok");
130
+ // Only a harvest opens proposed: a decision written at the CLI may still be decided in one go.
131
+ const cli = await write({ via: "cli" }, { title: "Three", state: "decided", choice: SAMPLE.choice, decided_by: "alice", decided_on: "2026-09-25" });
132
+ expect(code(cli)).toBe("ok");
133
+ });
134
+ });
135
+
136
+ describe("source-transcript-drift", () => {
137
+ test("warns when the transcript is here and its bytes changed, and not when they match or it is missing", async () => {
138
+ expect(code(await write(HARNESS))).toBe("ok");
139
+ const drift = async () => (await read()).find((r) => r.id === "ws-002")!.warnings.filter((w) => w.code === "source-transcript-drift");
140
+ expect(await drift()).toEqual([]);
141
+ writeFileSync(join(dir, "transcripts", "0f4c2a.jsonl"), TRANSCRIPT + '{"turn":2}\n');
142
+ const warned = await drift();
143
+ expect(warned).toHaveLength(1);
144
+ expect(warned[0].message).toContain("transcripts/0f4c2a.jsonl is pinned at sha256");
145
+ rmSync(join(dir, "transcripts", "0f4c2a.jsonl"));
146
+ expect(await drift()).toEqual([]);
147
+ });
148
+
149
+ test("reads an absolute path, ~/ and file: URIs, and never fetches another URI", () => {
150
+ const file = join(dir, "transcripts", "0f4c2a.jsonl");
151
+ const bad = "0".repeat(64);
152
+ expect(transcriptDrift({ transcript: { path: file, sha256: sha(TRANSCRIPT) } }, "source", "/nowhere")).toBeUndefined();
153
+ expect(transcriptDrift({ transcript: { path: file, sha256: bad } }, "source", "/nowhere")).toContain("is pinned at sha256 000000000000");
154
+ expect(transcriptDrift({ transcript: { uri: pathToFileURL(file).href, sha256: bad } }, "source", "/nowhere")).toContain("file:");
155
+ expect(transcriptDrift({ transcript: { uri: "https://example.com/t.jsonl", sha256: bad } }, "source", dir)).toBeUndefined();
156
+ expect(transcriptFile({ path: "~/t.jsonl" }, dir)).toMatch(/\/t\.jsonl$/);
157
+ expect(transcriptFile({ path: "t.jsonl" }, dir)).toBe(join(dir, "t.jsonl"));
158
+ });
159
+
160
+ test("a kind that does not opt in is not checked", async () => {
161
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
162
+ writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace(/\n\s*source: \{ field: "source" \},/, ""));
163
+ expect(code(await write({ ...HARNESS, turns: { from: 18, to: 12 } }))).toBe("ok");
164
+ writeFileSync(join(dir, "transcripts", "0f4c2a.jsonl"), "changed");
165
+ expect((await read()).flatMap((r) => r.warnings.map((w) => w.code))).not.toContain("source-transcript-drift");
166
+ });
167
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * A record's stored source block (#2708): where a proposal came from.
3
+ *
4
+ * chant's provenance (`trust/provenance.ts`) is computed from git: who
5
+ * committed a record, and with which key. It says nothing about the harness,
6
+ * model and session a decision was proposed in, or the conversation behind it.
7
+ * A record kind that declares `source: { field }` opts in to a stored block
8
+ * holding that, next to whatever else its source field says (a decision's
9
+ * issue row or workspace member, a work item's finding):
10
+ *
11
+ * - `via`: how the record reached the workspace, `cli`, `mcp` or `harvest`;
12
+ * - `client`: the MCP client's `clientInfo` (`name`, `version`), or the CLI;
13
+ * - `harness`: the harness's id, such as `claude-code` or `codex`;
14
+ * - `model`: the model id the harness reports;
15
+ * - `session`: the harness's session or conversation id, as a string, or as
16
+ * `{ id, record }` with the chant session record it was held in (#2697);
17
+ * - `turns`: the turn range the decision was made in, `{ from, to }`;
18
+ * - `transcript`: `{ path | uri, sha256 }`, pinning a transcript by hash and
19
+ * never copying it, the way evidence pins a file.
20
+ *
21
+ * Every field is optional, and the block is data about the proposal, not
22
+ * trust: nothing here raises or lowers a record's standing. The rules are
23
+ * three. The fields that are present must have these shapes
24
+ * (`record-schema-invalid` otherwise, beside the kind's own schema). A record
25
+ * written with `via: "harvest"` must open in the kind's first state, since a
26
+ * harvest proposes and a person decides (`source-harvest-not-proposed`, a
27
+ * write refusal). And `records` warns `source-transcript-drift` when the
28
+ * pinned transcript can be read here and its bytes hash to something else.
29
+ */
30
+
31
+ import { readFileSync } from "node:fs";
32
+ import { homedir } from "node:os";
33
+ import { isAbsolute, join, resolve } from "node:path";
34
+ import { fileURLToPath } from "node:url";
35
+ import { z } from "zod";
36
+ import { sha256Hex } from "../content-digest";
37
+
38
+ /** How a record reached the workspace. Closed. */
39
+ export const SOURCE_VIAS = ["cli", "mcp", "harvest"] as const;
40
+ export type SourceVia = (typeof SOURCE_VIAS)[number];
41
+
42
+ const text = z.string().min(1);
43
+ const sha256 = z.string().regex(/^[0-9a-f]{64}$/, "must be the lowercase hex SHA-256 of the transcript's bytes");
44
+
45
+ /** The proposal fields of a source block. Other fields of the block are the kind's, and left to its schema. */
46
+ export const sourceBlockSchema = z.object({
47
+ via: z.enum(SOURCE_VIAS).optional(),
48
+ client: z.object({ name: text, version: text.optional(), title: text.optional() }).strict().optional(),
49
+ harness: text.optional(),
50
+ model: text.optional(),
51
+ session: z
52
+ .union([
53
+ text,
54
+ z.null(),
55
+ z
56
+ .object({ id: text.optional(), record: text.optional() })
57
+ .strict()
58
+ .refine((s) => s.id !== undefined || s.record !== undefined, { message: "names neither the harness's session id nor a chant session record" }),
59
+ ])
60
+ .optional(),
61
+ turns: z
62
+ .object({ from: z.number().int().min(0), to: z.number().int().min(0) })
63
+ .strict()
64
+ .refine((t) => t.to >= t.from, { message: "ends before it starts" })
65
+ .optional(),
66
+ transcript: z
67
+ .union([z.object({ path: text, sha256 }).strict(), z.object({ uri: text, sha256 }).strict()])
68
+ .optional(),
69
+ });
70
+
71
+ export type SourceBlock = z.infer<typeof sourceBlockSchema>;
72
+
73
+ /** The source block of `data`, when `field` holds an object. */
74
+ export function sourceBlock(data: Record<string, unknown> | null, field: string): Record<string, unknown> | undefined {
75
+ const v = data?.[field];
76
+ return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
77
+ }
78
+
79
+ /** What is wrong with the proposal fields of `block`, as `<path> <message>` lines. Empty when nothing is. */
80
+ export function sourceBlockProblems(block: Record<string, unknown>, field: string): string[] {
81
+ const parsed = sourceBlockSchema.safeParse(block);
82
+ if (parsed.success) return [];
83
+ return parsed.error.issues.map((i) => `/${[field, ...i.path].join("/")} ${i.message}`);
84
+ }
85
+
86
+ /**
87
+ * The file a transcript pin names, when it can be read here: a `path`
88
+ * (absolute, `~/` from the home directory, or else from the workspace root)
89
+ * or a `file:` URI. Any other URI is not fetched, so it is never reachable.
90
+ */
91
+ export function transcriptFile(transcript: { path?: string; uri?: string }, workspaceDir: string): string | undefined {
92
+ if (transcript.path !== undefined) {
93
+ const p = transcript.path;
94
+ if (p === "~" || p.startsWith("~/")) return join(homedir(), p.slice(1));
95
+ return isAbsolute(p) ? p : resolve(workspaceDir, p);
96
+ }
97
+ if (transcript.uri !== undefined && transcript.uri.startsWith("file:")) {
98
+ try {
99
+ return fileURLToPath(transcript.uri);
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+ return undefined;
105
+ }
106
+
107
+ /**
108
+ * The `source-transcript-drift` message for `block`, or undefined: when its
109
+ * transcript pin is well formed, the file it names can be read here, and its
110
+ * bytes hash to something other than the pin. A transcript that can't be
111
+ * read says nothing either way.
112
+ */
113
+ export function transcriptDrift(block: Record<string, unknown>, field: string, workspaceDir: string): string | undefined {
114
+ const parsed = sourceBlockSchema.shape.transcript.safeParse(block.transcript);
115
+ if (!parsed.success || parsed.data === undefined) return undefined;
116
+ const t = parsed.data as { path?: string; uri?: string; sha256: string };
117
+ const file = transcriptFile(t, workspaceDir);
118
+ if (file === undefined) return undefined;
119
+ let bytes: Uint8Array;
120
+ try {
121
+ bytes = readFileSync(file);
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ const actual = sha256Hex(bytes);
126
+ if (actual === t.sha256) return undefined;
127
+ const named = t.path ?? t.uri!;
128
+ return `${field}.transcript ${named} is pinned at sha256 ${t.sha256.slice(0, 12)}, and the file here hashes to ${actual.slice(0, 12)}: it is not the transcript the record means`;
129
+ }
@@ -22,10 +22,15 @@ import {
22
22
  STATUS_GATE_REASON_CODES,
23
23
  STATUS_OUTPUT_SCHEMA_ID,
24
24
  STATUS_REASON_CODES,
25
+ STATUS_STEWARD_REASON_CODES,
25
26
  workspaceStatus,
26
27
  type StatusDocument,
27
28
  } from "./status";
28
29
  import schema from "./status.schema.json";
30
+ import { appendRunRecord } from "../lifecycle/run-ledger";
31
+ import { acquireStewardLease } from "../op/operator";
32
+ import { stewardWorkHolder } from "../op/work-lease-run";
33
+ import { claimWorkLease } from "../lifecycle/work-lease";
29
34
 
30
35
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
31
36
 
@@ -148,6 +153,7 @@ describe("status output schema", () => {
148
153
  expect(schema.$defs.environment.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_REASON_CODES]);
149
154
  expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...STATUS_ERROR_CODES]);
150
155
  expect(schema.$defs.gateLedger.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_GATE_REASON_CODES]);
156
+ expect(schema.$defs.member.properties.stewardReasons.items.properties.code.enum).toEqual([...STATUS_STEWARD_REASON_CODES]);
151
157
  });
152
158
  });
153
159
 
@@ -163,6 +169,8 @@ describe("chant workspace status on built workspaces", () => {
163
169
  expect(web.environments[0].ledger).toEqual({ layout: "members", path: "_members/web/staging/releases.jsonl", shared: false });
164
170
  // The later of the two records wins.
165
171
  expect(web.environments[0].releases.map((r) => [r.component, r.digest, r.gitSha])).toEqual([["web", D("a"), "a".repeat(40)]]);
172
+ // No plan was ever written for these digests (ws-055, #2733): the field is present, and null.
173
+ expect(web.environments[0].releases.map((r) => r.plan)).toEqual([null]);
166
174
  // api has no _members/api/ yet, so it falls back to the flat ledger, which the root member reads too.
167
175
  expect(api.environments[0].ledger).toEqual({ layout: "flat", path: "staging/releases.jsonl", shared: true });
168
176
  expect(site.environments[0].ledger).toEqual({ layout: "flat", path: "staging/releases.jsonl", shared: true });
@@ -199,6 +207,33 @@ describe("chant workspace status on built workspaces", () => {
199
207
  expect(text).toContain("3 members, 3 with a release in staging, 2 differ from prod, 0 unreadable");
200
208
  });
201
209
 
210
+ test("resolves a release's plan from _plans/<digest>.json, member-scoped per #2524 D7 (ws-055, #2733)", async () => {
211
+ const root = repo({
212
+ "chant.workspace.json": declaration([
213
+ { name: "site", dir: ".", kind: "other", because: "the root project" },
214
+ { name: "web", dir: "apps/web", kind: "chant" },
215
+ ]),
216
+ "apps/web/chant.config.ts": "",
217
+ });
218
+ const webPlan = { digest: D("a"), release: "r-web", units: [{ id: "w-1" }] };
219
+ const apiPlan = { digest: D("b"), release: "r-api", units: [] };
220
+ lifecycle(root, {
221
+ "_members/web/staging/releases.jsonl": jsonl(release("web", "staging", D("a"), "a".repeat(40))),
222
+ [`_members/web/_plans/sha256_${"a".repeat(64)}.json`]: JSON.stringify(webPlan),
223
+ "staging/releases.jsonl": jsonl(release("api", "staging", D("b"), "b".repeat(40)), release("worker", "staging", D("c"), "c".repeat(40))),
224
+ [`_plans/sha256_${"b".repeat(64)}.json`]: JSON.stringify(apiPlan),
225
+ });
226
+ const doc = result(await workspaceStatus({ cwd: root, env: "staging" }));
227
+ expectValid(doc);
228
+ const web = doc.members.find((m) => m.name === "web")!;
229
+ const site = doc.members.find((m) => m.name === "site")!;
230
+ expect(web.environments[0].releases[0].plan).toEqual(webPlan);
231
+ const byComponent = new Map(site.environments[0].releases.map((r) => [r.component, r]));
232
+ expect(byComponent.get("api")?.plan).toEqual(apiPlan);
233
+ // worker has no plan stored under its digest: reads null, not an error.
234
+ expect(byComponent.get("worker")?.plan).toBeNull();
235
+ });
236
+
202
237
  test("compares on the input digest when a release has one", async () => {
203
238
  const root = repo({ "chant.workspace.json": declaration([{ name: "chart", dir: "chart", kind: "other", because: "helm" }]) });
204
239
  lifecycle(root, {
@@ -253,6 +288,33 @@ describe("chant workspace status on built workspaces", () => {
253
288
  expect(formatStatus(doc)).toContain("web - no release");
254
289
  });
255
290
 
291
+ test("each member lists its box block: the capability, its broker and its scope (#2726)", async () => {
292
+ const root = repo({
293
+ "chant.workspace.json": declaration([
294
+ { name: "web", dir: "web", kind: "chant" },
295
+ {
296
+ name: "box",
297
+ dir: "box",
298
+ kind: "other",
299
+ because: "the box's declarations",
300
+ box: { capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent", "vault", "conversations", "sandboxes"] }, { name: "inference" }] },
301
+ },
302
+ ]),
303
+ });
304
+ const doc = result(await workspaceStatus({ cwd: root, env: "prod" }));
305
+ expectValid(doc);
306
+ expect(doc.members.map((m) => m.box)).toEqual([
307
+ null,
308
+ {
309
+ capabilities: [
310
+ { name: "fountain", broker: "lobby", scope: ["agent", "vault", "conversations", "sandboxes"] },
311
+ { name: "inference", broker: null, scope: [] },
312
+ ],
313
+ isolation: null,
314
+ },
315
+ ]);
316
+ });
317
+
256
318
  test("every failure validates with its code", async () => {
257
319
  const empty = repo({});
258
320
  const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-status-nogit-")));
@@ -459,6 +521,122 @@ describe("gate state in chant workspace status --json (#2674)", () => {
459
521
  });
460
522
  });
461
523
 
524
+ describe("stewards in chant workspace status --json (#2731)", () => {
525
+ const op = (name: string, cron?: string) => ({
526
+ name,
527
+ overview: name,
528
+ phases: [{ name: "Run", steps: [{ kind: "activity", fn: "noop", args: {} }] }],
529
+ ...(cron ? { schedule: { cron, overlap: "skip" } } : {}),
530
+ });
531
+
532
+ /** A box member declaring one steward, local by default and fountain on fountain-k3d. */
533
+ function stewarded(): string {
534
+ const steward = {
535
+ kind: "Chant::Steward",
536
+ name: "box-steward",
537
+ ops: [
538
+ op("box-converge", "* * * * *"),
539
+ op("box-release"),
540
+ { ...op("box-dispatch", "*/5 * * * *"), workLease: { item: ["W-1", "W-2"] }, changesCheckout: true },
541
+ ],
542
+ form: { default: "local", environments: { "fountain-k3d": "fountain" } },
543
+ capabilities: ["fountain", "inference"],
544
+ vault: null,
545
+ };
546
+ const root = repo({
547
+ "chant.workspace.json": declaration([
548
+ { name: "box", dir: "box", kind: "chant", box: { capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent", "vault"] }] } },
549
+ { name: "notes", dir: "notes", kind: "other", because: "prose" },
550
+ ]),
551
+ "box/chant.config.ts": "export default {};\n",
552
+ "box/ops/steward.op.ts": `export const steward = ${JSON.stringify(steward)};\n`,
553
+ "notes/README.md": "notes\n",
554
+ });
555
+ lifecycle(root, { "README": "ledger\n" });
556
+ return root;
557
+ }
558
+
559
+ test("names the steward, its form in the environment asked for, its Ops, schedules, last runs and lease", async () => {
560
+ const root = stewarded();
561
+ const box = join(root, "box");
562
+ await appendRunRecord(
563
+ { op: "box-converge", env: "local", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", status: "ok", labels: {}, outcomes: {}, phases: [] },
564
+ { cwd: box },
565
+ );
566
+ expect((await acquireStewardLease("box-steward", "box-host:1:abc", { cwd: box, now: () => new Date("2026-09-25T10:00:00.000Z") })).acquired).toBe(true);
567
+ // #2748: the dispatch turn holds W-1; someone else holds W-2.
568
+ const turn = await claimWorkLease("W-1", stewardWorkHolder("box-steward", "box-dispatch", "box-host:1:abc"), { cwd: box, ttlMs: 600_000, now: () => new Date("2026-09-25T10:00:30.000Z") });
569
+ expect(turn.ok).toBe(true);
570
+ expect((await claimWorkLease("W-2", "someone-else", { cwd: box, ttlMs: 600_000, now: () => new Date("2026-09-25T10:00:30.000Z") })).ok).toBe(true);
571
+
572
+ const doc = result(await workspaceStatus({ cwd: root, env: "minimal", now: "2026-09-25T10:01:00.000Z" }));
573
+ expectValid(doc);
574
+ const [boxMember, notes] = doc.members;
575
+ expect(notes.stewards).toEqual([]);
576
+ expect(boxMember.stewardReasons).toEqual([]);
577
+ expect(boxMember.stewards).toHaveLength(1);
578
+ const s = boxMember.stewards[0];
579
+ expect(s).toMatchObject({
580
+ name: "box-steward",
581
+ file: "ops/steward.op.ts",
582
+ form: "local",
583
+ forms: { default: "local", environments: { "fountain-k3d": "fountain" } },
584
+ // #2726: the steward holds no credential; its capabilities join the box block.
585
+ vault: null,
586
+ capabilities: [
587
+ { name: "fountain", broker: "lobby", declared: true },
588
+ { name: "inference", broker: null, declared: false },
589
+ ],
590
+ lease: { holder: "box-host:1:abc", acquiredAt: "2026-09-25T10:00:00.000Z", live: true },
591
+ });
592
+ expect(s.ops).toEqual([
593
+ {
594
+ name: "box-converge",
595
+ schedule: { cron: "* * * * *", overlap: "skip" },
596
+ env: "local",
597
+ lastRun: { id: expect.any(String), status: "ok", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", gate: null },
598
+ changesCheckout: false,
599
+ workLease: null,
600
+ },
601
+ { name: "box-release", schedule: null, env: "local", lastRun: null, changesCheckout: false, workLease: null },
602
+ {
603
+ name: "box-dispatch",
604
+ schedule: { cron: "*/5 * * * *", overlap: "skip" },
605
+ env: "local",
606
+ lastRun: null,
607
+ changesCheckout: true,
608
+ workLease: {
609
+ kind: null,
610
+ held: [
611
+ {
612
+ item: "W-1",
613
+ holder: "box-steward/box-dispatch@box-host:1:abc",
614
+ token: turn.ok ? turn.lease.token : "",
615
+ acquiredAt: "2026-09-25T10:00:30.000Z",
616
+ expiresAt: "2026-09-25T10:10:30.000Z",
617
+ state: "active",
618
+ },
619
+ ],
620
+ },
621
+ },
622
+ ]);
623
+
624
+ const k3d = result(await workspaceStatus({ cwd: root, env: "fountain-k3d", now: "2026-09-25T11:00:00.000Z" }));
625
+ expectValid(k3d);
626
+ expect(k3d.members[0].stewards[0].form).toBe("fountain");
627
+ expect(k3d.members[0].stewards[0].lease!.live).toBe(false);
628
+ });
629
+
630
+ test("an op file that can't be imported is a reason, and the read still succeeds", async () => {
631
+ const root = stewarded();
632
+ writeFileSync(join(root, "box", "ops", "broken.op.ts"), "export const x = ;\n");
633
+ const doc = result(await workspaceStatus({ cwd: root, env: "minimal" }));
634
+ expectValid(doc);
635
+ expect(doc.members[0].stewards.map((s) => s.name)).toEqual(["box-steward"]);
636
+ expect(doc.members[0].stewardReasons.map((r) => r.code)).toEqual(["stewards-unreadable"]);
637
+ });
638
+ });
639
+
462
640
  describe("chant workspace status on the chant repo (#2557)", () => {
463
641
  test("validates and lists every declared member", async () => {
464
642
  const doc = result(await workspaceStatus({ cwd: join(REPO, "packages", "core"), env: "prod", compareTo: "staging" }));