@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
@@ -49,6 +49,16 @@ vi.mock("../../lifecycle/build-ledger-store", () => ({
49
49
  readBuildManifest: (...args: unknown[]) => readBuildManifestMock(...args),
50
50
  }));
51
51
 
52
+ const persistReleasePlanMock = vi.fn();
53
+
54
+ vi.mock("../../lifecycle/plan-ledger", async () => {
55
+ const actual = await vi.importActual<typeof import("../../lifecycle/plan-ledger")>("../../lifecycle/plan-ledger");
56
+ return {
57
+ ...actual,
58
+ persistReleasePlan: (...args: unknown[]) => persistReleasePlanMock(...args),
59
+ };
60
+ });
61
+
52
62
  vi.mock("../../lifecycle/release-ledger", async () => {
53
63
  const actual = await vi.importActual<typeof import("../../lifecycle/release-ledger")>("../../lifecycle/release-ledger");
54
64
  return {
@@ -137,6 +147,7 @@ describe("components handlers", () => {
137
147
  discoverComponentsMock.mockReset().mockResolvedValue({ components: new Map(), sourceFiles: [], errors: [] });
138
148
  findBuildManifestByArtifactDigestMock.mockReset().mockResolvedValue(undefined);
139
149
  readBuildManifestMock.mockReset().mockResolvedValue(null);
150
+ persistReleasePlanMock.mockReset().mockResolvedValue({ commit: "a".repeat(40), written: true });
140
151
 
141
152
  delete process.env.GITHUB_RUN_ID;
142
153
  delete process.env.CI_PIPELINE_ID;
@@ -222,6 +233,88 @@ describe("components handlers", () => {
222
233
  await runComponentsReleaseRecord(ctx);
223
234
  expect(JSON.parse(stdoutBuf.join(""))).toMatchObject(record);
224
235
  });
236
+
237
+ describe("--plan (ws-055, #2733)", () => {
238
+ let planDir: string;
239
+
240
+ beforeEach(async () => {
241
+ planDir = await mkdtemp(join(tmpdir(), "chant-plan-"));
242
+ });
243
+
244
+ afterEach(async () => {
245
+ await rm(planDir, { recursive: true, force: true });
246
+ });
247
+
248
+ test("persists the plan and records its own digest, when --digest is omitted", async () => {
249
+ const planPath = join(planDir, "plan.json");
250
+ await writeFile(planPath, JSON.stringify({ digest: "sha256:fromplan", release: "r-1", units: [] }));
251
+ appendReleaseRecordMock.mockResolvedValue({
252
+ commit: "a".repeat(40),
253
+ record: { version: 1, component: "svc", env: "prod", digest: "sha256:fromplan", gitSha: "abc123headsha", runId: "local-123", timestamp: "2026-01-01T00:00:00.000Z", actor: "alice" },
254
+ });
255
+
256
+ const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
257
+ const exit = await runComponentsReleaseRecord(ctx);
258
+
259
+ expect(exit).toBe(0);
260
+ expect(persistReleasePlanMock).toHaveBeenCalledTimes(1);
261
+ expect(persistReleasePlanMock.mock.calls[0][0]).toEqual({ digest: "sha256:fromplan", release: "r-1", units: [] });
262
+ const [input] = appendReleaseRecordMock.mock.calls[0];
263
+ expect(input).toMatchObject({ digest: "sha256:fromplan" });
264
+ // The plan is persisted before the release record is appended, so the
265
+ // record's digest always names a plan this checkout can resolve.
266
+ expect(persistReleasePlanMock.mock.invocationCallOrder[0]).toBeLessThan(appendReleaseRecordMock.mock.invocationCallOrder[0]);
267
+ });
268
+
269
+ test("--digest agreeing with the plan's own digest is accepted", async () => {
270
+ const planPath = join(planDir, "plan.json");
271
+ await writeFile(planPath, JSON.stringify({ digest: "sha256:agree", release: "r-1" }));
272
+ appendReleaseRecordMock.mockResolvedValue({ commit: "a".repeat(40), record: { version: 1, component: "svc", env: "prod", digest: "sha256:agree", gitSha: "x", runId: "y", timestamp: "z", actor: "alice" } });
273
+
274
+ const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", digest: "sha256:agree", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
275
+ const exit = await runComponentsReleaseRecord(ctx);
276
+ expect(exit).toBe(0);
277
+ });
278
+
279
+ test("--digest disagreeing with the plan's own digest is refused", async () => {
280
+ const planPath = join(planDir, "plan.json");
281
+ await writeFile(planPath, JSON.stringify({ digest: "sha256:plandigest" }));
282
+
283
+ const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", digest: "sha256:other", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
284
+ const exit = await runComponentsReleaseRecord(ctx);
285
+ expect(exit).toBe(1);
286
+ expect(stderrBuf.join("\n")).toContain("does not match --release-plan");
287
+ expect(persistReleasePlanMock).not.toHaveBeenCalled();
288
+ expect(appendReleaseRecordMock).not.toHaveBeenCalled();
289
+ });
290
+
291
+ test("a plan file with no digest field is refused", async () => {
292
+ const planPath = join(planDir, "plan.json");
293
+ await writeFile(planPath, JSON.stringify({ release: "r-1" }));
294
+
295
+ const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
296
+ const exit = await runComponentsReleaseRecord(ctx);
297
+ expect(exit).toBe(1);
298
+ expect(stderrBuf.join("\n")).toContain("missing its own \"digest\" field");
299
+ });
300
+
301
+ test("a plan file that is not valid JSON is refused", async () => {
302
+ const planPath = join(planDir, "plan.json");
303
+ await writeFile(planPath, "{not json");
304
+
305
+ const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: planPath, actor: "alice" }), plugins: [], serializers: [] };
306
+ const exit = await runComponentsReleaseRecord(ctx);
307
+ expect(exit).toBe(1);
308
+ expect(stderrBuf.join("\n")).toContain("is not valid JSON");
309
+ });
310
+
311
+ test("a --plan path that can't be read is refused", async () => {
312
+ const ctx = { args: makeArgs({ extraPositional: "prod", component: "svc", releasePlanFile: join(planDir, "missing.json"), actor: "alice" }), plugins: [], serializers: [] };
313
+ const exit = await runComponentsReleaseRecord(ctx);
314
+ expect(exit).toBe(1);
315
+ expect(stderrBuf.join("\n")).toContain("Could not read --release-plan");
316
+ });
317
+ });
225
318
  });
