@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
@@ -24,7 +24,11 @@
24
24
  * The JSON also lists each member's gates, read from its gate ledger on the
25
25
  * same branch (#2674, `status-gates.ts`): the state of each, the approvals
26
26
  * that count, and the `chant approve` line that answers it. The text view
27
- * doesn't show them.
27
+ * doesn't show them. So does each member's box block (#2726): the
28
+ * capabilities it reaches through a broker, with the broker and the scope a
29
+ * broker enforces, and, when the block names a host, the box's ports, state
30
+ * paths and cookie names resolved from its identity (#2727,
31
+ * `box-isolation.ts`), so a runtime reads them instead of choosing its own.
28
32
  */
29
33
 
30
34
  import { execFileSync } from "node:child_process";
@@ -35,10 +39,14 @@ import type { CommandContext } from "../cli/registry";
35
39
  import { GATES_DIR } from "../lifecycle/gate-ledger";
36
40
  import { readPathSha } from "../lifecycle/git";
37
41
  import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
42
+ import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
43
+ import { listWorkLeases, type WorkLeaseState } from "../lifecycle/work-lease";
38
44
  import { findWorkspaceRoot } from "../project-root";
45
+ import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
39
46
  import { readDeclaration, readerVersion, WorkspaceReadError, type ErrorLocation, type Member } from "./declaration";
40
47
  import type { ReasonCode } from "./reason-codes";
41
48
  import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
49
+ import { STEWARD_REASON_CODES, readMemberStewards, type StatusSteward, type StewardReasonCode } from "./status-stewards";
42
50
  import { gitTop, workingTree } from "./tree";
43
51
  import { handToRootChant } from "./which-chant";
44
52
 
@@ -71,6 +79,9 @@ export type StatusReasonCode = (typeof STATUS_REASON_CODES)[number];
71
79
  /** Why a member's gates can't be listed (#2674). Closed, like {@link STATUS_REASON_CODES}. */
72
80
  export const STATUS_GATE_REASON_CODES = GATE_REASON_CODES;
73
81
 
82
+ /** Why a member's stewards can't be fully listed (#2731). Closed, like {@link STATUS_REASON_CODES}. */
83
+ export const STATUS_STEWARD_REASON_CODES = STEWARD_REASON_CODES;
84
+
74
85
  /**
75
86
  * Why the status couldn't be read at all. The declaration's own codes, except
76
87
  * the two that only `--at` returns, and one for the environment name.
@@ -104,6 +115,15 @@ export interface StatusRelease {
104
115
  actor: string;
105
116
  /** Read flags, such as `legacy-digest` (#2514). */
106
117
  flags: string[];
118
+ /**
119
+ * The release plan `digest` names, read from `_plans/<digest>.json` on
120
+ * chant/lifecycle (ws-055, #2733) — the work items and evidence this
121
+ * release shipped, without the runner's own files. Null when this
122
+ * checkout has no plan stored under that digest: an ordinary component
123
+ * release (most releases carry no plan at all), or a plan this checkout
124
+ * hasn't fetched yet.
125
+ */
126
+ plan: ReleasePlan | null;
107
127
  }
108
128
 
109
129
  export interface StatusLedger {
@@ -145,6 +165,29 @@ export interface StatusMember {
145
165
  gateLedger: StatusGateLedger;
146
166
  /** Each gate in the member's gate ledger, one per environment asked for, sorted by component then gate. */
147
167
  gates: StatusGate[];
168
+ /** The member's box block as declared, or null when it declares none (#2726). A broker reads the scopes it enforces here. */
169
+ box: StatusBox | null;
170
+ /** The stewards the member declares (#2731), with their form in `env`, their Ops and each Op's last run. Sorted by name. */
171
+ stewards: StatusSteward[];
172
+ /** Why a steward or a last run may be missing from `stewards`. */
173
+ stewardReasons: { code: StewardReasonCode; message: string }[];
174
+ }
175
+
176
+ /** A box's brokered capabilities, from the declaration (#2726). */
177
+ export interface StatusBox {
178
+ capabilities: { name: string; broker: string | null; scope: string[] }[];
179
+ /** The box's ports, state paths and cookie names, resolved from its identity, or null when the block declares no host (#2727). */
180
+ isolation: ResolvedIsolation | null;
181
+ }
182
+
183
+ /**
184
+ * One work lease (#2732): a work item's lease ref in the ledger of the member
185
+ * that owns its work kind, read from the local refs without fetching.
186
+ */
187
+ export interface StatusLease extends Omit<WorkLeaseState, "ref"> {
188
+ /** The member whose ledger holds the lease, or null for the flat ledger (the root member, or a kind no member owns). */
189
+ member: string | null;
190
+ ref: string;
148
191
  }
149
192
 
150
193
  export type StatusDocument =
@@ -157,6 +200,8 @@ export type StatusDocument =
157
200
  lifecycle: { ref: string; commit: string | null };
158
201
  workspace: { name: string; root: string; file: string };
159
202
  members: StatusMember[];
203
+ /** Every work lease in the workspace's ledgers, active and expired, by member then item (#2732). A released lease has no ref and is not listed. */
204
+ leases: StatusLease[];
160
205
  summary: { members: number; released: number; unreadable: number; differing: number | null };
161
206
  }
162
207
  | {
@@ -179,6 +224,8 @@ export interface StatusQuery {
179
224
  readGates?: GateLedgerReader;
180
225
  /** The instant a gate's expiry is measured against; now by default. */
181
226
  now?: string;
227
+ /** Reads one member's stewards; the declaration and ledger reader unless a test swaps it. */
228
+ readStewards?: typeof readMemberStewards;
182
229
  }
183
230
 
184
231
  class StatusError extends Error {
@@ -240,8 +287,16 @@ async function readEnvironment(member: Member, env: string, cwd: string, read: L
240
287
  reason: { code: "ledger-unreadable", message: `${ledger.path}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}` },
241
288
  };
242
289
  }
243
- const releases = [...latestPerComponent(records).values()]
244
- .map((r) => ({
290
+ // A member's plans live beside its other lifecycle stores (#2524 D7,
291
+ // #2538): _members/<member>/_plans/ for the "members" layout, the flat
292
+ // top-level _plans/ for "flat" — the same split ledger.path already
293
+ // resolved above for releases.jsonl, mirrored here since readReleasePlan
294
+ // reads through one process for every member without changing cwd per
295
+ // member (see plan-ledger.ts's readReleasePlan doc).
296
+ const planPrefix = ledger.layout === "members" ? `${MEMBERS_DIR}/${member.name}/` : "";
297
+ const latest = [...latestPerComponent(records).values()].sort((a, b) => (a.component < b.component ? -1 : a.component > b.component ? 1 : 0));
298
+ const releases: StatusRelease[] = await Promise.all(
299
+ latest.map(async (r) => ({
245
300
  component: r.component,
246
301
  digest: r.digest,
247
302
  gitSha: r.gitSha,
@@ -250,8 +305,9 @@ async function readEnvironment(member: Member, env: string, cwd: string, read: L
250
305
  timestamp: r.timestamp,
251
306
  actor: r.actor,
252
307
  flags: [...(r.flags ?? [])],
253
- }))
254
- .sort((a, b) => (a.component < b.component ? -1 : a.component > b.component ? 1 : 0));
308
+ plan: await readReleasePlan(r.digest, { cwd, prefix: planPrefix }),
309
+ })),
310
+ );
255
311
  return {
256
312
  env,
257
313
  ledger: { ...ledger, shared: false },
@@ -303,12 +359,14 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
303
359
  const commit = lifecycleTip(found.dir);
304
360
  const now = query.now ?? new Date().toISOString();
305
361
 
362
+ const isolation = new Map(resolveBoxes(declaration).map((b) => [b.member.name, b.isolation]));
306
363
  const members: StatusMember[] = [];
307
364
  for (const m of declaration.members) {
308
365
  const environments: StatusEnvironment[] = [];
309
366
  for (const env of envs) environments.push(await readEnvironment(m, env, found.dir, read));
310
367
  const own = await hasMemberLedger(m, found.dir);
311
368
  const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates);
369
+ const stewards = await (query.readStewards ?? readMemberStewards)(resolve(found.dir, m.dir), query.env, now, m.kind, m.box);
312
370
  members.push({
313
371
  name: m.name,
314
372
  dir: m.dir,
@@ -318,6 +376,15 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
318
376
  readable: environments.every((e) => e.reason === null),
319
377
  gateLedger: gates.ledger,
320
378
  gates: gates.gates,
379
+ box:
380
+ m.box === null
381
+ ? null
382
+ : {
383
+ capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
384
+ isolation: isolation.get(m.name) ?? null,
385
+ },
386
+ stewards: stewards.stewards,
387
+ stewardReasons: stewards.reasons,
321
388
  });
322
389
  }
323
390
  // Several members read from one flat ledger see the same records; say so.
@@ -327,6 +394,8 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
327
394
  const flatGates = members.filter((m) => m.gateLedger.layout === "flat").length;
328
395
  for (const m of members) m.gateLedger.shared = m.gateLedger.layout === "flat" && flatGates > 1;
329
396
 
397
+ const leases = await readWorkspaceLeases(declaration.members, found.dir, new Date(now));
398
+
330
399
  return {
331
400
  ...head,
332
401
  env: query.env,
@@ -334,6 +403,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
334
403
  lifecycle: { ref: LIFECYCLE_REF, commit },
335
404
  workspace: { name: declaration.name, root: rootDir === "" ? "." : rootDir, file: declaration.file },
336
405
  members,
406
+ leases,
337
407
  summary: {
338
408
  members: members.length,
339
409
  released: members.filter((m) => m.environments[0].releases.length > 0).length,
@@ -351,6 +421,21 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
351
421
  }
352
422
  }
353
423
 
424
+ /**
425
+ * The work leases of the flat ledger and of each member's (#2732). Leases are
426
+ * env-independent, so the environment asked for doesn't narrow them. Never
427
+ * fetches: the remote's leases are the ones last fetched.
428
+ */
429
+ async function readWorkspaceLeases(members: Member[], cwd: string, now: Date): Promise<StatusLease[]> {
430
+ const ledgers: { member: string | null; prefix: string }[] = [{ member: null, prefix: "" }];
431
+ for (const m of members) if (m.dir !== ".") ledgers.push({ member: m.name, prefix: `${MEMBERS_DIR}/${m.name}/` });
432
+ const out: StatusLease[] = [];
433
+ for (const l of ledgers) {
434
+ for (const lease of await listWorkLeases({ cwd, memberPrefix: l.prefix, now })) out.push({ ...lease, member: l.member });
435
+ }
436
+ return out;
437
+ }
438
+
354
439
  export async function runWorkspaceStatus(ctx: CommandContext): Promise<number> {
355
440
  const { args } = ctx;
356
441
  const env = args.extraPositional;
@@ -434,6 +519,22 @@ export function formatStatus(doc: Extract<StatusDocument, { members: unknown }>)
434
519
  for (const n of notes.get(i) ?? []) lines.push(n);
435
520
  });
436
521
  }
522
+ if (doc.leases.length > 0) {
523
+ lines.push("");
524
+ const rows: string[][] = [["WORK ITEM", "HOLDER", "EXPIRES", "STATE"]];
525
+ for (const l of doc.leases) rows.push([l.member ? `${l.member}/${l.item}` : l.item, l.holder, l.expiresAt, l.state]);
526
+ lines.push(...table(rows));
527
+ }
528
+ const boxes = doc.members.filter((m) => m.box?.isolation);
529
+ if (boxes.length > 0) {
530
+ lines.push("");
531
+ const rows: string[][] = [["BOX", "HOST", "SLOT", "PORTS", "STATE"]];
532
+ for (const m of boxes) {
533
+ const i = m.box!.isolation!;
534
+ rows.push([m.name, i.host, String(i.slot), `${i.portRange.from}-${i.portRange.to}`, i.stateDir]);
535
+ }
536
+ lines.push(...table(rows));
537
+ }
437
538
  const s = doc.summary;
438
539
  lines.push("");
439
540
  lines.push(
@@ -0,0 +1,180 @@
1
+ /**
2
+ * #2732 — `chant workspace work claim|renew|release`, run as the CLI: two
3
+ * processes racing for one work item, the lease in `records --json` and
4
+ * `workspace status --json`, and both over MCP.
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
12
+ import { McpServer } from "../cli/mcp/server";
13
+ import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
14
+ import recordsSchema from "./records.schema.json";
15
+ import statusSchema from "./status.schema.json";
16
+ import workSchema from "./work-lease.schema.json";
17
+
18
+ const REF = join(REPO, "reference-workspace");
19
+ const MAIN = join(REPO, "packages/core/src/cli/main.ts");
20
+ const LOADER = pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href;
21
+ const CHANT = [process.execPath, "--import", LOADER, MAIN];
22
+ const WORK = "work/work.kind.mjs";
23
+
24
+ function work(id: string, fields: Record<string, unknown> = {}): string {
25
+ const data = { schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: ["path:app/server.mjs"], evidence: [], opened_on: "2026-09-25", source: { kind: "workspace", member: "app" }, supersedes: [], ...fields };
26
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
27
+ }
28
+
29
+ interface Run {
30
+ code: number | null;
31
+ stdout: string;
32
+ stderr: string;
33
+ }
34
+
35
+ function chant(cwd: string, args: string[]): Promise<Run> {
36
+ return new Promise((resolve) => {
37
+ const child = spawn(CHANT[0], [...CHANT.slice(1), ...args], { cwd, env: { ...process.env, NO_COLOR: "1", TSX_DISABLE_CACHE: "1" } });
38
+ let stdout = "";
39
+ let stderr = "";
40
+ child.stdout.on("data", (d) => (stdout += d));
41
+ child.stderr.on("data", (d) => (stderr += d));
42
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
43
+ });
44
+ }
45
+
46
+ const json = (r: Run) => {
47
+ const doc = JSON.parse(r.stdout);
48
+ if ("item" in doc || "error" in doc) contract(workSchema).expectValid(doc);
49
+ return doc;
50
+ };
51
+
52
+ let root: string;
53
+ beforeAll(() => {
54
+ root = repo({
55
+ "chant.workspace.json": JSON.stringify(
56
+ {
57
+ name: "studio",
58
+ schema: 1,
59
+ members: [{ name: "app", dir: "app", kind: "other", because: "a plain Node server" }],
60
+ records: [{ kind: "decisions/decision.kind.mjs" }, { kind: WORK }],
61
+ },
62
+ null,
63
+ 2,
64
+ ),
65
+ "app/server.mjs": "export const port = 8080;\n",
66
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
67
+ "decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
68
+ "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
69
+ "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
70
+ "work/W-001-server.md": work("W-001"),
71
+ "work/W-002-next.md": work("W-002"),
72
+ "work/W-003-done.md": work("W-003", { state: "done", closed_on: "2026-09-25", evidence: [{ title: "The review", url: "https://example.com/review" }] }),
73
+ });
74
+ git(root, "add", "-A");
75
+ git(root, "commit", "-q", "-m", "the queue");
76
+ });
77
+ afterAll(cleanScratch);
78
+
79
+ describe("chant workspace work (#2732)", () => {
80
+ test("two processes claiming one work item: one wins, and the other is refused with the holder named", async () => {
81
+ const [a, b] = await Promise.all([
82
+ chant(root, ["workspace", "work", "claim", "W-001", "--holder", "worker-a", "--ttl", "600", "--json"]),
83
+ chant(root, ["workspace", "work", "claim", "W-001", "--holder", "worker-b", "--ttl", "600", "--json"]),
84
+ ]);
85
+ const runs = [a, b];
86
+ const won = runs.filter((r) => r.code === 0);
87
+ const lost = runs.filter((r) => r.code === 2);
88
+ expect(won, `${a.stderr}\n${b.stderr}`).toHaveLength(1);
89
+ expect(lost).toHaveLength(1);
90
+ const winner = json(won[0]);
91
+ expect(winner).toMatchObject({ item: "W-001", event: "claim", kind: WORK, ref: "refs/chant/lease/work/W-001", history: { path: "_leases/W-001.jsonl" } });
92
+ const refusal = json(lost[0]);
93
+ expect(refusal.refused.heldBy.holder).toBe(winner.lease.holder);
94
+ expect(refusal.refused.code).toMatch(/^lease-(held|race)$/);
95
+ expect(refusal.refused.message).toContain(winner.lease.holder);
96
+ expect(git(root, "show", "chant/lifecycle:_leases/W-001.jsonl").split("\n")).toHaveLength(1);
97
+ }, 120_000);
98
+
99
+ test("records --json gives each work item its active lease, and status --json lists active and expired leases", async () => {
100
+ const claim = await chant(root, ["workspace", "work", "claim", "W-002", "--holder", "worker-c", "--ttl", "1", "--json"]);
101
+ expect(claim.code, claim.stderr).toBe(0);
102
+ await new Promise((r) => setTimeout(r, 1_100));
103
+
104
+ const records = await chant(root, ["workspace", "records", "--kind", WORK, "--json"]);
105
+ expect(records.code, records.stderr).toBe(0);
106
+ const doc = json(records);
107
+ contract(recordsSchema).expectValid(doc);
108
+ const byId = new Map((doc.records as { id: string; lease?: unknown }[]).map((r) => [r.id, r.lease]));
109
+ expect(byId.get("W-001")).toMatchObject({ holder: expect.stringMatching(/^worker-[ab]$/) });
110
+ expect(byId.get("W-002")).toBeNull(); // expired, so not active
111
+ expect(byId.get("W-003")).toBeNull();
112
+
113
+ const status = await chant(root, ["workspace", "status", "dev", "--json"]);
114
+ expect(status.code, status.stderr).toBe(0);
115
+ const sdoc = json(status);
116
+ contract(statusSchema).expectValid(sdoc);
117
+ expect(sdoc.leases.map((l: { item: string; state: string; member: string | null }) => [l.item, l.state, l.member])).toEqual([
118
+ ["W-001", "active", null],
119
+ ["W-002", "expired", null],
120
+ ]);
121
+
122
+ // An expired lease can be claimed again, with a new token.
123
+ const again = await chant(root, ["workspace", "work", "claim", "W-002", "--holder", "worker-d", "--json"]);
124
+ expect(again.code, again.stderr).toBe(0);
125
+ expect(json(again).lease.token).not.toBe(json(claim).lease.token);
126
+ }, 120_000);
127
+
128
+ test("renew keeps the token and moves the expiry; release frees the item; the history has a line for each", async () => {
129
+ const claim = json(await chant(root, ["workspace", "work", "claim", "W-001", "--holder", "nobody", "--json"]));
130
+ const holder = claim.refused.heldBy.holder as string;
131
+ const token = claim.refused.heldBy.token as string;
132
+
133
+ const renew = await chant(root, ["workspace", "work", "renew", "W-001", "--holder", holder, "--token", token, "--ttl", "20m", "--json"]);
134
+ expect(renew.code, renew.stderr).toBe(0);
135
+ const renewed = json(renew).lease;
136
+ expect(renewed.token).toBe(token);
137
+ expect(Date.parse(renewed.expiresAt)).toBeGreaterThan(Date.parse(claim.refused.heldBy.expiresAt));
138
+
139
+ const wrong = await chant(root, ["workspace", "work", "release", "W-001", "--holder", "nobody", "--json"]);
140
+ expect(wrong.code).toBe(2);
141
+ expect(json(wrong).refused.code).toBe("lease-held");
142
+
143
+ const release = await chant(root, ["workspace", "work", "release", "W-001", "--holder", holder, "--outcome", "done"]);
144
+ expect(release.code, release.stderr).toBe(0);
145
+ expect(release.stdout).toContain("W-001 released by");
146
+
147
+ const events = git(root, "show", "chant/lifecycle:_leases/W-001.jsonl").split("\n").map((l) => JSON.parse(l));
148
+ expect(events.map((e) => e.event)).toEqual(["claim", "renew", "release"]);
149
+ expect(events[2]).toMatchObject({ holder, by: holder, outcome: "done", token });
150
+ }, 120_000);
151
+
152
+ test("an unknown item, a closed item and a bad verb are errors, not refusals", async () => {
153
+ const unknown = await chant(root, ["workspace", "work", "claim", "W-999", "--holder", "a", "--json"]);
154
+ expect(unknown.code).toBe(1);
155
+ expect(json(unknown).error.code).toBe("work-item-unknown");
156
+ const closed = await chant(root, ["workspace", "work", "claim", "W-003", "--holder", "a", "--json"]);
157
+ expect(closed.code).toBe(1);
158
+ expect(json(closed).error.code).toBe("work-item-closed");
159
+ const verb = await chant(root, ["workspace", "work", "grab", "W-001", "--holder", "a"]);
160
+ expect(verb.code).toBe(1);
161
+ expect(verb.stderr).toContain("claim, renew or release");
162
+ const noHolder = await chant(root, ["workspace", "work", "claim", "W-001"]);
163
+ expect(noHolder.code).toBe(1);
164
+ expect(noHolder.stderr).toContain("--holder");
165
+ }, 120_000);
166
+
167
+ test("the lease fields reach an MCP client through workspace-records and workspace-status", async () => {
168
+ const claim = await chant(root, ["workspace", "work", "claim", "W-001", "--holder", "mcp-worker", "--json"]);
169
+ expect(claim.code, claim.stderr).toBe(0);
170
+ const server = new McpServer([], { workspace: { cwd: root, chantCommand: CHANT } });
171
+ const call = async (name: string, args: Record<string, unknown>) => {
172
+ const res = await server.handleRequest({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } });
173
+ return (res.result as { structuredContent: Record<string, unknown> }).structuredContent;
174
+ };
175
+ const records = (await call("workspace-records", { kind: WORK })) as { records: { id: string; lease: { holder: string } | null }[] };
176
+ expect(records.records.find((r) => r.id === "W-001")?.lease?.holder).toBe("mcp-worker");
177
+ const status = (await call("workspace-status", { env: "dev" })) as { leases: { item: string; holder: string; state: string }[] };
178
+ expect(status.leases.find((l) => l.item === "W-001")).toMatchObject({ holder: "mcp-worker", state: "active" });
179
+ }, 120_000);
180
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * `chant workspace work claim|renew|release <id> --holder <name>` (#2732,
3
+ * ws-055): the work lease, who is working on which work item.
4
+ *
5
+ * The lease itself is `../lifecycle/work-lease.ts`, the operator lease under
6
+ * the key `work/<id>`. This file finds the work item first: in the work kind
7
+ * `--kind` names, or in the one work kind the declaration names that has a
8
+ * record with the id. A claim on an id no work record has is refused, and so
9
+ * is a claim on a closed item (done or dropped); renew and release are not,
10
+ * so a lease can always be given back.
11
+ *
12
+ * The lease lives in the ledger of the member that owns the work kind file
13
+ * (#2524 D7), whatever directory the command runs in: `records --json`
14
+ * reports it from there too.
15
+ *
16
+ * Exit codes: 0 when the lease was written, 2 when it was refused (someone
17
+ * holds it, it expired, another clone got there first), 1 when the command
18
+ * could not run (usage, an unreadable kind, an unknown item).
19
+ */
20
+
21
+ import { dirname, relative } from "node:path";
22
+ import { formatError } from "../cli/format";
23
+ import type { CommandContext } from "../cli/registry";
24
+ import { parseDuration } from "../op/duration";
25
+ import {
26
+ claimWorkLease,
27
+ releaseWorkLease,
28
+ renewWorkLease,
29
+ workLeaseRef,
30
+ WORK_ITEM_ID_PATTERN,
31
+ type WorkLease,
32
+ type WorkLeaseRefusal,
33
+ type WorkLeaseResult,
34
+ type LeaseHistoryWrite,
35
+ } from "../lifecycle/work-lease";
36
+ import { resolveMemberLedger } from "../lifecycle/member-ledger";
37
+ import { WorkspaceReadError } from "./declaration";
38
+ import { declaredKindFiles, readRecordsFor } from "./records-cli";
39
+ import { loadRecordKind, RecordReadError, type LoadedRecordKind } from "./records";
40
+ import { gitRoot } from "./record-source";
41
+ import type { ReasonCode } from "./reason-codes";
42
+
43
+ /** The version of the `work` output this chant writes. */
44
+ export const WORK_LEASE_CONTRACT_VERSION = 1;
45
+
46
+ /** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
47
+ export const WORK_LEASE_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/work-lease/v1/work-lease.schema.json";
48
+
49
+ export { WORK_LEASE_REFUSALS } from "../lifecycle/work-lease";
50
+
51
+ const USAGE = "chant workspace work claim|renew|release <id> --holder <name> [--kind <kind file>] [--ttl <seconds|duration>] [--token <token>] [--outcome <text>] [--note <text>] [--json]";
52
+
53
+ /** Exit code when the lease was refused rather than the command failing. */
54
+ export const EXIT_LEASE_REFUSED = 2;
55
+
56
+ export type WorkVerb = "claim" | "renew" | "release";
57
+
58
+ /** Why the command could not run. Closed. */
59
+ export const WORK_ERROR_CODES = [
60
+ "write-usage-invalid",
61
+ "kind-unreadable",
62
+ "work-kind-missing",
63
+ "work-kind-ambiguous",
64
+ "work-item-unknown",
65
+ "work-item-closed",
66
+ "not-a-git-repository",
67
+ ] as const satisfies readonly ReasonCode[];
68
+ export type WorkErrorCode = (typeof WORK_ERROR_CODES)[number];
69
+
70
+ interface DocHead {
71
+ $schema: string;
72
+ contract: number;
73
+ }
74
+
75
+ /** What `--json` prints. */
76
+ export type WorkLeaseDocument = DocHead &
77
+ (
78
+ | {
79
+ item: string;
80
+ event: WorkVerb;
81
+ /** The work kind file, from the repository root. */
82
+ kind: string;
83
+ ref: string;
84
+ lease: Omit<WorkLease, "item">;
85
+ history: LeaseHistoryWrite;
86
+ }
87
+ | {
88
+ item: string;
89
+ event: WorkVerb;
90
+ kind: string;
91
+ ref: string;
92
+ refused: { code: WorkLeaseRefusal; message: string; heldBy: Omit<WorkLease, "item"> | null };
93
+ }
94
+ | { error: { code: WorkErrorCode; message: string } }
95
+ );
96
+
97
+ class WorkError extends Error {
98
+ constructor(
99
+ readonly code: WorkErrorCode,
100
+ message: string,
101
+ ) {
102
+ super(message);
103
+ }
104
+ }
105
+
106
+ /** The work kind holding `id`: the one `kind` names, or the one declared work kind with a record `id`. */
107
+ async function findWorkItem(id: string, cwd: string, kind: string | undefined): Promise<{ loaded: LoadedRecordKind; state: string | null; root: string }> {
108
+ let candidates: string[];
109
+ if (kind !== undefined) {
110
+ candidates = [kind];
111
+ } else {
112
+ try {
113
+ candidates = declaredKindFiles(cwd).map((k) => k.file);
114
+ } catch (err) {
115
+ if (!(err instanceof WorkspaceReadError)) throw err;
116
+ throw new WorkError("work-kind-missing", `${err.code}: ${err.describe()}; without --kind, the declaration names the work kind`);
117
+ }
118
+ }
119
+ const found: { loaded: LoadedRecordKind; state: string | null; root: string }[] = [];
120
+ let workKinds = 0;
121
+ for (const file of candidates) {
122
+ let loaded: LoadedRecordKind;
123
+ try {
124
+ loaded = await loadRecordKind(file, cwd);
125
+ } catch (err) {
126
+ if (err instanceof RecordReadError && kind !== undefined) throw new WorkError("kind-unreadable", `${err.code}: ${err.message}`);
127
+ if (err instanceof RecordReadError) continue;
128
+ throw err;
129
+ }
130
+ if (!loaded.kind.work) {
131
+ if (kind !== undefined) throw new WorkError("work-kind-missing", `${kind} is the ${loaded.kind.name} kind, which has no work block`);
132
+ continue;
133
+ }
134
+ workKinds++;
135
+ let read;
136
+ try {
137
+ read = await readRecordsFor({ kind: loaded.file, cwd });
138
+ } catch (err) {
139
+ if (err instanceof RecordReadError) throw new WorkError("kind-unreadable", `${err.code}: ${err.message}`);
140
+ throw err;
141
+ }
142
+ const record = read.result.records.find((r) => r.id === id);
143
+ if (record) found.push({ loaded, state: record.state, root: read.root });
144
+ }
145
+ if (workKinds === 0) throw new WorkError("work-kind-missing", "the declaration names no work kind; pass --kind <kind file>");
146
+ if (found.length === 0) throw new WorkError("work-item-unknown", `no work record has the id ${id}`);
147
+ if (found.length > 1) throw new WorkError("work-kind-ambiguous", `${found.length} work kinds have a record ${id}; pass --kind <kind file>`);
148
+ return found[0];
149
+ }
150
+
151
+ /** Parse `--ttl`: a whole number of seconds, or a duration such as 10m. */
152
+ export function parseTtl(raw: string): number {
153
+ if (/^\d+$/.test(raw)) return Number(raw) * 1000;
154
+ return parseDuration(raw);
155
+ }
156
+
157
+ export interface WorkLeaseRequest {
158
+ verb: WorkVerb;
159
+ id: string;
160
+ holder: string;
161
+ cwd: string;
162
+ kind?: string;
163
+ ttlMs?: number;
164
+ token?: string;
165
+ outcome?: string;
166
+ note?: string;
167
+ }
168
+
169
+ /** Run one claim, renew or release and build the document `--json` prints. */
170
+ export async function workLease(req: WorkLeaseRequest): Promise<WorkLeaseDocument> {
171
+ const doc = { $schema: WORK_LEASE_OUTPUT_SCHEMA_ID, contract: WORK_LEASE_CONTRACT_VERSION };
172
+ try {
173
+ if (!WORK_ITEM_ID_PATTERN.test(req.id) || req.id.includes("..")) throw new WorkError("write-usage-invalid", `${JSON.stringify(req.id)} is not a work item id`);
174
+ if (!gitRoot(req.cwd)) throw new WorkError("not-a-git-repository", "a work lease is a git ref, and this directory is not in a git repository");
175
+ const { loaded, state, root } = await findWorkItem(req.id, req.cwd, req.kind);
176
+ if (req.verb === "claim" && state !== null && (loaded.kind.closedStates ?? []).includes(state)) {
177
+ throw new WorkError("work-item-closed", `${req.id} is ${state}, a closed state; there is no work left to claim`);
178
+ }
179
+ const leaseCwd = dirname(loaded.file);
180
+ const { prefix } = await resolveMemberLedger(leaseCwd);
181
+ const opts = { cwd: leaseCwd, ...(req.note ? { note: req.note } : {}) };
182
+ let result: WorkLeaseResult;
183
+ if (req.verb === "claim") result = await claimWorkLease(req.id, req.holder, { ...opts, ttlMs: req.ttlMs });
184
+ else if (req.verb === "renew") result = await renewWorkLease(req.id, req.holder, { ...opts, ttlMs: req.ttlMs, token: req.token });
185
+ else result = await releaseWorkLease(req.id, req.holder, { ...opts, token: req.token, outcome: req.outcome });
186
+ const head = { ...doc, item: req.id, event: req.verb, kind: relative(root, loaded.file).split("\\").join("/"), ref: workLeaseRef(req.id, prefix) };
187
+ if (!result.ok) {
188
+ const heldBy = result.heldBy ? strip(result.heldBy) : null;
189
+ return { ...head, refused: { code: result.reason, message: result.message, heldBy } };
190
+ }
191
+ return { ...head, lease: strip(result.lease), history: result.history };
192
+ } catch (err) {
193
+ if (err instanceof WorkError) return { ...doc, error: { code: err.code, message: err.message } };
194
+ throw err;
195
+ }
196
+ }
197
+
198
+ function strip(l: WorkLease): Omit<WorkLease, "item"> {
199
+ return { holder: l.holder, token: l.token, acquiredAt: l.acquiredAt, expiresAt: l.expiresAt };
200
+ }
201
+
202
+ export async function runWorkspaceWork(ctx: CommandContext): Promise<number> {
203
+ const { args } = ctx;
204
+ const verb = args.extraPositional;
205
+ const fail = (message: string): number => {
206
+ if (args.json) console.log(JSON.stringify({ $schema: WORK_LEASE_OUTPUT_SCHEMA_ID, contract: WORK_LEASE_CONTRACT_VERSION, error: { code: "write-usage-invalid", message } }, null, 2));
207
+ else console.error(formatError({ message, hint: USAGE }));
208
+ return 1;
209
+ };
210
+ if (verb !== "claim" && verb !== "renew" && verb !== "release") {
211
+ return fail(verb ? `chant workspace work takes claim, renew or release, not ${verb}` : "chant workspace work needs claim, renew or release");
212
+ }
213
+ const id = args.extraPositional2;
214
+ if (!id) return fail(`${verb} needs a work item id`);
215
+ if (!args.holder) return fail(`${verb} needs --holder <name>: who ${verb === "release" ? "gives the lease back" : "holds the lease"}`);
216
+ if (verb === "release" && args.ttl !== undefined) return fail("release takes no --ttl");
217
+ if (verb !== "release" && args.outcome !== undefined) return fail(`${verb} takes no --outcome; it is for release`);
218
+ if (verb === "claim" && args.token !== undefined) return fail("claim takes no --token; a claim mints one");
219
+ let ttlMs: number | undefined;
220
+ if (args.ttl !== undefined) {
221
+ try {
222
+ ttlMs = parseTtl(args.ttl);
223
+ } catch {
224
+ return fail(`--ttl takes seconds or a duration such as 10m, not ${JSON.stringify(args.ttl)}`);
225
+ }
226
+ if (ttlMs <= 0) return fail("--ttl must be more than zero");
227
+ }
228
+
229
+ const doc = await workLease({ verb, id, holder: args.holder, cwd: process.cwd(), kind: args.kind, ttlMs, token: args.token, outcome: args.outcome, note: args.note });
230
+ if (args.json) console.log(JSON.stringify(doc, null, 2));
231
+ if ("error" in doc) {
232
+ if (!args.json) console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
233
+ return 1;
234
+ }
235
+ if ("refused" in doc) {
236
+ if (!args.json) console.error(formatError({ message: `${doc.refused.code}: ${doc.refused.message}` }));
237
+ return EXIT_LEASE_REFUSED;
238
+ }
239
+ if (!args.json) {
240
+ const what = verb === "claim" ? "claimed" : verb === "renew" ? "renewed" : "released";
241
+ const until = verb === "release" ? "" : ` until ${doc.lease.expiresAt}`;
242
+ console.log(`${id} ${what} by ${args.holder}${until} (token ${doc.lease.token})`);
243
+ console.log(` ${doc.ref}; history ${doc.history.path}${doc.history.pushed ? "" : " (not pushed)"}`);
244
+ }
245
+ return 0;
246
+ }