@intentius/chant 0.84.0 → 0.86.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 (152) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/resource-handlers.d.ts +2 -1
  3. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  4. package/dist/cli/mcp/server.d.ts +1 -0
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/mcp/tools/composites.d.ts +44 -0
  7. package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
  8. package/dist/cli/mcp/tools/search.d.ts.map +1 -1
  9. package/dist/cli/registry.d.ts +13 -1
  10. package/dist/cli/registry.d.ts.map +1 -1
  11. package/dist/components/cli-support.d.ts +4 -0
  12. package/dist/components/cli-support.d.ts.map +1 -1
  13. package/dist/composite.d.ts +6 -0
  14. package/dist/composite.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +44 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  18. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  19. package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
  20. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  21. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  22. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  23. package/dist/workspace/checks/records.d.ts +1 -0
  24. package/dist/workspace/checks/records.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/composites.d.ts +152 -0
  28. package/dist/workspace/composites.d.ts.map +1 -0
  29. package/dist/workspace/conformance/index.d.ts +211 -0
  30. package/dist/workspace/conformance/index.d.ts.map +1 -0
  31. package/dist/workspace/conformance/vitest.d.ts +11 -0
  32. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  33. package/dist/workspace/declaration.d.ts +28 -0
  34. package/dist/workspace/declaration.d.ts.map +1 -1
  35. package/dist/workspace/declaration.schema.json +40 -0
  36. package/dist/workspace/declared-kinds.d.ts +43 -0
  37. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  38. package/dist/workspace/graph-cli.d.ts +24 -2
  39. package/dist/workspace/graph-cli.d.ts.map +1 -1
  40. package/dist/workspace/intent-cli.d.ts +6 -1
  41. package/dist/workspace/intent-cli.d.ts.map +1 -1
  42. package/dist/workspace/intent-joins.d.ts +74 -9
  43. package/dist/workspace/intent-joins.d.ts.map +1 -1
  44. package/dist/workspace/intent.d.ts +90 -6
  45. package/dist/workspace/intent.d.ts.map +1 -1
  46. package/dist/workspace/ls.d.ts +31 -1
  47. package/dist/workspace/ls.d.ts.map +1 -1
  48. package/dist/workspace/member-commands.d.ts +7 -2
  49. package/dist/workspace/member-commands.d.ts.map +1 -1
  50. package/dist/workspace/reason-codes.d.ts +52 -2
  51. package/dist/workspace/reason-codes.d.ts.map +1 -1
  52. package/dist/workspace/record-sessions.d.ts +51 -0
  53. package/dist/workspace/record-sessions.d.ts.map +1 -0
  54. package/dist/workspace/record-source.d.ts +2 -0
  55. package/dist/workspace/record-source.d.ts.map +1 -1
  56. package/dist/workspace/records-cli.d.ts +65 -4
  57. package/dist/workspace/records-cli.d.ts.map +1 -1
  58. package/dist/workspace/records-since.d.ts +90 -0
  59. package/dist/workspace/records-since.d.ts.map +1 -0
  60. package/dist/workspace/records-write.d.ts +164 -0
  61. package/dist/workspace/records-write.d.ts.map +1 -0
  62. package/dist/workspace/records.d.ts +202 -15
  63. package/dist/workspace/records.d.ts.map +1 -1
  64. package/dist/workspace/runtimes.d.ts +60 -0
  65. package/dist/workspace/runtimes.d.ts.map +1 -0
  66. package/dist/workspace/status-gates.d.ts +90 -0
  67. package/dist/workspace/status-gates.d.ts.map +1 -0
  68. package/dist/workspace/status.d.ts +17 -0
  69. package/dist/workspace/status.d.ts.map +1 -1
  70. package/dist/workspace/work.d.ts +56 -0
  71. package/dist/workspace/work.d.ts.map +1 -0
  72. package/package.json +19 -1
  73. package/src/cli/handlers/graph.ts +4 -0
  74. package/src/cli/main.test.ts +9 -0
  75. package/src/cli/main.ts +56 -3
  76. package/src/cli/mcp/resource-handlers.ts +17 -0
  77. package/src/cli/mcp/server.test.ts +140 -4
  78. package/src/cli/mcp/server.ts +5 -1
  79. package/src/cli/mcp/tools/composites.ts +98 -0
  80. package/src/cli/mcp/tools/search.ts +47 -5
  81. package/src/cli/registry.ts +13 -1
  82. package/src/components/cli-support.test.ts +16 -0
  83. package/src/components/cli-support.ts +8 -2
  84. package/src/composite.ts +9 -0
  85. package/src/lexicon.ts +47 -0
  86. package/src/lifecycle/gate-ledger.ts +14 -0
  87. package/src/workspace/__fixtures__/contract-repo.ts +17 -0
  88. package/src/workspace/__fixtures__/sessions.ts +66 -0
  89. package/src/workspace/checks/records.ts +19 -0
  90. package/src/workspace/checks.test.ts +2 -0
  91. package/src/workspace/checks.ts +7 -1
  92. package/src/workspace/composites.schema.json +533 -0
  93. package/src/workspace/composites.test.ts +334 -0
  94. package/src/workspace/composites.ts +316 -0
  95. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  96. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  97. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  98. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  99. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  100. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  101. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  102. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  103. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  104. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  105. package/src/workspace/conformance/conformance.test.ts +149 -0
  106. package/src/workspace/conformance/index.mjs +31 -0
  107. package/src/workspace/conformance/index.ts +453 -0
  108. package/src/workspace/conformance/vitest.ts +62 -0
  109. package/src/workspace/declaration.schema.json +40 -0
  110. package/src/workspace/declaration.ts +62 -0
  111. package/src/workspace/declared-kinds.test.ts +321 -0
  112. package/src/workspace/declared-kinds.ts +76 -0
  113. package/src/workspace/graph-cli.ts +40 -4
  114. package/src/workspace/intent-cli.ts +54 -7
  115. package/src/workspace/intent-joins.test.ts +60 -0
  116. package/src/workspace/intent-joins.ts +117 -20
  117. package/src/workspace/intent.schema.json +357 -19
  118. package/src/workspace/intent.test.ts +235 -20
  119. package/src/workspace/intent.ts +396 -51
  120. package/src/workspace/ls.schema.json +34 -0
  121. package/src/workspace/ls.ts +69 -4
  122. package/src/workspace/member-commands.ts +11 -5
  123. package/src/workspace/read-contract.test.ts +52 -3
  124. package/src/workspace/reason-codes.test.ts +48 -4
  125. package/src/workspace/reason-codes.ts +67 -2
  126. package/src/workspace/record-assets.test.ts +3 -1
  127. package/src/workspace/record-sessions.ts +105 -0
  128. package/src/workspace/record-source.ts +14 -5
  129. package/src/workspace/records-amend.schema.json +167 -0
  130. package/src/workspace/records-cli.ts +246 -19
  131. package/src/workspace/records-contract.test.ts +77 -2
  132. package/src/workspace/records-formats.test.ts +640 -0
  133. package/src/workspace/records-new.schema.json +158 -0
  134. package/src/workspace/records-quorum.test.ts +196 -0
  135. package/src/workspace/records-review.schema.json +202 -0
  136. package/src/workspace/records-sessions.test.ts +108 -0
  137. package/src/workspace/records-since.schema.json +193 -0
  138. package/src/workspace/records-since.test.ts +174 -0
  139. package/src/workspace/records-since.ts +259 -0
  140. package/src/workspace/records-write-contract.test.ts +125 -0
  141. package/src/workspace/records-write.test.ts +373 -0
  142. package/src/workspace/records-write.ts +736 -0
  143. package/src/workspace/records.schema.json +187 -9
  144. package/src/workspace/records.test.ts +93 -0
  145. package/src/workspace/records.ts +683 -49
  146. package/src/workspace/runtimes.ts +107 -0
  147. package/src/workspace/status-contract.test.ts +163 -0
  148. package/src/workspace/status-gates.ts +215 -0
  149. package/src/workspace/status.schema.json +69 -3
  150. package/src/workspace/status.ts +35 -2
  151. package/src/workspace/work.test.ts +388 -0
  152. package/src/workspace/work.ts +163 -0
