@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
@@ -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);
@@ -164,6 +164,7 @@
164
164
  "record-sign-failed",
165
165
  "source-harvest-not-proposed",
166
166
  "record-state-not-initial",
167
+ "ratify-quorum-not-met",
167
168
  "record-unparseable",
168
169
  "record-schema-invalid",
169
170
  "record-id-duplicate",
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
16
16
  import { workingTreeSource } from "./record-source";
17
- import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest, type QuorumOptions, type RecordEntry } from "./records";
17
+ import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest as rawDigest, type QuorumOptions, type RecordEntry } from "./records";
18
18
 
19
19
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
20
20
  const DECISIONS = join(REPO, "docs", "design", "decisions");
@@ -34,6 +34,13 @@ function review(reviewer: string, verdict: string, extra: Record<string, string>
34
34
  .join("\n");
35
35
  }
36
36
 
37
+ /**
38
+ * A decision's digest: the decision kind names a ratified state, so its
39
+ * state line leaves the digest with the reviews and seal blocks (#2873).
40
+ * The rule tests below hold for that list as for the reviews block alone.
41
+ */
42
+ const recordTextDigest = (text: string, fields: string | readonly string[] | null = ["reviews", "seal", "state"]) => rawDigest(text, fields);
43
+
37
44
  const BARE = recordTextDigest(SAMPLE);
38
45
 
