@intentius/chant 0.93.0 → 0.95.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 (149) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/op/builders.d.ts +14 -3
  8. package/dist/op/builders.d.ts.map +1 -1
  9. package/dist/op/index.d.ts +6 -3
  10. package/dist/op/index.d.ts.map +1 -1
  11. package/dist/op/operator.d.ts +90 -0
  12. package/dist/op/operator.d.ts.map +1 -1
  13. package/dist/op/steward-beside.d.ts +84 -0
  14. package/dist/op/steward-beside.d.ts.map +1 -0
  15. package/dist/op/steward.d.ts +87 -2
  16. package/dist/op/steward.d.ts.map +1 -1
  17. package/dist/workspace/box-intent.d.ts +85 -0
  18. package/dist/workspace/box-intent.d.ts.map +1 -0
  19. package/dist/workspace/box-services.d.ts +31 -0
  20. package/dist/workspace/box-services.d.ts.map +1 -0
  21. package/dist/workspace/chant-migrations.d.ts +5 -0
  22. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  23. package/dist/workspace/checks/boxes.d.ts +14 -1
  24. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/compose-graph.d.ts +11 -0
  28. package/dist/workspace/compose-graph.d.ts.map +1 -1
  29. package/dist/workspace/composites.d.ts +5 -1
  30. package/dist/workspace/composites.d.ts.map +1 -1
  31. package/dist/workspace/decision-points.schema.json +3 -3
  32. package/dist/workspace/declaration.d.ts +43 -0
  33. package/dist/workspace/declaration.d.ts.map +1 -1
  34. package/dist/workspace/declaration.schema.json +62 -1
  35. package/dist/workspace/graph-cache.d.ts +168 -0
  36. package/dist/workspace/graph-cache.d.ts.map +1 -0
  37. package/dist/workspace/graph-cli.d.ts +11 -5
  38. package/dist/workspace/graph-cli.d.ts.map +1 -1
  39. package/dist/workspace/intent-joins.d.ts +5 -5
  40. package/dist/workspace/kind-readers.d.ts +39 -0
  41. package/dist/workspace/kind-readers.d.ts.map +1 -0
  42. package/dist/workspace/kinds.d.ts +29 -0
  43. package/dist/workspace/kinds.d.ts.map +1 -1
  44. package/dist/workspace/member-commands.d.ts +15 -1
  45. package/dist/workspace/member-commands.d.ts.map +1 -1
  46. package/dist/workspace/member-run.d.ts +2 -0
  47. package/dist/workspace/member-run.d.ts.map +1 -1
  48. package/dist/workspace/points-cli.d.ts +2 -0
  49. package/dist/workspace/points-cli.d.ts.map +1 -1
  50. package/dist/workspace/points.d.ts +3 -5
  51. package/dist/workspace/points.d.ts.map +1 -1
  52. package/dist/workspace/reason-codes.d.ts +5 -1
  53. package/dist/workspace/reason-codes.d.ts.map +1 -1
  54. package/dist/workspace/records-cli.d.ts +10 -1
  55. package/dist/workspace/records-cli.d.ts.map +1 -1
  56. package/dist/workspace/records-write.d.ts +4 -2
  57. package/dist/workspace/records-write.d.ts.map +1 -1
  58. package/dist/workspace/records.d.ts +8 -3
  59. package/dist/workspace/records.d.ts.map +1 -1
  60. package/dist/workspace/status-stewards.d.ts +31 -9
  61. package/dist/workspace/status-stewards.d.ts.map +1 -1
  62. package/dist/workspace/status.d.ts +20 -0
  63. package/dist/workspace/status.d.ts.map +1 -1
  64. package/dist/workspace/work-evidence.d.ts +1 -1
  65. package/dist/workspace/work-evidence.d.ts.map +1 -1
  66. package/dist/workspace/workspace-kinds.schema.json +26 -0
  67. package/package.json +1 -1
  68. package/src/cli/commands/carve-bridge.test.ts +7 -3
  69. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  70. package/src/cli/handlers/operator.ts +53 -11
  71. package/src/cli/handlers/run.test.ts +71 -0
  72. package/src/cli/handlers/run.ts +65 -7
  73. package/src/cli/main.ts +21 -9
  74. package/src/cli/mcp/workspace-plugins.ts +6 -6
  75. package/src/cli/mcp/workspace-tools.ts +1 -1
  76. package/src/cli/registry.ts +2 -0
  77. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  78. package/src/cli/static-config-read.test.ts +8 -2
  79. package/src/meta/source-is-text.test.ts +21 -3
  80. package/src/okf.test.ts +6 -1
  81. package/src/op/activities/decide.test.ts +8 -0
  82. package/src/op/builders.ts +14 -3
  83. package/src/op/index.ts +8 -2
  84. package/src/op/operator.ts +264 -16
  85. package/src/op/steward-beside.test.ts +267 -0
  86. package/src/op/steward-beside.ts +219 -0
  87. package/src/op/steward-points.test.ts +112 -1
  88. package/src/op/steward.ts +135 -3
  89. package/src/workspace/box-intent.test.ts +205 -0
  90. package/src/workspace/box-intent.ts +159 -0
  91. package/src/workspace/box-services.test.ts +129 -0
  92. package/src/workspace/box-services.ts +51 -0
  93. package/src/workspace/chant-migrations.ts +5 -0
  94. package/src/workspace/check.schema.json +7 -3
  95. package/src/workspace/checks/boxes.test.ts +3 -1
  96. package/src/workspace/checks/boxes.ts +66 -0
  97. package/src/workspace/checks.ts +12 -1
  98. package/src/workspace/compose-graph.test.ts +1 -0
  99. package/src/workspace/compose-graph.ts +11 -0
  100. package/src/workspace/composites.test.ts +1 -1
  101. package/src/workspace/composites.ts +12 -5
  102. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  103. package/src/workspace/decision-points.schema.json +3 -3
  104. package/src/workspace/declaration.schema.json +62 -1
  105. package/src/workspace/declaration.ts +112 -0
  106. package/src/workspace/declared-kinds.test.ts +26 -0
  107. package/src/workspace/graph-cache.test.ts +343 -0
  108. package/src/workspace/graph-cache.ts +409 -0
  109. package/src/workspace/graph-cli.ts +93 -26
  110. package/src/workspace/graph-contract.test.ts +129 -6
  111. package/src/workspace/graph.schema.json +21 -1
  112. package/src/workspace/intent-joins.test.ts +5 -5
  113. package/src/workspace/intent-joins.ts +5 -5
  114. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  115. package/src/workspace/kind-readers.test.ts +134 -0
  116. package/src/workspace/kind-readers.ts +111 -0
  117. package/src/workspace/kinds.test.ts +49 -0
  118. package/src/workspace/kinds.ts +59 -2
  119. package/src/workspace/member-commands.test.ts +15 -0
  120. package/src/workspace/member-commands.ts +47 -7
  121. package/src/workspace/member-run.ts +10 -2
  122. package/src/workspace/points-cli.ts +3 -0
  123. package/src/workspace/points.schema.json +18 -0
  124. package/src/workspace/points.test.ts +15 -14
  125. package/src/workspace/points.ts +4 -16
  126. package/src/workspace/read-contract.test.ts +8 -0
  127. package/src/workspace/reason-codes.test.ts +7 -7
  128. package/src/workspace/reason-codes.ts +6 -1
  129. package/src/workspace/records-amend.schema.json +1 -0
  130. package/src/workspace/records-cli.ts +34 -13
  131. package/src/workspace/records-contract.test.ts +2 -1
  132. package/src/workspace/records-formats.test.ts +15 -15
  133. package/src/workspace/records-new.schema.json +1 -0
  134. package/src/workspace/records-quorum.test.ts +10 -3
  135. package/src/workspace/records-sessions-write.test.ts +2 -1
  136. package/src/workspace/records-since.test.ts +8 -7
  137. package/src/workspace/records-write.test.ts +101 -1
  138. package/src/workspace/records-write.ts +50 -6
  139. package/src/workspace/records.test.ts +1 -1
  140. package/src/workspace/records.ts +22 -5
  141. package/src/workspace/status-contract.test.ts +32 -1
  142. package/src/workspace/status-stewards.ts +88 -9
  143. package/src/workspace/status.schema.json +66 -4
  144. package/src/workspace/status.ts +32 -0
  145. package/src/workspace/trust/record-seal.test.ts +26 -2
  146. package/src/workspace/work-evidence.schema.json +1 -0
  147. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  148. package/src/workspace/work.test.ts +17 -0
  149. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -16,19 +16,27 @@
