@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
@@ -67,6 +67,16 @@ export type KindOutputs =
67
67
  /** The names listed here, plus any the member entry lists in `outputs` (`other`, and kinds from a package). */
68
68
  | { from: "declared"; names: string[] };
69
69
 
70
+ /**
71
+ * How `chant workspace graph` reads a member of a package's kind (#2874): a
72
+ * lexicon, by name, and that lexicon's config namespace for a reader project
73
+ * chant writes itself. See {@link substituteGraphConfig} for the placeholders.
74
+ */
75
+ export interface KindGraph {
76
+ lexicon: string;
77
+ config: Record<string, unknown>;
78
+ }
79
+
70
80
  export interface MemberKind {
71
81
  name: string;
72
82
  /** One line for listings and error messages. */
@@ -84,6 +94,10 @@ export interface MemberKind {
84
94
  outputs: KindOutputs;
85
95
  /** Where the kind comes from: `builtin`, or the package that supplies it. */
86
96
  source: string;
97
+ /** The supplying package's directory, absolute; unset for a built-in kind. */
98
+ packageDir?: string;
99
+ /** How `workspace graph` reads a member of this kind (#2874); unset, the member is `kind-not-run`. */
100
+ graph?: KindGraph;
87
101
  }
88
102
 
89
103
  export interface KindRegistry {
@@ -310,7 +324,7 @@ export function parseKindData(text: string, source: string): KindData {
310
324
  const problems: string[] = [];
311
325
  const kinds: MemberKind[] = [];
312
326
  const seen = new Set<string>();
313
- for (const k of (raw as { kinds: { name: string; description: string; precedence: number; probe: FileProbe; outputs?: string[] }[] }).kinds) {
327
+ for (const k of (raw as { kinds: { name: string; description: string; precedence: number; probe: FileProbe; outputs?: string[]; graph?: KindGraph }[] }).kinds) {
314
328
  if (BUILTIN_KIND_NAMES.includes(k.name)) {
315
329
  problems.push(`${source}: kind ${k.name} is built in and can't be supplied by a package`);
316
330
  continue;
@@ -331,6 +345,7 @@ export function parseKindData(text: string, source: string): KindData {
331
345
  shape: "member",
332
346
  outputs: { from: "declared", names: [...(k.outputs ?? [])] },
333
347
  source,
348
+ ...(k.graph ? { graph: { lexicon: k.graph.lexicon, config: structuredClone(k.graph.config) } } : {}),
334
349
  });
335
350
  }
336
351
  return { kinds, problems };
@@ -389,7 +404,49 @@ export function readPackageKinds(packageDir: string, source?: string): PackageKi
389
404
  } catch {
390
405
  return { kinds: [], problems: [`${name}: exports["${KINDS_SUBPATH}"] names ${target}, which does not exist`], file };
391
406
  }
392
- return { ...parseKindData(text, name), file };
407
+ const data = parseKindData(text, name);
408
+ const packageName = typeof pkg.name === "string" ? pkg.name : undefined;
409
+ for (const k of data.kinds) {
410
+ k.packageDir = packageDir;
411
+ if (k.graph && packageName !== lexiconPackageName(k.graph.lexicon)) {
412
+ data.problems.push(
413
+ `${name}: kind ${k.name} reads its members with lexicon ${k.graph.lexicon}, which is ${lexiconPackageName(k.graph.lexicon)}, ` +
414
+ `and this package is ${packageName ?? "unnamed"}; the kind is kept, and workspace graph lists its members with kind-not-run`,
415
+ );
416
+ delete k.graph;
417
+ }
418
+ }
419
+ return { ...data, file };
420
+ }
421
+
422
+ /** The package a lexicon name resolves to. */
423
+ export function lexiconPackageName(lexicon: string): string {
424
+ return `@intentius/chant-lexicon-${lexicon}`;
425
+ }
426
+
427
+ /** Where each placeholder in a kind's graph config points (#2874). */
428
+ export interface GraphPlaceholders {
429
+ /** The member's name. */
430
+ member: string;
431
+ /** The member's directory, absolute. */
432
+ dir: string;
433
+ /** The workspace root, absolute. */
434
+ workspace: string;
435
+ }
436
+
437
+ /**
438
+ * A kind's graph config with `{member}`, `{dir}` and `{workspace}` replaced
439
+ * in every string key and value. Other braces are left as they are.
440
+ */
441
+ export function substituteGraphConfig(config: Record<string, unknown>, at: GraphPlaceholders): Record<string, unknown> {
442
+ const text = (v: string) => v.replace(/\{(member|dir|workspace)\}/g, (_, key: keyof GraphPlaceholders) => at[key]);
443
+ const walk = (v: unknown): unknown => {
444
+ if (typeof v === "string") return text(v);
445
+ if (Array.isArray(v)) return v.map(walk);
446
+ if (v !== null && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [text(k), walk(x)]));
447
+ return v;
448
+ };
449
+ return walk(config) as Record<string, unknown>;
393
450
  }
394
451
 
395
452
  /**
@@ -156,6 +156,21 @@ describe("member command lines", () => {
156
156
  expect(memberArgv("audit", unit, args({ failOn: "error" }))).toEqual(["audit", ".", "--format", "json", "--fail-on", "error"]);
157
157
  expect(memberArgv("graph", unit, args({ format: "mermaid" }))).toEqual(["graph", ".", "--format", "ir"]);
158
158
  });
159
+
160
+ test("graph hands each member the live read's flags as they are (#2875)", () => {
161
+ expect(memberArgv("graph", unit, args({ env: "prod", live: true, overlay: true, traffic: "100 rps, p50" }))).toEqual([
162
+ "graph", ".", "--format", "ir", "--env", "prod", "--live", "--overlay", "--traffic", "100 rps, p50",
163
+ ]);
164
+ expect(memberArgv("graph", unit, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir", "--env", "prod"]);
165
+ });
166
+
167
+ test("a reader unit takes --env only with --live, which needs one (#2874, #2875)", () => {
168
+ const reader = { ...unit, reader: { lexicon: "terraform", config: {}, packageDir: "/p" } } as typeof unit;
169
+ expect(memberArgv("graph", reader, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir"]);
170
+ expect(memberArgv("graph", reader, args({ env: "prod", live: true, overlay: true }))).toEqual([
171
+ "graph", ".", "--format", "ir", "--env", "prod", "--live", "--overlay",
172
+ ]);
173
+ });
159
174
  });
160
175
 
161
176
  describe("the member-run protocol", () => {
@@ -9,6 +9,10 @@
9
9
  * Members of kind `other` are never read, and a nested `workspace` member
10
10
  * runs its own workspace commands, so both are listed as skipped with the
11
11
  * reason code `kind-not-run`.
12
+ * - For `graph` only, members of a pinned package's kind whose kinds file
13
+ * says how to read them (#2874, a `graph` block): each runs `chant graph`
14
+ * in a reader project chant writes for it (`kind-readers.ts`). Other
15
+ * package kinds are `kind-not-run`, as before.
12
16
  * - For `build` and `lint` only, every project an example group matches
13
17
  * (ws-051): groups are built and linted, and have no ledger, audit or place
14
18
  * in the graph. On a large repository this is most of the run, so
@@ -38,7 +42,7 @@ import { findWorkspaceRoot } from "../project-root";
38
42
  import type { ComposedMember, MemberReason } from "./compose-graph";
39
43
  import { mergeAudit, mergeSarif, type MemberOutput } from "./compose-reports";
40
44
  import { readDeclaration, resolveGroups, rootExclusions, WorkspaceReadError, type Declaration } from "./declaration";
41
- import { builtinKindRegistry, type KindRegistry } from "./kinds";
45
+ import { builtinKindRegistry, type KindGraph, type KindRegistry } from "./kinds";
42
46
  import { memberReason } from "./ls";
43
47
  import { MEMBER_RUN_PROTOCOL, PROTOCOL_PREFIX, type MemberRunLine, type MemberRunRequest } from "./member-run";
44
48
  import { workingTree, type WorkspaceTree } from "./tree";
@@ -58,6 +62,12 @@ export interface RunUnit {
58
62
  abs: string;
59
63
  /** Directories (relative to `dir`) left out of discovery; set for member `.`. */
60
64
  exclude: string[];
65
+ /**
66
+ * Set for a member read through its kind's `graph` block (#2874). `abs` is
67
+ * the member's directory until `kind-readers.ts` writes the reader project
68
+ * and points `abs` at it.
69
+ */
70
+ reader?: KindGraph & { packageDir: string };
61
71
  }
