@intentius/chant 0.85.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 (121) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/registry.d.ts +11 -1
  3. package/dist/cli/registry.d.ts.map +1 -1
  4. package/dist/lifecycle/gate-ledger.d.ts +13 -0
  5. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  6. package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
  7. package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
  8. package/dist/workspace/checks/records.d.ts +1 -0
  9. package/dist/workspace/checks/records.d.ts.map +1 -1
  10. package/dist/workspace/checks.d.ts +4 -0
  11. package/dist/workspace/checks.d.ts.map +1 -1
  12. package/dist/workspace/composites.d.ts +14 -1
  13. package/dist/workspace/composites.d.ts.map +1 -1
  14. package/dist/workspace/conformance/index.d.ts +211 -0
  15. package/dist/workspace/conformance/index.d.ts.map +1 -0
  16. package/dist/workspace/conformance/vitest.d.ts +11 -0
  17. package/dist/workspace/conformance/vitest.d.ts.map +1 -0
  18. package/dist/workspace/declaration.d.ts +28 -0
  19. package/dist/workspace/declaration.d.ts.map +1 -1
  20. package/dist/workspace/declaration.schema.json +40 -0
  21. package/dist/workspace/declared-kinds.d.ts +43 -0
  22. package/dist/workspace/declared-kinds.d.ts.map +1 -0
  23. package/dist/workspace/graph-cli.d.ts +11 -0
  24. package/dist/workspace/graph-cli.d.ts.map +1 -1
  25. package/dist/workspace/intent-cli.d.ts +2 -1
  26. package/dist/workspace/intent-cli.d.ts.map +1 -1
  27. package/dist/workspace/intent-joins.d.ts +45 -8
  28. package/dist/workspace/intent-joins.d.ts.map +1 -1
  29. package/dist/workspace/intent.d.ts +66 -7
  30. package/dist/workspace/intent.d.ts.map +1 -1
  31. package/dist/workspace/ls.d.ts +31 -1
  32. package/dist/workspace/ls.d.ts.map +1 -1
  33. package/dist/workspace/reason-codes.d.ts +40 -4
  34. package/dist/workspace/reason-codes.d.ts.map +1 -1
  35. package/dist/workspace/record-sessions.d.ts +51 -0
  36. package/dist/workspace/record-sessions.d.ts.map +1 -0
  37. package/dist/workspace/record-source.d.ts +2 -0
  38. package/dist/workspace/record-source.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +65 -4
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-since.d.ts +90 -0
  42. package/dist/workspace/records-since.d.ts.map +1 -0
  43. package/dist/workspace/records-write.d.ts +164 -0
  44. package/dist/workspace/records-write.d.ts.map +1 -0
  45. package/dist/workspace/records.d.ts +202 -15
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/runtimes.d.ts +60 -0
  48. package/dist/workspace/runtimes.d.ts.map +1 -0
  49. package/dist/workspace/status-gates.d.ts +90 -0
  50. package/dist/workspace/status-gates.d.ts.map +1 -0
  51. package/dist/workspace/status.d.ts +17 -0
  52. package/dist/workspace/status.d.ts.map +1 -1
  53. package/dist/workspace/work.d.ts +56 -0
  54. package/dist/workspace/work.d.ts.map +1 -0
  55. package/package.json +19 -1
  56. package/src/cli/main.ts +48 -3
  57. package/src/cli/registry.ts +11 -1
  58. package/src/lifecycle/gate-ledger.ts +14 -0
  59. package/src/workspace/__fixtures__/sessions.ts +66 -0
  60. package/src/workspace/checks/records.ts +19 -0
  61. package/src/workspace/checks.test.ts +2 -0
  62. package/src/workspace/checks.ts +7 -1
  63. package/src/workspace/composites.schema.json +65 -3
  64. package/src/workspace/composites.test.ts +95 -5
  65. package/src/workspace/composites.ts +28 -7
  66. package/src/workspace/conformance/__fixture__/app/package.json +7 -0
  67. package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
  68. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
  69. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
  70. package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
  71. package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
  72. package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
  73. package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
  74. package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
  75. package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
  76. package/src/workspace/conformance/conformance.test.ts +149 -0
  77. package/src/workspace/conformance/index.mjs +31 -0
  78. package/src/workspace/conformance/index.ts +453 -0
  79. package/src/workspace/conformance/vitest.ts +62 -0
  80. package/src/workspace/declaration.schema.json +40 -0
  81. package/src/workspace/declaration.ts +62 -0
  82. package/src/workspace/declared-kinds.test.ts +321 -0
  83. package/src/workspace/declared-kinds.ts +76 -0
  84. package/src/workspace/graph-cli.ts +8 -0
  85. package/src/workspace/intent-cli.ts +29 -6
  86. package/src/workspace/intent-joins.test.ts +60 -0
  87. package/src/workspace/intent-joins.ts +71 -19
  88. package/src/workspace/intent.schema.json +282 -7
  89. package/src/workspace/intent.test.ts +97 -0
  90. package/src/workspace/intent.ts +332 -45
  91. package/src/workspace/ls.schema.json +34 -0
  92. package/src/workspace/ls.ts +69 -4
  93. package/src/workspace/read-contract.test.ts +30 -9
  94. package/src/workspace/reason-codes.test.ts +15 -4
  95. package/src/workspace/reason-codes.ts +47 -4
  96. package/src/workspace/record-assets.test.ts +3 -1
  97. package/src/workspace/record-sessions.ts +105 -0
  98. package/src/workspace/record-source.ts +14 -5
  99. package/src/workspace/records-amend.schema.json +167 -0
  100. package/src/workspace/records-cli.ts +246 -19
  101. package/src/workspace/records-contract.test.ts +57 -2
  102. package/src/workspace/records-formats.test.ts +640 -0
  103. package/src/workspace/records-new.schema.json +158 -0
  104. package/src/workspace/records-quorum.test.ts +196 -0
  105. package/src/workspace/records-review.schema.json +202 -0
  106. package/src/workspace/records-sessions.test.ts +108 -0
  107. package/src/workspace/records-since.schema.json +193 -0
  108. package/src/workspace/records-since.test.ts +174 -0
  109. package/src/workspace/records-since.ts +259 -0
  110. package/src/workspace/records-write-contract.test.ts +125 -0
  111. package/src/workspace/records-write.test.ts +373 -0
  112. package/src/workspace/records-write.ts +736 -0
  113. package/src/workspace/records.schema.json +187 -9
  114. package/src/workspace/records.ts +631 -41
  115. package/src/workspace/runtimes.ts +107 -0
  116. package/src/workspace/status-contract.test.ts +163 -0
  117. package/src/workspace/status-gates.ts +215 -0
  118. package/src/workspace/status.schema.json +69 -3
  119. package/src/workspace/status.ts +35 -2
  120. package/src/workspace/work.test.ts +388 -0
  121. package/src/workspace/work.ts +163 -0