@@ -8,8 +8,13 @@
8
8
  * the exit code is 0 whenever the read itself worked. Only a kind, schema or
9
9
  * revision that cannot be read exits 1.
10
10
  *
11
- * It needs no `chant.workspace.json`. The kind is passed explicitly, so
12
- * nothing is inferred (#2525 rule 1).
11
+ * It needs no `chant.workspace.json`. The kind is passed explicitly, or,
12
+ * without `--kind`, it is every record kind the declaration names (#2680), so
13
+ * nothing is inferred (#2525 rule 1). Several kinds print one document per
14
+ * kind, in the declaration's order, inside one set.
15
+ *
16
+ * With `--since <rev>` it prints what changed between two revisions instead,
17
+ * through `records-since.ts` (#2673).
13
18
  */
14
19
 
15
20
  import { execFileSync } from "node:child_process";
@@ -20,8 +25,26 @@ import type { CommandContext } from "../cli/registry";
20
25
  import { findWorkspaceRoot } from "../project-root";
21
26
  import { fileDigest, isWorkspacePath } from "./record-assets";
22
27
  import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource } from "./record-source";
23
- import { loadRecordKind, readRecords, RecordReadError, type LoadedRecordKind, type ReadErrorCode, type ReadRecordsResult, type RecordEntry, type RecordHistory } from "./records";
24
- import { gitTree, workingTree } from "./tree";
28
+ import { declaredRecordKinds, readDeclaration, WorkspaceReadError, type RecordKindDeclaration } from "./declaration";
29
+ import { declaredKindFile } from "./declared-kinds";
30
+ import { locateWorkspace } from "./which-chant";
31
+ import {
32
+ computeQuorum,
33
+ DEFAULT_QUORUM,
34
+ loadRecordKind,
35
+ normalisePrincipal,
36
+ readRecords,
37
+ RecordReadError,
38
+ type LoadedRecordKind,
39
+ type Quorum,
40
+ type ReadErrorCode,
41
+ type ReadRecordsResult,
42
+ type RecordEntry,
43
+ type RecordFormat,
44
+ type RecordHistory,
45
+ } from "./records";
46
+ import { gitTree, workingTree, type WorkspaceTree } from "./tree";
47
+ import type { DecisionWork } from "./work";
25
48
  import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
26
49
  import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
27
50
 
@@ -31,7 +54,8 @@ export const RECORDS_CONTRACT_VERSION = 1;
31
54
  /** `$id` of the JSON Schema for the `--json` output, shipped beside this file. */
32
55
  export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
33
56
 
34
- const USAGE = "chant workspace records --kind <kind file> [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records pin <path>";
57
+ const USAGE =
58
+ "chant workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review (#2670)";
35
59
 
36
60
  /** Exit code when the read worked and a record falls below `--require`. */
37
61
  export const EXIT_BELOW_REQUIRED = 2;
@@ -46,8 +70,14 @@ export interface RecordsQuery {
46
70
  cwd: string;
47
71
  }
48
72
 
49
- /** A record as the output carries it: the entry plus its provenance (#2547). */
50
- export type RecordView = RecordEntry & { provenance: RecordProvenance };
73
+ /**
74
+ * A record as the output carries it: the entry plus its provenance (#2547),
75
+ * and its quorum when the kind has a reviews list (#2671).
76
+ */
77
+ export type RecordView = RecordEntry & { provenance: RecordProvenance; quorum?: Quorum | null };
78
+
79
+ /** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
80
+ export const AGENT_ROLE = "agent";
51
81
 
52
82
  /** Where provenance was judged from (#2547). */
53
83
  export interface TrustView {
@@ -65,7 +95,7 @@ export type RecordsDocument =
65
95
  | {
66
96
  $schema: string;
67
97
  contract: number;
68
- kind: { name: string; schema: string; file: string };
98
+ kind: { name: string; schema: string; file: string; format: RecordFormat };
69
99
  at: string | null;
70
100
  /** The directory pinned paths resolve in, from the repository root: the workspace holding the kind file, or the repository root (#2549). */
71
101
  workspaceRoot: string;
@@ -73,9 +103,58 @@ export type RecordsDocument =
73
103
  trust: TrustView;
74
104
  records: RecordView[];
75
105
  summary: { total: number; valid: number; invalid: number; superseded: number };
106
+ /** For a work kind (#2683): each decision its decision kind reads, with the work records implementing it. */
107
+ decisions?: DecisionWork[];
76
108
  }
77
109
  | { $schema: string; contract: number; error: { code: ReadErrorCode; message: string } };
78
110
 
111
+ /** Where a document in a {@link RecordsSetDocument} comes from: the declaration's entry for its kind (#2680). */
112
+ export interface DeclaredKindView {
113
+ /** The member that declares the kind, or null for the workspace's own. */
114
+ member: string | null;
115
+ /** The kind file from the workspace root. */
116
+ path: string;
117
+ /** The name the declaration gives the kind, or null. */
118
+ name: string | null;
119
+ }
120
+
121
+ /**
122
+ * The `records` output without `--kind` when the declaration names record
123
+ * kinds (#2680): one {@link RecordsDocument} per kind, in the declaration's
124
+ * order, each with the entry that declares it.
125
+ */
126
+ export interface RecordsSetDocument {
127
+ $schema: string;
128
+ contract: number;
129
+ kinds: (RecordsDocument & { declared: DeclaredKindView })[];
130
+ }
131
+
132
+ /**
133
+ * The record kinds the declaration nearest above `cwd` names, in the tree
134
+ * `at` reads, with each kind file on disk. Empty when there is no declaration.
135
+ * Throws a {@link WorkspaceReadError} for one that can't be read.
136
+ */
137
+ export function declaredKindFiles(cwd: string, at?: string): { declared: RecordKindDeclaration; file: string }[] {
138
+ let located;
139
+ try {
140
+ located = locateWorkspace(cwd, at);
141
+ } catch (err) {
142
+ if (err instanceof WorkspaceReadError && err.code === "declaration-missing") return [];
143
+ throw err;
144
+ }
145
+ return declaredRecordKinds(readDeclaration(located.tree)).map((declared) => ({ declared, file: declaredKindFile(declared, located.rootOnDisk) }));
146
+ }
147
+
148
+ /** Read every declared kind in `kinds`, as {@link queryRecords} reads one. */
149
+ export async function queryDeclaredRecords(kinds: { declared: RecordKindDeclaration; file: string }[], query: Omit<RecordsQuery, "kind">): Promise<RecordsSetDocument> {
150
+ const out: RecordsSetDocument["kinds"] = [];
151
+ for (const k of kinds) {
152
+ const doc = await queryRecords({ ...query, kind: k.file });
153
+ out.push({ ...doc, declared: { member: k.declared.member, path: k.declared.path, name: k.declared.name } });
154
+ }
155
+ return { $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds: out };
156
+ }
157
+
79
158
  /** A records read, before provenance. */
80
159
  export interface RecordsRead {
81
160
  loaded: LoadedRecordKind;
@@ -86,15 +165,32 @@ export interface RecordsRead {
86
165
  at: string | null;
87
166
  /** Where pinned paths resolve, relative to `root` ("." for the root itself). */
88
167
  workspaceRoot: string;
168
+ /** The workspace root's tree, as read: the working tree, or the revision under `--at`. */
169
+ tree: WorkspaceTree;
89
170
  result: ReadRecordsResult;
90
171
  }
91
172
 
173
+ /**
174
+ * The quorum the workspace declares: the declaration at the workspace root
175
+ * of the tree read, or the default when there is none, or when it can't be
176
+ * read (#2671). Records need no declaration, so neither does this.
177
+ */
178
+ function declaredQuorum(tree: WorkspaceTree): { need: number; needFrom: "declaration" | "default" } {
179
+ try {
180
+ const q = readDeclaration(tree).quorum;
181
+ if (q !== null) return { need: q, needFrom: "declaration" };
182
+ } catch {
183
+ // No declaration, or one this read can't use: the default applies.
184
+ }
185
+ return { need: DEFAULT_QUORUM, needFrom: "default" };
186
+ }
187
+
92
188
  /**
93
189
  * Where a kind's pinned paths resolve: the workspace whose declaration sits
94
190
  * nearest above the kind file, when it is inside the repository, or else the
95
191
  * repository root. Relative to `root`, with / separators.
96
192
  */
97
- function pinRoot(kindFile: string, root: string): string {
193
+ export function pinRoot(kindFile: string, root: string): string {
98
194
  const found = findWorkspaceRoot(dirname(kindFile));
99
195
  if (!found) return ".";
100
196
  const rel = relative(root, realpathOr(found.dir)).split(sep).join("/");
@@ -146,14 +242,20 @@ export async function readRecordsFor(query: Omit<RecordsQuery, "base">): Promise
146
242
  assets = gitTree(top, at, workspaceRoot === "." ? "" : workspaceRoot);
147
243
  }
148
244
  const history = top ? gitHistory(top, at ?? "HEAD", workspaceRoot) : undefined;
149
- const result = await readRecords(loaded, { root, source, current: !!query.current, assets, ...(history ? { history } : {}) });
150
- return { loaded, root, top, at, workspaceRoot, result };
245
+ // A session kind's verdicts name records of another kind, read from the same tree (#2673).
246
+ let subjects: { records: RecordEntry[]; reviews: string } | undefined;
247
+ if (loaded.kind.session) {
248
+ const subjectKind = await loadRecordKind(resolve(dirname(loaded.file), loaded.kind.session.subjects.kind), cwd);
249
+ subjects = { records: (await readRecords(subjectKind, { root, source })).records, reviews: subjectKind.kind.reviews?.field ?? "reviews" };
250
+ }
251
+ const result = await readRecords(loaded, { root, source, current: !!query.current, assets, workspaceRoot, ...(history ? { history } : {}), ...(subjects ? { subjects } : {}) });
252
+ return { loaded, root, top, at, workspaceRoot, tree: assets, result };
151
253
  }
152
254
 
153
255
  /** Run the query and build the document `--json` prints. Never throws a {@link RecordReadError}. */
154
256
  export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument> {
155
257
  try {
156
- const { loaded, root, top, at, workspaceRoot, result } = await readRecordsFor(query);
258
+ const { loaded, root, top, at, workspaceRoot, tree, result } = await readRecordsFor(query);
157
259
  // Provenance, judged by the policy at base and never by the tree read (#2547).
158
260
  const base = top ? resolveBase(top, query.base) : { commit: null, from: null };
159
261
  const policy = top ? policyAtBase(top, base) : policyAtBase(root, base);
@@ -164,6 +266,15 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
164
266
  paths: result.records.map((r) => r.path),
165
267
  attestors: policy.active ? await activeAttestors() : [],
166
268
  });
269
+ // The quorum: the need from the declaration in the tree read, agents and
270
+ // whether verdicts need a seal from the policy at base (#2671).
271
+ const quorumOptions = loaded.kind.reviews
272
+ ? {
273
+ ...declaredQuorum(tree),
274
+ agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
275
+ attestation: policy.active,
276
+ }
277
+ : undefined;
167
278
  return {
168
279
  $schema: RECORDS_OUTPUT_SCHEMA_ID,
169
280
  contract: RECORDS_CONTRACT_VERSION,
@@ -171,13 +282,19 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
171
282
  name: loaded.kind.name,
172
283
  schema: loaded.kind.schema.id,
173
284
  file: relative(root, loaded.file).split("\\").join("/"),
285
+ format: loaded.kind.format,
174
286
  },
175
287
  at,
176
288
  workspaceRoot,
177
289
  current: !!query.current,
178
290
  trust: { base: base.commit, baseFrom: base.from, active: policy.active, signersPath: policy.signersPath, problems: policy.problems },
179
- records: result.records.map((r) => ({ ...r, provenance: provenance.get(r.path)! })),
291
+ records: result.records.map((r) => ({
292
+ ...r,
293
+ provenance: provenance.get(r.path)!,
294
+ ...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
295
+ })),
180
296
  summary: result.summary,
297
+ ...(result.decisions ? { decisions: result.decisions } : {}),
181
298
  };
182
299
  } catch (err) {
183
300
  if (!(err instanceof RecordReadError)) throw err;
@@ -204,6 +321,9 @@ export function pinFile(file: string, cwd: string): { path: string; sha256: stri
204
321
 
205
322
  export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
206
323
  const { args } = ctx;
324
+ if (args.extraPositional === "new" || args.extraPositional === "amend" || args.extraPositional === "review") {
325
+ return (await import("./records-write")).runRecordsWrite(ctx);
326
+ }
207
327
  if (args.extraPositional === "pin") {
208
328
  if (!args.extraPositional2) {
209
329
  console.error(formatError({ message: "pin needs the path of a file", hint: USAGE }));
@@ -218,17 +338,27 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
218
338
  return 0;
219
339
  }
220
340
  if (args.extraPositional) {
221
- console.error(formatError({ message: `chant workspace records takes no argument but pin (got ${args.extraPositional})`, hint: USAGE }));
222
- return 1;
223
- }
224
- if (!args.kind) {
225
- console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
341
+ console.error(formatError({ message: `chant workspace records takes no argument but pin, new, amend or review (got ${args.extraPositional})`, hint: USAGE }));
226
342
  return 1;
227
343
  }
344
+ if (!args.kind) return runDeclaredRecords(args);
228
345
  if (args.require !== undefined && args.require !== "attested") {
229
346
  console.error(formatError({ message: `--require takes one level, attested, not ${JSON.stringify(args.require)}`, hint: USAGE }));
230
347
  return 1;
231
348
  }
349
+ if (args.since !== undefined) {
350
+ if (args.current || args.require !== undefined || args.base !== undefined) {
351
+ console.error(formatError({ message: "--since compares two revisions and takes no --current, --require or --base", hint: USAGE }));
352
+ return 1;
353
+ }
354
+ // Loaded here, so a plain records read never loads it (#2673).
355
+ const { formatSince, queryRecordsSince } = await import("./records-since");
356
+ const since = await queryRecordsSince({ kind: args.kind, since: args.since, at: args.at, cwd: process.cwd() });
357
+ if (args.json) console.log(JSON.stringify(since, null, 2));
358
+ else if ("error" in since) console.error(formatError({ message: `${since.error.code}: ${since.error.message}`, hint: USAGE }));
359
+ else console.log(formatSince(since));
360
+ return "error" in since ? 1 : 0;
361
+ }
232
362
  const doc = await queryRecords({ kind: args.kind, current: args.current, at: args.at, base: args.base, cwd: process.cwd() });
233
363
  if (args.json) {
234
364
  console.log(JSON.stringify(doc, null, 2));
@@ -256,7 +386,92 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
256
386
  return 0;
257
387
  }
258
388
 
259
- function realpathOr(dir: string): string {
389
+ /**
390
+ * `records` without `--kind` (#2680): every record kind the declaration
391
+ * names, or, when it names none or there is no declaration, the error it has
392
+ * always been. A kind whose read fails is listed with its error, the others
393
+ * are still read, and the exit code is 1.
394
+ */
395
+ async function runDeclaredRecords(args: CommandContext["args"]): Promise<number> {
396
+ if (args.require !== undefined && args.require !== "attested") {
397
+ console.error(formatError({ message: `--require takes one level, attested, not ${JSON.stringify(args.require)}`, hint: USAGE }));
398
+ return 1;
399
+ }
400
+ let declared: { declared: RecordKindDeclaration; file: string }[];
401
+ try {
402
+ declared = declaredKindFiles(process.cwd(), args.at);
403
+ } catch (err) {
404
+ if (!(err instanceof WorkspaceReadError)) throw err;
405
+ console.error(formatError({ message: `${err.code}: ${err.describe()}; without --kind, the declaration names the record kinds`, hint: USAGE }));
406
+ return 1;
407
+ }
408
+ if (declared.length === 0) {
409
+ console.error(formatError({ message: "--kind <kind file> is required", hint: USAGE }));
410
+ return 1;
411
+ }
412
+ if (args.since !== undefined) return runDeclaredSince(declared, args);
413
+ const set = await queryDeclaredRecords(declared, { current: args.current, at: args.at, base: args.base, cwd: process.cwd() });
414
+ if (args.json) console.log(JSON.stringify(set, null, 2));
415
+ for (const doc of args.json ? [] : set.kinds) {
416
+ if ("error" in doc) {
417
+ console.error(formatError({ message: `${doc.declared.path}: ${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
418
+ continue;
419
+ }
420
+ console.log(`${doc.declared.name ?? doc.kind.name} (${doc.declared.path})`);
421
+ console.log(formatRecords(doc.records, doc.summary, doc.at));
422
+ }
423
+ if (set.kinds.some((d) => "error" in d)) return 1;
424
+ if (args.require) {
425
+ const results = set.kinds.filter((d): d is Extract<typeof d, { records: unknown }> => !("error" in d));
426
+ const all = results.flatMap((d) => d.records);
427
+ const below = belowRequired(all, args.require);
428
+ if (below.length > 0) {
429
+ const inactive = results.find((d) => !d.trust.active);
430
+ console.error(
431
+ formatError({
432
+ message: `${below.length} of ${all.length} records are not ${args.require}: ${below
433
+ .slice(0, 5)
434
+ .map((r) => `${r.path} (${r.provenance.level})`)
435
+ .join(", ")}${below.length > 5 ? ", ..." : ""}`,
436
+ hint: inactive ? `there is no signers file (${inactive.trust.signersPath}) at base` : "run with --json to see each record's reason",
437
+ }),
438
+ );
439
+ return EXIT_BELOW_REQUIRED;
440
+ }
441
+ }
442
+ return 0;
443
+ }
444
+
445
+ /**
446
+ * `records --since` without `--kind` (#2680): what changed in every declared
447
+ * kind, one `records-since` document per kind inside one set, in the
448
+ * declaration's order. A kind whose read fails is listed with its error, and
449
+ * the exit code is 1.
450
+ */
451
+ async function runDeclaredSince(declared: { declared: RecordKindDeclaration; file: string }[], args: CommandContext["args"]): Promise<number> {
452
+ if (args.current || args.require !== undefined || args.base !== undefined) {
453
+ console.error(formatError({ message: "--since compares two revisions and takes no --current, --require or --base", hint: USAGE }));
454
+ return 1;
455
+ }
456
+ const { formatSince, queryRecordsSince, RECORDS_SINCE_OUTPUT_SCHEMA_ID } = await import("./records-since");
457
+ const kinds = [];
458
+ for (const k of declared) {
459
+ const doc = await queryRecordsSince({ kind: k.file, since: args.since!, at: args.at, cwd: process.cwd() });
460
+ kinds.push({ ...doc, declared: { member: k.declared.member, path: k.declared.path, name: k.declared.name } });
461
+ }
462
+ if (args.json) console.log(JSON.stringify({ $schema: RECORDS_SINCE_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, kinds }, null, 2));
463
+ for (const doc of args.json ? [] : kinds) {
464
+ if ("error" in doc) {
465
+ console.error(formatError({ message: `${doc.declared.path}: ${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
466
+ continue;
467
+ }
468
+ console.log(`${doc.declared.name ?? doc.kind.name} (${doc.declared.path})`);
469
+ console.log(formatSince(doc));
470
+ }
471
+ return kinds.some((d) => "error" in d) ? 1 : 0;
472
+ }
473
+
474
+ export function realpathOr(dir: string): string {
260
475
  try {
261
476
  return realpathSync(dir);
262
477
  } catch {
@@ -279,8 +494,20 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
279
494
  const superseded = r.supersededBy ? ` superseded by ${r.supersededBy}` : "";
280
495
  const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
281
496
  lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${attested}${flag}`);
497
+ if (r.ready !== undefined) {
498
+ const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
499
+ const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
500
+ const status = [r.ready ? "ready" : blocked ? `blocked by ${blocked}` : "", implemented ? `implements ${implemented}` : ""].filter(Boolean).join("; ");
501
+ if (status) lines.push(`${" ".repeat(idWidth + 2)}${status}`);
502
+ }
282
503
  for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
283
504
  for (const warning of r.warnings) lines.push(`${" ".repeat(idWidth + 2)}warning ${warning.code}: ${warning.message} (${r.path})`);
505
+ const q = r.quorum;
506
+ if (q && q.counted.length + q.notCounted.length > 0) {
507
+ const verdict = q.metWithObjections ? "met with objections" : q.met ? "met" : "not met";
508
+ const concerns = q.openConcerns.length > 0 ? `, ${q.openConcerns.length} open ${q.openConcerns.length === 1 ? "concern" : "concerns"}` : "";
509
+ lines.push(`${" ".repeat(idWidth + 2)}quorum ${q.agreed} of ${q.need} agreed, ${verdict}; ${q.notCounted.length} not counted${concerns}`);
510
+ }
284
511
  }
285
512
  lines.push(
286
513
  `${summary.total} records${at ? ` at ${at.slice(0, 8)}` : ""}: ${summary.valid} valid, ${summary.invalid} invalid, ${summary.superseded} superseded`,
@@ -5,13 +5,14 @@
5
5
  * doesn't exist yet, so the chant repo's own decision files stand in for it.
6
6
  */
7
7
 
8
- import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
8
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import Ajv2020 from "ajv/dist/2020";
12
12
  import { afterAll, describe, expect, test } from "vitest";
13
13
  import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
14
- import { READ_ERROR_CODES, RECORD_REASON_CODES } from "./records";
14
+ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, recordTextDigest, REVIEW_REASON_CODES } from "./records";
15
+ import { WORK_WARNING_CODES } from "./work";
15
16
  import schema from "./records.schema.json";
16
17
  import { PROVENANCE_LEVELS } from "./trust/attestor";
17
18
 
@@ -77,6 +78,80 @@ describe("records output schema", () => {
77
78
  expect(doc.summary.invalid).toBe(2);
78
79
  });
79
80
 
81
+ test("a workspace-sourced decision with no evidence validates, with its warning (#2654)", async () => {
82
+ const root = copyDecisions();
83
+ const dir = join(root, "docs", "design", "decisions");
84
+ const text = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8")
85
+ .replace(/^id: .*$/m, 'id: "ws-900"')
86
+ .replace(/^source:\n(?: .*\n)*/m, 'source:\n kind: "workspace"\n member: "app"\n')
87
+ .replace(/^evidence:\n(?: .*\n)*/m, "evidence: []\n");
88
+ writeFileSync(join(dir, "ws-900-extra.md"), text);
89
+ const doc = await queryRecords({ kind: KIND, current: true, cwd: root });
90
+ expectValid(doc);
91
+ if ("error" in doc) throw new Error(doc.error.message);
92
+ const r = doc.records.find((x) => x.id === "ws-900");
93
+ expect(r?.valid).toBe(true);
94
+ expect(r?.warnings?.map((w) => w.code)).toEqual(["record-no-evidence"]);
95
+ });
96
+
97
+ test("lists exactly the codes a verdict is not counted for (#2671)", () => {
98
+ expect(schema.$defs.verdict.properties.reason.properties.code.enum).toEqual([...REVIEW_REASON_CODES]);
99
+ });
100
+
101
+ test("every record carries its digest and quorum, and the chant repo's decisions need the default two (#2671, #2672)", async () => {
102
+ const doc = await queryRecords({ kind: KIND, current: true, cwd: REPO });
103
+ if ("error" in doc) throw new Error(doc.error.message);
104
+ for (const r of doc.records) {
105
+ expect(r.digest).toMatch(/^[0-9a-f]{64}$/);
106
+ expect(r.quorum).toMatchObject({ need: 2, needFrom: "default", agreed: 0, met: false, metWithObjections: false });
107
+ }
108
+ });
109
+
110
+ test("a decision with verdicts validates, with each verdict counted or not and why (#2671, #2672)", async () => {
111
+ const root = copyDecisions();
112
+ const dir = join(root, "docs", "design", "decisions");
113
+ writeFileSync(
114
+ join(root, "chant.workspace.json"),
115
+ JSON.stringify({ name: "w", schema: 1, quorum: 1, members: [{ name: "docs", dir: "docs", kind: "other", because: "decisions only" }] }),
116
+ );
117
+ const base = readFileSync(join(dir, "ws-003-seal-scope.md"), "utf-8").replace(/^id: .*$/m, 'id: "ws-900"');
118
+ const digest = recordTextDigest(base);
119
+ const entry = (reviewer: string, verdict: string, extra = "") => ` - reviewer: "${reviewer}"\n verdict: "${verdict}"\n on: "2026-09-24"${extra}`;
120
+ const reviews = [
121
+ entry("alice", "agree", `\n digest: "${digest}"`),
122
+ entry("Alice ", "agree", `\n digest: "${digest}"`),
123
+ entry("lex00", "agree"),
124
+ entry("bob", "dissent", `\n note: "a case is missing"\n digest: "${"0".repeat(64)}"`),
125
+ ];
126
+ writeFileSync(join(dir, "ws-900-extra.md"), base.replace(/^reviews: \[\]$/m, `reviews:\n${reviews.join("\n")}`));
127
+ const doc = await queryRecords({ kind: KIND, current: true, cwd: root });
128
+ expectValid(doc);
129
+ if ("error" in doc) throw new Error(doc.error.message);
130
+ const r = doc.records.find((x) => x.id === "ws-900")!;
131
+ expect(r.valid).toBe(true);
132
+ expect(r.digest).toBe(digest);
133
+ expect(r.warnings.map((w) => w.code)).toEqual(["review-undigested"]);
134
+ const q = r.quorum!;
135
+ expect(q).toMatchObject({ need: 1, needFrom: "declaration", agreed: 1, met: true, metWithObjections: true });
136
+ expect(q.counted.map((v) => v.reviewer)).toEqual(["Alice "]);
137
+ expect(q.notCounted.map((v) => [v.reviewer, v.reason?.code])).toEqual([
138
+ ["alice", "review-duplicate"],
139
+ ["lex00", "review-decider"],
140
+ ["bob", "review-older-digest"],
141
+ ]);
142
+ expect(q.openConcerns.map((c) => c.reviewer)).toEqual(["bob"]);
143
+ // A counted verdict with a reason, or a not-counted one without, is refused.
144
+ const bad = structuredClone(doc);
145
+ const record = bad.records.find((x) => x.id === "ws-900")!;
146
+ record.quorum!.counted[0].reason = { code: "review-duplicate", message: "x" };
147
+ expect(validate(bad)).toBe(false);
148
+ });
149
+
150
+ test("lists exactly the warning codes the code can return", () => {
151
+ // A work kind's records carry the work warnings too, except work-done-gap-open, which only graph --intent raises (#2683).
152
+ expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES.filter((c) => c !== "work-done-gap-open")]);
153
+ });
154
+
80
155
  test("every failure validates with its code", async () => {
81
156
  const root = copyDecisions();
82
157
  const docs = [