@intentius/chant 0.93.0 → 0.94.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 (62) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  3. package/dist/workspace/box-intent.d.ts +85 -0
  4. package/dist/workspace/box-intent.d.ts.map +1 -0
  5. package/dist/workspace/chant-migrations.d.ts +5 -0
  6. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  7. package/dist/workspace/checks/boxes.d.ts +14 -1
  8. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  9. package/dist/workspace/checks.d.ts +4 -0
  10. package/dist/workspace/checks.d.ts.map +1 -1
  11. package/dist/workspace/decision-points.schema.json +3 -3
  12. package/dist/workspace/declaration.d.ts +6 -0
  13. package/dist/workspace/declaration.d.ts.map +1 -1
  14. package/dist/workspace/declaration.schema.json +6 -1
  15. package/dist/workspace/intent-joins.d.ts +5 -5
  16. package/dist/workspace/points-cli.d.ts +2 -0
  17. package/dist/workspace/points-cli.d.ts.map +1 -1
  18. package/dist/workspace/points.d.ts +3 -5
  19. package/dist/workspace/points.d.ts.map +1 -1
  20. package/dist/workspace/reason-codes.d.ts +2 -0
  21. package/dist/workspace/reason-codes.d.ts.map +1 -1
  22. package/dist/workspace/records-cli.d.ts.map +1 -1
  23. package/dist/workspace/status-stewards.d.ts +8 -2
  24. package/dist/workspace/status-stewards.d.ts.map +1 -1
  25. package/dist/workspace/status.d.ts +8 -0
  26. package/dist/workspace/status.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/cli/main.ts +4 -3
  29. package/src/cli/mcp/workspace-plugins.ts +6 -6
  30. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  31. package/src/op/activities/decide.test.ts +8 -0
  32. package/src/op/steward-points.test.ts +51 -0
  33. package/src/workspace/box-intent.test.ts +205 -0
  34. package/src/workspace/box-intent.ts +159 -0
  35. package/src/workspace/chant-migrations.ts +5 -0
  36. package/src/workspace/check.schema.json +7 -3
  37. package/src/workspace/checks/boxes.test.ts +2 -1
  38. package/src/workspace/checks/boxes.ts +66 -0
  39. package/src/workspace/checks.ts +12 -1
  40. package/src/workspace/decision-points.schema.json +3 -3
  41. package/src/workspace/declaration.schema.json +6 -1
  42. package/src/workspace/declaration.ts +8 -0
  43. package/src/workspace/declared-kinds.test.ts +26 -0
  44. package/src/workspace/intent-joins.test.ts +5 -5
  45. package/src/workspace/intent-joins.ts +5 -5
  46. package/src/workspace/points-cli.ts +3 -0
  47. package/src/workspace/points.schema.json +18 -0
  48. package/src/workspace/points.test.ts +15 -14
  49. package/src/workspace/points.ts +4 -16
  50. package/src/workspace/read-contract.test.ts +8 -0
  51. package/src/workspace/reason-codes.test.ts +7 -7
  52. package/src/workspace/reason-codes.ts +3 -0
  53. package/src/workspace/records-cli.ts +14 -1
  54. package/src/workspace/records-formats.test.ts +15 -15
  55. package/src/workspace/records-since.test.ts +8 -7
  56. package/src/workspace/records.test.ts +1 -1
  57. package/src/workspace/status-contract.test.ts +1 -0
  58. package/src/workspace/status-stewards.ts +49 -3
  59. package/src/workspace/status.schema.json +19 -2
  60. package/src/workspace/status.ts +10 -0
  61. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  62. package/src/workspace/work.test.ts +17 -0
package/src/cli/main.ts CHANGED
@@ -1284,9 +1284,10 @@ async function loadPluginsOrExit(path: string): Promise<import("../lexicon").Lex
1284
1284
  * #2700 — whether `path` is the root of a declared workspace and holds no
1285
1285
  * lexicon of its own: no `lexicons` in a root config, and no lexicon import in
