@intentius/chant 0.93.0 → 0.95.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 (149) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/op/builders.d.ts +14 -3
  8. package/dist/op/builders.d.ts.map +1 -1
  9. package/dist/op/index.d.ts +6 -3
  10. package/dist/op/index.d.ts.map +1 -1
  11. package/dist/op/operator.d.ts +90 -0
  12. package/dist/op/operator.d.ts.map +1 -1
  13. package/dist/op/steward-beside.d.ts +84 -0
  14. package/dist/op/steward-beside.d.ts.map +1 -0
  15. package/dist/op/steward.d.ts +87 -2
  16. package/dist/op/steward.d.ts.map +1 -1
  17. package/dist/workspace/box-intent.d.ts +85 -0
  18. package/dist/workspace/box-intent.d.ts.map +1 -0
  19. package/dist/workspace/box-services.d.ts +31 -0
  20. package/dist/workspace/box-services.d.ts.map +1 -0
  21. package/dist/workspace/chant-migrations.d.ts +5 -0
  22. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  23. package/dist/workspace/checks/boxes.d.ts +14 -1
  24. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/compose-graph.d.ts +11 -0
  28. package/dist/workspace/compose-graph.d.ts.map +1 -1
  29. package/dist/workspace/composites.d.ts +5 -1
  30. package/dist/workspace/composites.d.ts.map +1 -1
  31. package/dist/workspace/decision-points.schema.json +3 -3
  32. package/dist/workspace/declaration.d.ts +43 -0
  33. package/dist/workspace/declaration.d.ts.map +1 -1
  34. package/dist/workspace/declaration.schema.json +62 -1
  35. package/dist/workspace/graph-cache.d.ts +168 -0
  36. package/dist/workspace/graph-cache.d.ts.map +1 -0
  37. package/dist/workspace/graph-cli.d.ts +11 -5
  38. package/dist/workspace/graph-cli.d.ts.map +1 -1
  39. package/dist/workspace/intent-joins.d.ts +5 -5
  40. package/dist/workspace/kind-readers.d.ts +39 -0
  41. package/dist/workspace/kind-readers.d.ts.map +1 -0
  42. package/dist/workspace/kinds.d.ts +29 -0
  43. package/dist/workspace/kinds.d.ts.map +1 -1
  44. package/dist/workspace/member-commands.d.ts +15 -1
  45. package/dist/workspace/member-commands.d.ts.map +1 -1
  46. package/dist/workspace/member-run.d.ts +2 -0
  47. package/dist/workspace/member-run.d.ts.map +1 -1
  48. package/dist/workspace/points-cli.d.ts +2 -0
  49. package/dist/workspace/points-cli.d.ts.map +1 -1
  50. package/dist/workspace/points.d.ts +3 -5
  51. package/dist/workspace/points.d.ts.map +1 -1
  52. package/dist/workspace/reason-codes.d.ts +5 -1
  53. package/dist/workspace/reason-codes.d.ts.map +1 -1
  54. package/dist/workspace/records-cli.d.ts +10 -1
  55. package/dist/workspace/records-cli.d.ts.map +1 -1
  56. package/dist/workspace/records-write.d.ts +4 -2
  57. package/dist/workspace/records-write.d.ts.map +1 -1
  58. package/dist/workspace/records.d.ts +8 -3
  59. package/dist/workspace/records.d.ts.map +1 -1
  60. package/dist/workspace/status-stewards.d.ts +31 -9
  61. package/dist/workspace/status-stewards.d.ts.map +1 -1
  62. package/dist/workspace/status.d.ts +20 -0
  63. package/dist/workspace/status.d.ts.map +1 -1
  64. package/dist/workspace/work-evidence.d.ts +1 -1
  65. package/dist/workspace/work-evidence.d.ts.map +1 -1
  66. package/dist/workspace/workspace-kinds.schema.json +26 -0
  67. package/package.json +1 -1
  68. package/src/cli/commands/carve-bridge.test.ts +7 -3
  69. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  70. package/src/cli/handlers/operator.ts +53 -11
  71. package/src/cli/handlers/run.test.ts +71 -0
  72. package/src/cli/handlers/run.ts +65 -7
  73. package/src/cli/main.ts +21 -9
  74. package/src/cli/mcp/workspace-plugins.ts +6 -6
  75. package/src/cli/mcp/workspace-tools.ts +1 -1
  76. package/src/cli/registry.ts +2 -0
  77. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  78. package/src/cli/static-config-read.test.ts +8 -2
  79. package/src/meta/source-is-text.test.ts +21 -3
  80. package/src/okf.test.ts +6 -1
  81. package/src/op/activities/decide.test.ts +8 -0
  82. package/src/op/builders.ts +14 -3
  83. package/src/op/index.ts +8 -2
  84. package/src/op/operator.ts +264 -16
  85. package/src/op/steward-beside.test.ts +267 -0
  86. package/src/op/steward-beside.ts +219 -0
  87. package/src/op/steward-points.test.ts +112 -1
  88. package/src/op/steward.ts +135 -3
  89. package/src/workspace/box-intent.test.ts +205 -0
  90. package/src/workspace/box-intent.ts +159 -0
  91. package/src/workspace/box-services.test.ts +129 -0
  92. package/src/workspace/box-services.ts +51 -0
  93. package/src/workspace/chant-migrations.ts +5 -0
  94. package/src/workspace/check.schema.json +7 -3
  95. package/src/workspace/checks/boxes.test.ts +3 -1
  96. package/src/workspace/checks/boxes.ts +66 -0
  97. package/src/workspace/checks.ts +12 -1
  98. package/src/workspace/compose-graph.test.ts +1 -0
  99. package/src/workspace/compose-graph.ts +11 -0
  100. package/src/workspace/composites.test.ts +1 -1
  101. package/src/workspace/composites.ts +12 -5
  102. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  103. package/src/workspace/decision-points.schema.json +3 -3
  104. package/src/workspace/declaration.schema.json +62 -1
  105. package/src/workspace/declaration.ts +112 -0
  106. package/src/workspace/declared-kinds.test.ts +26 -0
  107. package/src/workspace/graph-cache.test.ts +343 -0
  108. package/src/workspace/graph-cache.ts +409 -0
  109. package/src/workspace/graph-cli.ts +93 -26
  110. package/src/workspace/graph-contract.test.ts +129 -6
  111. package/src/workspace/graph.schema.json +21 -1
  112. package/src/workspace/intent-joins.test.ts +5 -5
  113. package/src/workspace/intent-joins.ts +5 -5
  114. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  115. package/src/workspace/kind-readers.test.ts +134 -0
  116. package/src/workspace/kind-readers.ts +111 -0
  117. package/src/workspace/kinds.test.ts +49 -0
  118. package/src/workspace/kinds.ts +59 -2
  119. package/src/workspace/member-commands.test.ts +15 -0
  120. package/src/workspace/member-commands.ts +47 -7
  121. package/src/workspace/member-run.ts +10 -2
  122. package/src/workspace/points-cli.ts +3 -0
  123. package/src/workspace/points.schema.json +18 -0
  124. package/src/workspace/points.test.ts +15 -14
  125. package/src/workspace/points.ts +4 -16
  126. package/src/workspace/read-contract.test.ts +8 -0
  127. package/src/workspace/reason-codes.test.ts +7 -7
  128. package/src/workspace/reason-codes.ts +6 -1
  129. package/src/workspace/records-amend.schema.json +1 -0
  130. package/src/workspace/records-cli.ts +34 -13
  131. package/src/workspace/records-contract.test.ts +2 -1
  132. package/src/workspace/records-formats.test.ts +15 -15
  133. package/src/workspace/records-new.schema.json +1 -0
  134. package/src/workspace/records-quorum.test.ts +10 -3
  135. package/src/workspace/records-sessions-write.test.ts +2 -1
  136. package/src/workspace/records-since.test.ts +8 -7
  137. package/src/workspace/records-write.test.ts +101 -1
  138. package/src/workspace/records-write.ts +50 -6
  139. package/src/workspace/records.test.ts +1 -1
  140. package/src/workspace/records.ts +22 -5
  141. package/src/workspace/status-contract.test.ts +32 -1
  142. package/src/workspace/status-stewards.ts +88 -9
  143. package/src/workspace/status.schema.json +66 -4
  144. package/src/workspace/status.ts +32 -0
  145. package/src/workspace/trust/record-seal.test.ts +26 -2
  146. package/src/workspace/work-evidence.schema.json +1 -0
  147. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  148. package/src/workspace/work.test.ts +17 -0
  149. package/src/workspace/workspace-kinds.schema.json +26 -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);