16
16
  * is the Op's own `labels.Env` or `local`;
17
17
  * - `waiting` lists the open decision points the steward waits on (#2749):
18
18
  * each Op whose newest run is the steward's own and stopped on a question,
19
- * as the run ledger records it. The question's state now is `points`'s;
19
+ * as the run ledger records it. The question's state now is `points`'s.
20
+ * A run is the steward's when its record names it (`steward`, written for
21
+ * a run started in the steward's turn or under `CHANT_STEWARD`), so this
22
+ * also covers the member's other Ops, those no steward lists, that such a
23
+ * run belongs to, such as an Op the steward's process starts itself;
20
24
  * - `lease` is the local steward's own lease ref
21
25
  * (`refs/chant/lease/[<prefix>]_stewards/<name>`), present while a
22
26
  * `chant operator --steward` holds it or until it expires;
23
27
  * - an Op's `workLease.held` is the work leases its turns hold (#2748): those
24
28
  * whose holder is `<steward>/<op>@...` (`stewardWorkHolder`), read from the
25
- * ledger of the Op's work kind, or the member's own.
29
+ * ledger of the Op's work kind, or the member's own;
30
+ * - an Op the steward runs beside its turns (#2861) has `beside`: whether it
31
+ * declares a ready step, and the Op's own lease
32
+ * (`refs/chant/lease/[<prefix>]<op>`), which a run of it holds while it runs.
33
+ * Its waiting run and its work leases are listed as for any of its Ops.
26
34
  */
27
35
 
28
36
  import { existsSync, realpathSync } from "node:fs";
29
37
  import { dirname, join, relative, resolve } from "node:path";
30
- import { discoverStewards } from "../op/discover";
31
- import { stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
38
+ import { discoverOps, discoverStewards } from "../op/discover";
39
+ import { stewardBesideFor, stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
32
40
  import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
33
41
  import { readConvergeLedger, type ConvergeTickRecord } from "../lifecycle/converge-ledger";
34
42
  import { leaseRef } from "../lifecycle/lease";
@@ -119,6 +127,21 @@ export interface StatusStewardOp {
119
127
  * the steward's turns of this Op hold, a live one while a turn runs.
120
128
  */
121
129
  workLease: { kind: string | null; held: StatusStewardWorkLease[] } | null;
130
+ /**
131
+ * For an Op the steward runs beside its turns (#2861): whether a ready step
132
+ * says when to start it, and the Op's own lease, which a run of it holds
133
+ * while it runs (null when none has held it). Null for an Op run as one of
134
+ * the steward's turns.
135
+ */
136
+ beside: { ready: boolean; lease: StatusStewardLease | null } | null;
137
+ }
138
+
139
+ /** A lease ref as status reads it: locally, never fetched. `live` is false once `expiresAt` has passed. */
140
+ export interface StatusStewardLease {
141
+ holder: string;
142
+ acquiredAt: string;
143
+ expiresAt: string;
144
+ live: boolean;
122
145
  }
123
146
 
124
147
  /** A work lease a steward's turn holds (#2748). */
@@ -148,11 +171,13 @@ export interface StatusSteward {
148
171
  */
149
172
  capabilities: { name: string; broker: string | null; declared: boolean }[];
150
173
  /** The local steward's own lease, or null when no local operator has held it. */
151
- lease: { holder: string; acquiredAt: string; expiresAt: string; live: boolean } | null;
174
+ lease: StatusStewardLease | null;
152
175
  ops: StatusStewardOp[];
153
176
  /**
154
177
  * The open decision points the steward waits on (#2749): each Op whose
155
- * newest run is the steward's own and stopped on a question.
178
+ * newest run is the steward's own and stopped on a question. Its declared
179
+ * Ops come first, in `ops` order, then any other Op of the member whose
180
+ * newest run names the steward, by Op name.
156
181
  */
157
182
  waiting: (StatusStewardWait & { op: string; run: string })[];
158
183
  }
@@ -200,10 +225,11 @@ function isChantProject(dir: string): boolean {
200
225
  return existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"));
201
226
  }
202
227
 
203
- async function readStewardLease(name: string, memberDir: string, now: string): Promise<StatusSteward["lease"]> {
228
+ /** A lease ref of the member, by its lease name (`_stewards/<name>`, or an Op's). */
229
+ async function readLeaseRef(leaseName: string, memberDir: string, now: string): Promise<StatusStewardLease | null> {
204
230
  try {
205
231
  const { prefix } = await resolveMemberLedger(memberDir);
206
- const sha = await readRefSha(leaseRef(stewardLeaseName(name), prefix), { cwd: memberDir });
232
+ const sha = await readRefSha(leaseRef(leaseName, prefix), { cwd: memberDir });
207
233
  if (!sha) return null;
208
234
  const record = JSON.parse((await readBlobBySha(sha, { cwd: memberDir })) ?? "") as Record<string, unknown>;
209
235
  if (typeof record.holder !== "string" || typeof record.expiresAt !== "string" || typeof record.acquiredAt !== "string") return null;
@@ -218,6 +244,18 @@ async function readStewardLease(name: string, memberDir: string, now: string): P
218
244
  }
219
245
  }
220
246
 
247
+ /** An Op's `beside` entry (#2861): null for an Op run as one of the steward's turns. */
248
+ async function besideOf(
249
+ declaration: Parameters<typeof stewardBesideFor>[0],
250
+ op: string,
251
+ memberDir: string,
252
+ now: string,
253
+ ): Promise<StatusStewardOp["beside"]> {
254
+ const beside = stewardBesideFor(declaration, op);
255
+ if (!beside) return null;
256
+ return { ready: beside.ready !== null && beside.ready !== undefined, lease: await readLeaseRef(op, memberDir, now) };
257
+ }
258
+
221
259
  /** The work leases `steward`'s turns of `op` hold, from the ledger of the Op's kind or the member's. */
222
260
  async function readHeldWorkLeases(steward: string, op: OpConfig, memberDir: string, now: string): Promise<StatusStewardWorkLease[]> {
223
261
  try {
@@ -308,6 +346,7 @@ export async function readMemberStewards(
308
346
  workLease: op.workLease
309
347
  ? { kind: op.workLease.kind ?? null, held: await readHeldWorkLeases(declaration.name, op, memberDir, now) }
310
348
  : null,
349
+ beside: await besideOf(declaration, op.name, memberDir, now),
311
350
  });
312
351
  }
313
352
  stewards.push({
@@ -320,10 +359,50 @@ export async function readMemberStewards(
320
359
  const declared = box?.capabilities.find((c) => c.name === name);
321
360
  return { name, broker: declared?.broker ?? null, declared: declared !== undefined };
322
361
  }),
323
- lease: await readStewardLease(declaration.name, memberDir, now),
362
+ lease: await readLeaseRef(stewardLeaseName(declaration.name), memberDir, now),
324
363
  ops,
325
364
  waiting,
326
365
  });
327
366
  }
367
+ if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards, found, reasons);
328
368
  return { stewards, reasons };
329
369
  }
370
+
371
+ /**
372
+ * The waiting runs of the member's Ops that no steward lists, each under the
373
+ * steward its record names. A steward's process can start an Op itself
374
+ * with `CHANT_STEWARD` set (studio#137), and the run ledger then records
375
+ * the run as the steward's though the declaration does not list the Op.
376
+ * Import failures are left out here: `discoverStewards` read the same files
377
+ * and reported them as `stewards-unreadable`.
378
+ */
379
+ async function addUndeclaredWaits(
380
+ memberDir: string,
381
+ stewards: StatusSteward[],
382
+ found: Awaited<ReturnType<typeof discoverStewards>>["stewards"],
383
+ reasons: MemberStewards["reasons"],
384
+ ): Promise<void> {
385
+ const declared = new Set([...found.values()].flatMap(({ declaration }) => declaration.ops.map((op) => op.name)));
386
+ let ops: OpConfig[];
387
+ try {
388
+ ops = [...(await discoverOps({ cwd: memberDir })).ops.values()].map((d) => d.config).filter((op) => !declared.has(op.name));
389
+ } catch {
390
+ return;
391
+ }
392
+ const byName = new Map(stewards.map((s) => [s.name, s]));
393
+ for (const op of ops.sort((a, b) => a.name.localeCompare(b.name))) {
394
+ const opEnv = runEnvOf(op);
395
+ try {
396
+ const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
397
+ const steward = newest?.steward ? byName.get(newest.steward) : undefined;
398
+ if (steward && newest?.status === "waiting" && newest.point) {
399
+ steward.waiting.push({ op: op.name, run: newest.id, ...waitOf(newest.point) });
400
+ }
401
+ } catch (err) {
402
+ reasons.push({
403
+ code: "steward-runs-unreadable",
404
+ message: `${opEnv}/runs__${op.name}.jsonl: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
405
+ });
406
+ }
407
+ }
408
+ }
@@ -200,11 +200,11 @@
200
200
  ]
201
201
  },
202
202
  "ops": {
203
- "description": "The Ops it runs, in declaration order.",
203
+ "description": "The Ops it runs, in declaration order: its turns' Ops, then those it runs beside its turns (#2861).",
204
204
  "type": "array",
205
205
  "items": {
206
206
  "type": "object",
207
- "required": ["name", "schedule", "env", "lastRun", "lastTick", "changesCheckout", "workLease"],
207
+ "required": ["name", "schedule", "env", "lastRun", "lastTick", "changesCheckout", "workLease", "beside"],
208
208
  "properties": {
209
209
  "name": { "type": "string" },
210
210
  "schedule": {
@@ -330,12 +330,40 @@
330
330
  }
331
331
  }
332
332
  ]
333
+ },
334
+ "beside": {
335
+ "description": "For an Op the steward runs beside its turns (#2861), in a process of its own under the Op's own lease: ready is whether a ready step says when the operator starts it, and lease is the Op's lease ref (refs/chant/lease/<op>), held by a run of it while it runs (holder <steward>/<op>@<operator> for a run the operator started), read locally and never fetched, or null when none has held it. live is false once expiresAt has passed. Null for an Op the steward runs as one of its turns.",
336
+ "oneOf": [
337
+ { "type": "null" },
338
+ {
339
+ "type": "object",
340
+ "required": ["ready", "lease"],
341
+ "properties": {
342
+ "ready": { "type": "boolean" },
343
+ "lease": {
344
+ "oneOf": [
345
+ { "type": "null" },
346
+ {
347
+ "type": "object",
348
+ "required": ["holder", "acquiredAt", "expiresAt", "live"],
349
+ "properties": {
350
+ "holder": { "type": "string" },
351
+ "acquiredAt": { "type": "string" },
352
+ "expiresAt": { "type": "string" },
353
+ "live": { "type": "boolean" }
354
+ }
355
+ }
356
+ ]
357
+ }
358
+ }
359
+ }
360
+ ]
333
361
  }
334
362
  }
335
363
  }
336
364
  },
337
365
  "waiting": {
338
- "description": "The open decision points the steward waits on (#2749): one for each of its Ops whose newest run is the steward's own and stopped on a question, in ops order. The state is the question's when the run stopped; `chant workspace points --open` has its state now, and hud asks a person there. The steward never answers one, and runs the Op again on the first round after it is answered.",
366
+ "description": "The open decision points the steward waits on (#2749): one for each Op whose newest run is the steward's own (its ledger record names the steward) and stopped on a question: the steward's own Ops in ops order, then any other Op of the member, such as one the steward's process starts itself (studio#137), by name. The state is the question's when the run stopped; `chant workspace points --open` has its state now, and hud asks a person there. The steward never answers one, and runs an Op it lists again on the first round after it is answered (an Op beside its turns, in a process of its own, #2861).",
339
367
  "type": "array",
340
368
  "items": {
341
369
  "allOf": [
@@ -362,10 +390,44 @@
362
390
  "since": { "type": "string", "description": "When the run stopped on it." }
363
391
  }
364
392
  },
393
+ "intent": {
394
+ "type": "object",
395
+ "required": ["id", "state", "question", "choice", "answer", "decided_by", "decided_on"],
396
+ "properties": {
397
+ "id": { "type": "string", "description": "The record's id, as the box block names it." },
398
+ "state": { "type": ["string", "null"], "description": "The record's state as written, such as proposed or decided, or null when no decision record has the id." },
399
+ "question": { "type": ["string", "null"], "description": "The question the record answers." },
400
+ "choice": { "description": "The record's choice as written, such as { option, reason }, or null while the record is proposed." },
401
+ "answer": { "type": ["string", "null"], "description": "The chosen option's label, from options[]'s entry whose id is choice.option, or null while no option is chosen (#2855). Added by chant 0.94.0." },
402
+ "decided_by": { "type": ["string", "null"], "description": "Who decided it, or null while it is proposed." },
403
+ "decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." }
404
+ }
405
+ },
365
406
  "box": {
366
407
  "type": "object",
367
- "required": ["capabilities", "isolation"],
408
+ "required": ["capabilities", "isolation", "intent", "services"],
368
409
  "properties": {
410
+ "services": {
411
+ "description": "The services the box block declares (#2880), in declaration order, or empty when it declares none. A reader has them before any converge tick; a steward's ConvergeOp's lastTick carries each one's verdict. cmd is as declared: its ${VAR} references are left for the process that applies it to expand. Added by chant 0.95.0.",
412
+ "type": "array",
413
+ "items": {
414
+ "type": "object",
415
+ "required": ["name", "cmd", "needs", "httpPort", "duration", "health", "optional"],
416
+ "properties": {
417
+ "name": { "type": "string" },
418
+ "cmd": { "type": "string", "description": "The command the supervisor runs, as declared." },
419
+ "needs": { "type": "array", "items": { "type": "string" }, "description": "Services of the same box that start first. Empty when none." },
420
+ "httpPort": { "type": ["integer", "null"], "description": "The port the supervisor routes the sprite's URL to, or null." },
421
+ "duration": { "type": ["string", "null"], "description": "How long the service must stay up after a create or start, such as 3s, or null for the supervisor's default." },
422
+ "health": { "type": ["string", "null"], "description": "A URL that answers 200 while the service works, or null when the supervisor's state decides." },
423
+ "optional": { "type": "boolean", "description": "True for a service something else creates by name, such as a site a release makes." }
424
+ }
425
+ }
426
+ },
427
+ "intent": {
428
+ "description": "The decision record the box block names as the box's intent (#2850), or null when it names none. A box starts as a question: the record is proposed with a null choice, and decided when the person who answered it amends it. Read from the working tree, from the declared record kinds named decision. When no decision record has the id, every field but id is null and chant workspace check fails (WSP126). Added by chant 0.94.0.",
429
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/intent" }]
430
+ },
369
431
  "isolation": {
370
432
  "description": "The box's isolation resolved from its identity, its host, the member's name and its slot (#2727), or null when the block declares no host. A runtime that plants or starts the box sets these values instead of choosing its own. The same for every environment.",
371
433
  "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/isolation" }]
@@ -43,6 +43,7 @@ import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
43
43
  import { listWorkLeases, type WorkLeaseState } from "../lifecycle/work-lease";
44
44
  import { findWorkspaceRoot } from "../project-root";
45
45
  import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
46
+ import { resolveBoxIntents, unresolvedIntent, type BoxIntent } from "./box-intent";
46
47
  import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError, type Declaration, type ErrorLocation, type Member } from "./declaration";
47
48
  import type { ReasonCode } from "./reason-codes";
48
49
  import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
@@ -178,6 +179,26 @@ export interface StatusBox {
178
179
  capabilities: { name: string; broker: string | null; scope: string[] }[];
179
180
  /** The box's ports, state paths and cookie names, resolved from its identity, or null when the block declares no host (#2727). */
180
181
  isolation: ResolvedIsolation | null;
182
+ /**
183
+ * The decision record the block names as the box's intent (#2850): its id,
184
+ * state, question, choice, answer (the chosen option's label), decided_by
185
+ * and decided_on. Null when the block names none; every field but the id
186
+ * is null when no decision record has the id (WSP126).
187
+ */
188
+ intent: BoxIntent | null;
189
+ /** The services the block declares (#2880), in file order, so a reader has them before any converge tick. Empty when it declares none. */
190
+ services: StatusBoxService[];
191
+ }
192
+
193
+ /** A declared box service as `status --json` prints it (#2880): every field present, null or false when not declared. */
194
+ export interface StatusBoxService {
195
+ name: string;
196
+ cmd: string;
197
+ needs: string[];
198
+ httpPort: number | null;
199
+ duration: string | null;
200
+ health: string | null;
201
+ optional: boolean;
181
202
  }
182
203
 
183
204
  /**
@@ -377,6 +398,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
377
398
  const now = query.now ?? new Date().toISOString();
378
399
 
379
400
  const isolation = new Map(resolveBoxes(declaration).map((b) => [b.member.name, b.isolation]));
401
+ const intents = new Map((await resolveBoxIntents(declaration, found.dir)).map((i) => [i.member, i.record?.intent ?? unresolvedIntent(i.id)]));
380
402
  const members: StatusMember[] = [];
381
403
  for (const m of declaration.members) {
382
404
  const environments: StatusEnvironment[] = [];
@@ -399,6 +421,16 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
399
421
  : {
400
422
  capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
401
423
  isolation: isolation.get(m.name) ?? null,
424
+ intent: intents.get(m.name) ?? null,
425
+ services: m.box.services.map((s) => ({
426
+ name: s.name,
427
+ cmd: s.cmd,
428
+ needs: [...s.needs],
429
+ httpPort: s.httpPort,
430
+ duration: s.duration,
431
+ health: s.health,
432
+ optional: s.optional,
433
+ })),
402
434
  },
403
435
  stewards: stewards.stewards,
404
436
  stewardReasons: stewards.reasons,
@@ -199,7 +199,7 @@ describe.skipIf(!hasSshKeygen)("author seals under a signers file at base", () =
199
199
  const doc = await amend(r, { evidence: [] });
200
200
  if ("error" in doc) throw new Error(doc.error.message);
201
201
  expect(doc.changed).toEqual(["evidence", "seal"]);
202
- expect(doc.sealDropped).toMatch(/ws-003 was sealed by lex00, and the amendment moves its digest, so the seal was removed: seal it again with records amend ws-003 --sign/);
202
+ expect(doc.sealDropped).toMatch(/ws-003 was sealed by lex00, and the amendment moves its digest or its state, so the seal was removed: seal it again with records amend ws-003 --sign/);
203
203
  expect(readFileSync(join(r.dir, FILE), "utf-8")).not.toMatch(/^seal:/m);
204
204
  // Changing only the reviews leaves the digest, so the seal stays.
205
205
  await amend(r, {}, lex);
@@ -215,6 +215,30 @@ describe.skipIf(!hasSshKeygen)("author seals under a signers file at base", () =
215
215
  expect(warningCodes(rec)).toContain("record-unattested");
216
216
  });
217
217
 
218
+ test("a move of state alone drops the seal, which signs the state though the digest leaves it out (#2873)", async () => {
219
+ const { r, lex } = workspace("state");
220
+ await amend(r, {}, lex);
221
+ const digest = (await record(r)).digest;
222
+ const doc = await amend(r, { state: "superseded" });
223
+ if ("error" in doc) throw new Error(doc.error.message);
224
+ expect(doc.changed).toEqual(["state", "seal"]);
225
+ expect(doc.sealDropped).toMatch(/moves its digest or its state/);
226
+ expect((await record(r)).digest).toBe(digest);
227
+ });
228
+
229
+ test("ratifying counts only verdicts whose seal verifies against the signers at base (#2873)", async () => {
230
+ const { r, alice, mallory } = workspace("ratify");
231
+ for (const k of [alice, mallory]) {
232
+ const by = k === alice ? "alice@example.test" : "mallory";
233
+ const doc = await reviewRecord({ kind: KIND, id: "ws-003", verdict: "agree", by, sign: k.file, cwd: r.dir });
234
+ if ("error" in doc) throw new Error(doc.error.message);
235
+ }
236
+ const refused = await amend(r, { state: "ratified" });
237
+ expect("error" in refused && refused.error.code).toBe("ratify-quorum-not-met");
238
+ expect("error" in refused && refused.error.message).toMatch(/needs 2 agreeing verdicts that count, and it has 1 \(not counted: mallory, review-unattested\)/);
239
+ expect(readFileSync(join(r.dir, FILE), "utf-8")).toMatch(/^state: "decided"$/m);
240
+ });
241
+
218
242
  test("a stale seal left by hand fails, and an edited state fails", async () => {
219
243
  const { r, lex } = workspace("stale");
220
244
  await amend(r, {}, lex);
@@ -308,7 +332,7 @@ describe.skipIf(!hasSshKeygen)("refusals", () => {
308
332
  expect("error" in from && from.error.code).toBe("write-input-invalid");
309
333
  expect(readFileSync(join(r.dir, FILE), "utf-8")).toBe(before);
310
334
 
311
- await amend(r, { state: "ratified" });
335
+ await amend(r, { state: "superseded" });
312
336
  const closed = await amend(r, {}, lex);
313
337
  expect("error" in closed && closed.error.code).toBe("record-closed");
314
338
  });
@@ -181,6 +181,7 @@
181
181
  "record-closed",
182
182
  "amend-supersede-instead",
183
183
  "record-sign-failed",
184
+ "ratify-quorum-not-met",
184
185
  "record-unparseable",
185
186
  "record-schema-invalid",
186
187
  "record-id-duplicate",
@@ -1,11 +1,10 @@
1
1
  /**
2
- * The work kind's `ready`/`blockedBy` against chud's ready queue (ws-055,
3
- * #2734).
4
- *
5
- * chud's `readyQueue` (packages/runtime/src/ready.mjs at jhgaylor/chud
6
- * 43afcf1) puts a contract in the ready set once it is approved and in
7
- * force, every `depends_on` id is `met` (drivers.mjs's `memberState`), and no
8
- * unit is open against it; a contract that is itself met leaves the queue.
2
+ * The work kind's `ready`/`blockedBy` against a reference ready-queue rule
3
+ * (ws-055, #2734), ported from chud's `readyQueue`
4
+ * (packages/runtime/src/ready.mjs at jhgaylor/chud 43afcf1): a contract
5
+ * enters the ready set once it is approved and in force, every `depends_on`
6
+ * id is `met` (drivers.mjs's `memberState`), and no unit is open against it;
7
+ * a contract that is itself met leaves the queue.
9
8
  *
10
9
  * Of those rules, the work kind's `ready` and `blockedBy` (work.ts) can
11
10
  * express two directly, with no new field:
@@ -17,22 +16,21 @@
17
16
  * retired contract does.
18
17
  * - a record that is itself already `done` (or `dropped`, or merely
19
18
  * `in-progress`) is not `ready`: `ready` requires `state === "open"`, the
20
- * same "already met/already running leaves the queue" rule ready.mjs
21
- * states for a contract that is itself met.
19
+ * same "already met/already running leaves the queue" rule the reference
20
+ * rule states for a contract that is itself met.
22
21
  *
23
- * The rules ready.mjs adds on top -- approval before a dependency counts
24
- * (memberState's draft/failing cases), re-checking that a `met` dependency's
25
- * newest evidence still passes, and refusing a contract while a unit is open
26
- * against it -- have no equivalent in a `needs` link. The full table of
27
- * every ready.mjs rule and its home (a `needs` link, the runner, or "not
22
+ * The rules the reference rule adds on top -- approval before a dependency
23
+ * counts (memberState's draft/failing cases), re-checking that a `met`
24
+ * dependency's newest evidence still passes, and refusing a contract while a
25
+ * unit is open against it -- have no equivalent in a `needs` link. The full
26
+ * table of every rule and its home (a `needs` link, the runner, or "not
28
27
  * applicable") is on INTENTIUS/chant#2734.
29
28
  *
30
- * This fixture mirrors a small chud contract graph as work items and checks
31
- * `queryRecords`'s `ready`/`blockedBy` against a minimal, local
32
- * re-implementation of the expressible subset of ready.mjs's rule (met
33
- * depends_on, self not already met), so a regression in either the work
34
- * kind's readiness formula or this reading of ready.mjs's rule shows up as a
35
- * mismatch.
29
+ * This fixture mirrors a small reference contract graph as work items and
30
+ * checks `queryRecords`'s `ready`/`blockedBy` against a minimal, local
31
+ * re-implementation of the expressible subset of that rule (met depends_on,
32
+ * self not already met), so a regression in either the work kind's readiness
33
+ * formula or this reading of the reference rule shows up as a mismatch.
36
34
  */
37
35
 
38
36
  import { readFileSync } from "node:fs";
@@ -63,13 +61,13 @@ function work(id: string, fields: Record<string, unknown>): string {
63
61
  }
64
62
 
65
63
  /**
66
- * The expressible subset of ready.mjs's rule, on a graph shaped like
64
+ * The expressible subset of the reference rule, on a graph shaped like
67
65
  * `depends_on`: ready once the item is `open` (not already met, not already
68
- * running) and every dependency is `done` (chud's "met"). A dependency that
69
- * is `dropped` never becomes done, so it blocks forever -- the same effect
70
- * ready.mjs gets from a retired dependency.
66
+ * running) and every dependency is `done` (the reference rule's "met"). A
67
+ * dependency that is `dropped` never becomes done, so it blocks forever --
68
+ * the same effect the reference rule gets from a retired dependency.
71
69
  */
72
- function chudReadyIsh(items: { id: string; state: string; needs: string[] }[]): { ready: Set<string>; blockedBy: Map<string, string[]> } {
70
+ function referenceReadyIsh(items: { id: string; state: string; needs: string[] }[]): { ready: Set<string>; blockedBy: Map<string, string[]> } {
73
71
  const stateOf = new Map(items.map((i) => [i.id, i.state]));
74
72
  const ready = new Set<string>();
75
73
  const blockedBy = new Map<string, string[]>();
@@ -90,13 +88,13 @@ beforeAll(() => {
90
88
  "decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
91
89
  "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
92
90
  "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
93
- // W-001: already done, so it leaves the ready queue -- ready.mjs's "a
94
- // contract that is itself met is done, not ready".
91
+ // W-001: already done, so it leaves the ready queue -- the reference
92
+ // rule's "a contract that is itself met is done, not ready".
95
93
  "work/W-001-base.md": work("W-001", { state: "done", closed_on: "2026-09-24", evidence: [{ title: "shipped", url: "https://example.com/w1" }] }),
96
94
  // W-002: needs W-001, which is done -- both rules agree this is ready.
97
95
  "work/W-002-after-base.md": work("W-002", { needs: ["W-001"] }),
98
96
  // W-003: needs W-004, which is dropped and so never turns done -- blocked
99
- // forever, the same as a retired dependency in ready.mjs.
97
+ // forever, the same as a retired dependency in the reference rule.
100
98
  "work/W-003-needs-dropped.md": work("W-003", { needs: ["W-004"] }),
101
99
  "work/W-004-dropped.md": work("W-004", { state: "dropped", closed_on: "2026-09-24" }),
102
100
  // W-005: needs W-002, which is open (not done yet) -- blocked.
@@ -111,22 +109,22 @@ beforeAll(() => {
111
109
  });
112
110
  afterAll(cleanScratch);
113
111
 
114
- describe("work kind ready/blockedBy vs. chud's ready queue, expressible rules (#2734)", () => {
112
+ describe("work kind ready/blockedBy vs. a reference ready-queue rule, expressible rules (#2734)", () => {
115
113
  test("matches on every item, for the rules a needs link can express", async () => {
116
114
  const doc = await queryRecords({ kind: WORK, cwd: root });
117
115
  if ("error" in doc) throw new Error(doc.error.message);
118
116
  expect(doc.summary).toEqual({ total: 7, valid: 7, invalid: 0, superseded: 0 });
119
117
 
120
118
  const items = doc.records.map((r) => ({ id: r.id!, state: r.state!, needs: (r.data as { needs?: string[] }).needs ?? [] }));
121
- const chud = chudReadyIsh(items);
119
+ const reference = referenceReadyIsh(items);
122
120
 
123
121
  for (const r of doc.records) {
124
122
  const id = r.id!;
125
- expect(r.ready, `${id}.ready`).toBe(chud.ready.has(id));
123
+ expect(r.ready, `${id}.ready`).toBe(reference.ready.has(id));
126
124
  expect(
127
125
  (r.blockedBy ?? []).map((b) => b.id),
128
126
  `${id}.blockedBy`,
129
- ).toEqual(chud.blockedBy.get(id));
127
+ ).toEqual(reference.blockedBy.get(id));
130
128
  }
131
129
  });
132
130
 
@@ -370,6 +370,13 @@ describe("writing work items on a copy of the reference workspace (#2683)", () =
370
370
  expect("error" in doc && doc.error.code).toBe("record-schema-invalid");
371
371
  });
372
372
 
373
+ test("records new refuses an ask source with no said or by (#2851)", async () => {
374
+ const noSaid = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { ask: { by: "morgan", at: "2026-09-25T10:00:00Z", via: "hud" } } }), dryRun: true, cwd: copy });
375
+ expect("error" in noSaid && noSaid.error.code).toBe("record-schema-invalid");
376
+ const noBy = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { ask: { said: "Can we add a dark mode toggle?", at: "2026-09-25T10:00:00Z", via: "hud" } } }), dryRun: true, cwd: copy });
377
+ expect("error" in noBy && noBy.error.code).toBe("record-schema-invalid");
378
+ });
379
+
373
380
  test("records new with a fresh prefix takes --prefix W", async () => {
374
381
  const doc = await newRecord({ kind: "work/work.kind.mjs", fields: fresh(), prefix: "W", dryRun: true, cwd: copy });
375
382
  expect(doc).toMatchObject({ id: "W-004" });
@@ -388,4 +395,14 @@ describe("writing work items on a copy of the reference workspace (#2683)", () =
388
395
  const doc = await reviewRecord({ kind: "work/work.kind.mjs", id: "W-003", verdict: "agree", by: "alice", cwd: copy });
389
396
  expect("error" in doc && doc.error.code).toBe("review-unsupported");
390
397
  });
398
+
399
+ test("records new with an ask source round-trips through records --json (#2851)", async () => {
400
+ const ask = { said: "Can we add a dark mode toggle to the wireframe?", by: "morgan", at: "2026-09-25T10:00:00Z", via: "hud", session: "hud-9f2" };
401
+ const doc = await newRecord({ kind: resolveWriteKind("work", copy), fields: fresh({ source: { ask } }), cwd: copy });
402
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
403
+ const read = await queryRecords({ kind: "work/work.kind.mjs", cwd: copy });
404
+ records.expectValid(read);
405
+ if ("error" in read) throw new Error(read.error.message);
406
+ expect(read.records.find((r) => r.id === doc.id)).toMatchObject({ valid: true, data: { source: { ask } } });
407
+ });
391
408
  });
@@ -72,6 +72,9 @@
72
72
  "maxLength": 255,
73
73
  "pattern": "^[^\\u0000-\\u001f]+$"
74
74
  }
75
+ },
76
+ "graph": {
77
+ "$ref": "#/$defs/graph"
75
78
  }
76
79
  },
77
80
  "patternProperties": {
@@ -128,6 +131,29 @@
128
131
  "type": "string",
129
132
  "description": "A file name, with no directory part. A * matches any run of characters.",
130
133
  "pattern": "^(?!\\.\\.?$)[^/\\\\]+$"
134
+ },
135
+ "graph": {
136
+ "type": "object",
137
+ "description": "How chant workspace graph reads a member of this kind (#2874). chant writes a reader project of its own outside the workspace, with a chant.config.json that declares lexicon and puts config under that lexicon's key, and runs the member's chant graph there. In every string key and value of config, {member} becomes the member's name, {dir} its absolute directory and {workspace} the absolute workspace root. Without this field a member of the kind is listed with kind-not-run.",
138
+ "required": [
139
+ "lexicon",
140
+ "config"
141
+ ],
142
+ "properties": {
143
+ "lexicon": {
144
+ "type": "string",
145
+ "pattern": "^[a-z][a-z0-9-]{0,39}$",
146
+ "description": "The lexicon that reads the member, by name. The package that supplies the kind must be @intentius/chant-lexicon-<lexicon>, so the lexicon chant loads is the one the declaration pins."
147
+ },
148
+ "config": {
149
+ "type": "object",
150
+ "description": "The lexicon's config namespace for the reader project, such as terraform's roots. Placeholders are substituted; nothing else is interpreted."
151
+ }
152
+ },
153
+ "patternProperties": {
154
+ "^x-": true
155
+ },
156
+ "additionalProperties": false
131
157
  }
132
158
  }
133
159
  }