@intentius/chant 0.85.0 → 0.87.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/registry.d.ts +13 -1
- package/dist/cli/registry.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__/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 +14 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- 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 +11 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +2 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +45 -8
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +71 -7
- 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/reason-codes.d.ts +47 -4
- 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 +71 -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 +171 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +244 -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/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.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/main.ts +55 -3
- package/src/cli/registry.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +14 -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 +65 -3
- package/src/workspace/composites.test.ts +95 -5
- package/src/workspace/composites.ts +28 -7
- 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 +376 -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 +8 -0
- package/src/workspace/intent-cli.ts +29 -6
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +304 -7
- package/src/workspace/intent.test.ts +99 -0
- package/src/workspace/intent.ts +365 -46
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/read-contract.test.ts +30 -9
- package/src/workspace/reason-codes.test.ts +16 -4
- package/src/workspace/reason-codes.ts +55 -4
- 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 +308 -19
- package/src/workspace/records-contract.test.ts +57 -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 +227 -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 +765 -0
- package/src/workspace/records.schema.json +202 -9
- package/src/workspace/records.ts +700 -41
- 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/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +390 -0
- package/src/workspace/work.ts +163 -0
|
@@ -0,0 +1,390 @@
|
|
|
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
|
+
// W-003 is done, and intent-commit-undecided, its gap, still fires on app/server.mjs: c0 changed it before any decision (#2686).
|
|
150
|
+
expect(by["W-003"]).toMatchObject({ ready: false, blockedBy: [] });
|
|
151
|
+
expect(by["W-003"].warnings.map((w: { code: string }) => w.code)).toEqual(["work-done-gap-open"]);
|
|
152
|
+
expect(doc.decisions).toEqual([
|
|
153
|
+
{ id: "dec-001", path: "decisions/dec-001-server.md", state: "decided", supersededBy: null, implementedBy: [{ id: "W-001", state: "in-progress" }] },
|
|
154
|
+
{ id: "dec-002", path: "decisions/dec-002-other.md", state: "decided", supersededBy: null, implementedBy: [{ id: "W-004", state: "dropped" }] },
|
|
155
|
+
{ id: "dec-003", path: "decisions/dec-003-maybe.md", state: "proposed", supersededBy: null, implementedBy: [] },
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("an item is ready once every need is done", async () => {
|
|
160
|
+
const doc = await queryRecords({ kind: WORK, cwd: root });
|
|
161
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
162
|
+
expect(doc.records.find((r) => r.id === "W-002")).toMatchObject({ ready: true, blockedBy: [] });
|
|
163
|
+
// W-001 is done with an empty evidence list.
|
|
164
|
+
expect(doc.records.find((r) => r.id === "W-001")!.warnings.map((w) => w.code)).toEqual(["work-done-unpinned"]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("each work warning, and no record-no-evidence on an open item", async () => {
|
|
168
|
+
const dir = repo({
|
|
169
|
+
"chant.workspace.json": JSON.stringify({ name: "w", schema: 1, members: [] }),
|
|
170
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decisions", "decision.kind.mjs"), "utf-8"),
|
|
171
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
|
|
172
|
+
"decisions/dec-003-maybe.md": decision("dec-003", { state: "proposed", constrains: ["member:app"] }),
|
|
173
|
+
"work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
|
|
174
|
+
"work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
|
|
175
|
+
"work/W-010-a.md": work("W-010", { needs: ["W-011", "W-099"], implements: ["dec-003", "dec-404"] }),
|
|
176
|
+
"work/W-011-b.md": work("W-011", { needs: ["W-010"] }),
|
|
177
|
+
"work/W-012-c.md": work("W-012", { state: "done" }),
|
|
178
|
+
"work/W-013-d.md": work("W-013", { state: "dropped", closed_on: "2026-09-24" }),
|
|
179
|
+
"work/W-014-e.md": work("W-014", {}),
|
|
180
|
+
});
|
|
181
|
+
const doc = await queryRecords({ kind: WORK, cwd: dir });
|
|
182
|
+
records.expectValid(doc);
|
|
183
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
184
|
+
const warned = Object.fromEntries(doc.records.map((r) => [r.id, r.warnings.map((w) => w.code)]));
|
|
185
|
+
expect(warned).toEqual({
|
|
186
|
+
"W-010": ["work-needs-unknown", "work-implements-unknown", "work-needs-cycle", "work-implements-undecided"],
|
|
187
|
+
"W-011": ["work-needs-cycle"],
|
|
188
|
+
"W-012": ["work-done-unpinned", "work-closed-without-date"],
|
|
189
|
+
"W-013": [],
|
|
190
|
+
"W-014": [],
|
|
191
|
+
});
|
|
192
|
+
const w010 = doc.records.find((r) => r.id === "W-010")!;
|
|
193
|
+
expect(w010.blockedBy).toEqual([
|
|
194
|
+
{ id: "W-011", state: "open" },
|
|
195
|
+
{ id: "W-099", state: null },
|
|
196
|
+
]);
|
|
197
|
+
expect(w010.implements).toEqual([
|
|
198
|
+
{ id: "dec-003", state: "proposed" },
|
|
199
|
+
{ id: "dec-404", state: null },
|
|
200
|
+
]);
|
|
201
|
+
expect(doc.records.find((r) => r.id === "W-014")!.ready).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the work warnings are the reason codes the schemas carry", () => {
|
|
205
|
+
const text = JSON.stringify(recordsSchema) + JSON.stringify(intentSchema);
|
|
206
|
+
for (const c of WORK_WARNING_CODES) expect(text, c).toContain(`"${c}"`);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("a work kind whose decision kind is missing fails the read with kind-unreadable", async () => {
|
|
210
|
+
const dir = repo({
|
|
211
|
+
"work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
|
|
212
|
+
"work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
|
|
213
|
+
});
|
|
214
|
+
const doc = await queryRecords({ kind: WORK, cwd: dir });
|
|
215
|
+
records.expectValid(doc);
|
|
216
|
+
expect("error" in doc && doc.error.code).toBe("kind-unreadable");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a work kind without states is kind-invalid", async () => {
|
|
220
|
+
const kindText = readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8")
|
|
221
|
+
.replace(/^ stateField: "state",\n/m, "")
|
|
222
|
+
.replace(/^ states: \[.*\],\n/m, "")
|
|
223
|
+
.replace(/^ closedStates: \[.*\],\n/m, "")
|
|
224
|
+
.replace(/^ supersedes: .*\n/m, "");
|
|
225
|
+
const dir = repo({ "work/work.kind.mjs": kindText, "work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8") });
|
|
226
|
+
const doc = await queryRecords({ kind: WORK, cwd: dir });
|
|
227
|
+
expect("error" in doc && doc.error.code).toBe("kind-invalid");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("the reference work schema's finding codes are the intent graph's", () => {
|
|
231
|
+
const schema = JSON.parse(readFileSync(join(REF, "work", "work.schema.json"), "utf-8")) as { definitions: { sourceGap: { properties: { finding: { anyOf: [{ enum: string[] }] } } } } };
|
|
232
|
+
expect(schema.definitions.sourceGap.properties.finding.anyOf[0].enum).toEqual([...INTENT_FINDING_CODES]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("work items in the intent graph (#2683)", () => {
|
|
237
|
+
test("at c2: work nodes, implements and needs edges, worked commits, and both work findings", async () => {
|
|
238
|
+
const doc = await walk("app/server.mjs", { at: sha.c2 });
|
|
239
|
+
expect(workNode(doc, "W-001")).toMatchObject({
|
|
240
|
+
id: "record:work/W-001",
|
|
241
|
+
recordKind: "work",
|
|
242
|
+
state: "in-progress",
|
|
243
|
+
closed: false,
|
|
244
|
+
owner: "alice",
|
|
245
|
+
ready: false,
|
|
246
|
+
blockedBy: [],
|
|
247
|
+
implements: [{ id: "dec-001", state: "decided" }],
|
|
248
|
+
source: { finding: "intent-decision-unimplemented", region: "app/server.mjs", decision: "dec-001" },
|
|
249
|
+
constrains: [{ entry: "path:app/server.mjs", granularity: "path" }],
|
|
250
|
+
warnings: [],
|
|
251
|
+
});
|
|
252
|
+
expect(workNode(doc, "W-002")).toMatchObject({ state: "open", ready: false, blockedBy: [{ id: "W-001", state: "in-progress" }], needs: ["W-001"] });
|
|
253
|
+
expect(doc.edges).toContainEqual({ kind: "implements", from: "record:work/W-001", to: "record:decision/dec-001" });
|
|
254
|
+
expect(doc.edges).toContainEqual({ kind: "needs", from: "record:work/W-002", to: "record:work/W-001" });
|
|
255
|
+
const within = doc.edges.filter((e) => e.kind === "within").map((e) => [e.from, e.to, "state" in e ? e.state : null]);
|
|
256
|
+
expect(within).toEqual([
|
|
257
|
+
[`commit:${sha.c2}`, "record:decision/dec-001", "decided"],
|
|
258
|
+
[`commit:${sha.c2}`, "record:work/W-001", "worked"],
|
|
259
|
+
[`commit:${sha.c2}`, "record:work/W-002", "worked"],
|
|
260
|
+
]);
|
|
261
|
+
// A worked edge leaves the commit's own state to the decisions.
|
|
262
|
+
expect(doc.nodes.find((n) => n.id === `commit:${sha.c2}`)).toMatchObject({ state: "decided" });
|
|
263
|
+
expect(codes(doc)).toContain("intent-work-blocked");
|
|
264
|
+
expect(codes(doc)).toContain("intent-work-open-decided-code");
|
|
265
|
+
expect(codes(doc)).not.toContain("intent-decision-unimplemented");
|
|
266
|
+
const blocked = doc.nodes.find((n) => n.kind === "finding" && n.code === "intent-work-blocked");
|
|
267
|
+
expect(blocked).toMatchObject({ concerns: ["record:work/W-002", `commit:${sha.c2}`, "record:work/W-001"] });
|
|
268
|
+
const open = doc.nodes.find((n) => n.kind === "finding" && n.code === "intent-work-open-decided-code");
|
|
269
|
+
expect(open).toMatchObject({ concerns: ["record:decision/dec-001", `commit:${sha.c2}`, "record:work/W-001"] });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("at HEAD: W-001's window closed with c3, so c4 is worked by W-002 alone, and neither work finding fires", async () => {
|
|
273
|
+
const doc = await walk("app/server.mjs");
|
|
274
|
+
const worked = doc.edges.filter((e) => e.kind === "within" && "state" in e && e.state === "worked").map((e) => [e.from, e.to]);
|
|
275
|
+
expect(worked).toEqual([
|
|
276
|
+
[`commit:${sha.c4}`, "record:work/W-002"],
|
|
277
|
+
[`commit:${sha.c2}`, "record:work/W-001"],
|
|
278
|
+
[`commit:${sha.c2}`, "record:work/W-002"],
|
|
279
|
+
]);
|
|
280
|
+
expect(workNode(doc, "W-001")).toMatchObject({ state: "done", closed: true, warnings: [{ code: "work-done-unpinned" }] });
|
|
281
|
+
expect(workNode(doc, "W-002")).toMatchObject({ ready: true, blockedBy: [] });
|
|
282
|
+
expect(codes(doc)).not.toContain("intent-work-blocked");
|
|
283
|
+
expect(codes(doc)).not.toContain("intent-work-open-decided-code");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
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 () => {
|
|
287
|
+
const doc = await walk("app/server.mjs");
|
|
288
|
+
const undecided = doc.nodes.filter((n) => n.kind === "finding" && n.code === "intent-commit-undecided");
|
|
289
|
+
// c0 changed the file before any decision.
|
|
290
|
+
expect(undecided.length).toBe(1);
|
|
291
|
+
expect(undecided[0]).toMatchObject({ addressed: true, addressedBy: [{ id: "W-003", state: "done" }] });
|
|
292
|
+
expect(doc.edges).toContainEqual({ kind: "addressed-by", from: undecided[0].id, to: "record:work/W-003" });
|
|
293
|
+
expect(workNode(doc, "W-003")!.warnings.map((w) => w.code)).toEqual(["work-done-gap-open"]);
|
|
294
|
+
// Every finding says whether a work item addresses it once a work kind is read.
|
|
295
|
+
for (const f of doc.nodes.filter((n) => n.kind === "finding")) expect(f).toHaveProperty("addressed");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a decided decision only a dropped item implements, and no commit carried out, is intent-decision-unimplemented, and nothing addresses it", async () => {
|
|
299
|
+
const doc = await walk("app/other.mjs");
|
|
300
|
+
expect(doc.edges).toContainEqual({ kind: "implements", from: "record:work/W-004", to: "record:decision/dec-002" });
|
|
301
|
+
const f = doc.nodes.find((n) => n.kind === "finding" && n.code === "intent-decision-unimplemented");
|
|
302
|
+
expect(f).toMatchObject({ concerns: ["record:decision/dec-002", "region:app/other.mjs"], addressed: false, addressedBy: [] });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("without a work kind the walk is what it was: no work node, no work finding, no addressed field", async () => {
|
|
306
|
+
const doc = await walk("app/other.mjs", { kinds: [DECISIONS, JOINS] });
|
|
307
|
+
expect(doc.nodes.some((n) => n.kind === "work")).toBe(false);
|
|
308
|
+
expect(codes(doc)).not.toContain("intent-decision-unimplemented");
|
|
309
|
+
for (const f of doc.nodes.filter((n) => n.kind === "finding")) expect(f).not.toHaveProperty("addressed");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("the walk prints each work item with its worked commits and warnings", async () => {
|
|
313
|
+
const text = formatIntent(await walk("app/server.mjs"));
|
|
314
|
+
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");
|
|
315
|
+
expect(text).toContain(` worked ${sha.c4.slice(0, 8)} move the port again; in W-002's window`);
|
|
316
|
+
expect(text).toContain(" warning work-done-gap-open:");
|
|
317
|
+
expect(text).toMatch(/finding {3}intent-commit-undecided: .*; addressed by W-003 \(done\)/);
|
|
318
|
+
expect(text).toMatch(/3 work items/);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe("writing work items on a copy of the reference workspace (#2683)", () => {
|
|
323
|
+
let copy: string;
|
|
324
|
+
beforeAll(() => {
|
|
325
|
+
copy = realpathSync(mkdtempSync(join(tmpdir(), "chant-2683-write-")));
|
|
326
|
+
for (const d of ["decisions", "work", "design"]) cpSync(join(REF, d), join(copy, d), { recursive: true });
|
|
327
|
+
cpSync(join(REF, "chant.workspace.json"), join(copy, "chant.workspace.json"));
|
|
328
|
+
});
|
|
329
|
+
afterAll(() => rmSync(copy, { recursive: true, force: true }));
|
|
330
|
+
|
|
331
|
+
const fresh = (over: Record<string, unknown> = {}) =>
|
|
332
|
+
JSON.stringify({
|
|
333
|
+
schema: 1,
|
|
334
|
+
title: "Draw the status region in the wireframe",
|
|
335
|
+
state: "open",
|
|
336
|
+
implements: ["ref-002"],
|
|
337
|
+
needs: ["W-001"],
|
|
338
|
+
constrains: ["path:design/screens/home.svg"],
|
|
339
|
+
evidence: [],
|
|
340
|
+
opened_on: "2026-09-25",
|
|
341
|
+
source: { finding: "intent-pin-drifted", region: "design/screens/home.json", decision: "ref-002" },
|
|
342
|
+
supersedes: [],
|
|
343
|
+
...over,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("records new work names the declared work kind", () => {
|
|
347
|
+
expect(resolveWriteKind("work", copy)).toBe(join(copy, "work", "work.kind.mjs"));
|
|
348
|
+
expect(resolveWriteKind("work/work.kind.mjs", copy)).toBe("work/work.kind.mjs");
|
|
349
|
+
expect(resolveWriteKind("nothing", copy)).toBe("nothing");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("records new allocates W-003 after W-001 and W-002, with a gap source that validates", async () => {
|
|
353
|
+
const doc = await newRecord({ kind: resolveWriteKind("work", copy), fields: fresh(), cwd: copy });
|
|
354
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
355
|
+
expect(doc).toMatchObject({ id: "W-003", path: "work/W-003-draw-the-status-region-in-the-wireframe.md" });
|
|
356
|
+
const read = await queryRecords({ kind: "work/work.kind.mjs", cwd: copy });
|
|
357
|
+
records.expectValid(read);
|
|
358
|
+
if ("error" in read) throw new Error(read.error.message);
|
|
359
|
+
expect(read.records.find((r) => r.id === "W-003")).toMatchObject({
|
|
360
|
+
valid: true,
|
|
361
|
+
ready: false,
|
|
362
|
+
blockedBy: [{ id: "W-001", state: "in-progress" }],
|
|
363
|
+
data: { source: { finding: "intent-pin-drifted", region: "design/screens/home.json", decision: "ref-002" } },
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("records new refuses a gap source whose finding is not an intent finding code", async () => {
|
|
368
|
+
const doc = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { finding: "not-a-finding", region: "design" } }), dryRun: true, cwd: copy });
|
|
369
|
+
expect("error" in doc && doc.error.code).toBe("record-schema-invalid");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("records new with a fresh prefix takes --prefix W", async () => {
|
|
373
|
+
const doc = await newRecord({ kind: "work/work.kind.mjs", fields: fresh(), prefix: "W", dryRun: true, cwd: copy });
|
|
374
|
+
expect(doc).toMatchObject({ id: "W-004" });
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("records amend moves W-003 to in-progress with an owner", async () => {
|
|
378
|
+
const doc = await amendRecord({ kind: "work/work.kind.mjs", id: "W-003", fields: JSON.stringify({ state: "in-progress", owner: "agent-1" }), cwd: copy });
|
|
379
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
380
|
+
expect(doc.changed.sort()).toEqual(["owner", "state"]);
|
|
381
|
+
const read = await queryRecords({ kind: "work/work.kind.mjs", cwd: copy });
|
|
382
|
+
if ("error" in read) throw new Error(read.error.message);
|
|
383
|
+
expect(read.records.find((r) => r.id === "W-003")).toMatchObject({ state: "in-progress", valid: true, data: { owner: "agent-1" } });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("records review on a work item is refused with review-unsupported: the kind declares no reviews", async () => {
|
|
387
|
+
const doc = await reviewRecord({ kind: "work/work.kind.mjs", id: "W-003", verdict: "agree", by: "alice", cwd: copy });
|
|
388
|
+
expect("error" in doc && doc.error.code).toBe("review-unsupported");
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work items on read (#2683): a record kind with a `work` block.
|
|
3
|
+
*
|
|
4
|
+
* A work item is a record in the workspace, a git-tracked file with its
|
|
5
|
+
* dependencies written inside it, that people and agents share as one queue
|
|
6
|
+
* with no server. Most work items come from a gap the intent graph already
|
|
7
|
+
* reports (`source.finding`), given an id, an owner and a lifecycle.
|
|
8
|
+
*
|
|
9
|
+
* `records.ts` calls {@link applyWork} after it has read a work kind's
|
|
10
|
+
* records, before it sets `valid` and applies `--current`. This module reads
|
|
11
|
+
* the decision kind the work kind names, at the same revision, and gives each
|
|
12
|
+
* work record:
|
|
13
|
+
*
|
|
14
|
+
* - `ready`: its state is the kind's open state, every need is done, and the
|
|
15
|
+
* record is valid and not superseded;
|
|
16
|
+
* - `blockedBy`: each need that is not done, with its state;
|
|
17
|
+
* - `implements`: each decision it names, with that decision's state;
|
|
18
|
+
*
|
|
19
|
+
* and each decision `implementedBy`, the work records naming it. The
|
|
20
|
+
* warnings are closed codes. `work-done-gap-open` is not raised here: it
|
|
21
|
+
* takes a walk of the item's region, which `graph --intent` makes and
|
|
22
|
+
* `records` asks it for (#2686). It never writes a record.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { dirname } from "node:path";
|
|
26
|
+
import type { ReasonCode } from "./reason-codes";
|
|
27
|
+
import { loadRecordKind, readRecords, type LoadedRecordKind, type ReadRecordsOptions, type RecordEntry } from "./records";
|
|
28
|
+
|
|
29
|
+
/** Why a work record carries a warning. Closed, like the record warning codes. */
|
|
30
|
+
export const WORK_WARNING_CODES = [
|
|
31
|
+
/** A `needs` entry names a work id no record has. */
|
|
32
|
+
"work-needs-unknown",
|
|
33
|
+
/** An `implements` entry names a decision id no decision has. */
|
|
34
|
+
"work-implements-unknown",
|
|
35
|
+
/** The record needs itself through its `needs` links, so it can never be ready. */
|
|
36
|
+
"work-needs-cycle",
|
|
37
|
+
/** The record implements a decision whose state is not approved, such as proposed or withdrawn. */
|
|
38
|
+
"work-implements-undecided",
|
|
39
|
+
/** The record is done and its evidence list is empty: nothing shows the work was done. */
|
|
40
|
+
"work-done-unpinned",
|
|
41
|
+
/** The record is in a closed state and has no closing date. */
|
|
42
|
+
"work-closed-without-date",
|
|
43
|
+
/** The record is done, and the finding it came from (`source.finding`) still fires on its region. Raised by `graph --intent`, and by `records` through a walk of that region (#2686). */
|
|
44
|
+
"work-done-gap-open",
|
|
45
|
+
] as const satisfies readonly ReasonCode[];
|
|
46
|
+
export type WorkWarningCode = (typeof WORK_WARNING_CODES)[number];
|
|
47
|
+
|
|
48
|
+
/** A link from a work record to another record, with that record's state now. */
|
|
49
|
+
export interface WorkLink {
|
|
50
|
+
id: string;
|
|
51
|
+
/** The linked record's state, or null when no record has the id. */
|
|
52
|
+
state: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A decision as a work read lists it: its state and the work records implementing it. */
|
|
56
|
+
export interface DecisionWork {
|
|
57
|
+
id: string;
|
|
58
|
+
path: string;
|
|
59
|
+
state: string | null;
|
|
60
|
+
supersededBy: string | null;
|
|
61
|
+
implementedBy: WorkLink[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The strings of a front-matter list, or none. */
|
|
65
|
+
export function idList(data: Record<string, unknown> | null, field: string): string[] {
|
|
66
|
+
const v = data?.[field];
|
|
67
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether `state` counts as decided in the decision kind: ranked above 0 by
|
|
72
|
+
* its approval ranks, or, for a kind without them, closed.
|
|
73
|
+
*/
|
|
74
|
+
export function isDecided(kind: LoadedRecordKind["kind"], state: string | null): boolean {
|
|
75
|
+
if (state === null) return false;
|
|
76
|
+
return kind.approval ? (kind.approval[state] ?? 0) > 0 : (kind.closedStates ?? []).includes(state);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Give each work record its links, ready and blockedBy, and its warnings, in
|
|
81
|
+
* place. Throws a `RecordReadError` when the decision kind can't be read.
|
|
82
|
+
*/
|
|
83
|
+
export async function applyWork(loaded: LoadedRecordKind, entries: RecordEntry[], options: ReadRecordsOptions): Promise<{ decisions: DecisionWork[] }> {
|
|
84
|
+
const { kind } = loaded;
|
|
85
|
+
const work = kind.work!;
|
|
86
|
+
const decisionsKind = await loadRecordKind(work.decisions, dirname(loaded.file));
|
|
87
|
+
const decisionRead = await readRecords(decisionsKind, { root: options.root, source: options.source });
|
|
88
|
+
const decisionById = new Map<string, RecordEntry>();
|
|
89
|
+
for (const d of decisionRead.records) if (d.id !== null && !decisionById.has(d.id)) decisionById.set(d.id, d);
|
|
90
|
+
|
|
91
|
+
const byId = new Map<string, RecordEntry>();
|
|
92
|
+
for (const e of entries) if (e.id !== null && !byId.has(e.id)) byId.set(e.id, e);
|
|
93
|
+
const closed = new Set(kind.closedStates);
|
|
94
|
+
|
|
95
|
+
// Records in a needs cycle: those that reach themselves.
|
|
96
|
+
const inCycle = new Set<string>();
|
|
97
|
+
for (const start of byId.keys()) {
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
const stack = [...idList(byId.get(start)!.data, work.needs)];
|
|
100
|
+
while (stack.length > 0) {
|
|
101
|
+
const id = stack.pop()!;
|
|
102
|
+
if (id === start) {
|
|
103
|
+
inCycle.add(start);
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
if (seen.has(id)) continue;
|
|
107
|
+
seen.add(id);
|
|
108
|
+
stack.push(...idList(byId.get(id)?.data ?? null, work.needs));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const implementedBy = new Map<string, WorkLink[]>();
|
|
113
|
+
for (const e of entries) {
|
|
114
|
+
if (e.data === null) continue;
|
|
115
|
+
const needs = idList(e.data, work.needs);
|
|
116
|
+
const implementsIds = idList(e.data, work.implements);
|
|
117
|
+
e.blockedBy = needs.filter((n) => byId.get(n)?.state !== work.done).map((n) => ({ id: n, state: byId.get(n)?.state ?? null }));
|
|
118
|
+
e.implements = implementsIds.map((d) => ({ id: d, state: decisionById.get(d)?.state ?? null }));
|
|
119
|
+
e.ready = e.state === work.open && e.blockedBy.length === 0 && e.reasons.length === 0 && e.supersededBy === null && !(e.id !== null && inCycle.has(e.id));
|
|
120
|
+
|
|
121
|
+
// An open item has no proof yet, so the kind's empty-evidence warning is
|
|
122
|
+
// replaced by work-done-unpinned, which only a done item gets.
|
|
123
|
+
e.warnings = e.warnings.filter((w) => w.code !== "record-no-evidence");
|
|
124
|
+
const unknownNeeds = needs.filter((n) => !byId.has(n));
|
|
125
|
+
if (unknownNeeds.length > 0) {
|
|
126
|
+
e.warnings.push({ code: "work-needs-unknown", message: `needs ${unknownNeeds.join(", ")}, which no work record has, so the item stays blocked` });
|
|
127
|
+
}
|
|
128
|
+
const unknownDecisions = implementsIds.filter((d) => !decisionById.has(d));
|
|
129
|
+
if (unknownDecisions.length > 0) {
|
|
130
|
+
e.warnings.push({ code: "work-implements-unknown", message: `implements ${unknownDecisions.join(", ")}, which no decision in ${work.decisions} has` });
|
|
131
|
+
}
|
|
132
|
+
if (e.id !== null && inCycle.has(e.id)) {
|
|
133
|
+
e.warnings.push({ code: "work-needs-cycle", message: `${e.id} needs itself through its needs links, so it can never be ready` });
|
|
134
|
+
}
|
|
135
|
+
const undecided = e.implements.filter((d) => d.state !== null && !isDecided(decisionsKind.kind, d.state));
|
|
136
|
+
if (undecided.length > 0) {
|
|
137
|
+
e.warnings.push({
|
|
138
|
+
code: "work-implements-undecided",
|
|
139
|
+
message: `implements ${undecided.map((d) => `${d.id}, which is ${d.state}`).join("; ")}: the work may carry out a choice nobody has made`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const evidence = kind.pins ? e.data[kind.pins.field] : undefined;
|
|
143
|
+
if (e.state === work.done && kind.pins && !(Array.isArray(evidence) && evidence.length > 0)) {
|
|
144
|
+
e.warnings.push({ code: "work-done-unpinned", message: `${e.id ?? e.path} is ${work.done} and ${kind.pins.field} is empty or missing: nothing shows the work was done` });
|
|
145
|
+
}
|
|
146
|
+
if (e.state !== null && closed.has(e.state) && typeof e.data[work.closedOn] !== "string") {
|
|
147
|
+
e.warnings.push({ code: "work-closed-without-date", message: `${e.id ?? e.path} is ${e.state} and has no ${work.closedOn}` });
|
|
148
|
+
}
|
|
149
|
+
if (e.id !== null && byId.get(e.id) === e) {
|
|
150
|
+
for (const d of implementsIds) {
|
|
151
|
+
const list = implementedBy.get(d) ?? [];
|
|
152
|
+
if (!list.some((x) => x.id === e.id)) list.push({ id: e.id, state: e.state });
|
|
153
|
+
implementedBy.set(d, list);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
decisions: decisionRead.records
|
|
160
|
+
.filter((d): d is RecordEntry & { id: string } => d.id !== null && decisionById.get(d.id) === d)
|
|
161
|
+
.map((d) => ({ id: d.id, path: d.path, state: d.state, supersededBy: d.supersededBy, implementedBy: implementedBy.get(d.id) ?? [] })),
|
|
162
|
+
};
|
|
163
|
+
}
|