@@ -20,6 +20,11 @@
20
20
  * declaration that can't be read, or an environment name that can't be one,
21
21
  * exits 1. The `--json` output is part of the read contract and is described
22
22
  * by `status.schema.json` beside this file.
23
+ *
24
+ * The JSON also lists each member's gates, read from its gate ledger on the
25
+ * same branch (#2674, `status-gates.ts`): the state of each, the approvals
26
+ * that count, and the `chant approve` line that answers it. The text view
27
+ * doesn't show them.
23
28
  */
24
29
 
25
30
  import { execFileSync } from "node:child_process";
@@ -27,11 +32,13 @@ import { existsSync, realpathSync } from "node:fs";
27
32
  import { relative, resolve } from "node:path";
28
33
  import { formatError } from "../cli/format";
29
34
  import type { CommandContext } from "../cli/registry";
35
+ import { GATES_DIR } from "../lifecycle/gate-ledger";
30
36
  import { readPathSha } from "../lifecycle/git";
31
37
  import { latestPerComponent, readReleaseLedger, type ReleaseRecord } from "../lifecycle/release-ledger";
32
38
  import { findWorkspaceRoot } from "../project-root";
33
39
  import { readDeclaration, readerVersion, WorkspaceReadError, type ErrorLocation, type Member } from "./declaration";
34
40
  import type { ReasonCode } from "./reason-codes";
41
+ import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
35
42
  import { gitTop, workingTree } from "./tree";
36
43
  import { handToRootChant } from "./which-chant";
37
44
 
@@ -61,6 +68,9 @@ export const STATUS_REASON_CODES = [
61
68
  ] as const satisfies readonly ReasonCode[];
62
69
  export type StatusReasonCode = (typeof STATUS_REASON_CODES)[number];
63
70
 
71
+ /** Why a member's gates can't be listed (#2674). Closed, like {@link STATUS_REASON_CODES}. */
72
+ export const STATUS_GATE_REASON_CODES = GATE_REASON_CODES;
73
+
64
74
  /**
65
75
  * Why the status couldn't be read at all. The declaration's own codes, except
66
76
  * the two that only `--at` returns, and one for the environment name.
@@ -129,7 +139,12 @@ export interface StatusMember {
129
139
  environments: StatusEnvironment[];
130
140
  /** Null without `--compare-to`. */
131
141
  compare: StatusCompare | null;
142
+ /** True when no environment's release ledger has a reason. Gate reasons don't change it. */
132
143
  readable: boolean;
144
+ /** Where the member's gates were read from, and why none are listed when none can be (#2674). */
145
+ gateLedger: StatusGateLedger;
146
+ /** Each gate in the member's gate ledger, one per environment asked for, sorted by component then gate. */
147
+ gates: StatusGate[];
133
148
  }
