@intentius/chant 0.86.0 → 0.88.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 (83) hide show
  1. package/dist/cli/handlers/misc.d.ts.map +1 -1
  2. package/dist/cli/main.d.ts.map +1 -1
  3. package/dist/cli/mcp/server.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +5 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/cli/version.d.ts +8 -0
  7. package/dist/cli/version.d.ts.map +1 -0
  8. package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
  9. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
  10. package/dist/workspace/composites.d.ts +13 -0
  11. package/dist/workspace/composites.d.ts.map +1 -1
  12. package/dist/workspace/environments.d.ts +80 -0
  13. package/dist/workspace/environments.d.ts.map +1 -0
  14. package/dist/workspace/intent.d.ts +6 -1
  15. package/dist/workspace/intent.d.ts.map +1 -1
  16. package/dist/workspace/reason-codes.d.ts +20 -4
  17. package/dist/workspace/reason-codes.d.ts.map +1 -1
  18. package/dist/workspace/records-cli.d.ts +12 -2
  19. package/dist/workspace/records-cli.d.ts.map +1 -1
  20. package/dist/workspace/records-close.d.ts +41 -0
  21. package/dist/workspace/records-close.d.ts.map +1 -0
  22. package/dist/workspace/records-since.d.ts +40 -1
  23. package/dist/workspace/records-since.d.ts.map +1 -1
  24. package/dist/workspace/records-write.d.ts +117 -12
  25. package/dist/workspace/records-write.d.ts.map +1 -1
  26. package/dist/workspace/records.d.ts +84 -14
  27. package/dist/workspace/records.d.ts.map +1 -1
  28. package/dist/workspace/runtimes.d.ts +6 -0
  29. package/dist/workspace/runtimes.d.ts.map +1 -1
  30. package/dist/workspace/session-kinds.d.ts +28 -0
  31. package/dist/workspace/session-kinds.d.ts.map +1 -0
  32. package/dist/workspace/status.d.ts +2 -0
  33. package/dist/workspace/status.d.ts.map +1 -1
  34. package/dist/workspace/trust/seal.d.ts +127 -0
  35. package/dist/workspace/trust/seal.d.ts.map +1 -0
  36. package/dist/workspace/trust/ssh-commit.d.ts +7 -0
  37. package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
  38. package/dist/workspace/work.d.ts +3 -3
  39. package/package.json +1 -1
  40. package/src/cli/handlers/misc.ts +1 -9
  41. package/src/cli/main.ts +29 -10
  42. package/src/cli/mcp/server.test.ts +14 -1
  43. package/src/cli/mcp/server.ts +3 -1
  44. package/src/cli/registry.ts +5 -0
  45. package/src/cli/version.ts +15 -0
  46. package/src/workspace/__fixtures__/sessions.ts +41 -0
  47. package/src/workspace/composites.schema.json +68 -3
  48. package/src/workspace/composites.test.ts +119 -5
  49. package/src/workspace/composites.ts +26 -7
  50. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
  51. package/src/workspace/environments.ts +165 -0
  52. package/src/workspace/intent-gaps.test.ts +217 -0
  53. package/src/workspace/intent.schema.json +23 -1
  54. package/src/workspace/intent.test.ts +2 -0
  55. package/src/workspace/intent.ts +35 -3
  56. package/src/workspace/read-contract.test.ts +3 -0
  57. package/src/workspace/reason-codes.test.ts +9 -3
  58. package/src/workspace/reason-codes.ts +24 -4
  59. package/src/workspace/record-assets.test.ts +4 -3
  60. package/src/workspace/records-amend.schema.json +30 -1
  61. package/src/workspace/records-cli.ts +113 -14
  62. package/src/workspace/records-close.schema.json +192 -0
  63. package/src/workspace/records-close.ts +129 -0
  64. package/src/workspace/records-contract.test.ts +4 -3
  65. package/src/workspace/records-new.schema.json +25 -0
  66. package/src/workspace/records-review.schema.json +81 -3
  67. package/src/workspace/records-sessions-write.test.ts +274 -0
  68. package/src/workspace/records-since.schema.json +27 -2
  69. package/src/workspace/records-since.ts +120 -6
  70. package/src/workspace/records-write-contract.test.ts +5 -1
  71. package/src/workspace/records-write.test.ts +4 -2
  72. package/src/workspace/records-write.ts +336 -43
  73. package/src/workspace/records.schema.json +37 -3
  74. package/src/workspace/records.ts +145 -25
  75. package/src/workspace/runtimes.ts +12 -3
  76. package/src/workspace/session-kinds.ts +79 -0
  77. package/src/workspace/status.ts +1 -1
  78. package/src/workspace/trust/record-seal.test.ts +315 -0
  79. package/src/workspace/trust/seal.test.ts +222 -0
  80. package/src/workspace/trust/seal.ts +289 -0
  81. package/src/workspace/trust/ssh-commit.ts +2 -2
  82. package/src/workspace/work.test.ts +3 -1
  83. package/src/workspace/work.ts +4 -4
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The two gaps a work item could not name before #2686, a stale pin and an
3
+ * open dissent, and `work-done-gap-open` on a records read, on a workspace
4
+ * built in a throwaway git repository:
5
+ *
6
+ * - c1 adds the app, the screen spec `design/screens/home.json`, and dec-001,
7
+ * which constrains the spec's path and pins it by hash.
8
+ * - c2 adds dec-002, which supersedes dec-001, constrains the same path and
9
+ * pins the spec at the same hash, so its pin is stale; dec-003, which
10
+ * constrains `app/server.mjs` and has one open dissent (bob's), one
11
+ * withdrawn and one addressed; and the work items:
12
+ * W-001 (open, from intent-pin-stale on the spec), W-002 (open, from
13
+ * intent-decision-contested on the server), W-003 (done, from
14
+ * intent-decision-contested on the server, which still fires), W-004
15
+ * (done, from intent-pin-drifted on the spec, which does not fire) and
16
+ * W-005 (open, implements dec-002 and dec-003, no gap source).
17
+ */
18
+
19
+ import { readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
22
+ import { sha256Hex } from "../content-digest";
23
+ import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
24
+ import { intentGraph, type FindingNode, type IntentDocument, type WorkNode } from "./intent";
25
+ import intentSchema from "./intent.schema.json";
26
+ import { parseFrontMatter } from "./records";
27
+ import { queryRecords } from "./records-cli";
28
+ import recordsSchema from "./records.schema.json";
29
+
30
+ const REF = join(REPO, "reference-workspace");
31
+ const BASE = (() => {
32
+ const fm = parseFrontMatter(readFileSync(join(REF, "decisions", "ref-001-how-the-app-is-deployed.md"), "utf-8"));
33
+ if (!fm.ok) throw new Error(fm.message);
34
+ return fm.value;
35
+ })();
36
+
37
+ const HOME = `${JSON.stringify({ route: "/", regions: ["header"] }, null, 2)}\n`;
38
+ const pin = { title: "The home screen spec", path: "design/screens/home.json", sha256: sha256Hex(Buffer.from(HOME)), as_of: "2026-09-24T12:00:00Z" };
39
+
40
+ function decision(id: string, fields: Record<string, unknown>): string {
41
+ const data = { ...BASE, id, title: `Decision ${id}`, state: "decided", supersedes: [], evidence: [], reviews: [], ...fields };
42
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
43
+ }
44
+
45
+ function work(id: string, fields: Record<string, unknown>): string {
46
+ const data = { schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: ["path:app/server.mjs"], evidence: [], opened_on: "2026-09-24", supersedes: [], ...fields };
47
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n\nWhat the work is.\n`;
48
+ }
49
+
50
+ const done = { state: "done", closed_on: "2026-09-25", evidence: [{ title: "The review", url: "https://example.com/review" }] };
51
+ const KIND_FILES = {
52
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
53
+ "decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
54
+ "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
55
+ "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
56
+ };
57
+ const DECLARATION = JSON.stringify({
58
+ name: "studio",
59
+ schema: 1,
60
+ members: [
61
+ { name: "app", dir: "app", kind: "other", because: "a plain Node server" },
62
+ { name: "design", dir: "design", kind: "other", because: "the screen specs" },
63
+ ],
64
+ });
65
+
66
+ const DEC_003 = decision("dec-003", {
67
+ constrains: ["path:app/server.mjs"],
68
+ reviews: [
69
+ { reviewer: "Bob", verdict: "dissent", note: "The port is wrong.", on: "2026-09-24" },
70
+ { reviewer: "carol", verdict: "dissent", note: "Too early.", on: "2026-09-24", withdrawn_on: "2026-09-25" },
71
+ { reviewer: "dave", verdict: "dissent", note: "Needs a test.", on: "2026-09-24", addressed_by: "acme/studio#7" },
72
+ { reviewer: "erin", verdict: "agree", on: "2026-09-24" },
73
+ ],
74
+ });
75
+
76
+ let root: string;
77
+
78
+ function commit(message: string): void {
79
+ git(root, "add", "-A");
80
+ git(root, "commit", "-q", "-m", message);
81
+ }
82
+
83
+ beforeAll(() => {
84
+ root = repo({
85
+ "chant.workspace.json": DECLARATION,
86
+ "app/server.mjs": "export const port = 8080;\n",
87
+ "design/screens/home.json": HOME,
88
+ ...KIND_FILES,
89
+ "decisions/dec-001-spec.md": decision("dec-001", { constrains: ["path:design/screens/home.json"], evidence: [pin] }),
90
+ });
91
+ commit("the app, the spec and dec-001");
92
+ writeFiles(root, {
93
+ "decisions/dec-002-spec-again.md": decision("dec-002", { constrains: ["path:design/screens/home.json"], evidence: [pin], supersedes: [{ decision: "dec-001" }] }),
94
+ "decisions/dec-003-server.md": DEC_003,
95
+ "work/W-001-stale.md": work("W-001", { constrains: ["path:design/screens/home.json"], source: { finding: "intent-pin-stale", region: "design/screens/home.json", decision: "dec-002" } }),
96
+ "work/W-002-contested.md": work("W-002", { source: { finding: "intent-decision-contested", region: "app/server.mjs", decision: "dec-003" } }),
97
+ "work/W-003-contested-done.md": work("W-003", { ...done, source: { finding: "intent-decision-contested", region: "app/server.mjs", decision: "dec-003" } }),
98
+ "work/W-004-drift-done.md": work("W-004", { ...done, constrains: ["path:design/screens/home.json"], source: { finding: "intent-pin-drifted", region: "design/screens/home.json" } }),
99
+ "work/W-005-carry-out.md": work("W-005", { implements: ["dec-002", "dec-003"] }),
100
+ });
101
+ commit("dec-002 supersedes dec-001, dec-003 is contested, and the work");
102
+ });
103
+ afterAll(cleanScratch);
104
+
105
+ const intent = contract(intentSchema);
106
+ const records = contract(recordsSchema);
107
+ type Result = Exclude<IntentDocument, { error: unknown }>;
108
+
109
+ async function walk(region: string): Promise<Result> {
110
+ const { doc } = await intentGraph({ cwd: root, region, kinds: ["decisions/decision.kind.mjs", "work/work.kind.mjs"].map((k) => join(root, k)) });
111
+ intent.expectValid(doc);
112
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
113
+ return doc;
114
+ }
115
+
116
+ const findingsOf = (doc: Result, code: string) => doc.nodes.filter((n): n is FindingNode => n.kind === "finding" && n.code === code);
117
+
118
+ describe("intent-pin-stale (#2686)", () => {
119
+ test("a current decision pinning the artifact at the hash the record it supersedes pinned is stale, beside drifted and missing", async () => {
120
+ const doc = await walk("design/screens/home.json");
121
+ expect(doc.nodes.find((n) => n.id === "artifact:design/screens/home.json")).toMatchObject({ pinState: "stale" });
122
+ const stale = findingsOf(doc, "intent-pin-stale");
123
+ expect(stale).toHaveLength(1);
124
+ expect(stale[0].concerns).toEqual(["record:decision/dec-002", "artifact:design/screens/home.json", "record:decision/dec-001"]);
125
+ expect(stale[0].message).toContain("dec-001");
126
+ expect(findingsOf(doc, "intent-pin-drifted")).toEqual([]);
127
+ expect(findingsOf(doc, "intent-pin-missing")).toEqual([]);
128
+ });
129
+
130
+ test("it is addressed by the item that came from it and by the item implementing the decision", async () => {
131
+ const doc = await walk("design/screens/home.json");
132
+ const [stale] = findingsOf(doc, "intent-pin-stale");
133
+ expect(stale).toMatchObject({ addressed: true, addressedBy: [{ id: "W-001", state: "open" }, { id: "W-005", state: "open" }] });
134
+ expect(doc.edges).toContainEqual({ kind: "addressed-by", from: stale.id, to: "record:work/W-001" });
135
+ expect(doc.edges).toContainEqual({ kind: "addressed-by", from: stale.id, to: "record:work/W-005" });
136
+ });
137
+
138
+ test("a stale pin on a decision in the graph only through supersession is not raised", async () => {
139
+ // app/server.mjs brings in no decision pinning the spec.
140
+ const doc = await walk("app/server.mjs");
141
+ expect(findingsOf(doc, "intent-pin-stale")).toEqual([]);
142
+ });
143
+ });
144
+
145
+ describe("intent-decision-contested (#2686)", () => {
146
+ test("a current decision constraining the region with a dissent neither addressed nor withdrawn is contested, with the count and principals", async () => {
147
+ const doc = await walk("app/server.mjs");
148
+ const contested = findingsOf(doc, "intent-decision-contested");
149
+ expect(contested).toHaveLength(1);
150
+ expect(contested[0]).toMatchObject({ concerns: ["record:decision/dec-003", "region:app/server.mjs"], openConcerns: { count: 1, principals: ["bob"] } });
151
+ expect(contested[0].message).toContain("1 open concern, from Bob");
152
+ });
153
+
154
+ test("it is addressed by the items that came from it, and not through implements", async () => {
155
+ const doc = await walk("app/server.mjs");
156
+ const [contested] = findingsOf(doc, "intent-decision-contested");
157
+ expect(contested.addressedBy).toEqual([
158
+ { id: "W-002", state: "open" },
159
+ { id: "W-003", state: "done" },
160
+ ]);
161
+ expect(doc.edges).toContainEqual({ kind: "addressed-by", from: contested.id, to: "record:work/W-002" });
162
+ const w003 = doc.nodes.find((n): n is WorkNode => n.kind === "work" && n.record === "W-003");
163
+ expect(w003!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
164
+ });
165
+
166
+ test("a decision whose dissents are all addressed or withdrawn is not contested", async () => {
167
+ writeFiles(root, { "decisions/dec-003-server.md": DEC_003.replace(`"note": "The port is wrong.",`, `"note": "The port is wrong.", "addressed_by": "acme/studio#8",`) });
168
+ try {
169
+ const doc = await walk("app/server.mjs");
170
+ expect(findingsOf(doc, "intent-decision-contested")).toEqual([]);
171
+ } finally {
172
+ writeFiles(root, { "decisions/dec-003-server.md": DEC_003 });
173
+ }
174
+ });
175
+ });
176
+
177
+ describe("work-done-gap-open in records (#2686)", () => {
178
+ test("records --json warns on a done item whose gap still fires, and not on one whose gap closed", async () => {
179
+ const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: root });
180
+ records.expectValid(doc);
181
+ if ("error" in doc) throw new Error(doc.error.message);
182
+ const warned = Object.fromEntries(doc.records.map((r) => [r.id, r.warnings.map((w) => w.code)]));
183
+ expect(warned).toEqual({ "W-001": [], "W-002": [], "W-003": ["work-done-gap-open"], "W-004": [], "W-005": [] });
184
+ expect(doc.records.find((r) => r.id === "W-003")!.warnings[0].message).toContain("intent-decision-contested");
185
+ });
186
+
187
+ test("the warning follows the revision read: under --at c1 there is no work, and under --at HEAD W-003 is warned", async () => {
188
+ const c1 = git(root, "rev-parse", "HEAD~1");
189
+ const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: root, at: c1 });
190
+ records.expectValid(doc);
191
+ if ("error" in doc) throw new Error(doc.error.message);
192
+ expect(doc.records).toEqual([]);
193
+ const head = await queryRecords({ kind: "work/work.kind.mjs", cwd: root, at: "HEAD" });
194
+ if ("error" in head) throw new Error(head.error.message);
195
+ expect(head.records.find((r) => r.id === "W-003")!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
196
+ });
197
+
198
+ test("without a workspace declaration the walk can't be made, so the record is left as it was", async () => {
199
+ const bare = repo({
200
+ "app/server.mjs": "export const port = 8080;\n",
201
+ ...KIND_FILES,
202
+ "decisions/dec-003-server.md": DEC_003,
203
+ "work/W-003-contested-done.md": work("W-003", { ...done, source: { finding: "intent-decision-contested", region: "app/server.mjs" } }),
204
+ });
205
+ git(bare, "add", "-A");
206
+ git(bare, "commit", "-q", "-m", "one");
207
+ const doc = await queryRecords({ kind: "work/work.kind.mjs", cwd: bare });
208
+ if ("error" in doc) throw new Error(doc.error.message);
209
+ expect(doc.records[0].warnings).toEqual([]);
210
+ });
211
+
212
+ test("the intent graph reads its kinds without asking records for the warning, so it is raised once", async () => {
213
+ const doc = await walk("app/server.mjs");
214
+ const w003 = doc.nodes.find((n): n is WorkNode => n.kind === "work" && n.record === "W-003");
215
+ expect(w003!.warnings.filter((w) => w.code === "work-done-gap-open")).toHaveLength(1);
216
+ });
217
+ });
@@ -924,7 +924,7 @@
924
924
  },
925
925
  "warnings": {
926
926
  "type": "array",
927
- "description": "The item's work warnings as records reports them, and work-done-gap-open, which only this walk raises: the item is done and the finding in its source still fires on its region.",
927
+ "description": "The item's work warnings as records reports them, and work-done-gap-open: the item is done and the finding in its source still fires on its region. records raises it too, by walking the source's region with this code (#2686).",
928
928
  "items": {
929
929
  "type": "object",
930
930
  "required": [
@@ -1088,9 +1088,11 @@
1088
1088
  "intent-commit-bare",
1089
1089
  "intent-pin-drifted",
1090
1090
  "intent-pin-missing",
1091
+ "intent-pin-stale",
1091
1092
  "intent-artifact-unpinned",
1092
1093
  "intent-decision-superseded-live",
1093
1094
  "intent-decision-provisional",
1095
+ "intent-decision-contested",
1094
1096
  "intent-constraint-coarse",
1095
1097
  "intent-constraint-lost",
1096
1098
  "intent-evidence-unpinned",
@@ -1129,6 +1131,26 @@
1129
1131
  },
1130
1132
  "description": "For a plugin's finding: the refs as the plugin gave them. Those that name a node in the graph are resolved to its id in concerns, after the commit the finding was returned for."
1131
1133
  },
1134
+ "openConcerns": {
1135
+ "type": "object",
1136
+ "description": "Added by #2686, on intent-decision-contested only: how many open concerns the decision's quorum reports, dissents neither addressed nor withdrawn, and the principals who raised them, normalised as the quorum compares them.",
1137
+ "required": [
1138
+ "count",
1139
+ "principals"
1140
+ ],
1141
+ "properties": {
1142
+ "count": {
1143
+ "type": "integer",
1144
+ "minimum": 1
1145
+ },
1146
+ "principals": {
1147
+ "type": "array",
1148
+ "items": {
1149
+ "type": "string"
1150
+ }
1151
+ }
1152
+ }
1153
+ },
1132
1154
  "addressed": {
1133
1155
  "type": "boolean",
1134
1156
  "description": "Added by #2683, present when a work kind was read: whether a work item addresses the finding, because the item's source names this finding code on a region that contains or is contained by this one, or because the item implements a decision the finding concerns. A finding with addressed false is work nobody has taken yet."
@@ -443,6 +443,8 @@ describe("findings the fixture does not raise (#2651)", () => {
443
443
  expect(findings(doc).map(([c]) => c)).toEqual([
444
444
  "intent-commit-undecided",
445
445
  "intent-commit-bare",
446
+ // dec-002 supersedes dec-001 and pins the spec at the hash dec-001 pinned, unchanged since (#2686).
447
+ "intent-pin-stale",
446
448
  "intent-decision-provisional",
447
449
  "intent-constraint-coarse",
448
450
  "intent-constraint-lost",
@@ -71,12 +71,16 @@ export const INTENT_FINDING_CODES = [
71
71
  "intent-pin-drifted",
72
72
  /** A pinned artifact does not exist in the tree read. */
73
73
  "intent-pin-missing",
74
+ /** A current decision's pin is unchanged at the hash a record it supersedes pinned: the decision moved on and the artifact did not (#2686). */
75
+ "intent-pin-stale",
74
76
  /** An artifact that decisions in the graph pinned, and that no current decision pins. */
75
77
  "intent-artifact-unpinned",
76
78
  /** Every decision constraining the region is superseded. */
77
79
  "intent-decision-superseded-live",
78
80
  /** The current decisions constraining the region are all in states their kind does not close, such as decided and not ratified. */
79
81
  "intent-decision-provisional",
82
+ /** A current decision constraining the region has an open concern: a dissent neither addressed nor withdrawn (#2686). */
83
+ "intent-decision-contested",
80
84
  /** The region is constrained only at member granularity. */
81
85
  "intent-constraint-coarse",
82
86
  /** A decision's path constraint names a path that does not exist. */
@@ -269,6 +273,8 @@ export interface FindingNode {
269
273
  plugin?: string;
270
274
  /** For a plugin's finding: the refs as the plugin gave them. The ones that name a node in the graph are in concerns. */
271
275
  refs?: string[];
276
+ /** For intent-decision-contested (#2686): how many open concerns the decision has, and whose, as its quorum reports them. */
277
+ openConcerns?: { count: number; principals: string[] };
272
278
  /** With a work kind read (#2683): whether a work item addresses the finding. */
273
279
  addressed?: boolean;
274
280
  /** With a work kind read: the work items addressing the finding, each with its state. */
@@ -618,7 +624,7 @@ async function loadKinds(query: IntentQuery, top: string): Promise<LoadedKind[]>
618
624
  if (typeof joins === "string") throw new IntentError("kind-invalid", `kind file ${k} has a commitJoins export that can't be read: ${joins}`);
619
625
  const kind: LoadedKind = { file, display, name: joins?.name ?? basename(file).replace(/(?:\.kind)?\.[cm]?[jt]s$/, ""), ...(joins ? { joins } : {}) };
620
626
  if (mod.recordKind !== undefined) {
621
- const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd });
627
+ const doc = await queryRecords({ kind: file, at: query.at, cwd: query.cwd, workGaps: false });
622
628
  if ("error" in doc) throw new IntentError(doc.error.code, doc.error.message);
623
629
  try {
624
630
  kind.records = { loaded: await loadRecordKind(file), views: doc.records, workspaceRoot: doc.workspaceRoot };
@@ -951,6 +957,18 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
951
957
  edges.push({ kind: "pins", from: p.decision.id, to: aid, pinnedSha256: p.sha256, pinState: p.state });
952
958
  if (p.state === "drifted") find("intent-pin-drifted", `${path} changed after ${p.decision.record} pinned it: sha256 ${p.sha256?.slice(0, 12)} is pinned, the file hashes to ${p.actual?.slice(0, 12)}`, [p.decision.id, aid]);
953
959
  if (p.state === "missing") find("intent-pin-missing", `${p.decision.record} pins ${path}, which does not exist${located.tree.label}`, [p.decision.id, aid]);
960
+ // Stale against a current decision constraining the region (#2686): the
961
+ // record it supersedes pinned the same hash, and the file has not
962
+ // changed since. A decision in the graph only through supersession
963
+ // does not govern the region, so its stale pin is not raised here.
964
+ if (p.state === "stale" && p.decision.supersededBy === null && p.decision.constrains.length > 0) {
965
+ const old = pins.find((o) => o.decision.supersededBy === p.decision.record && o.decision.recordKind === p.decision.recordKind && o.sha256 === p.sha256);
966
+ find(
967
+ "intent-pin-stale",
968
+ `${path} is pinned by ${p.decision.record} at sha256 ${p.sha256?.slice(0, 12)}, the hash ${old ? old.decision.record : "the record it supersedes"} pinned, and it has not changed since: the decision moved on and the artifact did not`,
969
+ [p.decision.id, aid, ...(old ? [old.decision.id] : [])],
970
+ );
971
+ }
954
972
  }
955
973
  if (!worst && pins.some((p) => p.state !== "unpinned")) {
956
974
  find("intent-artifact-unpinned", `${path} was pinned by ${[...new Set(pins.map((p) => p.decision.record))].join(", ")}, and no current decision pins it`, [aid, ...new Set(pins.map((p) => p.decision.id))]);
@@ -1143,6 +1161,20 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
1143
1161
  } else if (current.every((c) => !c.node.closed)) {
1144
1162
  find("intent-decision-provisional", `the decisions constraining ${region.path} are ${[...new Set(current.map((c) => c.node.state ?? "stateless"))].join(" or ")}, and none is in a closed state`, [rid, ...current.map((c) => c.node.id)]);
1145
1163
  }
1164
+ // A current decision with a dissent nobody addressed or withdrew (#2686), from its quorum.
1165
+ for (const c of current) {
1166
+ const open = c.view.quorum?.openConcerns ?? [];
1167
+ if (open.length === 0) continue;
1168
+ const principals = [...new Set(open.map((o) => o.principal))];
1169
+ findings.push({
1170
+ id: `finding:intent-decision-contested:${findings.filter((f) => f.code === "intent-decision-contested").length + 1}`,
1171
+ kind: "finding",
1172
+ code: "intent-decision-contested",
1173
+ message: `${c.node.record} constrains ${region.path} and has ${open.length} open ${open.length === 1 ? "concern" : "concerns"}, from ${open.map((o) => o.reviewer).join(", ")}: a dissent neither addressed nor withdrawn`,
1174
+ concerns: [c.node.id, rid],
1175
+ openConcerns: { count: open.length, principals },
1176
+ });
1177
+ }
1146
1178
  const granularities = new Set(covering.flatMap((c) => c.node.constrains.map((x) => x.granularity)));
1147
1179
  if (covering.length > 0 && !granularities.has("path") && granularities.has("member")) {
1148
1180
  find("intent-constraint-coarse", `${region.path} is constrained only through its member, ${member}`, [rid, ...covering.map((c) => c.node.id)]);
@@ -1222,7 +1254,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
1222
1254
  findings.push({ id: `finding:${code}:${findings.filter((f) => f.code === code).length + 1}`, kind: "finding", code, message: finding.message, concerns, plugin, refs });
1223
1255
  }
1224
1256
  // A finding a work item addresses (#2683): the item came from that gap on
1225
- // this region, or it implements the decision a drifted or missing pin, or a
1257
+ // this region, or it implements the decision a drifted, missing or stale pin, or a
1226
1258
  // plugin's finding, is about. A done item whose gap still fires here gets
1227
1259
  // work-done-gap-open.
1228
1260
  if (readsWork) {
@@ -1230,7 +1262,7 @@ async function walk(query: IntentQuery, head: Head): Promise<IntentResult> {
1230
1262
  // artifacts, not gaps in the decision record itself (its review, its
1231
1263
  // granularity, its evidence), so only these findings, and a plugin's,
1232
1264
  // count as addressed through implements.
1233
- const CLOSED_BY_IMPLEMENTING = new Set<string>(["intent-pin-drifted", "intent-pin-missing"]);
1265
+ const CLOSED_BY_IMPLEMENTING = new Set<string>(["intent-pin-drifted", "intent-pin-missing", "intent-pin-stale"]);
1234
1266
  const fromGap = (h: WorkHit, code: string): boolean => {
1235
1267
  const src = gapSource(h.view.data);
1236
1268
  if (src === null || src.finding !== code) return false;
@@ -160,6 +160,9 @@ describe("every schema against the reference workspace (#2543)", () => {
160
160
  // #2674: the reference config's lexicons host no component runs, so local is the only runtime, read at HEAD too.
161
161
  expect(doc.components[0].runtimes).toEqual([{ name: "local", lexicon: null, default: true, command: "chant run --components app" }]);
162
162
  expect(doc.members.find((m) => m.name === "delivery")!.runtimeReasons).toEqual([]);
163
+ // #2695: the reference config declares no environments, so the app deploys to local only.
164
+ expect(doc.components[0].environments).toEqual([{ name: "local", default: true, source: "builtin", command: "chant run --components app" }]);
165
+ expect(doc.members.find((m) => m.name === "delivery")!.environmentReasons.map((r) => r.code)).toEqual(["environments-none-declared"]);
163
166
  expect(doc.reasons).toEqual([]);
164
167
  }
165
168
  },
@@ -10,7 +10,7 @@ import { join } from "node:path";
10
10
  import { describe, expect, test } from "vitest";
11
11
  import { REPO } from "./__fixtures__/contract-repo";
12
12
  import { MEMBER_RUN_REASON_CODES } from "./compose-graph";
13
- import { COMPOSITES_ERROR_CODES, COMPOSITES_REASON_CODES, COMPOSITES_RUNTIME_REASON_CODES } from "./composites";
13
+ import { COMPOSITES_ENVIRONMENT_REASON_CODES, COMPOSITES_ERROR_CODES, COMPOSITES_REASON_CODES, COMPOSITES_RUNTIME_REASON_CODES } from "./composites";
14
14
  import { WORKSPACE_ERROR_CODES } from "./declaration";
15
15
  import { GRAPH_ERROR_CODES } from "./graph-cli";
16
16
  import { INTENT_ERROR_CODES, INTENT_FINDING_CODES, INTENT_REASON_CODES } from "./intent";
@@ -19,9 +19,10 @@ import { GROUP_REASON_CODES, MEMBER_REASON_CODES } from "./ls";
19
19
  import intentSchema from "./intent.schema.json";
20
20
  import { isPluginCode, isReasonCode, REASON_CODES, REASONS } from "./reason-codes";
21
21
  import { contract } from "./__fixtures__/contract-repo";
22
- import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REASON_CODES } from "./records";
22
+ import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, REVIEW_REASON_CODES, SEAL_REASON_CODES, SEAL_WARNING_CODES } from "./records";
23
23
  import { AMEND_ERROR_CODES, NEW_ERROR_CODES, REVIEW_ERROR_CODES } from "./records-write";
24
- import { RECORDS_SINCE_ERROR_CODES } from "./records-since";
24
+ import { CLOSE_ERROR_CODES } from "./records-close";
25
+ import { RECORDS_SINCE_ERROR_CODES, RECORDS_SINCE_REASON_CODES } from "./records-since";
25
26
  import { STATUS_ERROR_CODES, STATUS_GATE_REASON_CODES, STATUS_REASON_CODES } from "./status";
26
27
  import { WORK_WARNING_CODES } from "./work";
27
28
 
@@ -41,18 +42,23 @@ const PER_COMMAND: Record<string, readonly string[]> = {
41
42
  RECORD_REASON_CODES,
42
43
  RECORD_WARNING_CODES,
43
44
  REVIEW_REASON_CODES,
45
+ SEAL_REASON_CODES,
46
+ SEAL_WARNING_CODES,
44
47
  WORK_WARNING_CODES,
45
48
  READ_ERROR_CODES,
46
49
  NEW_ERROR_CODES,
47
50
  AMEND_ERROR_CODES,
48
51
  REVIEW_ERROR_CODES,
52
+ CLOSE_ERROR_CODES,
49
53
  RECORDS_SINCE_ERROR_CODES,
54
+ RECORDS_SINCE_REASON_CODES,
50
55
  INTENT_ERROR_CODES,
51
56
  INTENT_FINDING_CODES,
52
57
  INTENT_REASON_CODES,
53
58
  COMPOSITES_ERROR_CODES,
54
59
  COMPOSITES_REASON_CODES,
55
60
  COMPOSITES_RUNTIME_REASON_CODES,
61
+ COMPOSITES_ENVIRONMENT_REASON_CODES,
56
62
  };
57
63
 
58
64
  /** Every string in an `enum` under a property named `code`, anywhere in a schema. */
@@ -80,13 +80,20 @@ export const REASONS = {
80
80
  "work-implements-undecided": "A work record implements a decision whose state is not approved, such as proposed.",
81
81
  "work-done-unpinned": "A work record is done and its evidence list is empty: nothing shows the work was done.",
82
82
  "work-closed-without-date": "A work record is done or dropped and has no closing date.",
83
- "work-done-gap-open": "A work record is done, and the finding it came from still fires on its region. Only graph --intent raises it.",
83
+ "work-done-gap-open": "A work record is done, and the finding it came from still fires on its region. graph --intent raises it, and records does by walking that region.",
84
84
  // A verdict the quorum does not count (records, #2671, #2672).
85
85
  "review-decider": "The verdict is the decider's own, and the quorum counts verdicts besides the decider's.",
86
86
  "review-agent": "The reviewer holds the agent role in the trust policy at base.",
87
87
  "review-duplicate": "A later verdict by the same principal replaces this one. Names are compared after NFKC, trimming and lower-casing.",
88
88
  "review-older-digest": "The verdict names a digest other than the record's text now: the record changed after the verdict.",
89
- "review-unattested": "An attestation policy is active at base, and the verdict carries no seal.",
89
+ "review-unattested": "An attestation policy is active at base, and the verdict carries no seal that verifies for its reviewer.",
90
+ // A seal that is not attested (records): a verdict's (#2687) or a record's author seal (#2688). Each reports one of these in its attestation, unless its seal verified.
91
+ "seal-missing": "The verdict, or the record, carries no seal.",
92
+ "seal-signer-unlisted": "The reviewer, or the record's author, has no key in the signers file at base, so the seal can't count.",
93
+ "seal-signature-invalid": "The seal is malformed, names a signer other than the reviewer or author, or its signature does not verify over the verdict or record.",
94
+ "seal-unverifiable": "Nothing here can say whose seal it is: there is no signers file at base, or ssh-keygen is not installed.",
95
+ // A record whose author seal is not attested under a signers file at base (records, #2688). A warning: the record is still read.
96
+ "record-unattested": "A signers file is active at base, and the record names an author whose seal does not verify: it has none, the author has no key in the file, or the signature fails.",
90
97
  // A records read that fails (records).
91
98
  "kind-unreadable": "The record kind file is missing or could not be imported.",
92
99
  "kind-invalid": "The record kind file exports no recordKind, or its shape is wrong.",
@@ -106,8 +113,15 @@ export const REASONS = {
106
113
  "amend-supersede-instead": "The record is approved, and the amendment changes a field the approval rule does not let change in place; a new record supersedes it instead.",
107
114
  "review-unsupported": "The kind's schema has no reviews field, so its records take no review.",
108
115
  "review-note-required": "A dissent was given with no note: a dissent needs a reason.",
116
+ "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.",
117
+ "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.",
118
+ // A review given in a session (records review --session, #2693).
119
+ "session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
120
+ "session-not-open": "--session names a session in a closed state, which takes no more verdicts.",
109
121
  // records --since that fails (#2673).
110
- "since-rev-unknown": "--since names no commit.",
122
+ "since-rev-unknown": "--since names no commit, or a session with no opening revision and no commit that added it.",
123
+ "since-session-unknown": "--since has the shape of a session id and names no commit, and no session the kind or the declaration reads has that id.",
124
+ "since-session-open": "--since names a session that is still open, so the comparison runs to the working tree.",
111
125
  // The intent graph (graph --intent, #2651): a read that fails.
112
126
  "intent-region-invalid": "The region's path, or its line range, does not exist in the tree read.",
113
127
  // The intent graph: part of the walk that can't be read. The document is still printed.
@@ -118,9 +132,11 @@ export const REASONS = {
118
132
  "intent-commit-bare": "A commit names no unit, no pull request and no decision covering the region at its time.",
119
133
  "intent-pin-drifted": "A decision's pinned artifact no longer hashes to the pin.",
120
134
  "intent-pin-missing": "A decision's pinned artifact does not exist in the tree read.",
135
+ "intent-pin-stale": "A current decision pins an artifact at the hash a record it supersedes pinned, and the artifact has not changed since: the decision moved on and the artifact did not.",
121
136
  "intent-artifact-unpinned": "An artifact decisions in the graph pinned, which no current decision pins.",
122
137
  "intent-decision-superseded-live": "Every decision constraining the region is superseded.",
123
138
  "intent-decision-provisional": "The current decisions constraining the region are all in states their kind does not close, such as decided.",
139
+ "intent-decision-contested": "A current decision constraining the region has an open concern: a dissent neither addressed nor withdrawn.",
124
140
  "intent-constraint-coarse": "The region is constrained only through its member, not by path.",
125
141
  "intent-constraint-lost": "A decision's path constraint names a path that does not exist in the tree read.",
126
142
  "intent-evidence-unpinned": "A decision's evidence has no hash: a URL, or a path with no sha256.",
@@ -134,8 +150,12 @@ export const REASONS = {
134
150
  "composites-none-declared": "The members read declare no composite instance.",
135
151
  "composites-no-component": "The members read declare no component, so no composite instance has one.",
136
152
  // The runtimes a member's components can deploy on (graph --composites, #2674).
137
- "runtimes-config-unreadable": "The member's chant.config.ts could not be read, so only the built-in local runtime is listed.",
153
+ "runtimes-config-unreadable": "The member's chant.config.ts could not be read, so only the built-in local runtime is listed, and no environment from the config.",
138
154
  "runtimes-lexicon-unreadable": "A lexicon the member's config lists could not be loaded, so it is not listed as a runtime.",
155
+ // The environments a member's components may deploy to (graph --composites, #2695).
156
+ "environments-none-declared": "The member's chant.config.ts declares no environments, so only local and the environments in its ledger are listed.",
157
+ "environments-ledger-undeclared": "The member's ledger has releases in an environment its config's environments don't cover, so chant run --env would refuse it and it is not listed.",
158
+ "environments-ledger-unreadable": "The chant/lifecycle branch exists and the member's ledger environments could not be listed.",
139
159
  // The lineage lock (check).
140
160
  "lock-invalid": "The lineage lock can't be read.",
141
161
  "manual-step-open": "A scope in the lineage lock has an open manual step.",
@@ -16,7 +16,7 @@ import { cleanScratch, commitAll, contract, declaration, repo, REPO } from "./__
16
16
  import { runChecks } from "./lineage-check";
17
17
  import { constraintCovers, isWorkspacePath, memberHolding, WORKSPACE_PATH_PATTERN } from "./record-assets";
18
18
  import { pinFile, queryRecords, type RecordsDocument } from "./records-cli";
19
- import { parseFrontMatter, RECORD_WARNING_CODES } from "./records";
19
+ import { parseFrontMatter, RECORD_WARNING_CODES, SEAL_WARNING_CODES } from "./records";
20
20
  import { WORK_WARNING_CODES } from "./work";
21
21
  import { workspaceGraph } from "./graph-cli";
22
22
  import graphSchema from "./graph.schema.json";
@@ -169,8 +169,9 @@ describe("chant workspace records checks each pin", () => {
169
169
  });
170
170
 
171
171
  test("the schema lists exactly the warning codes", () => {
172
- // A work kind's records carry the work warnings too, except work-done-gap-open, which only graph --intent raises (#2683).
173
- expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES.filter((c) => c !== "work-done-gap-open")]);
172
+ // A work kind's records carry the work warnings too (#2683), work-done-gap-open included since records walks a done item's region (#2686),
173
+ // and records adds record-unattested for an author seal under a signers file at base (#2688).
174
+ expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...SEAL_WARNING_CODES]);
174
175
  });
175
176
 
176
177
  test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {
@@ -67,7 +67,35 @@
67
67
  "items": {
68
68
  "type": "string"
69
69
  },
70
- "description": "The top-level fields whose value differs from the record as it was, in the record's order. Empty when the fields set change nothing, and then nothing is written."
70
+ "description": "The top-level fields whose value differs from the record as it was, in the record's order, with seal last when the author seal was written or removed (#2688). Empty when the fields set change nothing, and then nothing is written."
71
+ },
72
+ "seal": {
73
+ "type": "object",
74
+ "description": "Added in contract 1 by #2688. With --sign only: the author seal written into the record's top-level seal field, an ssh signature by the key given (or git's user.signingkey) over <id>\\n<digest>\\n<author>\\n<state>, in the ssh-keygen namespace chant-record. The author is the kind's reviews.decider field (decided_by for decisions), and the digest is the record's digest by the digest rule, which leaves the seal field out. The write does not check the key against the signers file; chant workspace records reports whether the seal verifies.",
75
+ "required": [
76
+ "signer",
77
+ "key",
78
+ "signature"
79
+ ],
80
+ "properties": {
81
+ "signer": {
82
+ "type": "string",
83
+ "description": "The record's author, as its reviews.decider field names them."
84
+ },
85
+ "key": {
86
+ "type": "string",
87
+ "pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
88
+ "description": "The fingerprint of the key that signed, read back from the signature."
89
+ },
90
+ "signature": {
91
+ "type": "string",
92
+ "description": "The armored ssh signature."
93
+ }
94
+ }
95
+ },
96
+ "sealDropped": {
97
+ "type": "string",
98
+ "description": "Added in contract 1 by #2688. Present when the record carried an author seal, the amendment moved its digest and --sign was not given: the seal no longer covers the text, so it was removed, and this says so. changed then lists seal."
71
99
  },
72
100
  "dryRun": {
73
101
  "type": "boolean",
@@ -145,6 +173,7 @@
145
173
  "amend-id-immutable",
146
174
  "record-closed",
147
175
  "amend-supersede-instead",
176
+ "record-sign-failed",
148
177
  "record-unparseable",
149
178
  "record-schema-invalid",
150
179
  "record-id-duplicate",