39
46
  describe("recordTextDigest", () => {
@@ -41,7 +48,7 @@ describe("recordTextDigest", () => {
41
48
  expect(BARE).toMatch(/^[0-9a-f]{64}$/);
42
49
  const lines = SAMPLE.split("\n");
43
50
  const without = lines.filter((l) => l !== "reviews: []").join("\n");
44
- expect(recordTextDigest(SAMPLE)).toBe(recordTextDigest(without, null));
51
+ expect(rawDigest(SAMPLE)).toBe(rawDigest(without, null));
45
52
  });
46
53
 
47
54
  test("does not move when a verdict is added, changed or removed", () => {
@@ -78,7 +85,7 @@ describe("recordTextDigest", () => {
78
85
  const file = join(dir, "ws-003.md");
79
86
  writeFileSync(file, withReviews(`${review("alice", "agree")}\n${review("bob", "dissent")}`));
80
87
  // The recipe in docs/src/content/docs/cli/workspace-records.mdx.
81
- const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^reviews[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
88
+ const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^(reviews|seal|state)[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
82
89
  const out = execFileSync("sh", ["-c", awk], { encoding: "utf-8" });
83
90
  expect(out.split(" ")[0]).toBe(BARE);
84
91
  } finally {
@@ -164,7 +164,8 @@ describe("records review --session (#2693)", () => {
164
164
  const root = declared();
165
165
  const { path } = await opened(root);
166
166
  const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
167
- const digest = recordTextDigest(readFileSync(decision, "utf-8"));
167
+ // The decision kind names a ratified state, so the state leaves the digest too (#2873).
168
+ const digest = recordTextDigest(readFileSync(decision, "utf-8"), ["reviews", "seal", "state"]);
168
169
 
169
170
  const dry = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", dryRun: true, cwd: root });
170
171
  review.expectValid(dry);
@@ -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
  }
@@ -10,7 +10,8 @@ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
13
- import { parseFrontMatter, recordTextDigest } from "./records";
13
+ import { parseFrontMatter, recordTextDigest as textDigest } from "./records";
14
+ import { queryRecords } from "./records-cli";
14
15
  import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
15
16
 
16
17
  const REPO = join(import.meta.dirname, "..", "..", "..", "..");
@@ -19,6 +20,9 @@ const KIND = "decisions/decision.kind.mjs";
19
20
 
20
21
  type Data = Record<string, unknown>;
21
22
 
23
+ /** The digest of a decision: the kind names a ratified state, so the state leaves the digest with the reviews and seal (#2873). */
24
+ const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state"]);
25
+
22
26
  const SAMPLE = (() => {
23
27
  const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
24
28
  if (!fm.ok) throw new Error(fm.message);
@@ -282,6 +286,7 @@ describe("records amend", () => {
282
286
  test("a decided record may be ratified, re-pin its evidence and take reviews, but not move down", async () => {
283
287
  const evidence = [{ title: "A spec", url: "https://example.com/spec" }];
284
288
  expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
289
+ for (const by of ["alice", "bob"]) await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by, cwd: dir });
285
290
  expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state"] });
286
291
  put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
287
292
  expect(code(await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "withdrawn" }), cwd: dir }))).toBe("amend-supersede-instead");
@@ -319,6 +324,101 @@ describe("records amend", () => {
319
324
  });
320
325
  });
321
326
 
327
+ describe("ratifying against the quorum (#2873)", () => {
328
+ const ratify = (id = "ws-001") => amendRecord({ kind: KIND, id, fields: fields({ state: "ratified" }), cwd: dir });
329
+ const agree = (by: string, id = "ws-001") => reviewRecord({ kind: KIND, id, verdict: "agree", by, cwd: dir });
330
+ async function quorumOf(id: string) {
331
+ const doc = await queryRecords({ kind: KIND, cwd: dir });
332
+ if ("error" in doc) throw new Error(doc.error.message);
333
+ return doc.records.find((r) => r.id === id)!.quorum!;
334
+ }
335
+
336
+ test("below the quorum, ratified is refused with the count and what did not count, and nothing is written", async () => {
337
+ expect(SAMPLE.decided_by).toBe("lex00");
338
+ let before = snapshot();
339
+ let doc = await ratify();
340
+ expect(code(doc)).toBe("ratify-quorum-not-met");
341
+ expect("error" in doc && doc.error.message).toMatch(/^ws-001 can't be ratified yet: its quorum needs 2 agreeing verdicts that count, and it has 0\. Add verdicts with chant workspace records review ws-001/);
342
+ expect(touched(before, snapshot())).toEqual([]);
343
+ // One agreement and the decider's own: one counts.
344
+ await agree("alice");
345
+ await agree("lex00");
346
+ before = snapshot();
347
+ doc = await ratify();
348
+ expect("error" in doc && doc.error.message).toMatch(/it has 1 \(not counted: lex00, review-decider\)/);
349
+ expect(touched(before, snapshot())).toEqual([]);
350
+ });
351
+
352
+ test("at the quorum, ratified is taken, and the verdicts still count on the ratified record", async () => {
353
+ await agree("alice");
354
+ await agree("bob");
355
+ const judged = recordTextDigest(text("ws-001-one.md"));
356
+ expect(await ratify()).toMatchObject({ changed: ["state"] });
357
+ expect(data("ws-001-one.md").state).toBe("ratified");
358
+ expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
359
+ expect(await quorumOf("ws-001")).toMatchObject({ need: 2, agreed: 2, met: true, notCounted: [] });
360
+ });
361
+
362
+ test("a met quorum with an open concern ratifies, and the concern stays listed", async () => {
363
+ await agree("alice");
364
+ await agree("bob");
365
+ await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "carol", note: "It leaves out X.", cwd: dir });
366
+ expect(code(await ratify())).toBe("ok");
367
+ expect(await quorumOf("ws-001")).toMatchObject({ met: true, metWithObjections: true, openConcerns: [{ reviewer: "carol" }] });
368
+ });
369
+
370
+ test("verdicts on text an amendment changed do not count toward ratifying", async () => {
371
+ await agree("alice");
372
+ await agree("bob");
373
+ await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence: [] }), cwd: dir });
374
+ const doc = await ratify();
375
+ expect("error" in doc && doc.error.message).toMatch(/it has 0 \(not counted: alice, review-older-digest; bob, review-older-digest\)/);
376
+ });
377
+
378
+ test("the amendment that ratifies is checked as written, with its own changes to the reviews and pins", async () => {
379
+ await agree("alice");
380
+ const digest = recordTextDigest(text("ws-001-one.md"));
381
+ const reviews = [...(data("ws-001-one.md").reviews as Data[]), { reviewer: "bob", verdict: "agree", on: "2026-09-26", digest }];
382
+ expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews"] });
383
+ put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
384
+ await agree("alice", "ws-006");
385
+ await agree("bob", "ws-006");
386
+ // Re-pinning in the same write moves the digest, so the verdicts no longer match what would be ratified.
387
+ const doc = await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "ratified", evidence: [] }), cwd: dir });
388
+ expect(code(doc)).toBe("ratify-quorum-not-met");
389
+ });
390
+
391
+ test("records new refuses a record that opens ratified below its quorum", async () => {
392
+ const fresh = decision({ title: "Straight in", state: "ratified" });
393
+ delete fresh.id;
394
+ const before = snapshot();
395
+ expect(code(await newRecord({ kind: KIND, fields: fields(fresh), cwd: dir }))).toBe("ratify-quorum-not-met");
396
+ expect(touched(before, snapshot())).toEqual([]);
397
+ expect(code(await newRecord({ kind: KIND, fields: fields({ ...fresh, state: "decided" }), cwd: dir }))).toBe("ok");
398
+ });
399
+
400
+ test("a kind without reviews.ratified, or without reviews, takes ratified with no quorum, and its digest keeps the state", async () => {
401
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
402
+ const withRatified = readFileSync(kindFile, "utf-8");
403
+ expect(withRatified).toContain('reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },');
404
+ writeFileSync(kindFile, withRatified.replace(', ratified: "ratified" }', " }"));
405
+ const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal"]);
406
+ expect(await ratify()).toMatchObject({ changed: ["state"] });
407
+ expect(textDigest(text("ws-001-one.md"), ["reviews", "seal"])).not.toBe(judged);
408
+ writeFileSync(kindFile, withRatified.replace(' reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },\n', ""));
409
+ put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
410
+ expect(await ratify("ws-006")).toMatchObject({ changed: ["state"] });
411
+ });
412
+
413
+ test("a kind whose reviews.ratified is not one of its states is refused", async () => {
414
+ const kindFile = join(dir, "decisions", "decision.kind.mjs");
415
+ writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace('ratified: "ratified" }', 'ratified: "approved" }'));
416
+ const doc = await ratify();
417
+ expect(code(doc)).toBe("kind-invalid");
418
+ expect("error" in doc && doc.error.message).toMatch(/reviews\.ratified must be one of states/);
419
+ });
420
+ });
421
+
322
422
  describe("records review", () => {
323
423
  test("appends a dated verdict bound to the digest of the text it judged", async () => {
324
424
  const judged = recordTextDigest(text("ws-001-one.md"));
@@ -29,6 +29,7 @@ import type { CommandContext } from "../cli/registry";
29
29
  import type { ReasonCode } from "./reason-codes";
30
30
  import { gitRoot, workingTreeSource, type RecordSource } from "./record-source";
31
31
  import {
32
+ computeQuorum,
32
33
  loadRecordKind,
33
34
  parseFrontMatter,
34
35
  readRecords,
@@ -41,7 +42,8 @@ import {
41
42
  type RecordEntry,
42
43
  type RecordWarning,
43
44
  } from "./records";
44
- import { declaredKindFiles, pinRoot, realpathOr } from "./records-cli";
45
+ import { declaredKindFiles, pinRoot, quorumOptionsFor, realpathOr } from "./records-cli";
46
+ import { policyAtBase, resolveBase } from "./trust/provenance";
45
47
  import { WorkspaceReadError } from "./declaration";
46
48
  import { findSessionKinds, headCommit, sessionKindsFor } from "./session-kinds";
47
49
  import { workingTree } from "./tree";
@@ -69,6 +71,7 @@ export const NEW_ERROR_CODES = [
69
71
  "record-sign-failed",
70
72
  "source-harvest-not-proposed",
71
73
  "record-state-not-initial",
74
+ "ratify-quorum-not-met",
72
75
  ...RECORD_REASON_CODES,
73
76
  ] as const satisfies readonly ReasonCode[];
74
77
 
@@ -82,6 +85,7 @@ export const AMEND_ERROR_CODES = [
82
85
  "record-closed",
83
86
  "amend-supersede-instead",
84
87
  "record-sign-failed",
88
+ "ratify-quorum-not-met",
85
89
  ...RECORD_REASON_CODES,
86
90
  ] as const satisfies readonly ReasonCode[];
87
91
 
@@ -386,6 +390,11 @@ export function overlay(base: RecordSource, path: string, text: string): RecordS
386
390
  * record's warnings.
387
391
  */
388
392
  export async function validateWrite(o: Opened, before: RecordEntry[], path: string, text: string, base: RecordSource = o.source): Promise<RecordWarning[]> {
393
+ return (await validatedEntry(o, before, path, text, base)).warnings;
394
+ }
395
+
396
+ /** {@link validateWrite}, returning the written record as the read found it. */
397
+ export async function validatedEntry(o: Opened, before: RecordEntry[], path: string, text: string, base: RecordSource = o.source): Promise<RecordEntry> {
389
398
  const after = await readAll(o, overlay(base, path, text));
390
399
  const written = after.find((e) => e.path === path);
391
400
  if (!written) throw new RecordWriteError("record-path-unmatched", `${path} is not a file the kind ${o.view.name} reads`);
@@ -398,7 +407,30 @@ export async function validateWrite(o: Opened, before: RecordEntry[], path: stri
398
407
  const gained = e.reasons.find((r) => !had.get(e.path)?.has(`${r.code}\0${r.message}`));
399
408
  if (gained) throw new RecordWriteError(gained.code, `writing ${path} would make ${e.path} invalid: ${gained.message}`);
400
409
  }
401
- return written.warnings;
410
+ return written;
411
+ }
412
+
413
+ /**
414
+ * Refuse a written record in the kind's ratified state (`reviews.ratified`,
415
+ * #2873) whose quorum is not met. The quorum is computed on the record as
416
+ * written, the way `records --json` computes it: the need from the
417
+ * declaration in the working tree, and agents and seals from the policy at
418
+ * base. A met quorum with open concerns is met, and the concerns stay listed.
419
+ */
420
+ async function refuseRatifyBelowQuorum(o: Opened, written: RecordEntry, id: string): Promise<void> {
421
+ const ratified = o.loaded.kind.reviews?.ratified;
422
+ if (ratified === undefined || written.state !== ratified) return;
423
+ const top = gitRoot(o.root);
424
+ const policy = policyAtBase(top ?? o.root, top ? resolveBase(top) : { commit: null, from: null });
425
+ const tree = workingTree(o.workspaceRoot === "." ? o.root : join(o.root, ...o.workspaceRoot.split("/")));
426
+ const options = await quorumOptionsFor(o.loaded, tree, policy);
427
+ const q = options ? computeQuorum(o.loaded.kind, written, options) : null;
428
+ if (q === null || q.met) return;
429
+ const notCounted = q.notCounted.length > 0 ? ` (not counted: ${q.notCounted.map((v) => `${v.reviewer}, ${v.reason!.code}`).join("; ")})` : "";
430
+ throw new RecordWriteError(
431
+ "ratify-quorum-not-met",
432
+ `${id} can't be ${ratified} yet: its quorum needs ${q.need} agreeing verdicts that count, and it has ${q.agreed}${notCounted}. Add verdicts with chant workspace records review ${id} --verdict agree --by <reviewer>, then set ${o.loaded.kind.stateField} to ${ratified} again`,
433
+ );
402
434
  }
403
435
 
404
436
  export function findRecord(entries: RecordEntry[], id: string, kind: string): RecordEntry & { data: Record<string, unknown> } {
@@ -726,7 +758,9 @@ export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promis
726
758
  let text = renderRecord(data, title ? `\n# ${title}\n` : "");
727
759
  let seal: AuthorSeal | undefined;
728
760
  if (opts.sign !== undefined) ({ text, seal } = await sealAuthor(o, text, data, id, opts.sign, opts.cwd));
729
- const warnings = await validateWrite(o, before, path, text);
761
+ const written = await validatedEntry(o, before, path, text);
762
+ await refuseRatifyBelowQuorum(o, written, id);
763
+ const warnings = written.warnings;
730
764
  if (!opts.dryRun) writeFileSync(abs(o, path), text, { flag: "wx" });
731
765
  return {
732
766
  $schema: RECORDS_NEW_SCHEMA_ID,
@@ -823,16 +857,26 @@ export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Pr
823
857
  if (opts.sign !== undefined) {
824
858
  ({ text, seal } = await sealAuthor(o, text, merged, opts.id, opts.sign, opts.cwd));
825
859
  merged[RECORD_SEAL_FIELD] = seal;
826
- } else if (sealable && recordTextDigest(text, digestFields(kind), kind.format) !== recordTextDigest(current, digestFields(kind), kind.format)) {
860
+ } else if (
861
+ sealable &&
862
+ (recordTextDigest(text, digestFields(kind), kind.format) !== recordTextDigest(current, digestFields(kind), kind.format) ||
863
+ (kind.stateField !== undefined && stableJson(old[kind.stateField]) !== stableJson(merged[kind.stateField])))
864
+ ) {
865
+ // The seal signs the state as well as the digest, and a kind with reviews.ratified leaves the state out of the digest (#2873).
827
866
  const signer = (old[RECORD_SEAL_FIELD] as { signer?: unknown } | null)?.signer;
828
867
  delete merged[RECORD_SEAL_FIELD];
829
868
  const dropped = removeField(text, RECORD_SEAL_FIELD, merged);
830
869
  if (dropped === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: the ${RECORD_SEAL_FIELD} block can't be removed without changing the rest of the file`);
831
870
  text = dropped;
832
- sealDropped = `${opts.id} was sealed${typeof signer === "string" ? ` by ${signer}` : ""}, and the amendment moves its digest, so the seal was removed: seal it again with records amend ${opts.id} --sign`;
871
+ sealDropped = `${opts.id} was sealed${typeof signer === "string" ? ` by ${signer}` : ""}, and the amendment moves its digest or its state, so the seal was removed: seal it again with records amend ${opts.id} --sign`;
833
872
  }
834
873
  if (stableJson(old[RECORD_SEAL_FIELD]) !== stableJson(merged[RECORD_SEAL_FIELD])) changed.push(RECORD_SEAL_FIELD);
835
- const warnings = changed.length === 0 ? target.warnings : await validateWrite(o, before, target.path, text);
874
+ let warnings = target.warnings;
875
+ if (changed.length > 0) {
876
+ const written = await validatedEntry(o, before, target.path, text);
877
+ if (kind.stateField !== undefined && changed.includes(kind.stateField)) await refuseRatifyBelowQuorum(o, written, opts.id);
878
+ warnings = written.warnings;
879
+ }
836
880
  if (!opts.dryRun && changed.length > 0) writeFileSync(abs(o, target.path), text);
837
881
  return {
838
882
  $schema: RECORDS_AMEND_SCHEMA_ID,
@@ -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', ""));
@@ -166,10 +166,16 @@ export const RECORD_SEAL_FIELD = "seal";
166
166
  /**
167
167
  * The top-level fields {@link recordTextDigest} leaves out for a kind: its
168
168
  * reviews list and {@link RECORD_SEAL_FIELD} when it has a reviews list, and
169
- * nothing when it has none (#2672, #2688).
169
+ * nothing when it has none (#2672, #2688). A kind that names a ratified
170
+ * state (`reviews.ratified`, #2873) leaves its state field out too, so
171
+ * moving a record to that state keeps the verdicts that ratified it
172
+ * counting.
170
173
  */
171
- export function digestFields(kind: Pick<RecordKind, "reviews">): string[] | null {
172
- return kind.reviews ? [kind.reviews.field, RECORD_SEAL_FIELD] : null;
174
+ export function digestFields(kind: Pick<RecordKind, "reviews" | "stateField">): string[] | null {
175
+ if (!kind.reviews) return null;
176
+ const fields = [kind.reviews.field, RECORD_SEAL_FIELD];
177
+ if (kind.reviews.ratified !== undefined && kind.stateField !== undefined) fields.push(kind.stateField);
178
+ return fields;
173
179
  }
174
180
 
175
181
  /** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
@@ -330,8 +336,14 @@ export const recordKindSchema = z
330
336
  * entry holds `reviewer`, `verdict` (agree, dissent or abstain) and
331
337
  * optionally `digest`, `note`, `proposes`, `addressed_by` and
332
338
  * `withdrawn_on`. Optional.
339
+ *
340
+ * `ratified` names the state a record enters only once its quorum is met
341
+ * (#2873): `records new` and `records amend` refuse it below the quorum,
342
+ * with ratify-quorum-not-met. With it the digest leaves the state field
343
+ * out, so the move to that state keeps the verdicts counting. It must be
344
+ * one of `states`.
333
345
  */
334
- reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
346
+ reviews: z.object({ field: z.string().min(1), decider: z.string().min(1), ratified: z.string().min(1).optional() }).strict().optional(),
335
347
  /**
336
348
  * The front-matter field a new record's proposer is named on, when the
337
349
  * kind opts in (#2756). `records new --by` and the MCP `records-new`
@@ -448,6 +460,10 @@ export const recordKindSchema = z
448
460
  message: "a kind without states cannot have proposedBy: there is no first state for a record to open in",
449
461
  path: ["proposedBy"],
450
462
  })
463
+ .refine((k) => k.reviews?.ratified === undefined || (k.states ?? []).includes(k.reviews.ratified), {
464
+ message: "reviews.ratified must be one of states: it names the state a record enters once its quorum is met",
465
+ path: ["reviews", "ratified"],
466
+ })
451
467
  .refine((k) => (k.closedStates ?? []).every((s) => (k.states ?? []).includes(s)), {
452
468
  message: "every closed state must be listed in states",
453
469
  path: ["closedStates"],
@@ -731,7 +747,8 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
731
747
  * kind with a reviews list the list is the reviews field and `seal`, the
732
748
  * record's author seal (#2688; {@link digestFields}), so sealing a record
733
749
  * leaves its digest where it was too. A record with no `seal` hashes exactly
734
- * as it did before author seals existed.
750
+ * as it did before author seals existed. A kind with `reviews.ratified` adds
751
+ * its state field (#2873).
735
752
  *
736
753
  * The rule, which a hand-editor can follow with a text editor and
737
754
  * `sha256sum`:
@@ -311,10 +311,39 @@ describe("chant workspace status on built workspaces", () => {
311
311
  { name: "inference", broker: null, scope: [] },
312
312
  ],
313
313
  isolation: null,
314
+ intent: null,
315
+ services: [],
314
316
  },
315
317
  ]);
316
318
  });
317
319
 
320
+ test("each member's box lists the services its block declares, every field present (#2880)", async () => {
321
+ const root = repo({
322
+ "chant.workspace.json": declaration([
323
+ {
324
+ name: "box",
325
+ dir: "box",
326
+ kind: "other",
327
+ because: "the box's steward and its Ops",
328
+ box: {
329
+ services: [
330
+ { name: "app", cmd: "${HOME}/box/run-app.sh", duration: "3s", health: "http://127.0.0.1:5173/health" },
331
+ { name: "door", cmd: "${HOME}/box/run-door.sh", needs: ["app"], httpPort: 8080 },
332
+ { name: "site", cmd: "${HOME}/box/run-site.sh", optional: true },
333
+ ],
334
+ },
335
+ },
336
+ ]),
337
+ });
338
+ const doc = result(await workspaceStatus({ cwd: root, env: "prod" }));
339
+ expectValid(doc);
340
+ expect(doc.members[0].box?.services).toEqual([
341
+ { name: "app", cmd: "${HOME}/box/run-app.sh", needs: [], httpPort: null, duration: "3s", health: "http://127.0.0.1:5173/health", optional: false },
342
+ { name: "door", cmd: "${HOME}/box/run-door.sh", needs: ["app"], httpPort: 8080, duration: null, health: null, optional: false },
343
+ { name: "site", cmd: "${HOME}/box/run-site.sh", needs: [], httpPort: null, duration: null, health: null, optional: true },
344
+ ]);
345
+ });
346
+
318
347
  test("every failure validates with its code", async () => {
319
348
  const empty = repo({});
320
349
  const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-status-nogit-")));
@@ -599,8 +628,9 @@ describe("stewards in chant workspace status --json (#2731)", () => {
599
628
  lastTick: null,
600
629
  changesCheckout: false,
601
630
  workLease: null,
631
+ beside: null,
602
632
  },
603
- { name: "box-release", schedule: null, env: "local", lastRun: null, lastTick: null, changesCheckout: false, workLease: null },
633
+ { name: "box-release", schedule: null, env: "local", lastRun: null, lastTick: null, changesCheckout: false, workLease: null, beside: null },
604
634
  {
605
635
  name: "box-dispatch",
606
636
  schedule: { cron: "*/5 * * * *", overlap: "skip" },
@@ -621,6 +651,7 @@ describe("stewards in chant workspace status --json (#2731)", () => {
621
651
  },
622
652
  ],
623
653
  },
654
+ beside: null,
624
655
  },
625
656
  ]);
626
657
  // #2749: nothing the steward runs waits on a decision point.