134
149
 
135
150
  export type StatusDocument =
@@ -160,6 +175,10 @@ export interface StatusQuery {
160
175
  compareTo?: string;
161
176
  /** Reads one ledger; the lifecycle reader unless a test swaps it. */
162
177
  readLedger?: LedgerReader;
178
+ /** Reads one member's gate ledger directory; git unless a test swaps it. */
179
+ readGates?: GateLedgerReader;
180
+ /** The instant a gate's expiry is measured against; now by default. */
181
+ now?: string;
163
182
  }
164
183
 
165
184
  class StatusError extends Error {
@@ -192,9 +211,14 @@ function lifecycleTip(cwd: string): string | null {
192
211
  }
193
212
  }
194
213
 
214
+ /** Whether the member writes under `_members/<member>/` (#2538). The root member never does. */
215
+ async function hasMemberLedger(member: Member, cwd: string): Promise<boolean> {
216
+ return member.dir !== "." && (await readPathSha(MEMBERS_DIR, member.name, { cwd })) !== null;
217
+ }
218
+
195
219
  /** Which ledger a member's releases for `env` are in (#2524 D7). */
196
220
  async function ledgerFor(member: Member, env: string, cwd: string): Promise<Omit<StatusLedger, "shared">> {
197
- if (member.dir !== "." && (await readPathSha(MEMBERS_DIR, member.name, { cwd })) !== null) {
221
+ if (await hasMemberLedger(member, cwd)) {
198
222
  return { layout: "members", path: `${MEMBERS_DIR}/${member.name}/${env}/releases.jsonl` };
199
223
  }
200
224
  return { layout: "flat", path: `${env}/releases.jsonl` };
@@ -276,10 +300,15 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
276
300
  const read = query.readLedger ?? defaultReader;
277
301
  const envs = query.compareTo !== undefined && query.compareTo !== query.env ? [query.env, query.compareTo] : [query.env];
278
302
 
303
+ const commit = lifecycleTip(found.dir);
304
+ const now = query.now ?? new Date().toISOString();
305
+
279
306
  const members: StatusMember[] = [];
280
307
  for (const m of declaration.members) {
281
308
  const environments: StatusEnvironment[] = [];
282
309
  for (const env of envs) environments.push(await readEnvironment(m, env, found.dir, read));
310
+ const own = await hasMemberLedger(m, found.dir);
311
+ const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates);
283
312
  members.push({
284
313
  name: m.name,
285
314
  dir: m.dir,
@@ -287,18 +316,22 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
287
316
  environments,
288
317
  compare: query.compareTo === undefined ? null : compareEnvironments(environments[0], environments[environments.length - 1]),
289
318
  readable: environments.every((e) => e.reason === null),
319
+ gateLedger: gates.ledger,
320
+ gates: gates.gates,
290
321
  });
291
322
  }
292
323
  // Several members read from one flat ledger see the same records; say so.
293
324
  const readers = new Map<string, number>();
294
325
  for (const m of members) for (const e of m.environments) if (e.ledger.layout === "flat") readers.set(e.ledger.path, (readers.get(e.ledger.path) ?? 0) + 1);
295
326
  for (const m of members) for (const e of m.environments) e.ledger.shared = e.ledger.layout === "flat" && (readers.get(e.ledger.path) ?? 0) > 1;
327
+ const flatGates = members.filter((m) => m.gateLedger.layout === "flat").length;
328
+ for (const m of members) m.gateLedger.shared = m.gateLedger.layout === "flat" && flatGates > 1;
296
329
 
297
330
  return {
298
331
  ...head,
299
332
  env: query.env,
300
333
  compareTo: query.compareTo ?? null,
301
- lifecycle: { ref: LIFECYCLE_REF, commit: lifecycleTip(found.dir) },
334
+ lifecycle: { ref: LIFECYCLE_REF, commit },
302
335
  workspace: { name: declaration.name, root: rootDir === "" ? "." : rootDir, file: declaration.file },
303
336
  members,
304
337
  summary: {
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Work items (#2683): the work kind on read, and its place in the intent
3
+ * graph, on a workspace built in a throwaway git repository.
4
+ *
5
+ * - c0 adds `app/server.mjs` and `app/other.mjs` before any decision, so
6
+ * commits to them are undecided.
7
+ * - c1 adds the decision and work kinds, dec-001 (decided, constrains
8
+ * `path:app/server.mjs`), dec-002 (decided, constrains `path:app/other.mjs`,
9
+ * implemented by nothing), dec-003 (proposed), W-001 (in progress,
10
+ * implements dec-001, from the gap intent-decision-unimplemented on
11
+ * `app/server.mjs`), W-002 (open, needs W-001) and W-003 (done, from the
12
+ * gap intent-commit-undecided on `app/server.mjs`) and W-004 (dropped,
13
+ * implements dec-002).
14
+ * - c2 edits `app/server.mjs` from unit U-0001, whose record names dec-001:
15
+ * dec-001's own work, inside W-001's and W-002's windows.
16
+ * - c3 marks W-001 done, which closes its window after c3.
17
+ * - c4 edits `app/server.mjs` again: inside W-002's window, outside W-001's.
18
+ *
19
+ * W-003 was added done, so its window is c1 alone and no edit falls in it.
20
+ */
21
+
22
+ import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { join } from "node:path";
25
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
26
+ import { cleanScratch, contract, git, REPO, repo, writeFiles } from "./__fixtures__/contract-repo";
27
+ import { formatIntent } from "./intent-cli";
28
+ import { intentGraph, INTENT_FINDING_CODES, type IntentDocument, type WorkNode } from "./intent";
29
+ import intentSchema from "./intent.schema.json";
30
+ import { parseFrontMatter } from "./records";
31
+ import { queryRecords } from "./records-cli";
32
+ import { amendRecord, newRecord, resolveWriteKind, reviewRecord } from "./records-write";
33
+ import recordsSchema from "./records.schema.json";
34
+ import { WORK_WARNING_CODES } from "./work";
35
+
36
+ const REF = join(REPO, "reference-workspace");
37
+ const DECISION = (() => {
38
+ const fm = parseFrontMatter(readFileSync(join(REF, "decisions", "ref-001-how-the-app-is-deployed.md"), "utf-8"));
39
+ if (!fm.ok) throw new Error(fm.message);
40
+ return fm.value;
41
+ })();
42
+
43
+ function decision(id: string, fields: { state?: string; constrains: string[] }): string {
44
+ const proposed = fields.state === "proposed";
45
+ const data = { ...DECISION, id, title: `Decision ${id}`, state: fields.state ?? "decided", supersedes: [], evidence: [], constrains: fields.constrains, ...(proposed ? { choice: null, decided_by: null, decided_on: null } : {}) };
46
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
47
+ }
48
+
49
+ function work(id: string, fields: Record<string, unknown>): string {
50
+ const data = { schema: 1, id, title: `Work ${id}`, state: "open", implements: [], needs: [], constrains: ["path:app/server.mjs"], evidence: [], opened_on: "2026-09-24", source: { kind: "workspace", member: "app" }, supersedes: [], ...fields };
51
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n\nWhat the work is.\n`;
52
+ }
53
+
54
+ const PLUGIN = `
55
+ export function commitJoins(commit, context) {
56
+ const id = commit.trailers["Unit"]?.[0];
57
+ if (!id) return undefined;
58
+ const unit = JSON.parse(context.read(\`units/\${id}.json\`));
59
+ return { unit: { id, decisions: unit.decisions }, contract: { id: unit.contract } };
60
+ }
61
+ `;
62
+
63
+ const DECISIONS = "decisions/decision.kind.mjs";
64
+ const WORK = "work/work.kind.mjs";
65
+ const JOINS = "plugins/units.kind.mjs";
66
+
67
+ let root: string;
68
+ const sha: Record<string, string> = {};
69
+
70
+ function commit(message: string[]): string {
71
+ git(root, "add", "-A");
72
+ git(root, "commit", "-q", ...message.flatMap((m) => ["-m", m]));
73
+ return git(root, "rev-parse", "HEAD");
74
+ }
75
+
76
+ beforeAll(() => {
77
+ root = repo({
78
+ "chant.workspace.json": JSON.stringify({ name: "studio", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "a plain Node server" }] }, null, 2),
79
+ "app/server.mjs": "export const port = 8080;\n",
80
+ "app/other.mjs": "export const other = 1;\n",
81
+ });
82
+ sha.c0 = commit(["the app, before any decision"]);
83
+ writeFiles(root, {
84
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
85
+ "decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
86
+ "decisions/dec-001-server.md": decision("dec-001", { constrains: ["path:app/server.mjs"] }),
87
+ "decisions/dec-002-other.md": decision("dec-002", { constrains: ["path:app/other.mjs"] }),
88
+ "decisions/dec-003-maybe.md": decision("dec-003", { state: "proposed", constrains: ["member:app"] }),
89
+ "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
90
+ "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
91
+ "work/W-001-server.md": work("W-001", {
92
+ state: "in-progress",
93
+ owner: "alice",
94
+ implements: ["dec-001"],
95
+ source: { finding: "intent-decision-unimplemented", region: "app/server.mjs", decision: "dec-001" },
96
+ }),
97
+ "work/W-002-after.md": work("W-002", { needs: ["W-001"] }),
98
+ "work/W-003-undecided.md": work("W-003", {
99
+ state: "done",
100
+ closed_on: "2026-09-24",
101
+ evidence: [{ title: "The review", url: "https://example.com/review" }],
102
+ source: { finding: "intent-commit-undecided", region: "app/server.mjs" },
103
+ }),
104
+ "work/W-004-dropped.md": work("W-004", { state: "dropped", closed_on: "2026-09-24", implements: ["dec-002"], constrains: ["path:app/other.mjs"] }),
105
+ "plugins/units.kind.mjs": PLUGIN,
106
+ "units/U-0001.json": JSON.stringify({ contract: "C-001", decisions: ["dec-001"] }),
107
+ });
108
+ sha.c1 = commit(["decide and queue the work"]);
109
+ writeFiles(root, { "app/server.mjs": "export const port = 9090;\n" });
110
+ sha.c2 = commit(["move the port", "Unit: U-0001"]);
111
+ writeFiles(root, {
112
+ "work/W-001-server.md": work("W-001", {
113
+ state: "done",
114
+ owner: "alice",
115
+ implements: ["dec-001"],
116
+ closed_on: "2026-09-25",
117
+ source: { finding: "intent-decision-unimplemented", region: "app/server.mjs", decision: "dec-001" },
118
+ }),
119
+ });
120
+ sha.c3 = commit(["W-001 is done"]);
121
+ writeFiles(root, { "app/server.mjs": "export const port = 9191;\n" });
122
+ sha.c4 = commit(["move the port again"]);
123
+ });
124
+ afterAll(cleanScratch);
125
+
126
+ const records = contract(recordsSchema);
127
+ const intent = contract(intentSchema);
128
+ type Result = Exclude<IntentDocument, { error: unknown }>;
129
+
130
+ async function walk(region: string, options: { kinds?: string[]; at?: string } = {}): Promise<Result> {
131
+ const { doc } = await intentGraph({ cwd: root, region, at: options.at, kinds: (options.kinds ?? [DECISIONS, WORK, JOINS]).map((k) => join(root, k)) });
132
+ intent.expectValid(doc);
133
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
134
+ return doc;
135
+ }
136
+
137
+ const workNode = (doc: Result, id: string) => doc.nodes.find((n): n is WorkNode => n.kind === "work" && n.record === id);
138
+ const codes = (doc: Result) => doc.nodes.flatMap((n) => (n.kind === "finding" ? [n.code] : []));
139
+
140
+ describe("work records on read (#2683)", () => {
141
+ test("ready, blockedBy and implements on each record, and implementedBy on each decision", async () => {
142
+ const doc = await queryRecords({ kind: WORK, cwd: root, at: sha.c2 });
143
+ records.expectValid(doc);
144
+ if ("error" in doc) throw new Error(doc.error.message);
145
+ expect(doc.summary).toEqual({ total: 4, valid: 4, invalid: 0, superseded: 0 });
146
+ const by = Object.fromEntries(doc.records.map((r) => [r.id, r]));
147
+ expect(by["W-001"]).toMatchObject({ ready: false, blockedBy: [], implements: [{ id: "dec-001", state: "decided" }], warnings: [] });
148
+ expect(by["W-002"]).toMatchObject({ ready: false, blockedBy: [{ id: "W-001", state: "in-progress" }], implements: [], warnings: [] });
149
+ expect(by["W-003"]).toMatchObject({ ready: false, blockedBy: [], warnings: [] });
150
+ expect(doc.decisions).toEqual([
151
+ { id: "dec-001", path: "decisions/dec-001-server.md", state: "decided", supersededBy: null, implementedBy: [{ id: "W-001", state: "in-progress" }] },
152
+ { id: "dec-002", path: "decisions/dec-002-other.md", state: "decided", supersededBy: null, implementedBy: [{ id: "W-004", state: "dropped" }] },
153
+ { id: "dec-003", path: "decisions/dec-003-maybe.md", state: "proposed", supersededBy: null, implementedBy: [] },
154
+ ]);
155
+ });
156
+
157
+ test("an item is ready once every need is done", async () => {
158
+ const doc = await queryRecords({ kind: WORK, cwd: root });
159
+ if ("error" in doc) throw new Error(doc.error.message);
160
+ expect(doc.records.find((r) => r.id === "W-002")).toMatchObject({ ready: true, blockedBy: [] });
161
+ // W-001 is done with an empty evidence list.
162
+ expect(doc.records.find((r) => r.id === "W-001")!.warnings.map((w) => w.code)).toEqual(["work-done-unpinned"]);
163
+ });
164
+
165
+ test("each work warning, and no record-no-evidence on an open item", async () => {
166
+ const dir = repo({
167
+ "chant.workspace.json": JSON.stringify({ name: "w", schema: 1, members: [] }),
168
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
169
+ "decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
170
+ "decisions/dec-003-maybe.md": decision("dec-003", { state: "proposed", constrains: ["member:app"] }),
171
+ "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
172
+ "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
173
+ "work/W-010-a.md": work("W-010", { needs: ["W-011", "W-099"], implements: ["dec-003", "dec-404"] }),
174
+ "work/W-011-b.md": work("W-011", { needs: ["W-010"] }),
175
+ "work/W-012-c.md": work("W-012", { state: "done" }),
176
+ "work/W-013-d.md": work("W-013", { state: "dropped", closed_on: "2026-09-24" }),
177
+ "work/W-014-e.md": work("W-014", {}),
178
+ });
179
+ const doc = await queryRecords({ kind: WORK, cwd: dir });
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({
184
+ "W-010": ["work-needs-unknown", "work-implements-unknown", "work-needs-cycle", "work-implements-undecided"],
185
+ "W-011": ["work-needs-cycle"],
186
+ "W-012": ["work-done-unpinned", "work-closed-without-date"],
187
+ "W-013": [],
188
+ "W-014": [],
189
+ });
190
+ const w010 = doc.records.find((r) => r.id === "W-010")!;
191
+ expect(w010.blockedBy).toEqual([
192
+ { id: "W-011", state: "open" },
193
+ { id: "W-099", state: null },
194
+ ]);
195
+ expect(w010.implements).toEqual([
196
+ { id: "dec-003", state: "proposed" },
197
+ { id: "dec-404", state: null },
198
+ ]);
199
+ expect(doc.records.find((r) => r.id === "W-014")!.ready).toBe(true);
200
+ });
201
+
202
+ test("the work warnings are the reason codes the schemas carry", () => {
203
+ const text = JSON.stringify(recordsSchema) + JSON.stringify(intentSchema);
204
+ for (const c of WORK_WARNING_CODES) expect(text, c).toContain(`"${c}"`);
205
+ });
206
+
207
+ test("a work kind whose decision kind is missing fails the read with kind-unreadable", async () => {
208
+ const dir = repo({
209
+ "work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
210
+ "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
211
+ });
212
+ const doc = await queryRecords({ kind: WORK, cwd: dir });
213
+ records.expectValid(doc);
214
+ expect("error" in doc && doc.error.code).toBe("kind-unreadable");
215
+ });
216
+
217
+ test("a work kind without states is kind-invalid", async () => {
218
+ const kindText = readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8")
219
+ .replace(/^ stateField: "state",\n/m, "")
220
+ .replace(/^ states: \[.*\],\n/m, "")
221
+ .replace(/^ closedStates: \[.*\],\n/m, "")
222
+ .replace(/^ supersedes: .*\n/m, "");
223
+ const dir = repo({ "work/work.kind.mjs": kindText, "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8") });
224
+ const doc = await queryRecords({ kind: WORK, cwd: dir });
225
+ expect("error" in doc && doc.error.code).toBe("kind-invalid");
226
+ });
227
+
228
+ test("the reference work schema's finding codes are the intent graph's", () => {
229
+ const schema = JSON.parse(readFileSync(join(REF, "work", "work.schema.json"), "utf-8")) as { definitions: { sourceGap: { properties: { finding: { anyOf: [{ enum: string[] }] } } } } };
230
+ expect(schema.definitions.sourceGap.properties.finding.anyOf[0].enum).toEqual([...INTENT_FINDING_CODES]);
231
+ });
232
+ });
233
+
234
+ describe("work items in the intent graph (#2683)", () => {
235
+ test("at c2: work nodes, implements and needs edges, worked commits, and both work findings", async () => {
236
+ const doc = await walk("app/server.mjs", { at: sha.c2 });
237
+ expect(workNode(doc, "W-001")).toMatchObject({
238
+ id: "record:work/W-001",
239
+ recordKind: "work",
240
+ state: "in-progress",
241
+ closed: false,
242
+ owner: "alice",
243
+ ready: false,
244
+ blockedBy: [],
245
+ implements: [{ id: "dec-001", state: "decided" }],
246
+ source: { finding: "intent-decision-unimplemented", region: "app/server.mjs", decision: "dec-001" },
247
+ constrains: [{ entry: "path:app/server.mjs", granularity: "path" }],
248
+ warnings: [],
249
+ });
250
+ expect(workNode(doc, "W-002")).toMatchObject({ state: "open", ready: false, blockedBy: [{ id: "W-001", state: "in-progress" }], needs: ["W-001"] });
251
+ expect(doc.edges).toContainEqual({ kind: "implements", from: "record:work/W-001", to: "record:decision/dec-001" });
252
+ expect(doc.edges).toContainEqual({ kind: "needs", from: "record:work/W-002", to: "record:work/W-001" });
253
+ const within = doc.edges.filter((e) => e.kind === "within").map((e) => [e.from, e.to, "state" in e ? e.state : null]);
254
+ expect(within).toEqual([
255
+ [`commit:${sha.c2}`, "record:decision/dec-001", "decided"],
256
+ [`commit:${sha.c2}`, "record:work/W-001", "worked"],
257
+ [`commit:${sha.c2}`, "record:work/W-002", "worked"],
258
+ ]);
259
+ // A worked edge leaves the commit's own state to the decisions.
260
+ expect(doc.nodes.find((n) => n.id === `commit:${sha.c2}`)).toMatchObject({ state: "decided" });
261
+ expect(codes(doc)).toContain("intent-work-blocked");
262
+ expect(codes(doc)).toContain("intent-work-open-decided-code");
263
+ expect(codes(doc)).not.toContain("intent-decision-unimplemented");
264
+ const blocked = doc.nodes.find((n) => n.kind === "finding" && n.code === "intent-work-blocked");
265
+ expect(blocked).toMatchObject({ concerns: ["record:work/W-002", `commit:${sha.c2}`, "record:work/W-001"] });
266
+ const open = doc.nodes.find((n) => n.kind === "finding" && n.code === "intent-work-open-decided-code");
267
+ expect(open).toMatchObject({ concerns: ["record:decision/dec-001", `commit:${sha.c2}`, "record:work/W-001"] });
268
+ });
269
+
270
+ test("at HEAD: W-001's window closed with c3, so c4 is worked by W-002 alone, and neither work finding fires", async () => {
271
+ const doc = await walk("app/server.mjs");
272
+ const worked = doc.edges.filter((e) => e.kind === "within" && "state" in e && e.state === "worked").map((e) => [e.from, e.to]);
273
+ expect(worked).toEqual([
274
+ [`commit:${sha.c4}`, "record:work/W-002"],
275
+ [`commit:${sha.c2}`, "record:work/W-001"],
276
+ [`commit:${sha.c2}`, "record:work/W-002"],
277
+ ]);
278
+ expect(workNode(doc, "W-001")).toMatchObject({ state: "done", closed: true, warnings: [{ code: "work-done-unpinned" }] });
279
+ expect(workNode(doc, "W-002")).toMatchObject({ ready: true, blockedBy: [] });
280
+ expect(codes(doc)).not.toContain("intent-work-blocked");
281
+ expect(codes(doc)).not.toContain("intent-work-open-decided-code");
282
+ });
283
+
284
+ test("a finding a work item came from is addressed by it, and a done item whose gap still fires gets work-done-gap-open", async () => {
285
+ const doc = await walk("app/server.mjs");
286
+ const undecided = doc.nodes.filter((n) => n.kind === "finding" && n.code === "intent-commit-undecided");
287
+ // c0 changed the file before any decision.
288
+ expect(undecided.length).toBe(1);
289
+ expect(undecided[0]).toMatchObject({ addressed: true, addressedBy: [{ id: "W-003", state: "done" }] });
290
+ expect(doc.edges).toContainEqual({ kind: "addressed-by", from: undecided[0].id, to: "record:work/W-003" });
291
+ expect(workNode(doc, "W-003")!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
292
+ // Every finding says whether a work item addresses it once a work kind is read.
293
+ for (const f of doc.nodes.filter((n) => n.kind === "finding")) expect(f).toHaveProperty("addressed");
294
+ });
295
+
296
+ test("a decided decision only a dropped item implements, and no commit carried out, is intent-decision-unimplemented, and nothing addresses it", async () => {
297
+ const doc = await walk("app/other.mjs");
298
+ expect(doc.edges).toContainEqual({ kind: "implements", from: "record:work/W-004", to: "record:decision/dec-002" });
299
+ const f = doc.nodes.find((n) => n.kind === "finding" && n.code === "intent-decision-unimplemented");
300
+ expect(f).toMatchObject({ concerns: ["record:decision/dec-002", "region:app/other.mjs"], addressed: false, addressedBy: [] });
301
+ });
302
+
303
+ test("without a work kind the walk is what it was: no work node, no work finding, no addressed field", async () => {
304
+ const doc = await walk("app/other.mjs", { kinds: [DECISIONS, JOINS] });
305
+ expect(doc.nodes.some((n) => n.kind === "work")).toBe(false);
306
+ expect(codes(doc)).not.toContain("intent-decision-unimplemented");
307
+ for (const f of doc.nodes.filter((n) => n.kind === "finding")) expect(f).not.toHaveProperty("addressed");
308
+ });
309
+
310
+ test("the walk prints each work item with its worked commits and warnings", async () => {
311
+ const text = formatIntent(await walk("app/server.mjs"));
312
+ expect(text).toContain("work W-001 done, owned by alice: Work W-001; constrains path:app/server.mjs (path); implements dec-001 (decided); from intent-decision-unimplemented on app/server.mjs");
313
+ expect(text).toContain(` worked ${sha.c4.slice(0, 8)} move the port again; in W-002's window`);
314
+ expect(text).toContain(" warning work-done-gap-open:");
315
+ expect(text).toMatch(/finding {3}intent-commit-undecided: .*; addressed by W-003 \(done\)/);
316
+ expect(text).toMatch(/3 work items/);
317
+ });
318
+ });
319
+
320
+ describe("writing work items on a copy of the reference workspace (#2683)", () => {
321
+ let copy: string;
322
+ beforeAll(() => {
323
+ copy = realpathSync(mkdtempSync(join(tmpdir(), "chant-2683-write-")));
324
+ for (const d of ["decisions", "work", "design"]) cpSync(join(REF, d), join(copy, d), { recursive: true });
325
+ cpSync(join(REF, "chant.workspace.json"), join(copy, "chant.workspace.json"));
326
+ });
327
+ afterAll(() => rmSync(copy, { recursive: true, force: true }));
328
+
329
+ const fresh = (over: Record<string, unknown> = {}) =>
330
+ JSON.stringify({
331
+ schema: 1,
332
+ title: "Draw the status region in the wireframe",
333
+ state: "open",
334
+ implements: ["ref-002"],
335
+ needs: ["W-001"],
336
+ constrains: ["path:design/screens/home.svg"],
337
+ evidence: [],
338
+ opened_on: "2026-09-25",
339
+ source: { finding: "intent-pin-drifted", region: "design/screens/home.json", decision: "ref-002" },
340
+ supersedes: [],
341
+ ...over,
342
+ });
343
+
344
+ test("records new work names the declared work kind", () => {
345
+ expect(resolveWriteKind("work", copy)).toBe(join(copy, "work", "work.kind.mjs"));
346
+ expect(resolveWriteKind("work/work.kind.mjs", copy)).toBe("work/work.kind.mjs");
347
+ expect(resolveWriteKind("nothing", copy)).toBe("nothing");
348
+ });
349
+
350
+ test("records new allocates W-003 after W-001 and W-002, with a gap source that validates", async () => {
351
+ const doc = await newRecord({ kind: resolveWriteKind("work", copy), fields: fresh(), cwd: copy });
352
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
353
+ expect(doc).toMatchObject({ id: "W-003", path: "work/W-003-draw-the-status-region-in-the-wireframe.md" });
354
+ const read = await queryRecords({ kind: "work/work.kind.mjs", cwd: copy });
355
+ records.expectValid(read);
356
+ if ("error" in read) throw new Error(read.error.message);
357
+ expect(read.records.find((r) => r.id === "W-003")).toMatchObject({
358
+ valid: true,
359
+ ready: false,
360
+ blockedBy: [{ id: "W-001", state: "in-progress" }],
361
+ data: { source: { finding: "intent-pin-drifted", region: "design/screens/home.json", decision: "ref-002" } },
362
+ });
363
+ });
364
+
365
+ test("records new refuses a gap source whose finding is not an intent finding code", async () => {
366
+ const doc = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { finding: "not-a-finding", region: "design" } }), dryRun: true, cwd: copy });
367
+ expect("error" in doc && doc.error.code).toBe("record-schema-invalid");
368
+ });
369
+
370
+ test("records new with a fresh prefix takes --prefix W", async () => {
371
+ const doc = await newRecord({ kind: "work/work.kind.mjs", fields: fresh(), prefix: "W", dryRun: true, cwd: copy });
372
+ expect(doc).toMatchObject({ id: "W-004" });
373
+ });
374
+
375
+ test("records amend moves W-003 to in-progress with an owner", async () => {
376
+ const doc = await amendRecord({ kind: "work/work.kind.mjs", id: "W-003", fields: JSON.stringify({ state: "in-progress", owner: "agent-1" }), cwd: copy });
377
+ if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
378
+ expect(doc.changed.sort()).toEqual(["owner", "state"]);
379
+ const read = await queryRecords({ kind: "work/work.kind.mjs", cwd: copy });
380
+ if ("error" in read) throw new Error(read.error.message);
381
+ expect(read.records.find((r) => r.id === "W-003")).toMatchObject({ state: "in-progress", valid: true, data: { owner: "agent-1" } });
382
+ });
383
+
384
+ test("records review on a work item is refused with review-unsupported: the kind declares no reviews", async () => {
385
+ const doc = await reviewRecord({ kind: "work/work.kind.mjs", id: "W-003", verdict: "agree", by: "alice", cwd: copy });
386
+ expect("error" in doc && doc.error.code).toBe("review-unsupported");
387
+ });
388
+ });