@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.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts +2 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +1 -0
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/tools/composites.d.ts +44 -0
- package/dist/cli/mcp/tools/composites.d.ts.map +1 -0
- package/dist/cli/mcp/tools/search.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +13 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +4 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/lexicon.d.ts +44 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.d.ts +7 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +152 -0
- package/dist/workspace/composites.d.ts.map +1 -0
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +24 -2
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +6 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +74 -9
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +90 -6
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +7 -2
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +52 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +65 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +164 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +202 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/handlers/graph.ts +4 -0
- package/src/cli/main.test.ts +9 -0
- package/src/cli/main.ts +56 -3
- package/src/cli/mcp/resource-handlers.ts +17 -0
- package/src/cli/mcp/server.test.ts +140 -4
- package/src/cli/mcp/server.ts +5 -1
- package/src/cli/mcp/tools/composites.ts +98 -0
- package/src/cli/mcp/tools/search.ts +47 -5
- package/src/cli/registry.ts +13 -1
- package/src/components/cli-support.test.ts +16 -0
- package/src/components/cli-support.ts +8 -2
- package/src/composite.ts +9 -0
- package/src/lexicon.ts +47 -0
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/contract-repo.ts +17 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +533 -0
- package/src/workspace/composites.test.ts +334 -0
- package/src/workspace/composites.ts +316 -0
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +364 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +40 -4
- package/src/workspace/intent-cli.ts +54 -7
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +117 -20
- package/src/workspace/intent.schema.json +357 -19
- package/src/workspace/intent.test.ts +235 -20
- package/src/workspace/intent.ts +396 -51
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/member-commands.ts +11 -5
- package/src/workspace/read-contract.test.ts +52 -3
- package/src/workspace/reason-codes.test.ts +48 -4
- package/src/workspace/reason-codes.ts +67 -2
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +246 -19
- package/src/workspace/records-contract.test.ts +77 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +202 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +736 -0
- package/src/workspace/records.schema.json +187 -9
- package/src/workspace/records.test.ts +93 -0
- package/src/workspace/records.ts +683 -49
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/work.test.ts +388 -0
- package/src/workspace/work.ts +163 -0
package/src/workspace/status.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
+
});
|