226
319
 
227
320
  describe("runComponentsStatus", () => {
@@ -38,6 +38,7 @@ import {
38
38
  resolveRunId,
39
39
  InvalidReleaseRecordError,
40
40
  } from "../../lifecycle/release-ledger";
41
+ import { persistReleasePlan, InvalidReleasePlanError, type ReleasePlan } from "../../lifecycle/plan-ledger";
41
42
  import { reconcileStatus, liveEvidenceFromChangeSet, compareAcrossEnvironments, mergeLiveEvidence, type LiveComponentEvidence, type EntityLiveRead } from "../../lifecycle/status";
42
43
  import { commandBuildParams } from "../build-params-cli";
43
44
  import { buildChangeSet } from "../../lifecycle/change-set";
@@ -75,6 +76,14 @@ import { withoutReadFlags } from "../../lifecycle/legacy-digest";
75
76
  * never threaded in from elsewhere and never mocked in production code, per
76
77
  * #568: "the session cannot call Date.now() in some contexts; take the
77
78
  * timestamp from the environment/CLI at record time."
79
+ *
80
+ * `--release-plan <file>` (ws-055, #2733) takes `--digest`'s place: the file is a
81
+ * release plan JSON object naming its own `digest` field, persisted
82
+ * content-addressed to `_plans/<digest>.json` (../../lifecycle/plan-
83
+ * ledger.ts) before the release record is appended, so the record's digest
84
+ * always names a plan this checkout can resolve. Passing both requires them
85
+ * to agree — a plan is content-addressed by its own digest, so a caller
86
+ * naming a different one is a mistake, not an override.
78
87
  */
79
88
  export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<number> {
80
89
  const { args } = ctx;
@@ -87,11 +96,42 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
87
96
  }
88
97
 
89
98
  const component = args.component;
90
- const digest = args.digest;
99
+ let digest = args.digest;
100
+ let plan: ReleasePlan | undefined;
101
+ if (args.releasePlanFile) {
102
+ let raw: string;
103
+ try {
104
+ raw = await readFile(args.releasePlanFile, "utf-8");
105
+ } catch (err) {
106
+ console.error(formatError({ message: `Could not read --release-plan ${args.releasePlanFile}: ${err instanceof Error ? err.message : String(err)}` }));
107
+ return 1;
108
+ }
109
+ let parsed: unknown;
110
+ try {
111
+ parsed = JSON.parse(raw);
112
+ } catch (err) {
113
+ console.error(formatError({ message: `--release-plan ${args.releasePlanFile} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }));
114
+ return 1;
115
+ }
116
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
117
+ console.error(formatError({ message: `--release-plan ${args.releasePlanFile} must be a JSON object` }));
118
+ return 1;
119
+ }
120
+ plan = parsed as ReleasePlan;
121
+ if (typeof plan.digest !== "string" || plan.digest.length === 0) {
122
+ console.error(formatError({ message: `--release-plan ${args.releasePlanFile} is missing its own "digest" field — a release plan is content-addressed by its own digest` }));
123
+ return 1;
124
+ }
125
+ if (digest !== undefined && digest !== plan.digest) {
126
+ console.error(formatError({ message: `--digest ${digest} does not match --release-plan ${args.releasePlanFile}'s own digest ${plan.digest}` }));
127
+ return 1;
128
+ }
129
+ digest = plan.digest;
130
+ }
91
131
  if (!component || !digest) {
92
132
  console.error(formatError({
93
133
  message: "--component and --digest are required",
94
- hint: "chant components release <env> --component <name> --digest <sha256:...> [--git-sha <sha>] [--run-id <id>] [--actor <name>] [--approver <name>]",
134
+ hint: "chant components release <env> --component <name> --digest <sha256:...> [--release-plan <file>] [--git-sha <sha>] [--run-id <id>] [--actor <name>] [--approver <name>]",
95
135
  }));
96
136
  return 1;
97
137
  }
@@ -121,6 +161,7 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
121
161
  const timestamp = new Date().toISOString();
122
162
 
123
163
  try {
164
+ if (plan) await persistReleasePlan(plan);
124
165
  const { commit, record } = await appendReleaseRecord({
125
166
  component,
126
167
  env: environment,
@@ -142,7 +183,7 @@ export async function runComponentsReleaseRecord(ctx: CommandContext): Promise<n
142
183
  }
143
184
  return 0;
144
185
  } catch (err) {
145
- if (err instanceof InvalidReleaseRecordError) {
186
+ if (err instanceof InvalidReleaseRecordError || err instanceof InvalidReleasePlanError) {
146
187
  console.error(formatError({ message: err.message }));
147
188
  return 1;
148
189
  }
@@ -11,7 +11,8 @@ import { loadChantConfig } from "../../config";
11
11
  import { build } from "../../build";
12
12
  import { isResourceDeclarable } from "../../declarable";
13
13
  import { collectBuildRootContributors, collectChangeSubscribers } from "../plugins";
14
- import { discoverOps } from "../../op/discover";
14
+ import { discoverOps, discoverStewards } from "../../op/discover";
15
+ import { pickSteward, stewardFormFor, stewardLeaseName, DEFAULT_STEWARD_ENV } from "../../op/steward";
15
16
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
16
17
  import { parseDuration } from "../../op/local-executor";
17
18
  import {
@@ -21,11 +22,12 @@ import {
21
22
  formatRoundLine,
22
23
  formatSignalLine,
23
24
  DEFAULT_OPERATOR_INTERVAL_MS,
25
+ acquireStewardLease,
24
26
  type ChangeSubscriber,
25
27
  type OperatorSignalEvent,
26
28
  type OperatorTickEvent,
27
29
  } from "../../op/operator";
28
- import { readLease, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
30
+ import { readLease, releaseLease, currentHolderId, DEFAULT_LEASE_TTL_MS } from "../../lifecycle/lease";
29
31
  import { readConvergeLedger, type ConvergeTickRecord } from "../../lifecycle/converge-ledger";
30
32
  import { readRunLedger } from "../../lifecycle/run-ledger";
31
33
  import type { OpRunRecord } from "../../op/runtime";
@@ -141,6 +143,7 @@ async function collectOperatorSubscribers(
141
143
  * leaving the daemon running); omitted, the daemon loops until Ctrl-C.
142
144
  */
143
145
  export async function runOperator(ctx: CommandContext): Promise<number> {
146
+ if (ctx.args.steward !== undefined) return runStewardOperator(ctx);
144
147
  const { ops, errors } = await discoverConvergeOps({ env: ctx.args.env });
145
148
  for (const err of errors) console.error(formatWarning({ message: err }));
146
149
 
@@ -205,6 +208,108 @@ export async function runOperator(ctx: CommandContext): Promise<number> {
205
208
  }
206
209
  }
207
210
 
211
+ // ── chant operator --steward ────────────────────────────────────────────────
212
+
213
+ /**
214
+ * `chant operator --steward [<name>] [--env <env>] [--interval] [--lease-ttl]
215
+ * [--once]` (#2731) — a steward's local form. The same Ops the steward runs on
216
+ * Fountain, on the same crons, one at a time, each under its operator lease,
217
+ * and the whole process under the steward's own lease.
218
+ *
219
+ * Refuses, before ticking anything:
220
+ * - an environment whose form is `fountain`, because there the composite's
221
+ * Agent and Teammate are the steward and this process would be a second
222
+ * writer beside them;
223
+ * - a steward whose lease another live process holds (a second local
224
+ * operator for it).
225
+ */
226
+ export async function runStewardOperator(ctx: CommandContext): Promise<number> {
227
+ const { stewards, errors, conflicts } = await discoverStewards();
228
+ for (const err of errors) console.error(formatWarning({ message: err }));
229
+ for (const conflict of conflicts) console.error(formatError({ message: conflict }));
230
+
231
+ const picked = pickSteward(stewards, ctx.args.steward ?? "");
232
+ if (typeof picked === "string") {
233
+ console.error(formatError({ message: picked }));
234
+ return 1;
235
+ }
236
+ const steward = picked;
237
+ const env = ctx.args.env ?? DEFAULT_STEWARD_ENV;
238
+ const form = stewardFormFor(steward, env);
239
+ if (form !== "local") {
240
+ console.error(formatError({
241
+ message: `steward "${steward.name}" runs on ${form} in environment "${env}", so a local operator for it would be a second writer`,
242
+ hint: `Its turns run on the fountain teammate "${steward.name}". Run this in an environment the declaration's form makes local, or change the form.`,
243
+ }));
244
+ return 1;
245
+ }
246
+
247
+ let activities, profiles;
248
+ try {
249
+ [activities, profiles] = await loadOperatorActivities();
250
+ } catch (err) {
251
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
252
+ return 1;
253
+ }
254
+
255
+ const intervalMs = ctx.args.interval ? parseDuration(ctx.args.interval) : DEFAULT_OPERATOR_INTERVAL_MS;
256
+ const leaseTtlMs = ctx.args.leaseTtl ? parseDuration(ctx.args.leaseTtl) : DEFAULT_LEASE_TTL_MS;
257
+ const holder = currentHolderId();
258
+
259
+ // Refuse a second local operator up front rather than letting it loop on
260
+ // `steward-busy` rounds: one steward is one writer.
261
+ const first = await acquireStewardLease(steward.name, holder, { ttlMs: leaseTtlMs });
262
+ if (!first.acquired) {
263
+ console.error(formatError({
264
+ message: `steward "${steward.name}" is already running (lease held by ${first.heldBy?.holder ?? "another process"} until ${first.heldBy?.expiresAt ?? "?"})`,
265
+ hint: "One steward is one writer. Stop the other operator, or wait for its lease to expire.",
266
+ }));
267
+ return 1;
268
+ }
269
+
270
+ const controller = new AbortController();
271
+ const onSigint = () => {
272
+ console.error(formatWarning({ message: "interrupted — stopping steward" }));
273
+ controller.abort();
274
+ };
275
+ process.once("SIGINT", onSigint);
276
+ process.once("SIGTERM", onSigint);
277
+
278
+ const printRound = (events: OperatorTickEvent[]) => {
279
+ for (const event of events) console.error(formatInfo(formatRoundLine(event)));
280
+ };
281
+
282
+ const scheduled = steward.ops.filter((op) => op.schedule !== undefined);
283
+ try {
284
+ if (ctx.args.once) {
285
+ const events = await runOperatorRound({ steward, holder, leaseTtlMs, activities, profiles, signal: controller.signal });
286
+ printRound(events);
287
+ return events.some((e) => e.kind === "tick-failed" || e.kind === "steward-busy") ? 1 : 0;
288
+ }
289
+ console.error(formatInfo(
290
+ `chant operator: steward ${steward.name} (local) runs ${scheduled.length} scheduled Op(s) of ${steward.ops.length}, ` +
291
+ `checking every ${intervalMs}ms (Ctrl-C to stop)`,
292
+ ));
293
+ await runOperatorForever({
294
+ steward,
295
+ holder,
296
+ intervalMs,
297
+ leaseTtlMs,
298
+ activities,
299
+ profiles,
300
+ signal: controller.signal,
301
+ onRound: printRound,
302
+ });
303
+ return 0;
304
+ } finally {
305
+ process.removeListener("SIGINT", onSigint);
306
+ process.removeListener("SIGTERM", onSigint);
307
+ // A courtesy: the lease expires on its own if this never runs.
308
+ const { record } = await readLease(stewardLeaseName(steward.name)).catch(() => ({ record: undefined }));
309
+ if (record?.holder === holder) await releaseLease(stewardLeaseName(steward.name), holder, record.token).catch(() => false);
310
+ }
311
+ }
312
+
208
313
  // ── chant operator status ───────────────────────────────────────────────────
209
314
 
210
315
  interface OpStatusLine {
@@ -199,6 +199,25 @@ describe("runOp dispatcher", () => {
199
199
  stderrWrite.mockRestore();
200
200
  });
201
201
 
202
+ test("--work on an Op with no work lease → exit 1, nothing run (#2748)", async () => {
203
+ discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
204
+ const stderr = makeStderrSpy();
205
+ const exit = await runOp({ args: makeArgs({ path: "hello", work: "W-1" }), plugins: [], serializers: [] });
206
+ expect(exit).toBe(1);
207
+ expect(stderr.join("\n")).toContain('--work W-1: Op "hello" declares no work lease');
208
+ });
209
+
210
+ test("an Op whose work lease leaves the item to the run, run without --work → exit 1 naming the flag (#2748)", async () => {
211
+ discoverOpsMock.mockResolvedValue({
212
+ ops: new Map([["leased", { config: { name: "leased", overview: "leased", phases: [], workLease: {} } }]]),
213
+ errors: [],
214
+ });
215
+ const stderr = makeStderrSpy();
216
+ const exit = await runOp({ args: makeArgs({ path: "leased", holder: "me" }), plugins: [], serializers: [] });
217
+ expect(exit).toBe(1);
218
+ expect(stderr.join("\n")).toContain("chant run leased --work <id>");
219
+ });
220
+
202
221
  test("--report → exit 1 naming the removal, nothing run (#2116)", async () => {
203
222
  discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
204
223
  const stderr = makeStderrSpy();
@@ -2,7 +2,8 @@ import { lexiconNames } from "../../lexicon-module";
2
2
  import { resolve, dirname } from "node:path";
3
3
  import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
4
4
  import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
5
- import { discoverOps } from "../../op/discover";
5
+ import { discoverOps, discoverStewards } from "../../op/discover";
6
+ import { stewardWorkHolder } from "../../op/work-lease-run";
6
7
  import type { OpConfig } from "../../op/types";
7
8
  import { loadActivities, loadProfiles } from "../../op/activity-registry";
8
9
  import { runOpLocally, findPolicyGateStep, OpRunFailure, type StepRecord } from "../../op/local-executor";
@@ -866,6 +867,12 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
866
867
  const runtime = await resolveOpRuntime(ctx);
867
868
  if (!runtime) return 1;
868
869
 
870
+ // The work lease (#2748). `--work` names the item an Op with a work lease
871
+ // runs under; it is taken where the run executes, which for a hosted
872
+ // runtime is not this process.
873
+ const work = await resolveRunWork(ctx, config, runtime.name);
874
+ if (work === null) return 1;
875
+
869
876
  // `--progress-json` streams one NDJSON StepRecord per settled step, fed by
870
877
  // whatever the runtime reports through `progress`.
871
878
  const progress = ctx.args.progressJson ? ndjsonProgressSink<StepRecord>() : undefined;
@@ -885,6 +892,7 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
885
892
  ...(ctx.args.profile !== undefined ? { profile: ctx.args.profile } : {}),
886
893
  progress,
887
894
  signal: controller.signal,
895
+ ...(work ? { work } : {}),
888
896
  });
889
897
  const status = await handle.result();
890
898
 
@@ -945,6 +953,50 @@ export async function runOpOnRuntime(ctx: CommandContext): Promise<number> {
945
953
  }
946
954
  }
947
955
 
956
+ /**
957
+ * The work item and holder a `chant run` passes to the runtime (#2748), or
958
+ * `null` after printing why the run is refused. The holder is `--holder`, or,
959
+ * for an Op a steward lists, `<steward>/<op>@<process>` so `workspace status`
960
+ * shows the lease beside the steward's Ops, or this process's id.
961
+ */
962
+ async function resolveRunWork(
963
+ ctx: CommandContext,
964
+ config: OpConfig,
965
+ runtime: string,
966
+ ): Promise<{ item?: string; holder?: string } | undefined | null> {
967
+ const item = ctx.args.work;
968
+ if (!config.workLease) {
969
+ if (item === undefined) return undefined;
970
+ console.error(formatError({
971
+ message: `--work ${item}: Op "${config.name}" declares no work lease`,
972
+ hint: "Declare workLease on the Op to run it under a work item's lease.",
973
+ }));
974
+ return null;
975
+ }
976
+ if (runtime !== "local" && (item !== undefined || ctx.args.holder !== undefined)) {
977
+ console.error(formatError({
978
+ message: `--work and --holder run on the local runtime; "${runtime}" takes the work lease where it executes the run`,
979
+ hint: "Omit --on, or name the item in the Op's workLease.",
980
+ }));
981
+ return null;
982
+ }
983
+ let holder = ctx.args.holder;
984
+ if (holder === undefined) {
985
+ try {
986
+ const { stewards } = await discoverStewards();
987
+ for (const { declaration } of stewards.values()) {
988
+ if (declaration.ops.some((op) => op.name === config.name)) {
989
+ holder = stewardWorkHolder(declaration.name, config.name);
990
+ break;
991
+ }
992
+ }
993
+ } catch {
994
+ // No steward can be read: the run holds the lease as this process.
995
+ }
996
+ }
997
+ return { ...(item !== undefined ? { item } : {}), ...(holder !== undefined ? { holder } : {}) };
998
+ }
999
+
948
1000
  // ── fallback ────────────────────────────────────────────────────────────────���─
949
1001
 
950
1002
  export function runOpUnknown(ctx: CommandContext): Promise<number> {
@@ -20,7 +20,8 @@ export async function runServeMcp(ctx: CommandContext): Promise<number> {
20
20
  const { loadWorkspacePlugins } = await import("../mcp/workspace-plugins");
21
21
  ({ plugins, instructions } = await loadWorkspacePlugins(process.cwd()));
22
22
  }
23
- const server = new McpServer(plugins, { instructions });
23
+ // #2707 — at or inside a declared workspace, the workspace tools are served too.
24
+ const server = new McpServer(plugins, { instructions, workspace: { cwd: process.cwd() } });
24
25
  await server.start();
25
26
  await new Promise(() => {});
26
27
  return 0; // unreachable
@@ -276,6 +276,18 @@ describe("parseArgs", () => {
276
276
  expect(result.actor).toBe("alice");
277
277
  });
278
278
 
279
+ test("parses --release-plan for components release, distinct from --plan (#2300)'s approval-digest flag (ws-055, #2733)", () => {
280
+ const result = parseArgs([
281
+ "components", "release", "prod",
282
+ "--component", "search-service",
283
+ "--release-plan", "plan.json",
284
+ "--actor", "alice",
285
+ ]);
286
+ expect(result.releasePlanFile).toBe("plan.json");
287
+ expect(result.digest).toBeUndefined();
288
+ expect(result.plan).toBeUndefined();
289
+ });
290
+
279
291
  test("--digest repeats for components promote, and --digest-file is parsed (#2602)", () => {
280
292
  const promote = parseArgs([
281
293
  "components", "promote", "--from", "staging", "--to", "prod",
@@ -734,13 +746,28 @@ describe("workspace init and ls (#2534)", () => {
734
746
  "workspace lint",
735
747
  "workspace ls",
736
748
  "workspace member-run",
749
+ "workspace points",
737
750
  "workspace records",
738
751
  "workspace status",
739
752
  "workspace upgrade",
740
753
  "workspace verify",
754
+ "workspace work",
741
755
  ]);
742
756
  });
743
757
 
758
+ test("workspace work takes its verb, id and lease flags (#2732)", () => {
759
+ const claim = parseArgs(["workspace", "work", "claim", "W-001", "--holder", "a", "--ttl", "60", "--json"]);
760
+ expect(claim).toMatchObject({ command: "workspace", path: "work", extraPositional: "claim", extraPositional2: "W-001", holder: "a", ttl: "60", json: true });
761
+ expect(resolveCommand(claim, commandRegistry)?.def.name).toBe("workspace work");
762
+ expect(parseArgs(["workspace", "work", "release", "W-001", "--holder=a", "--token", "t", "--outcome", "done"])).toMatchObject({ holder: "a", token: "t", outcome: "done" });
763
+ expect(() => parseArgs(["workspace", "work", "claim", "W-001", "--holder"])).toThrow(/--holder needs a value/);
764
+ });
765
+
766
+ test("run takes --work <id> for an Op with a work lease (#2748)", () => {
767
+ expect(parseArgs(["run", "dispatch", "--work", "W-001", "--holder", "box/dispatch@h"])).toMatchObject({ command: "run", path: "dispatch", work: "W-001", holder: "box/dispatch@h" });
768
+ expect(() => parseArgs(["run", "dispatch", "--work"])).toThrow(/--work needs a work item id/);
769
+ });
770
+
744
771
  test("load their modules only when they run", () => {
745
772
  const source = readFileSync(join(import.meta.dirname, "main.ts"), "utf-8");
746
773
  expect(source).toMatch(/await import\("\.\.\/workspace\/init"\)/);
@@ -748,6 +775,19 @@ describe("workspace init and ls (#2534)", () => {
748
775
  });
749
776
  });
750
777
 
778
+ describe("workspace points (ws-058, #2739)", () => {
779
+ test("ask and answer take their flags, and --by repeats", () => {
780
+ const ask = parseArgs(["workspace", "points", "ask", "slice-tier", "--inputs", "in.json", "--response", "r.json", "--subject", "W-001", "--dry-run"]);
781
+ expect(ask).toMatchObject({ path: "points", extraPositional: "ask", extraPositional2: "slice-tier", inputs: "in.json", response: "r.json", subject: "W-001", dryRun: true });
782
+ expect(resolveCommand(ask, commandRegistry)?.def.name).toBe("workspace points");
783
+ expect(parseArgs(["workspace", "points", "ask", "p", "--inputs", "-"]).inputs).toBe("-");
784
+ const answer = parseArgs(["workspace", "points", "answer", "slice-tier-0123456789ab", "--answer", "medium", "--by", "alice", "--by", "bob"]);
785
+ expect(answer).toMatchObject({ extraPositional: "answer", answer: "medium", bys: ["alice", "bob"] });
786
+ expect(parseArgs(["workspace", "points", "--open", "--json"])).toMatchObject({ open: true, json: true });
787
+ expect(() => parseArgs(["workspace", "points", "ask", "p", "--response", "--json"])).toThrow(/--response needs a JSON file/);
788
+ });
789
+ });
790
+
751
791
  describe("workspace status (#2544)", () => {
752
792
  test("takes the environment as extra positional, a directory after it, and --compare-to", () => {
753
793
  const status = parseArgs(["workspace", "status", "staging", "apps", "--compare-to", "prod", "--json"]);