1286
1286
  * the root's source, which leaves the members' directories out (#2527). A
1287
- * generated chud repo is this shape: its lexicons are all in `delivery/`.
1288
- * `chant serve mcp` starts there instead of refusing with "No lexicon
1289
- * detected", and serves core with the chant members' lexicons.
1287
+ * generated app repo can be this shape: its lexicons are all under a
1288
+ * delivery directory, not the root. `chant serve mcp` starts there instead of
1289
+ * refusing with "No lexicon detected", and serves core with the chant
1290
+ * members' lexicons.
1290
1291
  *
1291
1292
  * A directory with no workspace declaration costs only `findWorkspaceRoot`'s
1292
1293
  * existence checks, so a level-0 project reaches `loadPluginsOrExit` as before.
@@ -2,12 +2,12 @@
2
2
  * #2700 — the lexicons `chant serve mcp` loads at the root of a declared
3
3
  * workspace that has no lexicon of its own.
4
4
  *
5
- * Such a root (a generated chud repo, say, whose lexicons are all in
6
- * `delivery/`) used to refuse with "No lexicon detected", so an agent started
7
- * there got none of chant's tools. The server now starts with core's tools and
8
- * resources, and with the lexicons of the workspace's members of kind chant,
9
- * each read from that member's own config the way `chant build` in the
10
- * member's directory reads it.
5
+ * Such a root (a generated app repo, say, whose lexicons are all under a
6
+ * delivery directory) used to refuse with "No lexicon detected", so an agent
7
+ * started there got none of chant's tools. The server now starts with core's
8
+ * tools and resources, and with the lexicons of the workspace's members of
9
+ * kind chant, each read from that member's own config the way `chant build`
10
+ * in the member's directory reads it.
11
11
  *
12
12
  * Loading is best effort, one member and one lexicon at a time: a member whose
13
13
  * config does not load, or a lexicon this chant cannot import, is left out and
@@ -73,8 +73,8 @@ describe("chant serve mcp at a workspace root with no lexicon of its own (#2700)
73
73
  expect(init.instructions).toContain("Member delivery (delivery/) declares docker.");
74
74
  }, TIMEOUT);
75
75
 
76
- test("a chud-shaped root, lexicons only in a member, serves core and the lexicons that load", () => {
77
- const root = join(scratch, "chud-shaped");
76
+ test("a lexiconless root, lexicons only in a member, serves core and the lexicons that load", () => {
77
+ const root = join(scratch, "lexiconless-shaped");
78
78
  mkdirSync(join(root, ".git"), { recursive: true });
79
79
  mkdirSync(join(root, "delivery"), { recursive: true });
80
80
  mkdirSync(join(root, "app"), { recursive: true });
@@ -87,9 +87,9 @@ describe("chant serve mcp at a workspace root with no lexicon of its own (#2700)
87
87
  ],
88
88
  pins: [],
89
89
  }));
90
- // `chud` is not installed here, as it would not be for a chant without
91
- // @intentius/chud: it is named as not loaded and the rest is served.
92
- writeFileSync(join(root, "delivery", "chant.config.ts"), 'export default { lexicons: ["docker", "chud"] };\n');
90
+ // `acme` names a lexicon package that isn't installed here: it is named
91
+ // as not loaded and the rest is served.
92
+ writeFileSync(join(root, "delivery", "chant.config.ts"), 'export default { lexicons: ["docker", "acme"] };\n');
93
93
  writeFileSync(join(root, "app", "server.js"), "// not chant\n");
94
94
 
95
95
  const { status, stderr, init, tools } = initializeAndList(root);
@@ -97,7 +97,7 @@ describe("chant serve mcp at a workspace root with no lexicon of its own (#2700)
97
97
  for (const name of CORE_TOOLS) expect(tools).toContain(name);
98
98
  expect(tools).toContain("docker:diff");
99
99
  expect(init.instructions).toContain('workspace "t"');
100
- expect(init.instructions).toContain("Lexicon chud did not load");
100
+ expect(init.instructions).toContain("Lexicon acme did not load");
101
101
  expect(init.instructions).toContain("Lexicon tools and resources served: docker.");
102
102
  }, TIMEOUT);
103
103
 
@@ -65,6 +65,14 @@ afterAll(async () => {
65
65
  });
66
66
 
