@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
@@ -37,11 +37,23 @@
37
37
  * imports `Box` from `@intentius/chant-lexicon-fountain` and calls it, read as
38
38
  * syntax and never run. Upstream, managoat/fountain#2497 asks for a way to run
39
39
  * a persistent agent with no callback token.
40
+ *
41
+ * WSP126 (`box-intent-unknown`) and WSP127 (`box-intent-unconstrained`), #2850,
42
+ * read the decision record a box block names as its intent (`box-intent.ts`).
43
+ * WSP126 fails when no record of a declared kind named decision has the id.
44
+ * WSP127 warns when the record's constrains names no member or path of this
45
+ * workspace at all: no `member:` entry for a declared member and no `path:`
46
+ * entry at, above or inside one's directory. It need not reach the box's own
47
+ * member: the decision an intent names can constrain the member whose app the
48
+ * box runs, on a workspace where the box block sits on a different member,
49
+ * the box's steward (studio's template, #2857). Both read the working
50
+ * tree's records, so neither runs under `--at`.
40
51
  */
41
52
 
42
53
  import * as ts from "typescript";
43
54
  import type { WorkspaceCheck, WorkspaceCheckContext, WorkspaceDiagnostic } from "../checks";
44
55
  import type { Member } from "../declaration";
56
+ import { constrainsWorkspace } from "../box-intent";
45
57
  import type { ReasonCode } from "../reason-codes";
46
58
  import { joinPath, skippedDir, type WorkspaceTree } from "../tree";
47
59
  import { BOX_ISOLATION_CHECKS } from "./box-isolation";
@@ -53,11 +65,15 @@ export const BOX_FINDING_CODES = [
53
65
  "box-isolation-collision",
54
66
  "box-isolation-literal",
55
67
  "box-fountain-callback-undeclared",
68
+ "box-intent-unknown",
69
+ "box-intent-unconstrained",
56
70
  ] as const satisfies readonly ReasonCode[];
57
71
 
58
72
  export const WSP_BOX_CREDENTIAL = "WSP121";
59
73
  export const WSP_BOX_UNBROKERED = "WSP122";
60
74
  export const WSP_BOX_FOUNTAIN_CALLBACK = "WSP125";
75
+ export const WSP_BOX_INTENT_UNKNOWN = "WSP126";
76
+ export const WSP_BOX_INTENT_UNCONSTRAINED = "WSP127";
61
77
 
62
78
  /** The fountain lexicon's package, whose `Box` composite runs a persistent sandbox. */
63
79
  const FOUNTAIN_LEXICON = "@intentius/chant-lexicon-fountain";
@@ -439,4 +455,54 @@ export const BOX_CHECKS: readonly WorkspaceCheck[] = [
439
455
  return out;
440
456
  },
441
457
  },
458
+ {
459
+ id: WSP_BOX_INTENT_UNKNOWN,
460
+ name: "box-intent-unknown",
461
+ description: "The intent a box block names is the id of a decision record: a record of a declared kind named decision.",
462
+ severity: "error",
463
+ configurable: true,
464
+ check(ctx) {
465
+ const out: WorkspaceDiagnostic[] = [];
466
+ for (const i of ctx.facts?.boxIntents ?? []) {
467
+ if (i.record) continue;
468
+ out.push({
469
+ checkId: this.id,
470
+ severity: this.severity,
471
+ code: "box-intent-unknown",
472
+ message: `box-intent-unknown: member ${i.member}'s box names the intent ${i.id}, and ${i.why}; propose the decision with chant workspace records new, or fix the id`,
473
+ entity: i.member,
474
+ pointer: i.pointer,
475
+ });
476
+ }
477
+ return out;
478
+ },
479
+ },
480
+ {
481
+ id: WSP_BOX_INTENT_UNCONSTRAINED,
482
+ name: "box-intent-unconstrained",
483
+ description:
484
+ "The decision record a box names as its intent constrains a member or path of this workspace: member:<a declared member's name>, or a path: entry at, above or inside a declared member's directory. It need not be the box's own member: a box one member runs can be what a decision about another member constrains.",
485
+ severity: "warning",
486
+ configurable: true,
487
+ check(ctx) {
488
+ const out: WorkspaceDiagnostic[] = [];
489
+ const members = ctx.declaration.members;
490
+ for (const i of ctx.facts?.boxIntents ?? []) {
491
+ if (!i.record) continue;
492
+ if (i.record.constrains.some((c) => constrainsWorkspace(c, members))) continue;
493
+ out.push({
494
+ checkId: this.id,
495
+ severity: this.severity,
496
+ code: "box-intent-unconstrained",
497
+ message:
498
+ `box-intent-unconstrained: member ${i.member}'s box names the intent ${i.id} (${i.record.path}), whose constrains ` +
499
+ (i.record.constrains.length === 0 ? "is empty" : `names ${i.record.constrains.join(", ")}`) +
500
+ `, and none of it is a member or path of this workspace; add member:<name> for the member the intent is about, or a path: entry at, above or inside a member's directory`,
501
+ entity: i.member,
502
+ pointer: i.pointer,
503
+ });
504
+ }
505
+ return out;
506
+ },
507
+ },
442
508
  ];
