@intentius/chant 0.93.0 → 0.94.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 (62) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  3. package/dist/workspace/box-intent.d.ts +85 -0
  4. package/dist/workspace/box-intent.d.ts.map +1 -0
  5. package/dist/workspace/chant-migrations.d.ts +5 -0
  6. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  7. package/dist/workspace/checks/boxes.d.ts +14 -1
  8. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  9. package/dist/workspace/checks.d.ts +4 -0
  10. package/dist/workspace/checks.d.ts.map +1 -1
  11. package/dist/workspace/decision-points.schema.json +3 -3
  12. package/dist/workspace/declaration.d.ts +6 -0
  13. package/dist/workspace/declaration.d.ts.map +1 -1
  14. package/dist/workspace/declaration.schema.json +6 -1
  15. package/dist/workspace/intent-joins.d.ts +5 -5
  16. package/dist/workspace/points-cli.d.ts +2 -0
  17. package/dist/workspace/points-cli.d.ts.map +1 -1
  18. package/dist/workspace/points.d.ts +3 -5
  19. package/dist/workspace/points.d.ts.map +1 -1
  20. package/dist/workspace/reason-codes.d.ts +2 -0
  21. package/dist/workspace/reason-codes.d.ts.map +1 -1
  22. package/dist/workspace/records-cli.d.ts.map +1 -1
  23. package/dist/workspace/status-stewards.d.ts +8 -2
  24. package/dist/workspace/status-stewards.d.ts.map +1 -1
  25. package/dist/workspace/status.d.ts +8 -0
  26. package/dist/workspace/status.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/main.ts +4 -3
  29. package/src/cli/mcp/workspace-plugins.ts +6 -6
  30. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  31. package/src/op/activities/decide.test.ts +8 -0
  32. package/src/op/steward-points.test.ts +51 -0
  33. package/src/workspace/box-intent.test.ts +205 -0
  34. package/src/workspace/box-intent.ts +159 -0
  35. package/src/workspace/chant-migrations.ts +5 -0
  36. package/src/workspace/check.schema.json +7 -3
  37. package/src/workspace/checks/boxes.test.ts +2 -1
  38. package/src/workspace/checks/boxes.ts +66 -0
  39. package/src/workspace/checks.ts +12 -1
  40. package/src/workspace/decision-points.schema.json +3 -3
  41. package/src/workspace/declaration.schema.json +6 -1
  42. package/src/workspace/declaration.ts +8 -0
  43. package/src/workspace/declared-kinds.test.ts +26 -0
  44. package/src/workspace/intent-joins.test.ts +5 -5
  45. package/src/workspace/intent-joins.ts +5 -5
  46. package/src/workspace/points-cli.ts +3 -0
  47. package/src/workspace/points.schema.json +18 -0
  48. package/src/workspace/points.test.ts +15 -14
  49. package/src/workspace/points.ts +4 -16
  50. package/src/workspace/read-contract.test.ts +8 -0
  51. package/src/workspace/reason-codes.test.ts +7 -7
  52. package/src/workspace/reason-codes.ts +3 -0
  53. package/src/workspace/records-cli.ts +14 -1
  54. package/src/workspace/records-formats.test.ts +15 -15
  55. package/src/workspace/records-since.test.ts +8 -7
  56. package/src/workspace/records.test.ts +1 -1
  57. package/src/workspace/status-contract.test.ts +1 -0
  58. package/src/workspace/status-stewards.ts +49 -3
  59. package/src/workspace/status.schema.json +19 -2
  60. package/src/workspace/status.ts +10 -0
  61. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  62. package/src/workspace/work.test.ts +17 -0
@@ -3,11 +3,11 @@
3
3
  * kinds without a lifecycle, a supersedes field of bare ids, schema files a
4
4
  * schema `$ref`s, and content-addressed ids.
5
5
  *