62
72
 
63
73
  export interface SkippedEntry {
@@ -189,6 +199,25 @@ export function planMembers(verb: WorkspaceVerb, root: string, options: PlanOpti
189
199
  }
190
200
  continue;
191
201
  }
202
+ const byKind = verb === "graph" && entry.kind !== "chant" ? kinds.get(entry.kind) : undefined;
203
+ if (byKind?.graph && byKind.packageDir) {
204
+ const reason = memberReason(entry, tree, kinds);
205
+ if (reason) {
206
+ unreadable.push({ name: entry.name, dir: entry.dir, kind: entry.kind, reason });
207
+ continue;
208
+ }
209
+ units.push({
210
+ id: entry.name,
211
+ member: entry.name,
212
+ kind: entry.kind,
213
+ group: false,
214
+ dir: entry.dir,
215
+ abs: entry.dir === "." ? root : join(root, entry.dir),
216
+ exclude: [],
217
+ reader: { ...byKind.graph, packageDir: byKind.packageDir },
218
+ });
219
+ continue;
220
+ }
192
221
  if (entry.kind !== "chant") {
193
222
  const why =
194
223
  entry.kind === "workspace"
@@ -265,8 +294,15 @@ export function memberArgv(verb: WorkspaceVerb, unit: RunUnit, args: ParsedArgs)
265
294
  if (args.tier) argv.push("--tier", args.tier);
266
295
  if (args.failOn) argv.push("--fail-on", args.failOn);
267
296
  if (args.maxFiles !== undefined) argv.push("--max-files", String(args.maxFiles));
268
- } else if (args.env) {
269
- argv.push("--env", args.env);
297
+ } else {
298
+ // graph. A reader project declares no environments, so a source read
299
+ // leaves --env to the chant members. A live read needs one, and a reader
300
+ // project with none declared takes any name, so --live carries it (#2875).
301
+ if (args.env && (!unit.reader || args.live)) argv.push("--env", args.env);
302
+ // The live read's flags go to every member as they are (#2875).
303
+ if (args.live) argv.push("--live");
304
+ if (args.overlay) argv.push("--overlay");
305
+ if (args.traffic) argv.push("--traffic", args.traffic);
270
306
  }