@@ -33,6 +33,7 @@
33
33
  * | WSP121, WSP122 | boxes: no literal credential, every capability brokered (#2726) |
34
34
  * | WSP123, WSP124 | box isolation: no shared port, state path or cookie, no literal machine path (#2727) |
35
35
  * | WSP125 | boxes: a fountain Box declares the callback token fountain gives its sandbox (#2780) |
36
+ * | WSP126, WSP127 | box intent: the decision record a box names exists, and constrains a member or path of this workspace (#2850, #2857) |
36
37
  * | WSP131 to WSP133 | diagram artifacts: source and render exist, a recorded source hash still matches (#2764) |
37
38
  */
38
39
 
@@ -54,6 +55,7 @@ import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
54
55
  import { BOX_CHECKS } from "./checks/boxes";
55
56
  import { DIAGRAM_CHECKS } from "./checks/diagrams";
56
57
  import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
58
+ import { resolveBoxIntents, type ResolvedBoxIntent } from "./box-intent";
57
59
 
58
60
  /**
59
61
  * What the checks beyond the declaration read, gathered from the checkout
@@ -71,6 +73,8 @@ export interface WorkspaceFacts {
71
73
  records?: RecordFacts;
72
74
  /** The record kinds the declaration names, each loaded, or only looked for under `--at` (#2680). */
73
75
  declaredKinds?: readonly DeclaredKind[];
76
+ /** The decision record each box's intent names, read from the working tree (#2850). */
77
+ boxIntents?: readonly ResolvedBoxIntent[];
74
78
  }
75
79
 
76
80
  /** What every declaration check reads. */
@@ -469,7 +473,14 @@ export async function runDeclarationChecks(
469
473
  // The declared record kinds load from the working tree; under --at only whether each exists at the revision is checked (#2680).
470
474
  // A declared work kind's acceptance criteria are counted in the working tree too (#2772).
471
475
  const declaredKinds = options.gather === false ? undefined : await loadDeclaredKinds(declaration, tree, root, { load: !options.tree, acceptance: !options.tree });
472
- const facts: WorkspaceFacts = { ...gathered, ...(options.records ? { records: options.records } : {}), ...(declaredKinds ? { declaredKinds } : {}) };
476
+ // A box's intent is read from the working tree's records, so not under --at (#2850).
477
+ const boxIntents = declaredKinds && !options.tree ? await resolveBoxIntents(declaration, root, declaredKinds) : undefined;
478
+ const facts: WorkspaceFacts = {
479
+ ...gathered,
480
+ ...(options.records ? { records: options.records } : {}),
481
+ ...(declaredKinds ? { declaredKinds } : {}),
482
+ ...(boxIntents ? { boxIntents } : {}),
483
+ };
473
484
  const ctx: WorkspaceCheckContext = { declaration, tree, groups, kinds: registry, kindProblems: problems, facts };
474
485
  const findings = runWorkspaceChecks(ctx);
475
486
  const { active, suppressed } = applyCheckSettings(declaration, findings);
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://intentius.io/chant/schemas/workspace/decision-points/v1/decision-points.schema.json",
4
4
  "title": "Decision points",
5
- "description": "A workspace's decision points (ws-058, #2738): the recurring questions it asks of its own graph, declared as data in a JSON file that an answer record kind names in `answers.points`. Each point is a typed question (`noul`, `choice` with 2 to 255 options, or `score` with 2 to 10 ordered levels, the question types of the POST /v1/systemone wire format, #2491), the inputs it reads, each named as a read-contract output, and an ordered chain of deciders: `table` rows, `model` deciders with a backend, a pinned model id and a threshold, and a `quorum` of people, always last. Taken from chud's decision-points.schema.json; `boolean` is read as `noul`. What JSON Schema cannot say is checked in code (`points.ts`): an input's output is one the read contract has, a table row tests declared inputs and answers a candidate, a field belongs to its decider's kind, a model id is pinned rather than an alias, and the chain ends in its one quorum. Unknown fields are refused, except fields whose names start with x-.",
5
+ "description": "A workspace's decision points (ws-058, #2738): the recurring questions it asks of its own graph, declared as data in a JSON file that an answer record kind names in `answers.points`. Each point is a typed question (`noul`, `choice` with 2 to 255 options, or `score` with 2 to 10 ordered levels, the question types of the POST /v1/systemone wire format, #2491), the inputs it reads, each named as a read-contract output, and an ordered chain of deciders: `table` rows, `model` deciders with a backend, a pinned model id and a threshold, and a `quorum` of people, always last. What JSON Schema cannot say is checked in code (`points.ts`): an input's output is one the read contract has, a table row tests declared inputs and answers a candidate, a field belongs to its decider's kind, a model id is pinned rather than an alias, and the chain ends in its one quorum. Unknown fields are refused, except fields whose names start with x-.",
6
6
  "type": "object",
7
7
  "required": ["points"],
8
8
  "properties": {
@@ -44,7 +44,7 @@
44
44
  "type": "object",
45
45
  "required": ["type", "instructions", "criteria"],
46
46
  "properties": {
47
- "type": { "enum": ["noul", "boolean", "choice", "score"], "description": "boolean is chud's name for noul, read as noul." },
47
+ "type": { "enum": ["noul", "choice", "score"] },
48
48
  "instructions": { "$ref": "#/$defs/text" },
49
49
  "criteria": {
50
50
  "description": "The candidates. noul: {true, false} descriptions. choice: option to description, 2 to 255 options. score: 2 to 10 ordered levels.",
@@ -54,7 +54,7 @@
54
54
  "additionalProperties": false,
55
55
  "allOf": [
56
56
  {
57
- "if": { "properties": { "type": { "enum": ["noul", "boolean"] } } },
57
+ "if": { "properties": { "type": { "const": "noul" } } },
58
58
  "then": {
59
59
  "properties": {
60
60
  "criteria": {
@@ -402,8 +402,13 @@
402
402
  },
403
403
  "box": {
404
404
  "type": "object",
405
- "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
405
+ "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
406
406
  "properties": {
407
+ "intent": {
408
+ "type": "string",
409
+ "minLength": 1,
410
+ "description": "The id of the decision record that says what the box is for (#2850), a record of a declared kind named decision. A new box starts as a question: the record is proposed with a null choice until the person who answers it decides it. The record constrains a member or path of this workspace, with member:<a member's name> or a path: entry at, above or inside a member's directory. It need not be this member: the intent can constrain the app the box runs when that app is another member (#2857). When it constrains this member, chant workspace graph --intent shows it for the box's files. Added by chant 0.94.0, so a declaration that uses it sets minReader to 0.94.0 or newer."
411
+ },
407
412
  "capabilities": {
408
413
  "type": "array",
409
414
  "description": "Each capability the box reaches outside itself, named once. None when omitted.",
@@ -210,6 +210,12 @@ export interface BoxDeclaration {
210
210
  capabilities: BoxCapability[];
211
211
  /** The host and slot of the box's isolation, or null when the block declares none (#2727). The values are derived in `boxes.ts`. */
212
212
  isolation: BoxIsolationDeclaration | null;
213
+ /**
214
+ * The id of the decision record that says what the box is for, or null when
215
+ * the block names none (#2850). A box starts as a question: the record is
216
+ * proposed with no choice until the person who answers it decides it.
217
+ */
218
+ intent: string | null;
213
219
  /** The block's JSON Pointer in the file, for messages. */
214
220
  pointer: string;
215
221
  }
@@ -713,6 +719,7 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
713
719
  ports?: Record<string, number>;
714
720
  state?: Record<string, string>;
715
721
  cookies?: string[];
722
+ intent?: string;
716
723
  };
717
724
  return {
718
725
  capabilities: (b.capabilities ?? []).map((c, i) => ({ name: c.name, broker: c.broker ?? null, scope: [...(c.scope ?? [])], pointer: `${pointer}/capabilities/${i}` })),
@@ -721,6 +728,7 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
721
728
  b.host === undefined
722
729
  ? null
723
730
  : { host: b.host, slot: b.slot!, ports: { ...(b.ports ?? {}) }, state: { ...(b.state ?? {}) }, cookies: [...(b.cookies ?? [])] },
731
+ intent: b.intent ?? null,
724
732
  pointer,
725
733
  };
726
734
  }
@@ -242,6 +242,32 @@ describe("records without --kind reads every declared kind (#2680)", () => {
242
242
  expect(await run(workspace(), "--current")).toBe(0);
243
243
  expect(out.filter((l) => l.includes("("))).toEqual(["decision (decisions/decision.kind.mjs)", "notes (design/notes/note.kind.mjs)"]);
244
244
  });
245
+
246
+ test("--at an unknown revision with --json prints the error document, not just text on stderr (#2860)", async () => {
247
+ const root = workspace();
248
+ expect(await run(root, "--at", "refs/heads/no-such-branch", "--json")).toBe(1);
249
+ expect(err).toEqual([]);
250
+ const printed = JSON.parse(out.join("\n"));
251
+ expectValid(printed);
252
+ expect(printed).toEqual({
253
+ $schema: recordsSchema.$id,
254
+ contract: 1,
255
+ error: { code: "revision-unknown", message: expect.stringContaining("refs/heads/no-such-branch") as unknown as string },
256
+ });
257
+
258
+ out.length = 0;
259
+ err.length = 0;
260
+ expect(await run(root, "--at", "refs/heads/no-such-branch")).toBe(1);
261
+ expect(out).toEqual([]);
262
+ expect(err.join("\n")).toContain("revision-unknown");
263
+ });
264
+
265
+ test("a declaration-level code the records schema doesn't carry, such as declaration-invalid, stays text-only even with --json (#2860)", async () => {
266
+ const root = repo({ "chant.workspace.json": decl(members(), { records: [{ kind: "a.kind.mjs", file: "b" }] }), "app/x": "", "design/x": "" });
267
+ expect(await run(root, "--json")).toBe(1);
268
+ expect(out).toEqual([]);
269
+ expect(err.join("\n")).toContain("declaration-invalid");
270
+ });
245
271
  });
246
272
 
247
273
  describe("records new, amend and review without --kind (#2680)", () => {
@@ -36,7 +36,7 @@ describe("the data form reads every trailer value (#2663)", () => {
36
36
  });
37
37
 
38
38
  test("trailerValues keeps git's order, trims, and drops empty values and repeats", () => {
39
- expect(trailerValues({ "Chud-Evidence": ["h1", "h2"], "chud-evidence": ["h2", " h3", " "] }, "CHUD-EVIDENCE")).toEqual(["h1", "h2", "h3"]);
39
+ expect(trailerValues({ "Acme-Evidence": ["h1", "h2"], "acme-evidence": ["h2", " h3", " "] }, "ACME-EVIDENCE")).toEqual(["h1", "h2", "h3"]);
40
40
  expect(trailerValues({}, "X")).toEqual([]);
41
41
  expect(trailerValue({ X: [" "] }, "x")).toBeUndefined();
42
42
  });
@@ -45,16 +45,16 @@ describe("the data form reads every trailer value (#2663)", () => {
45
45
  describe("commitJoinsName (#2663)", () => {
46
46
  test("names the findings of either form, and leaves the data form's own keys alone", () => {
47
47
  const join = () => undefined;
48
- expect(readCommitJoins({ commitJoins: join, commitJoinsName: "chud" })).toEqual({ form: "function", join, name: "chud" });
49
- expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "chud" })).toEqual({ form: "data", data: { trailers: { unit: "Unit" } }, name: "chud" });
48
+ expect(readCommitJoins({ commitJoins: join, commitJoinsName: "acme" })).toEqual({ form: "function", join, name: "acme" });
49
+ expect(readCommitJoins({ commitJoins: { trailers: { unit: "Unit" } }, commitJoinsName: "acme" })).toEqual({ form: "data", data: { trailers: { unit: "Unit" } }, name: "acme" });
50
50
  expect(readCommitJoins({ commitJoins: join })).toEqual({ form: "function", join });
51
51
  // A name inside the data form is still a key the data form does not have.
52
- expect(readCommitJoins({ commitJoins: { name: "chud", trailers: {} } })).toMatch(/Unrecognized key/);
52
+ expect(readCommitJoins({ commitJoins: { name: "acme", trailers: {} } })).toMatch(/Unrecognized key/);
53
53
  });
54
54
 
55
55
  test("a name that can't be a plugin:<name>: segment, or one with no joins, is refused", () => {
56
56
  for (const bad of ["a:b", "a b", "", 7]) expect(readCommitJoins({ commitJoins: () => undefined, commitJoinsName: bad }), String(bad)).toMatch(/^commitJoinsName:/);
57
- expect(readCommitJoins({ commitJoinsName: "chud" })).toMatch(/no commitJoins/);
57
+ expect(readCommitJoins({ commitJoinsName: "acme" })).toMatch(/no commitJoins/);
58
58
  expect(readCommitJoins({})).toBeUndefined();
59
59
  });
60
60
  });
@@ -2,10 +2,10 @@
2
2
  * The commit-join hook of the intent graph (#2651; #2650 C1 and C13).
3
3
  *
4
4
  * Commits, decisions and artifacts come from core. Units of work, contracts
5
- * and evidence come from a plugin, such as chud's development model, because
6
- * core ships no model of them (#2555, "Core ships no decision kind"). A kind
7
- * file passed to `chant workspace graph --intent --kind <file>` supplies them
8
- * through one export, `commitJoins`, in one of two forms:
5
+ * and evidence come from a plugin, such as a workspace's own development
6
+ * model, because core ships no model of them (#2555, "Core ships no decision
7
+ * kind"). A kind file passed to `chant workspace graph --intent --kind <file>`
8
+ * supplies them through one export, `commitJoins`, in one of two forms:
9
9
  *
10
10
  * - A function `commitJoins(commit, context)` that returns the unit, contract
11
11
  * and evidence for one commit, or nothing. It is given the commit's sha,
@@ -36,7 +36,7 @@
36
36
  * `<name>` is the kind's name by default: its record kind's name, or the
37
37
  * file's name without `.kind.mjs`. A kind file may name its findings itself
38
38
  * with a sibling export, `commitJoinsName` (#2663), so joins that live beside
39
- * a record kind called `decision` can still report `plugin:chud:<code>`. It is
39
+ * a record kind called `decision` can still report `plugin:acme:<code>`. It is
40
40
  * a sibling export rather than a property because the data form is a closed
41
41
  * object whose keys are all joins, and a function's `name` property is
42
42
  * already its own name.
@@ -71,6 +71,8 @@ export interface PointView {
71
71
  questionType: string;
72
72
  instructions: string;
73
73
  candidates: (string | boolean)[];
74
+ /** What each candidate means, as the points file declares it: an object of strings for noul and choice, an array for score. */
75
+ criteria: Record<string, string> | string[];
74
76
  /** Each input: its name, the read-contract output it names, and its description. */
75
77
  inputs: { name: string; output: string; description: string }[];
76
78
  deciders: Decider[];
@@ -163,6 +165,7 @@ export async function workspacePoints(query: PointsQuery): Promise<PointsDocumen
163
165
  questionType: p.question.type,
164
166
  instructions: p.question.instructions,
165
167
  candidates: candidates(p.question),
168
+ criteria: p.question.criteria,
166
169
  inputs: Object.entries(p.inputs).map(([n, description]) => ({ name: n, output: inputOutput(n), description })),
167
170
  deciders: p.deciders,
168
171
  quorum: quorumOf(p),
@@ -191,6 +191,7 @@
191
191
  "questionType",
192
192
  "instructions",
193
193
  "candidates",
194
+ "criteria",
194
195
  "inputs",
195
196
  "deciders",
196
197
  "quorum"
@@ -236,6 +237,23 @@
236
237
  ]
237
238
  }
238
239
  },
240
+ "criteria": {
241
+ "description": "What each candidate means, as the points file declares it: noul and choice give an object of strings keyed by candidate, score an array of ordered level descriptions.",
242
+ "oneOf": [
243
+ {
244
+ "type": "object",
245
+ "additionalProperties": {
246
+ "type": "string"
247
+ }
248
+ },
249
+ {
250
+ "type": "array",
251
+ "items": {
252
+ "type": "string"
253
+ }
254
+ }
255
+ ]
256
+ },
239
257
  "inputs": {
240
258
  "type": "array",
241
259
  "items": {
@@ -24,10 +24,11 @@ import {
24
24
  } from "./points";
25
25
 
26
26
  /**
27
- * chud's points, as template/delivery/decisions/points.yaml declares them at
28
- * jhgaylor/chud@43afcf1, as JSON.
27
+ * Points shaped like an externally authored template's decisions file
28
+ * (ported from chud, jhgaylor/chud@43afcf1's
29
+ * template/delivery/decisions/points.yaml), as JSON.
29
30
  */
30
- const CHUD = {
31
+ const IMPORTED = {
31
32
  points: {
32
33
  "slice-tier": {
33
34
  title: "Which builder tier builds this contract",
@@ -63,7 +64,7 @@ const CHUD = {
63
64
  "ship-skip": {
64
65
  title: "May this release skip the human gate",
65
66
  question: {
66
- type: "boolean",
67
+ type: "noul",
67
68
  instructions: "May this release pass the ship gate without a person approving it? The state is what the release plan would change.",
68
69
  criteria: {
69
70
  true: "An agent may pass the gate for this release (only in enforce mode).",
@@ -86,9 +87,9 @@ const CHUD = {
86
87
  },
87
88
  };
88
89
 
89
- /** chud's points with each input named as a read-contract output: the slice tier reads a work item, the ship gate a release. */
90
- function renamed(): typeof CHUD {
91
- const copy = JSON.parse(JSON.stringify(CHUD)) as typeof CHUD;
90
+ /** The imported points with each input named as a read-contract output: the slice tier reads a work item, the ship gate a release. */
91
+ function renamed(): typeof IMPORTED {
92
+ const copy = JSON.parse(JSON.stringify(IMPORTED)) as typeof IMPORTED;
92
93
  const prefix: Record<string, string> = { "slice-tier": "work-item", "ship-skip": "release" };
93
94
  for (const [name, point] of Object.entries(copy.points) as [string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }][]) {
94
95
  const to = (k: string) => `${prefix[name]}.${k}`;
@@ -123,14 +124,14 @@ describe("the decision points schema (#2738)", () => {
123
124
  }
124
125
  });
125
126
 
126
- test("chud's slice-tier and ship-skip validate unchanged, apart from the input names", () => {
127
- // As chud declares them, only the input names are refused: each names no read-contract output.
128
- const found = problems(CHUD);
127
+ test("the imported slice-tier and ship-skip validate unchanged, apart from the input names", () => {
128
+ // As the template declares them, only the input names are refused: each names no read-contract output.
129
+ const found = problems(IMPORTED);
129
130
  expect(found.length).toBeGreaterThan(0);
130
131
  for (const p of found) expect(p.message).toMatch(/is not a read-contract output|is not one of this point's inputs/);
131
132
  expect(found.filter((p) => p.message.includes("read-contract output")).map((p) => p.field)).toEqual([
132
- ...Object.keys(CHUD.points["slice-tier"].inputs).map((k) => `points.slice-tier.inputs.${k}`),
133
- ...Object.keys(CHUD.points["ship-skip"].inputs).map((k) => `points.ship-skip.inputs.${k}`),
133
+ ...Object.keys(IMPORTED.points["slice-tier"].inputs).map((k) => `points.slice-tier.inputs.${k}`),
134
+ ...Object.keys(IMPORTED.points["ship-skip"].inputs).map((k) => `points.ship-skip.inputs.${k}`),
134
135
  ]);
135
136
  // With the inputs renamed, nothing else changes and both validate.
136
137
  const points = parsePoints(text(renamed()), "points.json");
@@ -156,7 +157,7 @@ describe("the decision points schema (#2738)", () => {
156
157
 
157
158
  test("a point with an unknown input is refused, and so is a row testing an undeclared one", () => {
158
159
  const v = renamed() as unknown as { points: Record<string, { inputs: Record<string, string>; deciders: { rows?: { when: Record<string, unknown> }[] }[] }> };
159
- v.points["slice-tier"].inputs["contract.size"] = "chud's contract, which the read contract has no output for";
160
+ v.points["slice-tier"].inputs["contract.size"] = "a contract, which the read contract has no output for";
160
161
  expect(problems(v)).toEqual([
161
162
  {
162
163
  field: "points.slice-tier.inputs.contract.size",
@@ -168,7 +169,7 @@ describe("the decision points schema (#2738)", () => {
168
169
  expect(problems(w)).toEqual([{ field: "points.slice-tier.deciders.0.rows.0.when.work-item.tier", message: expect.stringContaining("is not one of this point's inputs") }]);
169
170
  });
170
171
 
171
- test("the schema and the code refuse the rest of what chud refused, and an alias model id", () => {
172
+ test("the schema and the code refuse the rest of what an imported points file can get wrong, and an alias model id", () => {
172
173
  const cases: [(p: Record<string, unknown>) => void, RegExp][] = [
173
174
  [(p) => ((p.deciders as Record<string, unknown>[])[1].model = "jev-latest"), /is an alias/],
174
175
  [(p) => ((p.deciders as Record<string, unknown>[])[1].count = 2), /is only for a quorum decider/],
@@ -20,7 +20,7 @@
20
20
  * runtime's decider or a test's stub supplies. Without one, a model decider is
21
21
  * not asked and the chain moves on.
22
22
  *
23
- * Taken from chud's `packages/runtime/src/decide.mjs` at 43afcf1: the chain,
23
+ * Ported from chud's `packages/runtime/src/decide.mjs` at 43afcf1: the chain,
24
24
  * the observation rule, the point version and the inputs hash. The records
25
25
  * are chant records: `decide.ts` writes them, and {@link applyAnswers} warns
26
26
  * about them on read.
@@ -71,7 +71,6 @@ export function inputOutput(name: string): string {
71
71
  export type QuestionType = "noul" | "choice" | "score";
72
72
 
73
73
  export interface Question {
74
- /** `boolean` in a points file is read as `noul`. */
75
74
  type: QuestionType;
76
75
  instructions: string;
77
76
  criteria: Record<string, string> | string[];
@@ -220,20 +219,9 @@ export function schemaProblems(data: unknown): PointProblem[] {
220
219
  return out;
221
220
  }
222
221
 
223
- /** `boolean` read as `noul`. The declaration is otherwise kept as written. */
224
- function normalise(points: Record<string, Point>): Record<string, Point> {
225
- const out: Record<string, Point> = {};
226
- for (const [name, p] of Object.entries(points)) {
227
- const type = (p.question.type as string) === "boolean" ? "noul" : p.question.type;
228
- out[name] = { ...p, question: { ...p.question, type } };
229
- }
230
- return out;
231
- }
232
-
233
222
  /**
234
- * Parse and validate a points file's text. Returns its points, with
235
- * `boolean` read as `noul`. Throws a {@link PointsError} naming the file and
236
- * each field.
223
+ * Parse and validate a points file's text. Returns its points. Throws a
224
+ * {@link PointsError} naming the file and each field.
237
225
  */
238
226
  export function parsePoints(text: string, file: string): Record<string, Point> {
239
227
  let data: unknown;
@@ -244,7 +232,7 @@ export function parsePoints(text: string, file: string): Record<string, Point> {
244
232
  }
245
233
  const problems = schemaProblems(data);
246
234
  if (problems.length > 0) throw new PointsError(file, problems);
247
- const points = normalise((data as { points: Record<string, Point> }).points);
235
+ const points = (data as { points: Record<string, Point> }).points;
248
236
  const more = pointProblems(points);
249
237
  if (more.length > 0) throw new PointsError(file, more);
250
238
  return points;
@@ -193,6 +193,14 @@ describe("every schema against the reference workspace (#2543)", () => {
193
193
  expectValid(doc);
194
194
  if ("error" in doc) throw new Error(doc.error.message);
195
195
  expect(doc.points.map((p) => p.name)).toEqual(["slice-tier", "ship-skip", "finding-triage", "needs-a-decision"]);
196
+ const sliceTier = doc.points.find((p) => p.name === "slice-tier")!;
197
+ expect(sliceTier.criteria).toEqual({
198
+ small: "A haiku-class builder. The work item fits the small limits.",
199
+ medium: "A mid-size builder. The work item fits the medium limits.",
200
+ large: "The largest builder. The work item is bigger than the medium limits.",
201
+ });
202
+ const shipSkip = doc.points.find((p) => p.name === "ship-skip")!;
203
+ expect(shipSkip.criteria).toEqual({ true: "An agent may pass the gate for this release.", false: "A person approves the release at the gate." });
196
204
  }
197
205
  const open = await workspacePoints({ cwd: FIXTURE, open: true });
198
206
  expectValid(open);
@@ -157,11 +157,11 @@ describe("the closed list of reason codes", () => {
157
157
  });
158
158
 
159
159
  test("a plugin's finding codes are in its own namespace, outside the list, and the intent schema accepts them (#2656)", () => {
160
- expect(isPluginCode("plugin:chud:contract-criteria-changed")).toBe(true);
161
- expect(isPluginCode("plugin:chud:contract-criteria-changed", "chud")).toBe(true);
162
- expect(isPluginCode("plugin:chud:contract-criteria-changed", "units")).toBe(false);
163
- for (const bad of ["plugin:chud", "plugin::x", "plugin:chud:Upper", "plugin:chud:a:b", "intent-commit-bare", 7]) expect(isPluginCode(bad), String(bad)).toBe(false);
164
- expect(isReasonCode("plugin:chud:contract-criteria-changed")).toBe(false);
160
+ expect(isPluginCode("plugin:acme:contract-criteria-changed")).toBe(true);
161
+ expect(isPluginCode("plugin:acme:contract-criteria-changed", "acme")).toBe(true);
162
+ expect(isPluginCode("plugin:acme:contract-criteria-changed", "units")).toBe(false);
163
+ for (const bad of ["plugin:acme", "plugin::x", "plugin:acme:Upper", "plugin:acme:a:b", "intent-commit-bare", 7]) expect(isPluginCode(bad), String(bad)).toBe(false);
164
+ expect(isReasonCode("plugin:acme:contract-criteria-changed")).toBe(false);
165
165
  const { validate } = contract(intentSchema);
166
166
  const finding = (code: string) => ({ id: `finding:${code}:1`, kind: "finding", code, message: "m", concerns: [] });
167
167
  const doc = (code: string) => ({
@@ -178,9 +178,9 @@ describe("the closed list of reason codes", () => {
178
178
  reasons: [],
179
179
  summary: { commits: 0, decisions: 0, artifacts: 0, findings: 1 },
180
180
  });
181
- expect(validate(doc("plugin:chud:contract-criteria-changed"))).toBe(true);
181
+ expect(validate(doc("plugin:acme:contract-criteria-changed"))).toBe(true);
182
182
  expect(validate(doc("intent-commit-bare"))).toBe(true);
183
- expect(validate(doc("plugin:chud:Nope"))).toBe(false);
183
+ expect(validate(doc("plugin:acme:Nope"))).toBe(false);
184
184
  expect(validate(doc("made-up-code"))).toBe(false);
185
185
  });
186
186
 
@@ -192,6 +192,9 @@ export const REASONS = {
192
192
  "box-capability-unbrokered": "A capability in a member's box block names no broker, so the box would hold its credential.",
193
193
  // The credential fountain itself hands a persistent box (check, #2780). Also a WSP finding.
194
194
  "box-fountain-callback-undeclared": "A box member builds a fountain Box, whose persistent sandbox fountain gives a callback token scoped to its owner, and the member's box block does not declare the fountain-callback capability brokered by fountain with scope owner.",
195
+ // A box's intent, the decision record its box block names (check, #2850). Each is also a WSP finding.
196
+ "box-intent-unknown": "A box block names an intent, and no record of a declared kind named decision has that id.",
197
+ "box-intent-unconstrained": "The decision record a box names as its intent constrains no member or path of this workspace: no member: entry for a declared member and no path: entry at, above or inside one's directory.",
195
198
  // The work lease (chant workspace work claim|renew|release, #2732): why the command could not run.
196
199
  "work-kind-missing": "No work kind to find the item in: --kind names a kind with no work block, or the declaration names no work kind.",
197
200
  "work-kind-ambiguous": "More than one declared work kind has a record with the id, so --kind must name one.",
@@ -510,6 +510,14 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
510
510
  * names, or, when it names none or there is no declaration, the error it has
511
511
  * always been. A kind whose read fails is listed with its error, the others
512
512
  * are still read, and the exit code is 1.
513
+ *
514
+ * Locating the declaration itself can fail before any kind is known. For
515
+ * `not-a-git-repository` and `revision-unknown`, the codes a single kind's
516
+ * read can also fail with (#2860), `--json` prints the same
517
+ * `{ $schema, contract, error: { code, message } }` document that read
518
+ * failure would, so a reader of `--json` never sees a silent exit 1. A
519
+ * declaration-level code the schema doesn't carry (`declaration-invalid` and
520
+ * the rest of `WorkspaceErrorCode`) still prints text on stderr only.
513
521
  */
514
522
  async function runDeclaredRecords(args: CommandContext["args"]): Promise<number> {
515
523
  if (args.require !== undefined && args.require !== "attested") {
@@ -521,7 +529,12 @@ async function runDeclaredRecords(args: CommandContext["args"]): Promise<number>
521
529
  declared = declaredKindFiles(process.cwd(), args.at);
522
530
  } catch (err) {
523
531
  if (!(err instanceof WorkspaceReadError)) throw err;
524
- console.error(formatError({ message: `${err.code}: ${err.describe()}; without --kind, the declaration names the record kinds`, hint: USAGE }));
532
+ const message = `${err.describe()}; without --kind, the declaration names the record kinds`;
533
+ if (args.json && (err.code === "not-a-git-repository" || err.code === "revision-unknown")) {
534
+ console.log(JSON.stringify({ $schema: RECORDS_OUTPUT_SCHEMA_ID, contract: RECORDS_CONTRACT_VERSION, error: { code: err.code, message } }, null, 2));
535
+ } else {
536
+ console.error(formatError({ message: `${err.code}: ${message}`, hint: USAGE }));
537
+ }
525
538
  return 1;
526
539
  }
527
540
  if (declared.length === 0) {