6
- * The fixtures are shaped like chud's records (jhgaylor/chud#78: units,
7
- * evidence named by the hash of its bytes, sessions, driver closures and
8
- * Markdown contracts, with draft-07 schemas that `$ref` a shared
9
- * `defs.schema.json`), trimmed to the fields that matter here. Each one reads
10
- * through a kind file alone: no reader code.
6
+ * The fixtures are shaped like a development-model plugin's records (ported
7
+ * from chud, jhgaylor/chud#78: units, evidence named by the hash of its
8
+ * bytes, sessions, driver closures and Markdown contracts, with draft-07
9
+ * schemas that `$ref` a shared `defs.schema.json`), trimmed to the fields
10
+ * that matter here. Each one reads through a kind file alone: no reader code.
11
11
  */
12
12
 
13
13
  import { execFileSync } from "node:child_process";
@@ -29,7 +29,7 @@ const validateOutput = new Ajv2020({ strict: true, allErrors: true }).compile(sc
29
29
  let root: string;
30
30
  beforeEach(() => {
31
31
  root = realpathSync(mkdtempSync(join(tmpdir(), "chant-record-formats-")));
32
- writeChudModel();
32
+ writeAcmeModel();
33
33
  });
34
34
  afterEach(() => rmSync(root, { recursive: true, force: true }));
35
35
 
@@ -72,9 +72,9 @@ async function failure(kind: string): Promise<{ code: string; message: string }>
72
72
  const codes = (r: { reasons: Array<{ code: string }> }) => r.reasons.map((x) => x.code);
73
73
  const warned = (r: { warnings: Array<{ code: string }> }) => r.warnings.map((x) => x.code);
74
74
 
75
- // ── chud's model, as kind files ──────────────────────────────────────────────
75
+ // ── acme's model, as kind files ──────────────────────────────────────────────
76
76
 
77
- const BASE = "https://schemas.example.test/chud-runtime/v0";
77
+ const BASE = "https://schemas.example.test/acme-runtime/v0";
78
78
  const ref = (name: string) => ({ $ref: `defs.schema.json#/definitions/${name}` });
79
79
 
80
80
  const DEFS = {
@@ -106,7 +106,7 @@ const UNIT_SCHEMA = {
106
106
  scope: { type: "array", minItems: 1, items: ref("text") },
107
107
  base_commit: ref("gitId"),
108
108
  result: { type: "object", required: ["commit", "ref"], properties: { commit: { type: ["string", "null"] }, ref: { type: ["string", "null"] } } },
109
- // A bare hash, as chud writes today, or a pin chant checks.
109
+ // A bare hash, as acme writes today, or a pin chant checks.
110
110
  evidence: {
111
111
  type: "array",
112
112
  items: { anyOf: [ref("sha256"), { type: "object", required: ["path", "sha256"], properties: { path: ref("text"), sha256: ref("sha256") } }] },
@@ -178,7 +178,7 @@ const CONTRACT_SCHEMA = {
178
178
  },
179
179
  };
180
180
 
181
- /** A kind file: data only, the way chud's model package would ship it. */
181
+ /** A kind file: data only, the way acme's model package would ship it. */
182
182
  function kindFile(kind: Record<string, unknown>): string {
183
183
  return `export const recordKind = ${JSON.stringify(kind, null, 2)};\n`;
184
184
  }
@@ -235,7 +235,7 @@ const KINDS: Record<string, Record<string, unknown>> = {
235
235
  },
236
236
  };
237
237
 
238
- function writeChudModel(): void {
238
+ function writeAcmeModel(): void {
239
239
  write("design/schemas/defs.schema.json", json(DEFS));
240
240
  write("design/schemas/unit.schema.json", json(UNIT_SCHEMA));
241
241
  write("design/schemas/evidence.schema.json", json(EVIDENCE_SCHEMA));
@@ -304,7 +304,7 @@ test("the output schema lists exactly the formats a kind file may name", () => {
304
304
  });
305
305
 
306
306
  describe("format json", () => {
307
- test("chud's units, sessions and driver closures read through kind files alone", async () => {
307
+ test("acme's units, sessions and driver closures read through kind files alone", async () => {
308
308
  write("design/units/U-0001.json", json(unit("U-0001", { outcome: "done", closed_at: "2026-09-24T11:00:00.000Z" })));
309
309
  write("design/units/U-0002.json", json(unit("U-0002")));
310
310
  write("design/sessions/S-0001.json", json(SESSION));
@@ -407,7 +407,7 @@ describe("the write commands through a declared kind (#2680)", () => {
407
407
  test.each(["unit", "evidence"])("refuse the declared %s kind with write-usage-invalid", async (kind) => {
408
408
  write(
409
409
  "chant.workspace.json",
410
- json({ name: "chud", schema: 1, members: [{ name: "design", dir: "design", kind: "other", because: "chud's records" }], records: [{ kind: `design/${kind}.kind.mjs` }] }),
410
+ json({ name: "acme", schema: 1, members: [{ name: "design", dir: "design", kind: "other", because: "acme's records" }], records: [{ kind: `design/${kind}.kind.mjs` }] }),
411
411
  );
412
412
  write("design/units/U-0001.json", json(unit("U-0001")));
413
413
  write("fields.json", "{}\n");
@@ -510,7 +510,7 @@ describe("kinds without a lifecycle", () => {
510
510
  const contract = (id: string, status: string, supersedes?: string | string[]) =>
511
511
  `---\nid: "${id}"\nstatus: "${status}"\n${supersedes === undefined ? "" : `supersedes: ${JSON.stringify(supersedes)}\n`}---\n\n# ${id}\n`;
512
512
 
513
- test("supersedes as one id, as chud's contracts write it, derives supersededBy under approval ranks", async () => {
513
+ test("supersedes as one id, as acme's contracts write it, derives supersededBy under approval ranks", async () => {
514
514
  write("design/contracts/C-001-first.md", contract("C-001", "approved"));
515
515
  write("design/contracts/C-002-second.md", contract("C-002", "approved", "C-001"));
516
516
  write("design/contracts/C-003-draft.md", contract("C-003", "draft", "C-002"));
@@ -588,7 +588,7 @@ describe("schema.refs", () => {
588
588
  // ── 4. Content-addressed ids ─────────────────────────────────────────────────
589
589
 
590
590
  describe("idFrom sha256", () => {
591
- test("chud's evidence reads with the hash of its bytes as its id, in the tree and under --at", async () => {
591
+ test("acme's evidence reads with the hash of its bytes as its id, in the tree and under --at", async () => {
592
592
  const h = writeEvidence();
593
593
  const bytes = readFileSync(join(root, "design", "evidence", `${h}.json`));
594
594
  expect(createHash("sha256").update(bytes).digest("hex")).toBe(h);
@@ -3,7 +3,8 @@
3
3
  * and the comparison. The fixture is a repository with commits for a review
4
4
  * session's open and close: between them the session closes with two
5
5
  * verdicts, ref-001 gains the two reviews that name it and is ratified,
6
- * a new decision supersedes ref-002, and ref-002's pin moves. Every document
6
+ * a new decision (ref-004, after the reference workspace's own ref-003)
7
+ * supersedes ref-002, and ref-002's pin moves. Every document
7
8
  * validates against records-since.schema.json.
8
9
  */
9
10
 
@@ -56,8 +57,8 @@ function sessionHistory(): { root: string; before: string; open: string; close:
56
57
  const text = readFileSync(ref002, "utf-8");
57
58
  writeFileSync(ref002, text.replace(/sha256: "[0-9a-f]{64}"/, `sha256: "${"a".repeat(64)}"`));
58
59
  writeFileSync(
59
- join(root, "decisions", "ref-003-a-successor.md"),
60
- text.replace(/^id: .*$/m, 'id: "ref-003"').replace(/^supersedes: \[\]$/m, 'supersedes:\n - decision: "ref-002"'),
60
+ join(root, "decisions", "ref-004-a-successor.md"),
61
+ text.replace(/^id: .*$/m, 'id: "ref-004"').replace(/^supersedes: \[\]$/m, 'supersedes:\n - decision: "ref-002"'),
61
62
  );
62
63
  const close = commitAll(root, "close S-0002");
63
64
  return { root, before, open, close };
@@ -85,11 +86,11 @@ describe("records --since (#2673)", () => {
85
86
  expect(doc.at).toBe(h.close);
86
87
  expect(doc.kind).toEqual({ name: "decision", schema: "urn:intentius:chant:decision:1", file: "decisions/decision.kind.mjs" });
87
88
  expect(doc.changes).toEqual([
88
- { change: "new", id: "ref-003", path: "decisions/ref-003-a-successor.md", state: "decided" },
89
+ { change: "new", id: "ref-004", path: "decisions/ref-004-a-successor.md", state: "decided" },
89
90
  { change: "state", id: "ref-001", from: "decided", to: "ratified" },
90
91
  { change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" },
91
92
  { change: "verdict", id: "ref-001", principal: "bob", verdict: "agree", index: 1, session: "S-0002" },
92
- { change: "supersession", id: "ref-003", supersedes: "ref-002" },
93
+ { change: "supersession", id: "ref-004", supersedes: "ref-002" },
93
94
  { change: "pin", id: "ref-002", path: "design/screens/home.json", from: expect.stringMatching(/^[0-9a-f]{64}$/), to: "a".repeat(64) },
94
95
  ]);
95
96
  expect(doc.summary).toEqual({ new: 1, removed: 0, state: 1, verdict: 2, supersession: 1, pin: 1 });
@@ -105,11 +106,11 @@ describe("records --since (#2673)", () => {
105
106
  });
106
107
 
107
108
  test("without --at it compares with the working tree, and a record deleted there is removed", async () => {
108
- rmSync(join(h.root, "decisions", "ref-003-a-successor.md"));
109
+ rmSync(join(h.root, "decisions", "ref-004-a-successor.md"));
109
110
  try {
110
111
  const doc = ok(await since({ kind: DECISIONS_KIND, since: h.close, cwd: h.root }));
111
112
  expect(doc.at).toBeNull();
112
- expect(doc.changes).toEqual([{ change: "removed", id: "ref-003", path: "decisions/ref-003-a-successor.md", state: "decided" }]);
113
+ expect(doc.changes).toEqual([{ change: "removed", id: "ref-004", path: "decisions/ref-004-a-successor.md", state: "decided" }]);
113
114
  } finally {
114
115
  git(h.root, "checkout", "--", "decisions");
115
116
  }
@@ -311,7 +311,7 @@ describe("a decision that originates in the workspace (#2654)", () => {
311
311
 
312
312
  test("takes a session and an issue, and nothing else", async () => {
313
313
  const base = workspaceDecision("ws-001");
314
- write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "jhgaylor/chud#77"\n'));
314
+ write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "acme/example#77"\n'));
315
315
  write("ws-002-b.md", base.replace('id: "ws-001"', 'id: "ws-002"').replace(' member: "app"\n', ' member: "app"\n session: null\n'));
316
316
  write("ws-003-c.md", base.replace('id: "ws-001"', 'id: "ws-003"').replace(' member: "app"\n', ' member: "app"\n row: "Sort order"\n'));
317
317
  write("ws-004-d.md", base.replace('id: "ws-001"', 'id: "ws-004"').replace(' member: "app"\n', ""));
@@ -311,6 +311,7 @@ describe("chant workspace status on built workspaces", () => {
311
311
  { name: "inference", broker: null, scope: [] },
312
312
  ],
313
313
  isolation: null,
314
+ intent: null,
314
315
  },
315
316
  ]);
316
317
  });
@@ -16,7 +16,11 @@
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;
@@ -27,7 +31,7 @@
27
31
 
28
32
  import { existsSync, realpathSync } from "node:fs";
29
33
  import { dirname, join, relative, resolve } from "node:path";
30
- import { discoverStewards } from "../op/discover";
34
+ import { discoverOps, discoverStewards } from "../op/discover";
31
35
  import { stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
32
36
  import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
33
37
  import { readConvergeLedger, type ConvergeTickRecord } from "../lifecycle/converge-ledger";
@@ -152,7 +156,9 @@ export interface StatusSteward {
152
156
  ops: StatusStewardOp[];
153
157
  /**
154
158
  * 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.
159
+ * newest run is the steward's own and stopped on a question. Its declared
160
+ * Ops come first, in `ops` order, then any other Op of the member whose
161
+ * newest run names the steward, by Op name.
156
162
  */
157
163
  waiting: (StatusStewardWait & { op: string; run: string })[];
158
164
  }
@@ -325,5 +331,45 @@ export async function readMemberStewards(
325
331
  waiting,
326
332
  });
327
333
  }
334
+ if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards, found, reasons);
328
335
  return { stewards, reasons };
329
336
  }
337
+
338
+ /**
339
+ * The waiting runs of the member's Ops that no steward lists, each under the
340
+ * steward its record names. A steward's process can start an Op itself
341
+ * with `CHANT_STEWARD` set (studio#137), and the run ledger then records
342
+ * the run as the steward's though the declaration does not list the Op.
343
+ * Import failures are left out here: `discoverStewards` read the same files
344
+ * and reported them as `stewards-unreadable`.
345
+ */
346
+ async function addUndeclaredWaits(
347
+ memberDir: string,
348
+ stewards: StatusSteward[],
349
+ found: Awaited<ReturnType<typeof discoverStewards>>["stewards"],
350
+ reasons: MemberStewards["reasons"],
351
+ ): Promise<void> {
352
+ const declared = new Set([...found.values()].flatMap(({ declaration }) => declaration.ops.map((op) => op.name)));
353
+ let ops: OpConfig[];
354
+ try {
355
+ ops = [...(await discoverOps({ cwd: memberDir })).ops.values()].map((d) => d.config).filter((op) => !declared.has(op.name));
356
+ } catch {
357
+ return;
358
+ }
359
+ const byName = new Map(stewards.map((s) => [s.name, s]));
360
+ for (const op of ops.sort((a, b) => a.name.localeCompare(b.name))) {
361
+ const opEnv = runEnvOf(op);
362
+ try {
363
+ const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
364
+ const steward = newest?.steward ? byName.get(newest.steward) : undefined;
365
+ if (steward && newest?.status === "waiting" && newest.point) {
366
+ steward.waiting.push({ op: op.name, run: newest.id, ...waitOf(newest.point) });
367
+ }
368
+ } catch (err) {
369
+ reasons.push({
370
+ code: "steward-runs-unreadable",
371
+ message: `${opEnv}/runs__${op.name}.jsonl: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
372
+ });
373
+ }
374
+ }
375
+ }
@@ -335,7 +335,7 @@
335
335
  }
336
336
  },
337
337
  "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.",
338
+ "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.",
339
339
  "type": "array",
340
340
  "items": {
341
341
  "allOf": [
@@ -362,10 +362,27 @@
362
362
  "since": { "type": "string", "description": "When the run stopped on it." }
363
363
  }
364
364
  },
365
+ "intent": {
366
+ "type": "object",
367
+ "required": ["id", "state", "question", "choice", "answer", "decided_by", "decided_on"],
368
+ "properties": {
369
+ "id": { "type": "string", "description": "The record's id, as the box block names it." },
370
+ "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." },
371
+ "question": { "type": ["string", "null"], "description": "The question the record answers." },
372
+ "choice": { "description": "The record's choice as written, such as { option, reason }, or null while the record is proposed." },
373
+ "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." },
374
+ "decided_by": { "type": ["string", "null"], "description": "Who decided it, or null while it is proposed." },
375
+ "decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." }
376
+ }
377
+ },
365
378
  "box": {
366
379
  "type": "object",
367
- "required": ["capabilities", "isolation"],
380
+ "required": ["capabilities", "isolation", "intent"],
368
381
  "properties": {
382
+ "intent": {
383
+ "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.",
384
+ "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/intent" }]
385
+ },
369
386
  "isolation": {
370
387
  "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
388
  "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,13 @@ 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;
181
189
  }
182
190
 
183
191
  /**
@@ -377,6 +385,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
377
385
  const now = query.now ?? new Date().toISOString();
378
386
 
379
387
  const isolation = new Map(resolveBoxes(declaration).map((b) => [b.member.name, b.isolation]));
388
+ const intents = new Map((await resolveBoxIntents(declaration, found.dir)).map((i) => [i.member, i.record?.intent ?? unresolvedIntent(i.id)]));
380
389
  const members: StatusMember[] = [];
381
390
  for (const m of declaration.members) {
382
391
  const environments: StatusEnvironment[] = [];
@@ -399,6 +408,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
399
408
  : {
400
409
  capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
401
410
  isolation: isolation.get(m.name) ?? null,
411
+ intent: intents.get(m.name) ?? null,
402
412
  },
403
413
  stewards: stewards.stewards,
404
414
  stewardReasons: stewards.reasons,
@@ -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
  });