@@ -17,6 +17,7 @@ const member = (name: string, dir: string): ComposedMember => ({
17
17
  reason: null,
18
18
  chant: "0.80.0",
19
19
  irVersion: 1,
20
+ live: false,
20
21
  });
21
22
 
22
23
  const web: GraphIR = {
@@ -76,6 +76,17 @@ export interface ComposedMember {
76
76
  chant: string | null;
77
77
  /** The IR version the member printed; `null` when it printed none and was upgraded as version 1. */
78
78
  irVersion: number | null;
79
+ /** Whether the member was read with `--live` (#2875): its graph is the account as it stands, not its source. */
80
+ live: boolean;
81
+ /** For a live read, when the member's read finished, as an ISO time. */
82
+ readAt?: string;
83
+ /**
84
+ * Whether the per-member cache answered this read (#2876). Set on composed
85
+ * members by `chant workspace graph`.
86
+ */
87
+ cached?: boolean;
88
+ /** The member's stamp (#2876): what the cache keys the read on, or null when none could be taken. */
89
+ stamp?: string | null;
79
90
  /** Whole-read facts the member's IR carried (`meta`, `pipeline`), kept apart from the composed sections. */
80
91
  meta?: Record<string, unknown>;
81
92
  pipeline?: unknown;
@@ -113,7 +113,7 @@ describe("composites output schema", () => {
113
113
  test("lists exactly the reason and error codes the code can return", () => {
114
114
  expect(schema.$defs.reason.properties.code.enum).toEqual([...COMPOSITES_REASON_CODES]);
115
115
  expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...COMPOSITES_ERROR_CODES]);
116
- expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
116
+ expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES.filter((c) => c !== "live-at-revision"));
117
117
  expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
118
118
  expect(schema.$defs.member.properties.runtimeReasons.items.properties.code.enum).toEqual([...COMPOSITES_RUNTIME_REASON_CODES]);
119
119
  expect(schema.$defs.member.properties.environmentReasons.items.properties.code.enum).toEqual([...COMPOSITES_ENVIRONMENT_REASON_CODES]);
@@ -44,10 +44,10 @@ import type { CommandContext } from "../cli/registry";
44
44
  import { joinLabel, type JoinLabel } from "../join-key";
45
45
  import type { ComposedMember, MemberReason, WorkspaceGraph } from "./compose-graph";
46
46
  import { readMemberIr } from "./compose-graph";
47
- import { GRAPH_ERROR_CODES, workspaceGraph, type GraphQuery } from "./graph-cli";
47
+ import { workspaceGraph, type GraphQuery } from "./graph-cli";
48
48
  import type { LinkRow } from "./links";
49
49
  import { emitDocument, type UnitResult } from "./member-commands";
50
- import { readerVersion, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
50
+ import { readerVersion, WORKSPACE_ERROR_CODES, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
51
51
  import type { ReasonCode } from "./reason-codes";
52
52
  import { componentEnvironments, ENVIRONMENT_REASON_CODES, memberEnvironments, readLedgerEnvironments, type ComponentEnvironment, type EnvironmentReason, type LedgerEnvironmentReader, type MemberEnvironments } from "./environments";
53
53
  import { componentRuntimes, readRuntimesIn, RUNTIME_REASON_CODES, type ComponentRuntime, type MemberRuntimes, type PluginLoader, type RuntimeReason } from "./runtimes";
@@ -58,8 +58,12 @@ export const COMPOSITES_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/w
58
58
  /** The read-contract version the document follows. */
59
59
  export const COMPOSITES_CONTRACT_VERSION = 1;
60
60
 
61
- /** Why the composites couldn't be read at all: the graph's codes. */
62
- export const COMPOSITES_ERROR_CODES = GRAPH_ERROR_CODES;
61
+ /**
62
+ * Why the composites couldn't be read at all: the declaration's codes, as for
63
+ * the graph. The composites never read live, so the graph's own
64
+ * `live-at-revision` (#2875) can't reach them.
65
+ */
66
+ export const COMPOSITES_ERROR_CODES = WORKSPACE_ERROR_CODES;
63
67
 
64
68
  /** Why the list is empty, or why no instance has a component. Closed: part of the read contract. */
65
69
  export const COMPOSITES_REASON_CODES = [
@@ -262,12 +266,15 @@ export async function workspaceComposites(
262
266
  const { loadPlugin, readLedgerEnvironments: readLedger = readLedgerEnvironments, ...graphQuery } = query;
263
267
  const { doc: graph, failed, components: runs } = await workspaceGraph({
264
268
  ...graphQuery,
269
+ // The composites read source only; a live flag never reaches the members.
270
+ ...(graphQuery.args ? { args: { ...graphQuery.args, live: false, overlay: false, traffic: undefined } } : {}),
265
271
  components: true,
266
272
  inTree: async (root, members) => {
267
273
  runtimes = await readRuntimesIn(root, members, loadPlugin);
268
274
  },
269
275
  });
270
- if ("error" in graph) return { doc: { ...head, error: graph.error }, failed: true };
276
+ // With no live read, the graph fails only with a declaration code.
277
+ if ("error" in graph) return { doc: { ...head, error: { ...graph.error, code: graph.error.code as WorkspaceErrorCode } }, failed: true };
271
278
 
272
279
  const byMember = new Map((runs ?? []).map((r) => [r.unit.member, r]));
273
280
  const members: CompositesMember[] = [];
@@ -33,8 +33,10 @@ export const recordKind = {
33
33
  // (#2773): chant workspace check --changes reports change-out-of-scope.
34
34
  outOfScope: { field: "out_of_scope" },
35
35
  // Verdicts, and the field naming the decider, for each record's digest and
36
- // quorum (#2671, #2672).
37
- reviews: { field: "reviews", decider: "decided_by" },
36
+ // quorum (#2671, #2672). A decision becomes ratified only once its quorum
37
+ // is met: records new and amend refuse it below, and the digest leaves the
38
+ // state out, so ratifying keeps the verdicts counting (#2873).
39
+ reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },
38
40
  // records new --by and the MCP records-new tool's by name a proposal's
39
41
  // proposer here, apart from decided_by, which stays null until the
40
42
  // decision is decided (#2756).
@@ -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,20 @@
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). Its services list what the box runs under its supervisor (#2880), which the fly lexicon observes, restarts and applies through sprite-env, and which chant workspace status --json prints. 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
+ },
412
+ "services": {
413
+ "type": "array",
414
+ "description": "The services the box runs under its supervisor, sprite-env on a sprite (#2880). Each name is given once, every needs entry names a service of this list, the needs form no cycle, and at most one service sets httpPort; a declaration that breaks one of these can't be read (declaration-invalid). The fly lexicon's spriteServicesObserve and spriteServiceRestart read the list with box: true, and spriteApplyServices with box: true creates and replaces the services through sprite-env. None when omitted. Added by chant 0.95.0, so a declaration that uses it sets minReader to 0.95.0 or newer.",
415
+ "items": {
416
+ "$ref": "#/$defs/boxService"
417
+ }
418
+ },
407
419
  "capabilities": {
408
420
  "type": "array",
409
421
  "description": "Each capability the box reaches outside itself, named once. None when omitted.",
@@ -476,6 +488,55 @@
476
488
  },
477
489
  "additionalProperties": false
478
490
  },
491
+ "boxService": {
492
+ "type": "object",
493
+ "description": "A service a box runs under its supervisor (#2880).",
494
+ "required": [
495
+ "name",
496
+ "cmd"
497
+ ],
498
+ "properties": {
499
+ "name": {
500
+ "$ref": "#/$defs/kindName",
501
+ "description": "The service's name in the supervisor. Unique within the box."
502
+ },
503
+ "cmd": {
504
+ "type": "string",
505
+ "minLength": 1,
506
+ "description": "The command the supervisor runs (sprite-env services create --cmd). ${HOME} and other ${VAR} references are expanded from the environment of the process that applies the list, so the declaration holds no machine path."
507
+ },
508
+ "needs": {
509
+ "type": "array",
510
+ "uniqueItems": true,
511
+ "description": "Names of services of the same box that start before this one (sprite-env services create --needs).",
512
+ "items": {
513
+ "$ref": "#/$defs/kindName"
514
+ }
515
+ },
516
+ "httpPort": {
517
+ "$ref": "#/$defs/port",
518
+ "description": "The port the supervisor routes the sprite's URL to (sprite-env services create --http-port). At most one service of a box sets it."
519
+ },
520
+ "duration": {
521
+ "type": "string",
522
+ "pattern": "^[0-9]+(\\.[0-9]+)?(ms|s|m)$",
523
+ "description": "How long the service must stay up after it is created or started, such as 3s (sprite-env services create --duration)."
524
+ },
525
+ "health": {
526
+ "type": "string",
527
+ "pattern": "^https?://",
528
+ "description": "A URL that answers 200 while the service works. spriteServicesObserve probes it, and spriteServiceRestart waits for it after a restart. Without it, the supervisor's state decides."
529
+ },
530
+ "optional": {
531
+ "type": "boolean",
532
+ "description": "True for a service something else creates by name, such as a site a release Op makes: spriteApplyServices creates it only when its only names it, and spriteServicesObserve skips it while the supervisor has no such service."
533
+ }
534
+ },
535
+ "patternProperties": {
536
+ "^x-": true
537
+ },
538
+ "additionalProperties": false
539
+ },
479
540
  "capability": {
480
541
  "type": "object",
481
542
  "description": "A capability a box needs and does not hold the credential for, such as inference, fountain or a third-party API (#2726).",
@@ -210,10 +210,43 @@ 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;
219
+ /**
220
+ * The services the box runs under its supervisor (sprite-env on a sprite),
221
+ * in file order, or empty when the block declares none (#2880). The fly
222
+ * lexicon's `spriteServicesObserve`, `spriteServiceRestart` and
223
+ * `spriteApplyServices` read them with `box: true`.
224
+ */
225
+ services: BoxService[];
213
226
  /** The block's JSON Pointer in the file, for messages. */
214
227
  pointer: string;
215
228
  }
216
229
 
230
+ /** A service a box runs under its supervisor (#2880). */
231
+ export interface BoxService {
232
+ /** Unique in the box. */
233
+ name: string;
234
+ /** The command the supervisor runs, as written: `${VAR}` references are left for the applying process to expand. */
235
+ cmd: string;
236
+ /** Names of services in the same block that start first. Empty when none. */
237
+ needs: string[];
238
+ /** The port the supervisor routes the sprite's URL to, or null. At most one service of a box sets it. */
239
+ httpPort: number | null;
240
+ /** How long the service must stay up after a create or start, such as `3s` (`sprite-env services create --duration`), or null for the supervisor's default. */
241
+ duration: string | null;
242
+ /** A URL that answers 200 while the service works, or null when the supervisor's state decides. */
243
+ health: string | null;
244
+ /** True: an apply creates it only when named, and an observer skips it while the supervisor has no such service. */
245
+ optional: boolean;
246
+ /** The entry's JSON Pointer in the file, for messages. */
247
+ pointer: string;
248
+ }
249
+
217
250
  /** What a box declares about its isolation (#2727): its identity on a host, and the names of what it needs kept apart. */
218
251
  export interface BoxIsolationDeclaration {
219
252
  host: string;
@@ -623,6 +656,9 @@ export function parseDeclaration(text: string, file: string, reader: string = re
623
656
  if (first) throw new WorkspaceReadError("declaration-invalid", `member ${m.name}'s box lists the capability ${c.name} twice; the first is at ${first.pointer}`, at(`${c.pointer}/name`));
624
657
  seen.set(c.name, c);
625
658
  }
659
+ // Its services name each other only within the block, without a cycle (#2880).
660
+ const problem = m.box ? boxServicesProblem(m.name, m.box.services) : null;
661
+ if (problem) throw new WorkspaceReadError("declaration-invalid", problem.message, at(problem.pointer));
626
662
  }
627
663
 
628
664
  // A diagram name is given once across the declaration (#2764): a reader keys diagrams by name.
@@ -713,6 +749,8 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
713
749
  ports?: Record<string, number>;
714
750
  state?: Record<string, string>;
715
751
  cookies?: string[];
752
+ intent?: string;
753
+ services?: { name: string; cmd: string; needs?: string[]; httpPort?: number; duration?: string; health?: string; optional?: boolean }[];
716
754
  };
717
755
  return {
718
756
  capabilities: (b.capabilities ?? []).map((c, i) => ({ name: c.name, broker: c.broker ?? null, scope: [...(c.scope ?? [])], pointer: `${pointer}/capabilities/${i}` })),
@@ -721,10 +759,84 @@ function boxOf(raw: unknown, pointer: string): BoxDeclaration | null {
721
759
  b.host === undefined
722
760
  ? null
723
761
  : { host: b.host, slot: b.slot!, ports: { ...(b.ports ?? {}) }, state: { ...(b.state ?? {}) }, cookies: [...(b.cookies ?? [])] },
762
+ intent: b.intent ?? null,
763
+ services: (b.services ?? []).map((s, i) => ({
764
+ name: s.name,
765
+ cmd: s.cmd,
766
+ needs: [...(s.needs ?? [])],
767
+ httpPort: s.httpPort ?? null,
768
+ duration: s.duration ?? null,
769
+ health: s.health ?? null,
770
+ optional: s.optional ?? false,
771
+ pointer: `${pointer}/services/${i}`,
772
+ })),
724
773
  pointer,
725
774
  };
726
775
  }
727
776
 
777
+ /**
778
+ * The rules the schema can't say about a box's services (#2880), as a
779
+ * message and the pointer to show, or null when they hold: each name is
780
+ * given once, every `needs` names a service of the same block, the `needs`
781
+ * form no cycle, and at most one service sets `httpPort`. The fly lexicon
782
+ * checks a list it is handed inline with the same rules.
783
+ */
784
+ export function boxServicesProblem(member: string, services: readonly BoxService[]): { message: string; pointer: string } | null {
785
+ const byName = new Map<string, BoxService>();
786
+ for (const s of services) {
787
+ const first = byName.get(s.name);
788
+ if (first) return { message: `member ${member}'s box declares the service ${s.name} twice; the first is at ${first.pointer}`, pointer: `${s.pointer}/name` };
789
+ byName.set(s.name, s);
790
+ }
791
+ let routed: BoxService | undefined;
792
+ for (const s of services) {
793
+ if (s.httpPort === null) continue;
794
+ if (routed) {
795
+ return {
796
+ message: `member ${member}'s box gives both ${routed.name} (port ${routed.httpPort}) and ${s.name} (port ${s.httpPort}) an httpPort, and the supervisor routes the sprite's URL to one service`,
797
+ pointer: `${s.pointer}/httpPort`,
798
+ };
799
+ }
800
+ routed = s;
801
+ }
802
+ for (const s of services) {
803
+ const j = s.needs.findIndex((n) => !byName.has(n));
804
+ if (j >= 0) {
805
+ const known = services.map((x) => x.name).join(", ");
806
+ return { message: `member ${member}'s box service ${s.name} needs ${JSON.stringify(s.needs[j])}, which the block does not declare; declared services: ${known}`, pointer: `${s.pointer}/needs/${j}` };
807
+ }
808
+ }
809
+ const cycle = serviceCycle(services);
810
+ if (cycle) {
811
+ const s = byName.get(cycle[0])!;
812
+ return { message: `member ${member}'s box services need each other in a cycle: ${cycle.join(" -> ")}`, pointer: `${s.pointer}/needs` };
813
+ }
814
+ return null;
815
+ }
816
+
817
+ /** The first cycle the services' `needs` form, as names ending where it starts, or null. */
818
+ function serviceCycle(services: readonly { name: string; needs: readonly string[] }[]): string[] | null {
819
+ const byName = new Map(services.map((s) => [s.name, s]));
820
+ const state = new Map<string, "visiting" | "done">();
821
+ const visit = (name: string, trail: string[]): string[] | null => {
822
+ const st = state.get(name);
823
+ if (st === "done") return null;
824
+ if (st === "visiting") return [...trail.slice(trail.indexOf(name)), name];
825
+ state.set(name, "visiting");
826
+ for (const dep of byName.get(name)?.needs ?? []) {
827
+ const found = visit(dep, [...trail, name]);
828
+ if (found) return found;
829
+ }
830
+ state.set(name, "done");
831
+ return null;
832
+ };
833
+ for (const s of services) {
834
+ const found = visit(s.name, []);
835
+ if (found) return found;
836
+ }
837
+ return null;
838
+ }
839
+
728
840
  /**
729
841
  * The hosts, already validated, with the rules the schema can't say (#2727):
730
842
  * host names are unique, a box's host is declared, its slot's block fits the
@@ -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)", () => {