271
307
  return argv;
272
308
  }
@@ -286,6 +322,8 @@ export interface UnitResult extends MemberOutput {
286
322
  chant: string | null;
287
323
  /** How the member ran: inside its toolchain's shared process, or in a level-0 process of its own. */
288
324
  mode: "member-run" | "per-member";
325
+ /** When the member's command finished, as an ISO time (#2875). */
326
+ finishedAt?: string;
289
327
  }
290
328
 
291
329
  interface Spawned {
@@ -349,13 +387,15 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
349
387
  units: units.map((u) => ({ id: u.id, dir: u.abs, argv: argvs.get(u.id)!, ...(u.exclude.length ? { exclude: u.exclude } : {}) })),
350
388
  };
351
389
  const answer = await run(toolchain.command, ["workspace", "member-run"], root, JSON.stringify(request));
390
+ const ended = new Date().toISOString();
352
391
  const parsed = parseMemberRunOutput(answer.stdout);
353
392
  if (parsed) {
354
393
  if (parsed.stray) process.stderr.write(`${parsed.stray}\n`);
355
394
  return units.map((unit) => {
356
395
  const r = parsed.results.get(unit.id);
357
396
  const base = { unit, toolchain, chant: parsed.chant, mode: "member-run" as const, id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude };
358
- if (r) return { ...base, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr };
397
+ // A member-run from before #2875 names no time; the group's end is the closest one.
398
+ if (r) return { ...base, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, finishedAt: r.finishedAt ?? ended };
359
399
  return { ...base, exitCode: answer.exitCode || 1, stdout: "", stderr: `the member-run process for ${toolchain.identity} ended before running this member\n${answer.stderr}` };
360
400
  });
361
401
  }
@@ -363,7 +403,7 @@ async function runGroup(verb: WorkspaceVerb, group: ToolchainGroup, root: string
363
403
  const out: UnitResult[] = [];
364
404
  for (const unit of units) {
365
405
  const r = await run(toolchain.command, argvs.get(unit.id)!, unit.abs);
366
- out.push({ unit, toolchain, chant: null, mode: "per-member", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, ...r });
406
+ out.push({ unit, toolchain, chant: null, mode: "per-member", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, ...r, finishedAt: new Date().toISOString() });
367
407
  }
368
408
  return out;
369
409
  }
@@ -385,7 +425,7 @@ const USAGE: Record<WorkspaceVerb, string> = {
385
425
  build: "chant workspace build [dir] [--member <name>] [-o <dir>] [--format json|yaml] [--env <env>] [--param k=v] [--dry-run]",
386
426
  lint: "chant workspace lint [dir] [--member <name>] [--format stylish|json|sarif] [-o <file>] [--fix] [--dry-run]",
387
427
  audit: "chant workspace audit [dir] [--member <name>] [--format stylish|json] [-o <file>] [--tier <tier>] [--fail-on <level>] [--dry-run]",
388
- graph: "chant workspace graph [dir] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run]",
428
+ graph: "chant workspace graph [dir] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--live [--overlay] [--traffic <level>]] [--no-cache] [--dry-run]",
389
429
  };
390
430
 
391
431
  export function describePlan(plan: MemberPlan): string {
@@ -461,7 +501,7 @@ function formatAuditText(doc: ReturnType<typeof mergeAudit>, plan: MemberPlan):
461
501
  }
462
502
 