67
67
  describe("decide against the stub (#2740)", () => {
68
+ test("points --json carries each point's criteria, as the points file declares it (#2853)", async () => {
69
+ const doc = await workspacePoints({ cwd: root });
70
+ if ("error" in doc) throw new Error(doc.error.message);
71
+ expect(doc.points.find((p) => p.name === "triage")?.criteria).toEqual({ true: "A person looks at it today.", false: "It can wait." });
72
+ expect(doc.points.find((p) => p.name === "route")?.criteria).toEqual({ platform: "The platform team.", app: "The app team.", docs: "The docs team." });
73
+ expect(doc.points.find((p) => p.name === "effort")?.criteria).toEqual(["low", "medium", "high"]);
74
+ });
75
+
68
76
  test("a noul: the model's answer at the threshold is recorded as proposed, and the run waits on it", async () => {
69
77
  script = { triage: { type: "noul", noul: 0.91 } };
70
78
  const w = await waitsOn(ask({ point: "triage", inputs: { "record.size": 5, "record.risky": true }, subject: "T-1" }));
@@ -311,3 +311,54 @@ describe("an Op waiting on a decision point (#2749)", () => {
311
311
  expect((await readMemberStewards(root, "local", "2027-01-01T00:11:00Z")).stewards[0].waiting).toEqual([]);
312
312
  });
313
313
  });
314
+
315
+ describe("a waiting run of an Op the steward does not list (studio#137)", () => {
316
+ test("status lists it under the steward its record names, and leaves out a run no steward of the member started", async () => {
317
+ // The steward lists one Op; its process starts `dispatch` itself, with CHANT_STEWARD set.
318
+ const tick = shipOp("factory-tick", "r-20", "0 0 1 1 *");
319
+ const steward = declareSteward({ name: "factory-steward", ops: [tick], capabilities: ["inference"] });
320
+ const dispatch = shipOp("factory-dispatch", "r-21");
321
+ const lonely = shipOp("factory-lonely", "r-22");
322
+ const stranger = shipOp("factory-stranger", "r-23");
323
+ mkdirSync(join(root, "ops"), { recursive: true });
324
+ writeFileSync(join(root, "chant.config.json"), "{}\n");
325
+ writeFileSync(join(root, "ops", "factory-steward.op.ts"), `export const steward = ${JSON.stringify(steward)};\n`);
326
+ writeFileSync(
327
+ join(root, "ops", "factory.op.ts"),
328
+ [dispatch, lonely, stranger].map((op, i) => `export const op${i} = { props: ${JSON.stringify(op)} };\n`).join(""),
329
+ );
330
+
331
+ // Each release its own subject, so no earlier answer to ship-now stands for it.
332
+ const log: string[] = [];
333
+ const acts = new Map<string, ActivityFn>([
334
+ ...shipActivities(log),
335
+ ["askShip", async (args) => (await askPointInRun({ cwd: root, point: "ship-now", inputs: { release: args.release }, subject: String(args.release), on })).answer],
336
+ ]);
337
+ process.env[STEWARD_ENV] = "factory-steward";
338
+ const run = await runOpLocally(dispatch, acts, PROFILES, undefined, { ledger: { cwd: root } });
339
+ process.env[STEWARD_ENV] = "not-a-steward-here";
340
+ await runOpLocally(stranger, acts, PROFILES, undefined, { ledger: { cwd: root } });
341
+ resetStewardTurn();
342
+ await runOpLocally(lonely, acts, PROFILES, undefined, { ledger: { cwd: root } });
343
+ expect(run.status).toBe("waiting");
344
+ const newest = (await readRunLedger("local", "factory-dispatch", { cwd: root })).records.at(-1)!;
345
+ expect(newest).toMatchObject({ status: "waiting", steward: "factory-steward", point: { id: run.point!.id } });
346
+ expect((await readRunLedger("local", "factory-lonely", { cwd: root })).records.at(-1)).toMatchObject({ status: "waiting" });
347
+ expect((await readRunLedger("local", "factory-stranger", { cwd: root })).records.at(-1)).toMatchObject({ status: "waiting", steward: "not-a-steward-here" });
348
+
349
+ const status = await readMemberStewards(root, "local", "2027-01-01T00:01:00Z");
350
+ expect(status.reasons).toEqual([]);
351
+ const entry = status.stewards.find((s) => s.name === "factory-steward")!;
352
+ const stewardShape = contract({ $schema: statusSchema.$schema, $id: "urn:test:status-steward-undeclared", $defs: statusSchema.$defs, $ref: "#/$defs/steward" });
353
+ stewardShape.expectValid(entry);
354
+ // `ops` stays the declared Ops; `waiting` also names the run of the Op the steward's process started.
355
+ expect(entry.ops.map((o) => o.name)).toEqual(["factory-tick"]);
356
+ expect(entry.waiting).toEqual([
357
+ { op: "factory-dispatch", run: newest.id, id: run.point!.id, point: "ship-now", state: "escalated", path: newest.point!.path, subject: "r-21", since: newest.point!.since },
358
+ ]);
359
+ // A run nobody's turn started, or another steward's, is no steward's wait here.
360
+ const listed = status.stewards.flatMap((s) => s.waiting.map((w) => w.op));
361
+ expect(listed).not.toContain("factory-lonely");
362
+ expect(listed).not.toContain("factory-stranger");
363
+ });
364
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * A box's intent (#2850): the decision record the box block names, as
3
+ * `status --json` reports it, as `check` finds it (WSP126, WSP127) and as
4
+ * `graph --intent` shows it for the box's files.
5
+ */
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { afterAll, describe, expect, test } from "vitest";
10
+ import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
11
+ import { constrainsBox, constrainsWorkspace } from "./box-intent";
12
+ import { runDeclarationChecks } from "./checks";
13
+ import { parseDeclaration } from "./declaration";
14
+ import { intentGraph } from "./intent";
15
+ import { parseFrontMatter } from "./records";
16
+ import { workspaceStatus, type StatusDocument } from "./status";
17
+ import statusSchema from "./status.schema.json";
18
+
19
+ afterAll(cleanScratch);
20
+
21
+ const REF = join(REPO, "reference-workspace", "decisions");
22
+ const BASE = (() => {
23
+ const fm = parseFrontMatter(readFileSync(join(REF, "ref-001-how-the-app-is-deployed.md"), "utf-8"));
24
+ if (!fm.ok) throw new Error(fm.message);
25
+ return fm.value as Record<string, unknown>;
26
+ })();
27
+
28
+ const QUESTION = "What is this box for?";
29
+ const CHOICE = { option: "a", reason: "The person who planted the box answered it." };
30
+ const ANSWER = "a chant project with the docker lexicon";
31
+
32
+ /** A decision record from ref-001: proposed with no choice, or decided by alex. JSON is YAML, so the front matter is JSON. */
33
+ function decision(id: string, state: "proposed" | "decided", constrains: string[]): string {
34
+ const data = {
35
+ ...BASE,
36
+ id,
37
+ title: `Intent ${id}`,
38
+ state,
39
+ question: QUESTION,
40
+ choice: state === "decided" ? CHOICE : null,
41
+ rejected: [],
42
+ evidence: [],
43
+ decided_by: state === "decided" ? "alex" : null,
44
+ decided_on: state === "decided" ? "2026-09-26" : null,
45
+ constrains,
46
+ };
47
+ return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
48
+ }
49
+
50
+ function workspace(box: Record<string, unknown>, records: Record<string, string>, declareKind = true): string {
51
+ return repo({
52
+ "chant.workspace.json": JSON.stringify(
53
+ {
54
+ name: "acme",
55
+ schema: 1,
56
+ members: [{ name: "app", dir: "app", kind: "other", because: "the box", box }],
57
+ ...(declareKind ? { records: [{ kind: "decisions/decision.kind.mjs" }] } : {}),
58
+ },
59
+ null,
60
+ 2,
61
+ ),
62
+ "app/server.mjs": "export const port = 8080;\n",
63
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decision.kind.mjs"), "utf-8"),
64
+ "decisions/decision.schema.json": readFileSync(join(REF, "decision.schema.json"), "utf-8"),
65
+ ...records,
66
+ });
67
+ }
68
+
69
+ /**
70
+ * A workspace shaped like the studio template (studio#112, #2857): the box
71
+ * block sits on member `box`, the steward and its Ops, and a separate
72
+ * member `app` is the app the box runs. `records` overlays the decision
73
+ * files, keyed the same way `repo()` takes every other file.
74
+ */
75
+ function studioShapedWorkspace(records: Record<string, string>): string {
76
+ return repo({
77
+ "chant.workspace.json": JSON.stringify(
78
+ {
79
+ name: "acme",
80
+ schema: 1,
81
+ members: [
82
+ { name: "box", dir: "box", kind: "other", because: "the box's steward and its Ops", box: { intent: "box-001" } },
83
+ { name: "app", dir: "app", kind: "other", because: "the app the box runs" },
84
+ ],
85
+ records: [{ kind: "decisions/decision.kind.mjs" }],
86
+ },
87
+ null,
88
+ 2,
89
+ ),
90
+ "box/ops.mjs": "export const ops = true;\n",
91
+ "app/server.mjs": "export const port = 8080;\n",
92
+ "decisions/decision.kind.mjs": readFileSync(join(REF, "decision.kind.mjs"), "utf-8"),
93
+ "decisions/decision.schema.json": readFileSync(join(REF, "decision.schema.json"), "utf-8"),
94
+ ...records,
95
+ });
96
+ }
97
+
98
+ const intentFindings = async (root: string) =>
99
+ (await runDeclarationChecks(root)).diagnostics.filter((d) => d.ruleId === "WSP126" || d.ruleId === "WSP127").map((d) => [d.ruleId, d.severity, d.code, d.entity]);
100
+
101
+ const status = contract(statusSchema);
102
+ async function boxOf(root: string) {
103
+ const doc: StatusDocument = await workspaceStatus({ cwd: root, env: "prod" });
104
+ if ("error" in doc) throw new Error(doc.error.message);
105
+ status.expectValid(doc);
106
+ return doc.members[0].box;
107
+ }
108
+
109
+ describe("the box block's intent", () => {
110
+ test("is parsed into the box declaration, and is null when the block names none", () => {
111
+ const parse = (box: unknown) =>
112
+ parseDeclaration(JSON.stringify({ name: "acme", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "x", box }] }), "chant.workspace.json").members[0].box;
113
+ expect(parse({ intent: "box-001" })?.intent).toBe("box-001");
114
+ expect(parse({})?.intent).toBeNull();
115
+ });
116
+
117
+ test("covers the box through member:, or a path: at, above or inside its directory", () => {
118
+ const m = { name: "app", dir: "apps/app" };
119
+ expect(["member:app", "path:apps/app", "path:apps", "path:apps/app/server.mjs"].map((c) => constrainsBox(c, m))).toEqual([true, true, true, true]);
120
+ expect(["member:web", "path:apps/application", "path:web", "issue:1"].map((c) => constrainsBox(c, m))).toEqual([false, false, false, false]);
121
+ });
122
+
123
+ test("constrainsWorkspace: an entry names a member or path of the workspace whether or not it's the box's own (#2857)", () => {
124
+ const members = [
125
+ { name: "box", dir: "box" },
126
+ { name: "app", dir: "app" },
127
+ ];
128
+ expect(["member:box", "member:app", "path:app", "path:box/ops.mjs"].map((c) => constrainsWorkspace(c, members))).toEqual([true, true, true, true]);
129
+ expect(["member:web", "path:docs", "issue:1"].map((c) => constrainsWorkspace(c, members))).toEqual([false, false, false]);
130
+ expect(constrainsWorkspace("member:app", [])).toBe(false);
131
+ });
132
+ });
133
+
134
+ describe("a box planted as a question", () => {
135
+ test("a proposed intent reports its question and no answer, and passes the checks", async () => {
136
+ const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
137
+ expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "proposed", question: QUESTION, choice: null, answer: null, decided_by: null, decided_on: null });
138
+ expect(await intentFindings(root)).toEqual([]);
139
+ });
140
+
141
+ test("a decided intent reports the answer and who gave it, and graph --intent shows it for the box's files", async () => {
142
+ const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "decided", ["member:app"]) });
143
+ expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "decided", question: QUESTION, choice: CHOICE, answer: ANSWER, decided_by: "alex", decided_on: "2026-09-26" });
144
+ expect(await intentFindings(root)).toEqual([]);
145
+ commitAll(root, "plant the box");
146
+ const { doc } = await intentGraph({ cwd: root, region: "app" });
147
+ if ("error" in doc) throw new Error(doc.error.message);
148
+ expect(doc.nodes.filter((n) => n.kind === "decision").map((n) => (n.kind === "decision" ? [n.record, n.state, n.decided_by] : []))).toEqual([["box-001", "decided", "alex"]]);
149
+ });
150
+
151
+ test("a decided intent whose choice names an option not in options[] reports a null answer", async () => {
152
+ const data = { ...BASE, id: "box-001", title: "Intent box-001", state: "decided", question: QUESTION, choice: { option: "z", reason: "no such option" }, rejected: [], evidence: [], decided_by: "alex", decided_on: "2026-09-26", constrains: ["member:app"] };
153
+ const record = `---\n${JSON.stringify(data, null, 2)}\n---\n\n# box-001\n`;
154
+ const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": record });
155
+ expect((await boxOf(root))?.intent?.answer).toBeNull();
156
+ });
157
+
158
+ test("an intent no decision record has fails WSP126, and status reports only its id", async () => {
159
+ const root = workspace({ intent: "box-009" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
160
+ expect((await boxOf(root))?.intent).toEqual({ id: "box-009", state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null });
161
+ expect(await intentFindings(root)).toEqual([["WSP126", "error", "box-intent-unknown", "app"]]);
162
+ const [d] = (await runDeclarationChecks(root)).diagnostics.filter((x) => x.ruleId === "WSP126");
163
+ expect(d.message).toContain("no record of decisions/decision.kind.mjs has that id");
164
+ });
165
+
166
+ test("an intent in a workspace that declares no decision kind fails WSP126", async () => {
167
+ const root = workspace({ intent: "box-001" }, {}, false);
168
+ const [d] = (await runDeclarationChecks(root)).diagnostics.filter((x) => x.ruleId === "WSP126");
169
+ expect(d.message).toContain("the declaration names no record kind called decision");
170
+ });
171
+
172
+ test("an intent whose constrains names no member or path of the workspace warns WSP127", async () => {
173
+ const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["path:docs"]) });
174
+ expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "app"]]);
175
+ });
176
+
177
+ test("an intent with empty constrains warns WSP127", async () => {
178
+ const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", []) });
179
+ expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "box"]]);
180
+ const d = (await runDeclarationChecks(root)).diagnostics.find((x) => x.ruleId === "WSP127")!;
181
+ expect(d.message).toContain("whose constrains is empty");
182
+ expect(d.message).toContain("add member:<name> for the member the intent is about");
183
+ });
184
+
185
+ test("an intent naming an unknown member warns WSP127", async () => {
186
+ const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:web"]) });
187
+ expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "box"]]);
188
+ });
189
+
190
+ test("an intent that constrains the app a box runs passes WSP127, on a workspace shaped like the studio template (#2857)", async () => {
191
+ const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
192
+ expect(await intentFindings(root)).toEqual([]);
193
+ });
194
+
195
+ test("an intent with a path: entry inside another member's directory passes WSP127 (#2857)", async () => {
196
+ const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["path:app/server.mjs"]) });
197
+ expect(await intentFindings(root)).toEqual([]);
198
+ });
199
+
200
+ test("a box with no intent reports null", async () => {
201
+ const root = workspace({ capabilities: [] }, {});
202
+ expect((await boxOf(root))?.intent).toBeNull();
203
+ expect(await intentFindings(root)).toEqual([]);
204
+ });
205
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * A box's intent (#2850): the decision record its box block names.
3
+ *
4
+ * A new box starts as a question, and the first answer to it is what the box
5
+ * is for. That answer is a decision record, so the intent graph, hud and a
6
+ * box's runtime all read the same thing. The box block names the record's
7
+ * id in `intent`; the record is looked for among the records of every
8
+ * declared kind named `decision` (the entry's `name`, or the kind file's
9
+ * `recordKind.name`), read from the working tree through the same reader as
10
+ * `chant workspace records`.
11
+ *
12
+ * `chant workspace status --json` reports the record's state and answer
13
+ * under the member's `box.intent`, and `chant workspace check` fails when no
14
+ * decision record has the id (WSP126) and warns when the record constrains
15
+ * no member or path of this workspace at all (WSP127, #2857): the member it
16
+ * names need not be the box's own, since a decision naming another member
17
+ * can be what the box is for, such as the app it runs.
18
+ */
19
+
20
+ import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
21
+ import type { Declaration, Member } from "./declaration";
22
+ import { constraintCovers, isWorkspacePath } from "./record-assets";
23
+ import { workingTree } from "./tree";
24
+
25
+ /** The declared kind name a box's intent is looked for in. */
26
+ export const INTENT_KIND_NAME = "decision";
27
+
28
+ /** A box's intent as `status --json` reports it: the record's id, state and answer. */
29
+ export interface BoxIntent {
30
+ id: string;
31
+ /** The record's state, such as proposed or decided, or null when no decision record has the id. */
32
+ state: string | null;
33
+ question: string | null;
34
+ /** The record's choice as written, such as `{ option, reason }`, or null while it is proposed. */
35
+ choice: unknown;
36
+ /** The chosen option's label, from `options[]`, or null while no option is chosen. */
37
+ answer: string | null;
38
+ decided_by: string | null;
39
+ decided_on: string | null;
40
+ }
41
+
42
+ /** The decision record a box's intent names, as the checks read it. */
43
+ export interface ResolvedBoxIntent {
44
+ member: string;
45
+ id: string;
46
+ /** The `intent` field's JSON Pointer in the declaration. */
47
+ pointer: string;
48
+ /** The record, or null when no decision record has the id. */
49
+ record: {
50
+ /** The kind file that holds it, from the workspace root. */
51
+ kind: string;
52
+ /** The record file, from the repository root. */
53
+ path: string;
54
+ /** The record's constrains entries, as written. */
55
+ constrains: string[];
56
+ intent: BoxIntent;
57
+ } | null;
58
+ /** Why no record was found when none was, such as no declared decision kind. Empty when one was found. */
59
+ why: string;
60
+ }
61
+
62
+ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
63
+
64
+ /** The label of `options[]`'s entry whose id is `choice.option`, or null when no option is chosen. */
65
+ function answerOf(choice: unknown, options: unknown): string | null {
66
+ const chosen = choice && typeof choice === "object" ? (choice as { option?: unknown }).option : null;
67
+ if (typeof chosen !== "string" || !Array.isArray(options)) return null;
68
+ const found = options.find((o): o is { id: unknown; label: unknown } => !!o && typeof o === "object" && (o as { id?: unknown }).id === chosen);
69
+ return found && typeof found.label === "string" ? found.label : null;
70
+ }
71
+
72
+ /** The intent of `id` with no record: every field but the id null. */
73
+ export function unresolvedIntent(id: string): BoxIntent {
74
+ return { id, state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null };
75
+ }
76
+
77
+ /**
78
+ * Resolve the intent of every box that names one, reading the records of each
79
+ * declared decision kind once. `kinds` are the declared kinds already loaded,
80
+ * when the caller has them. Only the working tree is read.
81
+ */
82
+ export async function resolveBoxIntents(declaration: Declaration, root: string, kinds?: readonly DeclaredKind[]): Promise<ResolvedBoxIntent[]> {
83
+ const boxes = declaration.members.filter((m): m is Member & { box: NonNullable<Member["box"]> & { intent: string } } => m.box?.intent != null);
84
+ if (boxes.length === 0) return [];
85
+ const loaded = kinds ?? (await loadDeclaredKinds(declaration, workingTree(root), root));
86
+ const decisionKinds = loaded.filter((k) => (k.declared.name ?? k.kind) === INTENT_KIND_NAME);
87
+ const found = new Map<string, NonNullable<ResolvedBoxIntent["record"]>>();
88
+ const problems: string[] = [];
89
+ const { readRecordsFor } = await import("./records-cli");
90
+ const { RecordReadError } = await import("./records");
91
+ for (const k of decisionKinds) {
92
+ if (k.reason) {
93
+ problems.push(`${k.declared.path} can't be loaded: ${k.reason.message}`);
94
+ continue;
95
+ }
96
+ try {
97
+ const read = await readRecordsFor({ kind: k.file, cwd: root, workGaps: false });
98
+ const field = read.loaded.kind.constrains?.field ?? "constrains";
99
+ for (const r of read.result.records) {
100
+ if (r.id === null || found.has(r.id)) continue;
101
+ const data = r.data ?? {};
102
+ const constrains = Array.isArray(data[field]) ? (data[field] as unknown[]).filter((c): c is string => typeof c === "string") : [];
103
+ found.set(r.id, {
104
+ kind: k.declared.path,
105
+ path: r.path,
106
+ constrains,
107
+ intent: {
108
+ id: r.id,
109
+ state: r.state,
110
+ question: str(data.question),
111
+ choice: data.choice ?? null,
112
+ answer: answerOf(data.choice, data.options),
113
+ decided_by: str(data.decided_by),
114
+ decided_on: str(data.decided_on),
115
+ },
116
+ });
117
+ }
118
+ } catch (err) {
119
+ if (!(err instanceof RecordReadError)) throw err;
120
+ problems.push(`${k.declared.path} can't be read: ${err.message}`);
121
+ }
122
+ }
123
+ const why =
124
+ decisionKinds.length === 0
125
+ ? `the declaration names no record kind called ${INTENT_KIND_NAME}`
126
+ : `no record of ${decisionKinds.map((k) => k.declared.path).join(" or ")} has that id` + (problems.length > 0 ? ` (${problems.join("; ")})` : "");
127
+ return boxes.map((m) => {
128
+ const record = found.get(m.box.intent) ?? null;
129
+ return { member: m.name, id: m.box.intent, pointer: `${m.box.pointer}/intent`, record, why: record ? "" : why };
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Whether a constrains entry covers something of the box: `member:<name>`, or
135
+ * a `path:` entry that is the member's directory, a directory above it or a
136
+ * path inside it.
137
+ */
138
+ export function constrainsBox(entry: string, member: { name: string; dir: string }): boolean {
139
+ if (entry === `member:${member.name}`) return true;
140
+ if (!entry.startsWith("path:")) return false;
141
+ const path = entry.slice("path:".length);
142
+ if (!isWorkspacePath(path)) return false;
143
+ // The root member holds every path.
144
+ if (member.dir === ".") return true;
145
+ return constraintCovers(path, member.dir) || constraintCovers(member.dir, path);
146
+ }
147
+
148
+ /**
149
+ * Whether a constrains entry names a member or path of this workspace at
150
+ * all: `member:<name>` for any declared member, or a `path:` entry that is
151
+ * some member's directory, a directory above it or a path inside it. An
152
+ * intent's constrains can govern a member other than the one whose box
153
+ * names it (a box run by one member can be what a decision about another
154
+ * member's app is for), so this asks only whether the entry is real, not
155
+ * whether it reaches a particular member.
156
+ */
157
+ export function constrainsWorkspace(entry: string, members: readonly { name: string; dir: string }[]): boolean {
158
+ return members.some((m) => constrainsBox(entry, m));
159
+ }
@@ -26,6 +26,11 @@
26
26
  * still the template's, so to a later upgrade they are the project's own
27
27
  * edits, merged per file like any other (ws-005). A deleted file keeps its
28
28
  * entry too, so a template version that still has it leaves it deleted.
29
+ *
30
+ * chant itself carries no chud lexicon or chud-shaped code (#2830, once
31
+ * #2715's proof passed): `chud-lexicon-exit` and its follow-ons are the
32
+ * retirement tool, not a remaining dependency. They stay until no known repo
33
+ * still depends on chud, and are removed only in a major version.
29
34
  */
30
35
 
31
36
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/check/v1/check.schema.json",
4
4
  "title": "chant workspace check output",
5
- "description": "What `chant workspace check --format json` prints, version 1 of the read contract for checking a workspace (#2524 D9, D15, D16, #2535, #2536). The lineage lock findings carry reason codes; the declaration findings carry WSP ids from the check catalog, WSP001, a declaration that can't be read, also carries the declaration's reason code, and the box checks WSP121 to WSP125 carry theirs. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
5
+ "description": "What `chant workspace check --format json` prints, version 1 of the read contract for checking a workspace (#2524 D9, D15, D16, #2535, #2536). The lineage lock findings carry reason codes; the declaration findings carry WSP ids from the check catalog, WSP001, a declaration that can't be read, also carries the declaration's reason code, and the box checks WSP121 to WSP127 carry theirs. Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
6
6
  "oneOf": [
7
7
  {
8
8
  "$ref": "#/$defs/result"
@@ -228,12 +228,14 @@
228
228
  "box-isolation-collision",
229
229
  "box-isolation-literal",
230
230
  "box-fountain-callback-undeclared",
231
+ "box-intent-unknown",
232
+ "box-intent-unconstrained",
231
233
  "work-acceptance-unmet",
232
234
  "diagram-source-missing",
233
235
  "diagram-render-missing",
234
236
  "diagram-render-drift"
235
237
  ],
236
- "description": "For WSP001: why the declaration can't be read. For WSP121 to WSP125, the box finding (#2726, #2727, #2780). For WSP117, work-acceptance-unmet (#2772). For WSP131 to WSP133, the diagram finding (#2764)."
238
+ "description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP131 to WSP133, the diagram finding (#2764)."
237
239
  }
238
240
  }
239
241
  },
@@ -304,12 +306,14 @@
304
306
  "box-isolation-collision",
305
307
  "box-isolation-literal",
306
308
  "box-fountain-callback-undeclared",
309
+ "box-intent-unknown",
310
+ "box-intent-unconstrained",
307
311
  "work-acceptance-unmet",
308
312
  "diagram-source-missing",
309
313
  "diagram-render-missing",
310
314
  "diagram-render-drift"
311
315
  ],
312
- "description": "For WSP001: why the declaration can't be read. For WSP121 to WSP125, the box finding (#2726, #2727, #2780). For WSP117, work-acceptance-unmet (#2772). For WSP131 to WSP133, the diagram finding (#2764)."
316
+ "description": "For WSP001: why the declaration can't be read. For WSP121 to WSP127, the box finding (#2726, #2727, #2780, #2850). For WSP117, work-acceptance-unmet (#2772). For WSP131 to WSP133, the diagram finding (#2764)."
313
317
  },
314
318
  "reason": {
315
319
  "type": "string"
@@ -89,7 +89,7 @@ describe("box-credential-declared (WSP121)", () => {
89
89
  test("an env file and a shell script in the box's directory are read too; prose only for credential shapes", async () => {
90
90
  const root = repo({
91
91
  "chant.workspace.json": declaration(BROKERED),
92
- "spec/.env": `FOUNTAIN_API_KEY=abc123def456\nPORT=8080\nGIT_TOKEN=\${CHUD_GIT_TOKEN}\n`,
92
+ "spec/.env": `FOUNTAIN_API_KEY=abc123def456\nPORT=8080\nGIT_TOKEN=\${ACME_GIT_TOKEN}\n`,
93
93
  "spec/run.sh": `#!/bin/sh\nexport GITHUB_TOKEN=${GITHUB_TOKEN}\necho "password=$GIT_TOKEN"\nTOKEN=$1\ntoken=~/box/llm-token\nSECRET=/run/secrets/x\n`,
94
94
  "spec/README.md": `Token: rotate it monthly.\n`,
95
95
  "spec/node_modules/pkg/index.js": `export const k = ${JSON.stringify(ANTHROPIC_KEY)};\n`,
@@ -156,6 +156,7 @@ describe("the box block in the declaration", () => {
156
156
  expect(d.members[0].box).toEqual({
157
157
  pointer: "/members/0/box",
158
158
  isolation: null,
159
+ intent: null,
159
160
  capabilities: [
160
161
  { name: "fountain", broker: "lobby", scope: ["agent", "vault"], pointer: "/members/0/box/capabilities/0" },
161
162
  { name: "inference", broker: null, scope: [], pointer: "/members/0/box/capabilities/1" },