463
503
  export function memberStatus(name: string, dir: string, kind: string, status: ComposedMember["status"], reason: MemberReason | null, chant: string | null): ComposedMember {
464
- return { name, dir, kind, status, reason, chant, irVersion: null };
504
+ return { name, dir, kind, status, reason, chant, irVersion: null, live: false };
465
505
  }
466
506
 
467
507
  export async function runWorkspaceMembers(ctx: CommandContext, verb: WorkspaceVerb): Promise<number> {
@@ -49,7 +49,15 @@ export interface MemberRunUnit {
49
49
 
50
50
  export type MemberRunLine =
51
51
  | { type: "header"; protocol: number; chant: string }
52
- | { type: "result"; id: string; exitCode: number; stdout: string; stderr: string };
52
+ | {
53
+ type: "result";
54
+ id: string;
55
+ exitCode: number;
56
+ stdout: string;
57
+ stderr: string;
58
+ /** When the member's command finished, as an ISO time (#2875). A member-run from before it leaves this out. */
59
+ finishedAt?: string;
60
+ };
53
61
 
54
62
  /** A `process.exit` inside a command, turned into a value. */
55
63
  class ExitRequest extends Error {
@@ -125,7 +133,7 @@ export async function runMemberUnits(request: MemberRunRequest, run: (argv: stri
125
133
  if (startEnv === undefined) delete process.env[ENV_VAR];
126
134
  else process.env[ENV_VAR] = startEnv;
127
135
  process.chdir(startDir);
128
- emit({ type: "result", id: unit.id, ...result });
136
+ emit({ type: "result", id: unit.id, ...result, finishedAt: new Date().toISOString() });
129
137
  }
130
138
  return 0;
131
139
  }
@@ -71,6 +71,8 @@ export interface PointView {
71
71
  questionType: string;
72
72
  instructions: string;
73
73
  candidates: (string | boolean)[];
74
+ /** What each candidate means, as the points file declares it: an object of strings for noul and choice, an array for score. */
75
+ criteria: Record<string, string> | string[];
74
76
  /** Each input: its name, the read-contract output it names, and its description. */
75
77
  inputs: { name: string; output: string; description: string }[];
76
78
  deciders: Decider[];
@@ -163,6 +165,7 @@ export async function workspacePoints(query: PointsQuery): Promise<PointsDocumen
163
165
  questionType: p.question.type,
164
166
  instructions: p.question.instructions,
165
167
  candidates: candidates(p.question),
168
+ criteria: p.question.criteria,
166
169
  inputs: Object.entries(p.inputs).map(([n, description]) => ({ name: n, output: inputOutput(n), description })),
167
170
  deciders: p.deciders,
168
171
  quorum: quorumOf(p),
@@ -191,6 +191,7 @@
191
191
  "questionType",
192
192
  "instructions",
193
193
  "candidates",
194
+ "criteria",
194
195
  "inputs",
195
196
  "deciders",
196
197
  "quorum"
@@ -236,6 +237,23 @@
236
237
  ]
237
238
  }
238
239
  },
240
+ "criteria": {
241
+ "description": "What each candidate means, as the points file declares it: noul and choice give an object of strings keyed by candidate, score an array of ordered level descriptions.",
242
+ "oneOf": [
243
+ {
244
+ "type": "object",
245
+ "additionalProperties": {
246
+ "type": "string"
247
+ }
248
+ },
249
+ {
250
+ "type": "array",
251
+ "items": {
252
+ "type": "string"
253
+ }
254
+ }
255
+ ]
256
+ },
239
257
  "inputs": {
240
258
  "type": "array",
241
259
  "items": {
@@ -24,10 +24,11 @@ import {
24
24
  } from "./points";
25
25
 
26
26
  /**
27
- * chud's points, as template/delivery/decisions/points.yaml declares them at
28
- * jhgaylor/chud@43afcf1, as JSON.
27
+ * Points shaped like an externally authored template's decisions file
28
+ * (ported from chud, jhgaylor/chud@43afcf1's
29
+ * template/delivery/decisions/points.yaml), as JSON.
29
30
  */
30
- const CHUD = {
31
+ const IMPORTED = {
31
32
  points: {
32
33
  "slice-tier": {
33
34
  title: "Which builder tier builds this contract",
@@ -63,7 +64,7 @@ const CHUD = {
63
64
  "ship-skip": {
64
65
  title: "May this release skip the human gate",
65
66
  question: {
66
- type: "boolean",
67
+ type: "noul",
67
68
  instructions: "May this release pass the ship gate without a person approving it? The state is what the release plan would change.",
68
69
  criteria: {
69
70
  true: "An agent may pass the gate for this release (only in enforce mode).",
@@ -86,9 +87,9 @@ const CHUD = {
86
87
  },
87
88
  };
88
89
 
89
- /** chud's points with each input named as a read-contract output: the slice tier reads a work item, the ship gate a release. */
90
- function renamed(): typeof CHUD {
91
- const copy = JSON.parse(JSON.stringify(CHUD)) as typeof CHUD;
90
+ /** The imported points with each input named as a read-contract output: the slice tier reads a work item, the ship gate a release. */
91
+ function renamed(): typeof IMPORTED {
92
+ const copy = JSON.parse(JSON.stringify(IMPORTED)) as typeof IMPORTED;
92
93
  const prefix: Record<string, string> = { "slice-tier": "work-item", "ship-skip": "release" };
93
94
  for (const [name, point] of Object.entries(copy.points) as [string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }][]) {
94
95
  const to = (k: string) => `${prefix[name]}.${k}`;
@@ -123,14 +124,14 @@ describe("the decision points schema (#2738)", () => {
123
124
  }
124
125
  });
125
126
 
126
- test("chud's slice-tier and ship-skip validate unchanged, apart from the input names", () => {
127
- // As chud declares them, only the input names are refused: each names no read-contract output.
128
- const found = problems(CHUD);
127
+ test("the imported slice-tier and ship-skip validate unchanged, apart from the input names", () => {
128
+ // As the template declares them, only the input names are refused: each names no read-contract output.
129
+ const found = problems(IMPORTED);
129
130
  expect(found.length).toBeGreaterThan(0);
130
131
  for (const p of found) expect(p.message).toMatch(/is not a read-contract output|is not one of this point's inputs/);
131
132
  expect(found.filter((p) => p.message.includes("read-contract output")).map((p) => p.field)).toEqual([
132
- ...Object.keys(CHUD.points["slice-tier"].inputs).map((k) => `points.slice-tier.inputs.${k}`),
133
- ...Object.keys(CHUD.points["ship-skip"].inputs).map((k) => `points.ship-skip.inputs.${k}`),
133
+ ...Object.keys(IMPORTED.points["slice-tier"].inputs).map((k) => `points.slice-tier.inputs.${k}`),
134
+ ...Object.keys(IMPORTED.points["ship-skip"].inputs).map((k) => `points.ship-skip.inputs.${k}`),
134
135
  ]);
135
136
  // With the inputs renamed, nothing else changes and both validate.
136
137
  const points = parsePoints(text(renamed()), "points.json");
@@ -156,7 +157,7 @@ describe("the decision points schema (#2738)", () => {
156
157
 
157
158
  test("a point with an unknown input is refused, and so is a row testing an undeclared one", () => {
158
159
  const v = renamed() as unknown as { points: Record<string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }> };
159
- v.points["slice-tier"].inputs["contract.size"] = "chud's contract, which the read contract has no output for";
160
+ v.points["slice-tier"].inputs["contract.size"] = "a contract, which the read contract has no output for";
160
161
  expect(problems(v)).toEqual([
161
162
  {
162
163
  field: "points.slice-tier.inputs.contract.size",
@@ -168,7 +169,7 @@ describe("the decision points schema (#2738)", () => {
168
169
  expect(problems(w)).toEqual([{ field: "points.slice-tier.deciders.0.rows.0.when.work-item.tier", message: expect.stringContaining("is not one of this point's inputs") }]);
169
170
  });
170
171
 
171
- test("the schema and the code refuse the rest of what chud refused, and an alias model id", () => {
172
+ test("the schema and the code refuse the rest of what an imported points file can get wrong, and an alias model id", () => {
172
173
  const cases: [(p: Record<string, unknown>) => void, RegExp][] = [
173
174
  [(p) => ((p.deciders as Record<string, unknown>[])[1].model = "jev-latest"), /is an alias/],
174
175
  [(p) => ((p.deciders as Record<string, unknown>[])[1].count = 2), /is only for a quorum decider/],
@@ -20,7 +20,7 @@
20
20
  * runtime's decider or a test's stub supplies. Without one, a model decider is
21
21
  * not asked and the chain moves on.
22
22
  *
23
- * Taken from chud's `packages/runtime/src/decide.mjs` at 43afcf1: the chain,
23
+ * Ported from chud's `packages/runtime/src/decide.mjs` at 43afcf1: the chain,
24
24
  * the observation rule, the point version and the inputs hash. The records
25
25
  * are chant records: `decide.ts` writes them, and {@link applyAnswers} warns
26
26
  * about them on read.
@@ -71,7 +71,6 @@ export function inputOutput(name: string): string {
71
71
  export type QuestionType = "noul" | "choice" | "score";
72
72
 
73
73
  export interface Question {
74
- /** `boolean` in a points file is read as `noul`. */
75
74
  type: QuestionType;
76
75
  instructions: string;
77
76
  criteria: Record<string, string> | string[];
@@ -220,20 +219,9 @@ export function schemaProblems(data: unknown): PointProblem[] {
220
219
  return out;
221
220
  }
222
221
 
223
- /** `boolean` read as `noul`. The declaration is otherwise kept as written. */
224
- function normalise(points: Record<string, Point>): Record<string, Point> {
225
- const out: Record<string, Point> = {};
226
- for (const [name, p] of Object.entries(points)) {
227
- const type = (p.question.type as string) === "boolean" ? "noul" : p.question.type;
228
- out[name] = { ...p, question: { ...p.question, type } };
229
- }
230
- return out;
231
- }
232
-
233
222
  /**
234
- * Parse and validate a points file's text. Returns its points, with
235
- * `boolean` read as `noul`. Throws a {@link PointsError} naming the file and
236
- * each field.
223
+ * Parse and validate a points file's text. Returns its points. Throws a
224
+ * {@link PointsError} naming the file and each field.
237
225
  */
238
226
  export function parsePoints(text: string, file: string): Record<string, Point> {
239
227
  let data: unknown;
@@ -244,7 +232,7 @@ export function parsePoints(text: string, file: string): Record<string, Point> {
244
232
  }
245
233
  const problems = schemaProblems(data);
246
234
  if (problems.length > 0) throw new PointsError(file, problems);
247
- const points = normalise((data as { points: Record<string, Point> }).points);
235
+ const points = (data as { points: Record<string, Point> }).points;
248
236
  const more = pointProblems(points);
249
237
  if (more.length > 0) throw new PointsError(file, more);
250
238
  return points;
@@ -193,6 +193,14 @@ describe("every schema against the reference workspace (#2543)", () => {
193
193
  expectValid(doc);
194
194
  if ("error" in doc) throw new Error(doc.error.message);
195
195
  expect(doc.points.map((p) => p.name)).toEqual(["slice-tier", "ship-skip", "finding-triage", "needs-a-decision"]);
196
+ const sliceTier = doc.points.find((p) => p.name === "slice-tier")!;
197
+ expect(sliceTier.criteria).toEqual({
198
+ small: "A haiku-class builder. The work item fits the small limits.",
199
+ medium: "A mid-size builder. The work item fits the medium limits.",
200
+ large: "The largest builder. The work item is bigger than the medium limits.",
201
+ });
202
+ const shipSkip = doc.points.find((p) => p.name === "ship-skip")!;
203
+ expect(shipSkip.criteria).toEqual({ true: "An agent may pass the gate for this release.", false: "A person approves the release at the gate." });
196
204
  }
197
205
  const open = await workspacePoints({ cwd: FIXTURE, open: true });
198
206
  expectValid(open);
@@ -157,11 +157,11 @@ describe("the closed list of reason codes", () => {
157
157
  });
158
158
 
159
159
  test("a plugin's finding codes are in its own namespace, outside the list, and the intent schema accepts them (#2656)", () => {
160
- expect(isPluginCode("plugin:chud:contract-criteria-changed")).toBe(true);
161
- expect(isPluginCode("plugin:chud:contract-criteria-changed", "chud")).toBe(true);
162
- expect(isPluginCode("plugin:chud:contract-criteria-changed", "units")).toBe(false);
163
- for (const bad of ["plugin:chud", "plugin::x", "plugin:chud:Upper", "plugin:chud:a:b", "intent-commit-bare", 7]) expect(isPluginCode(bad), String(bad)).toBe(false);
164
- expect(isReasonCode("plugin:chud:contract-criteria-changed")).toBe(false);
160
+ expect(isPluginCode("plugin:acme:contract-criteria-changed")).toBe(true);
161
+ expect(isPluginCode("plugin:acme:contract-criteria-changed", "acme")).toBe(true);
162
+ expect(isPluginCode("plugin:acme:contract-criteria-changed", "units")).toBe(false);
163
+ for (const bad of ["plugin:acme", "plugin::x", "plugin:acme:Upper", "plugin:acme:a:b", "intent-commit-bare", 7]) expect(isPluginCode(bad), String(bad)).toBe(false);
164
+ expect(isReasonCode("plugin:acme:contract-criteria-changed")).toBe(false);
165
165
  const { validate } = contract(intentSchema);
166
166
  const finding = (code: string) => ({ id: `finding:${code}:1`, kind: "finding", code, message: "m", concerns: [] });
167
167
  const doc = (code: string) => ({
@@ -178,9 +178,9 @@ describe("the closed list of reason codes", () => {
178
178
  reasons: [],
179
179
  summary: { commits: 0, decisions: 0, artifacts: 0, findings: 1 },
180
180
  });
181
- expect(validate(doc("plugin:chud:contract-criteria-changed"))).toBe(true);
181
+ expect(validate(doc("plugin:acme:contract-criteria-changed"))).toBe(true);
182
182
  expect(validate(doc("intent-commit-bare"))).toBe(true);
183
- expect(validate(doc("plugin:chud:Nope"))).toBe(false);
183
+ expect(validate(doc("plugin:acme:Nope"))).toBe(false);
184
184
  expect(validate(doc("made-up-code"))).toBe(false);
185
185
  });
186
186
 
@@ -38,6 +38,7 @@ export const REASONS = {
38
38
  // --at and git.
39
39
  "not-a-git-repository": "--at, or a ledger read, needs a git repository and there is none.",
40
40
  "revision-unknown": "--at names no commit.",
41
+ "live-at-revision": "graph was given --live and --at: a live read is of the account now, not of a revision.",
41
42
  // status.
42
43
  "environment-invalid": "An environment name that can't name a ledger directory.",
43
44
  // A member or group that can't be read (ls, graph).
@@ -47,7 +48,7 @@ export const REASONS = {
47
48
  "no-matches": "An example group matches no directory holding a chant project.",
48
49
  // A member graph leaves out (graph, and the other per-member commands).
49
50
  "kind-not-run": "The member's kind is one the per-member commands don't run, such as other.",
50
- "command-failed": "The member's own command exited with a failure.",
51
+ "command-failed": "The member's own command exited with a failure, or the lexicon that reads the member through its kind's graph block isn't installed where the workspace resolves its pin.",
51
52
  "output-unreadable": "The member's command printed something that isn't the document asked for.",
52
53
  "ir-version-unsupported": "The member's IR has a version this chant can't read.",
53
54
  // A ledger status can't fully read.
@@ -129,6 +130,7 @@ export const REASONS = {
129
130
  "review-sign-failed": "--sign was given and no seal could be made: the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
130
131
  "record-state-not-initial": "A record written through chant serve mcp gives a state other than the kind's first: a new record opens proposed, and a person moves it on.",
131
132
  "source-harvest-not-proposed": "A harvested record (source.via harvest) was written in a state other than the kind's first: a harvest proposes, and a person decides.",
133
+ "ratify-quorum-not-met": "The write puts a record in its kind's ratified state (reviews.ratified), and the record's quorum is not met: too few agreeing verdicts count.",
132
134
  "record-sign-failed": "--sign was given and no author seal could be made: the record names no author, the key can't be read or used, git names no ssh signing key, or ssh-keygen is not installed.",
133
135
  // A review given in a session (records review --session, #2693).
134
136
  "session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
@@ -192,6 +194,9 @@ export const REASONS = {
192
194
  "box-capability-unbrokered": "A capability in a member's box block names no broker, so the box would hold its credential.",
193
195
  // The credential fountain itself hands a persistent box (check, #2780). Also a WSP finding.
194
196
  "box-fountain-callback-undeclared": "A box member builds a fountain Box, whose persistent sandbox fountain gives a callback token scoped to its owner, and the member's box block does not declare the fountain-callback capability brokered by fountain with scope owner.",
197
+ // A box's intent, the decision record its box block names (check, #2850). Each is also a WSP finding.
198
+ "box-intent-unknown": "A box block names an intent, and no record of a declared kind named decision has that id.",
199
+ "box-intent-unconstrained": "The decision record a box names as its intent constrains no member or path of this workspace: no member: entry for a declared member and no path: entry at, above or inside one's directory.",
195
200
  // The work lease (chant workspace work claim|renew|release, #2732): why the command could not run.
196
201
  "work-kind-missing": "No work kind to find the item in: --kind names a kind with no work block, or the declaration names no work kind.",
197
202
  "work-kind-ambiguous": "More than one declared work kind has a record with the id, so --kind must name one.",
@@ -175,6 +175,7 @@
175
175
  "record-closed",
176
176
  "amend-supersede-instead",
177
177
  "record-sign-failed",
178
+ "ratify-quorum-not-met",
178
179
  "record-unparseable",
179
180
  "record-schema-invalid",
180
181
  "record-id-duplicate",
@@ -38,6 +38,7 @@ import {
38
38
  RecordReadError,
39
39
  type LoadedRecordKind,
40
40
  type Quorum,
41
+ type QuorumOptions,
41
42
  type ReadErrorCode,
42
43
  type ReadRecordsResult,
43
44
  type RecordEntry,
@@ -210,6 +211,24 @@ function declaredQuorum(tree: WorkspaceTree): { need: number; needFrom: "declara
210
211
  return { need: DEFAULT_QUORUM, needFrom: "default" };
211
212
  }
212
213
 
214
+ /**
215
+ * The options a kind's quorum is computed with, or undefined when the kind
216
+ * has no reviews list: the need from the declaration in the tree read, and
217
+ * the agents, whether verdicts need a seal, and the keys a seal verifies
218
+ * against, all from the policy at base (#2671, #2687). `records` and the
219
+ * ratified check of `records new` and `amend` (#2873) both use it.
220
+ */
221
+ export async function quorumOptionsFor(loaded: LoadedRecordKind, tree: WorkspaceTree, policy: TrustPolicy): Promise<QuorumOptions | undefined> {
222
+ if (!loaded.kind.reviews) return undefined;
223
+ const { checkVerdictSeal } = await import("./trust/seal");
224
+ return {
225
+ ...declaredQuorum(tree),
226
+ agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
227
+ attestation: policy.active,
228
+ verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
229
+ };
230
+ }
231
+
213
232
  /**
214
233
  * Where a kind's pinned paths resolve: the workspace whose declaration sits
215
234
  * nearest above the kind file, when it is inside the repository, or else the
@@ -300,19 +319,8 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
300
319
  paths: result.records.map((r) => r.path),
301
320
  attestors: policy.active ? await activeAttestors() : [],
302
321
  });
303
- // The quorum: the need from the declaration in the tree read, agents,
304
- // whether verdicts need a seal, and the keys a seal verifies against,
305
- // all from the policy at base (#2671, #2687).
306
322
  const seals = loaded.kind.reviews ? await import("./trust/seal") : undefined;
307
- const checkVerdictSeal = seals?.checkVerdictSeal;
308
- const quorumOptions = checkVerdictSeal
309
- ? {
310
- ...declaredQuorum(tree),
311
- agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
312
- attestation: policy.active,
313
- verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
314
- }
315
- : undefined;
323
+ const quorumOptions = await quorumOptionsFor(loaded, tree, policy);
316
324
  const records: RecordView[] = result.records.map((r) => ({
317
325
  ...r,
318
326
  provenance: provenance.get(r.path)!,
@@ -510,6 +518,14 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
510
518
  * names, or, when it names none or there is no declaration, the error it has
511
519
  * always been. A kind whose read fails is listed with its error, the others
512
520
  * are still read, and the exit code is 1.
521
+ *
522
+ * Locating the declaration itself can fail before any kind is known. For
523
+ * `not-a-git-repository` and `revision-unknown`, the codes a single kind's
524
+ * read can also fail with (#2860), `--json` prints the same
525
+ * `{ $schema, contract, error: { code, message } }` document that read
526
+ * failure would, so a reader of `--json` never sees a silent exit 1. A
527
+ * declaration-level code the schema doesn't carry (`declaration-invalid` and
528
+ * the rest of `WorkspaceErrorCode`) still prints text on stderr only.
513
529
  */
514
530
  async function runDeclaredRecords(args: CommandContext["args"]): Promise<number> {
515
531
  if (args.require !== undefined && args.require !== "attested") {
@@ -521,7 +537,12 @@ async function runDeclaredRecords(args: CommandContext["args"]): Promise<number>
521
537
  declared = declaredKindFiles(process.cwd(), args.at);
522
538
  } catch (err) {
523
539
  if (!(err instanceof WorkspaceReadError)) throw err;
524
- console.error(formatError({ message: `${err.code}: ${err.describe()}; without --kind, the declaration names the record kinds`, hint: USAGE }));
540
+ const message = `${err.describe()}; without --kind, the declaration names the record kinds`;
541
+ if (args.json && (err.code === "not-a-git-repository" || err.code === "revision-unknown")) {
542
+ console.log(JSON.stringify({ $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, error: { code: err.code, message } }, null, 2));
543
+ } else {
544
+ console.error(formatError({ message: `${err.code}: ${message}`, hint: USAGE }));
545
+ }
525
546
  return 1;
526
547
  }
527
548
  if (declared.length === 0) {
@@ -116,7 +116,8 @@ describe("records output schema", () => {
116
116
  JSON.stringify({ name: "w", schema: 1, quorum: 1, members: [{ name: "docs", dir: "docs", kind: "other", because: "decisions only" }] }),
117
117
  );
118
118
  const base = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8").replace(/^id: .*$/m, 'id: "ws-900"');
119
- const digest = recordTextDigest(base);
119
+ // The decision kind names a ratified state, so the state leaves the digest too (#2873).
120
+ const digest = recordTextDigest(base, ["reviews", "seal", "state"]);
120
121
  const entry = (reviewer: string, verdict: string, extra = "") => ` - reviewer: "${reviewer}"\n verdict: "${verdict}"\n on: "2026-09-24"${extra}`;
121
122
  const reviews = [
122
123
  entry("alice", "agree", `\n digest: "${digest}"`),