@intentius/chant 0.89.0 → 0.91.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 (194) hide show
  1. package/dist/cli/handlers/components.d.ts +8 -0
  2. package/dist/cli/handlers/components.d.ts.map +1 -1
  3. package/dist/cli/handlers/operator.d.ts +14 -0
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/run.d.ts.map +1 -1
  6. package/dist/cli/handlers/serve.d.ts.map +1 -1
  7. package/dist/cli/main.d.ts.map +1 -1
  8. package/dist/cli/mcp/server.d.ts +10 -5
  9. package/dist/cli/mcp/server.d.ts.map +1 -1
  10. package/dist/cli/mcp/types.d.ts +13 -5
  11. package/dist/cli/mcp/types.d.ts.map +1 -1
  12. package/dist/cli/mcp/workspace-tools.d.ts +55 -0
  13. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -0
  14. package/dist/cli/registry.d.ts +31 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/verbs/vuln-scan.d.ts +72 -0
  17. package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
  18. package/dist/lifecycle/git.d.ts +40 -5
  19. package/dist/lifecycle/git.d.ts.map +1 -1
  20. package/dist/lifecycle/lease.d.ts +72 -16
  21. package/dist/lifecycle/lease.d.ts.map +1 -1
  22. package/dist/lifecycle/member-ledger.d.ts +3 -2
  23. package/dist/lifecycle/member-ledger.d.ts.map +1 -1
  24. package/dist/lifecycle/plan-ledger.d.ts +114 -0
  25. package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
  26. package/dist/lifecycle/work-lease.d.ts +140 -0
  27. package/dist/lifecycle/work-lease.d.ts.map +1 -0
  28. package/dist/op/activities/activity-contracts.d.ts +2 -2
  29. package/dist/op/builders.d.ts.map +1 -1
  30. package/dist/op/discover.d.ts +25 -0
  31. package/dist/op/discover.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +9 -3
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/lifecycle-receipt-store.d.ts +34 -0
  35. package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
  36. package/dist/op/local-executor.d.ts +19 -0
  37. package/dist/op/local-executor.d.ts.map +1 -1
  38. package/dist/op/local-output.d.ts.map +1 -1
  39. package/dist/op/op-ir.d.ts +10 -1
  40. package/dist/op/op-ir.d.ts.map +1 -1
  41. package/dist/op/op-verb-class.d.ts.map +1 -1
  42. package/dist/op/operator.d.ts +29 -0
  43. package/dist/op/operator.d.ts.map +1 -1
  44. package/dist/op/runtime.d.ts +9 -0
  45. package/dist/op/runtime.d.ts.map +1 -1
  46. package/dist/op/runtimes/local.d.ts.map +1 -1
  47. package/dist/op/step-output-ref.d.ts +2 -2
  48. package/dist/op/step-output-ref.d.ts.map +1 -1
  49. package/dist/op/steward.d.ts +140 -0
  50. package/dist/op/steward.d.ts.map +1 -0
  51. package/dist/op/types.d.ts +51 -0
  52. package/dist/op/types.d.ts.map +1 -1
  53. package/dist/op/work-lease-decl.d.ts +18 -0
  54. package/dist/op/work-lease-decl.d.ts.map +1 -0
  55. package/dist/op/work-lease-run.d.ts +173 -0
  56. package/dist/op/work-lease-run.d.ts.map +1 -0
  57. package/dist/workspace/box-isolation.d.ts +99 -0
  58. package/dist/workspace/box-isolation.d.ts.map +1 -0
  59. package/dist/workspace/checks/box-isolation.d.ts +18 -0
  60. package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
  61. package/dist/workspace/checks/boxes.d.ts +71 -0
  62. package/dist/workspace/checks/boxes.d.ts.map +1 -0
  63. package/dist/workspace/checks/records.d.ts +1 -0
  64. package/dist/workspace/checks/records.d.ts.map +1 -1
  65. package/dist/workspace/checks.d.ts +10 -1
  66. package/dist/workspace/checks.d.ts.map +1 -1
  67. package/dist/workspace/conformance/index.d.ts +42 -2
  68. package/dist/workspace/conformance/index.d.ts.map +1 -1
  69. package/dist/workspace/conformance/vitest.d.ts.map +1 -1
  70. package/dist/workspace/decide.d.ts +184 -0
  71. package/dist/workspace/decide.d.ts.map +1 -0
  72. package/dist/workspace/decision-points.schema.json +137 -0
  73. package/dist/workspace/declaration.d.ts +51 -0
  74. package/dist/workspace/declaration.d.ts.map +1 -1
  75. package/dist/workspace/declaration.schema.json +172 -0
  76. package/dist/workspace/declared-kinds.d.ts +12 -0
  77. package/dist/workspace/declared-kinds.d.ts.map +1 -1
  78. package/dist/workspace/points-cli.d.ts +113 -0
  79. package/dist/workspace/points-cli.d.ts.map +1 -0
  80. package/dist/workspace/points.d.ts +320 -0
  81. package/dist/workspace/points.d.ts.map +1 -0
  82. package/dist/workspace/reason-codes.d.ts +29 -0
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/record-assets.d.ts.map +1 -1
  85. package/dist/workspace/records-cli.d.ts +12 -0
  86. package/dist/workspace/records-cli.d.ts.map +1 -1
  87. package/dist/workspace/records-write.d.ts +35 -3
  88. package/dist/workspace/records-write.d.ts.map +1 -1
  89. package/dist/workspace/records.d.ts +12 -3
  90. package/dist/workspace/records.d.ts.map +1 -1
  91. package/dist/workspace/source-block.d.ts +85 -0
  92. package/dist/workspace/source-block.d.ts.map +1 -0
  93. package/dist/workspace/status-stewards.d.ts +121 -0
  94. package/dist/workspace/status-stewards.d.ts.map +1 -0
  95. package/dist/workspace/status.d.ts +52 -1
  96. package/dist/workspace/status.d.ts.map +1 -1
  97. package/dist/workspace/work-cli.d.ts +78 -0
  98. package/dist/workspace/work-cli.d.ts.map +1 -0
  99. package/package.json +1 -1
  100. package/src/cli/handlers/components.test.ts +93 -0
  101. package/src/cli/handlers/components.ts +44 -3
  102. package/src/cli/handlers/operator.ts +107 -2
  103. package/src/cli/handlers/run.test.ts +19 -0
  104. package/src/cli/handlers/run.ts +53 -1
  105. package/src/cli/handlers/serve.ts +2 -1
  106. package/src/cli/main.test.ts +40 -0
  107. package/src/cli/main.ts +78 -2
  108. package/src/cli/mcp/docs-parity.test.ts +20 -2
  109. package/src/cli/mcp/server.ts +23 -6
  110. package/src/cli/mcp/types.ts +15 -2
  111. package/src/cli/mcp/workspace-tools.test.ts +211 -0
  112. package/src/cli/mcp/workspace-tools.ts +449 -0
  113. package/src/cli/registry.ts +31 -0
  114. package/src/components/verbs/vuln-scan.test.ts +124 -1
  115. package/src/components/verbs/vuln-scan.ts +142 -1
  116. package/src/lifecycle/git.ts +65 -15
  117. package/src/lifecycle/lease.test.ts +22 -0
  118. package/src/lifecycle/lease.ts +133 -29
  119. package/src/lifecycle/member-ledger.ts +3 -2
  120. package/src/lifecycle/plan-ledger.test.ts +148 -0
  121. package/src/lifecycle/plan-ledger.ts +158 -0
  122. package/src/lifecycle/work-lease.test.ts +236 -0
  123. package/src/lifecycle/work-lease.ts +426 -0
  124. package/src/op/builders.ts +5 -0
  125. package/src/op/discover.ts +71 -0
  126. package/src/op/index.ts +16 -3
  127. package/src/op/lifecycle-receipt-store.test.ts +60 -0
  128. package/src/op/lifecycle-receipt-store.ts +61 -0
  129. package/src/op/local-executor.ts +216 -18
  130. package/src/op/local-output.ts +13 -0
  131. package/src/op/op-ir.ts +14 -0
  132. package/src/op/op-verb-class.ts +6 -0
  133. package/src/op/operator.ts +75 -4
  134. package/src/op/runtime.ts +6 -0
  135. package/src/op/runtimes/local.ts +3 -0
  136. package/src/op/step-output-ref.ts +6 -2
  137. package/src/op/steward.test.ts +212 -0
  138. package/src/op/steward.ts +253 -0
  139. package/src/op/types.ts +53 -0
  140. package/src/op/work-lease-decl.ts +80 -0
  141. package/src/op/work-lease-run.test.ts +326 -0
  142. package/src/op/work-lease-run.ts +395 -0
  143. package/src/workspace/box-isolation.test.ts +261 -0
  144. package/src/workspace/box-isolation.ts +205 -0
  145. package/src/workspace/check-contract.test.ts +3 -1
  146. package/src/workspace/check.schema.json +15 -7
  147. package/src/workspace/checks/box-isolation.ts +68 -0
  148. package/src/workspace/checks/boxes.test.ts +197 -0
  149. package/src/workspace/checks/boxes.ts +307 -0
  150. package/src/workspace/checks/records.ts +25 -0
  151. package/src/workspace/checks.test.ts +7 -0
  152. package/src/workspace/checks.ts +19 -2
  153. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -0
  154. package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +100 -6
  155. package/src/workspace/conformance/index.mjs +3 -0
  156. package/src/workspace/conformance/index.ts +185 -7
  157. package/src/workspace/conformance/vitest.ts +17 -8
  158. package/src/workspace/decide.test.ts +224 -0
  159. package/src/workspace/decide.ts +576 -0
  160. package/src/workspace/decision-points.schema.json +137 -0
  161. package/src/workspace/declaration.schema.json +172 -0
  162. package/src/workspace/declaration.ts +137 -0
  163. package/src/workspace/declared-kinds.ts +25 -2
  164. package/src/workspace/intent.schema.json +4 -1
  165. package/src/workspace/point-answer.schema.json +95 -0
  166. package/src/workspace/points-cli.ts +273 -0
  167. package/src/workspace/points-write.schema.json +489 -0
  168. package/src/workspace/points.schema.json +710 -0
  169. package/src/workspace/points.test.ts +264 -0
  170. package/src/workspace/points.ts +564 -0
  171. package/src/workspace/read-contract.test.ts +18 -1
  172. package/src/workspace/reason-codes.test.ts +14 -1
  173. package/src/workspace/reason-codes.ts +36 -0
  174. package/src/workspace/record-assets.test.ts +3 -2
  175. package/src/workspace/record-assets.ts +4 -1
  176. package/src/workspace/records-amend.schema.json +2 -1
  177. package/src/workspace/records-cli.ts +15 -2
  178. package/src/workspace/records-close.schema.json +2 -1
  179. package/src/workspace/records-contract.test.ts +3 -2
  180. package/src/workspace/records-new.schema.json +4 -1
  181. package/src/workspace/records-review.schema.json +2 -1
  182. package/src/workspace/records-write.ts +85 -7
  183. package/src/workspace/records.schema.json +18 -0
  184. package/src/workspace/records.ts +61 -3
  185. package/src/workspace/source-block.test.ts +167 -0
  186. package/src/workspace/source-block.ts +129 -0
  187. package/src/workspace/status-contract.test.ts +178 -0
  188. package/src/workspace/status-stewards.ts +225 -0
  189. package/src/workspace/status.schema.json +230 -4
  190. package/src/workspace/status.ts +106 -5
  191. package/src/workspace/work-cli.test.ts +180 -0
  192. package/src/workspace/work-cli.ts +246 -0
  193. package/src/workspace/work-lease.schema.json +233 -0
  194. package/src/workspace/work-readiness-chud.test.ts +145 -0
@@ -0,0 +1,449 @@
1
+ /**
2
+ * #2707 — the workspace tools of `chant serve mcp`: the read contract and the
3
+ * record writes, for a harness that speaks MCP and has no shell.
4
+ *
5
+ * Served when the server starts at or inside a declared workspace. Each tool
6
+ * is a thin call into the code the CLI runs:
7
+ *
8
+ * - The reads (`workspace-ls`, `workspace-status`, `workspace-graph`,
9
+ * `workspace-records`, `workspace-points`) run `chant workspace <command>
10
+ * ... --json` with the chant this server runs as, in the server's
11
+ * directory, and return the
12
+ * document it printed, unchanged, with its reason codes (#2536). Running the
13
+ * command, rather than calling into it, keeps every rule it has, handing the
14
+ * read to the workspace root's pinned chant included (ws-021), and keeps
15
+ * anything a command prints away from the protocol on stdout.
16
+ * - The writes (`records-new`, `records-amend`, `records-review`,
17
+ * `records-close`, `points-answer`) call the functions `chant workspace
18
+ * records new|amend|review|close` and `points answer` call, and return the
19
+ * same JSON result. They keep every rule
20
+ * the CLI keeps: the kind's schema, a closed record never changing, an
21
+ * approved one changing only as its approval rule allows, a dissent needing
22
+ * a note, and `sign` using this host's configured key or refusing with the
23
+ * CLI's remedy. Through MCP a new record also opens in the kind's first
24
+ * state, `proposed` for a decision, and its stored source block (#2708) says
25
+ * it came through MCP: `via: "mcp"` and `client`, the MCP client's
26
+ * `clientInfo`. Computed provenance, from git, is unchanged.
27
+ *
28
+ * Nothing here listens on a port or authenticates anyone (ws-052): the server
29
+ * speaks over stdio, and `by` is recorded as given, as `--by` is.
30
+ */
31
+
32
+ import { spawn } from "node:child_process";
33
+ import type { ToolContext, ToolDefinition, ToolHandler } from "./types";
34
+
35
+ /** How the reads run chant: a command and its leading arguments. */
36
+ export type ChantCommand = string[];
37
+
38
+ /**
39
+ * The chant this process runs as: node with the same flags (the tsx loader
40
+ * `bin/chant` registers) and the same entry script.
41
+ */
42
+ export function ownChantCommand(): ChantCommand {
43
+ return [process.execPath, ...process.execArgv, process.argv[1]];
44
+ }
45
+
46
+ export interface WorkspaceToolsOptions {
47
+ /** Where reads run and writes resolve kinds: the directory the server started in. */
48
+ cwd: string;
49
+ /** The chant the reads run. Defaults to {@link ownChantCommand}. */
50
+ chantCommand?: ChantCommand;
51
+ }
52
+
53
+ const PROTOCOL =
54
+ "Records are proposals until they are reviewed: a new record opens proposed, and people decide it through reviews and amendments. " +
55
+ "by must name the person or agent that actually decided, as it is recorded as given.";
56
+
57
+ const kindProp = {
58
+ type: "string",
59
+ description:
60
+ "The record kind: a kind file relative to the server's directory, such as decisions/decision.kind.mjs, or a kind the workspace declaration names. Without it, the one kind the declaration names.",
61
+ };
62
+ const atProp = { type: "string", description: "Read at this git revision instead of the working tree (--at)." };
63
+ const dryRunProp = { type: "boolean", description: "Return the result, with the text it would write, and write nothing (--dry-run)." };
64
+ const signProp = {
65
+ type: "boolean",
66
+ description:
67
+ "Seal with this host's configured key, git's user.signingkey with gpg.format ssh, as --sign does. Refused, with the CLI's remedy, when none is set.",
68
+ };
69
+
70
+ export const workspaceReadTools: ToolDefinition[] = [
71
+ {
72
+ name: "workspace-ls",
73
+ description:
74
+ "List the workspace's members and groups: chant workspace ls --json. Returns that document unchanged, which follows ls.schema.json of the read contract, reason codes included.",
75
+ inputSchema: { type: "object", properties: { at: atProp } },
76
+ },
77
+ {
78
+ name: "workspace-status",
79
+ description:
80
+ "What each member has released to an environment, its gates, and the steward that runs it with each Op's last run, from the declaration and the lifecycle ledgers: chant workspace status <env> --json. Returns that document unchanged (status.schema.json).",
81
+ inputSchema: {
82
+ type: "object",
83
+ properties: {
84
+ env: { type: "string", description: "The environment, such as dev." },
85
+ compareTo: { type: "string", description: "A second environment to compare with (--compare-to)." },
86
+ },
87
+ required: ["env"],
88
+ },
89
+ },
90
+ {
91
+ name: "workspace-graph",
92
+ description:
93
+ "The workspace graph: chant workspace graph --json (graph.schema.json); with intent, the intent graph over one region (graph --intent, intent.schema.json); with composites, each composite instance and the components that can deploy it (graph --composites, composites.schema.json). Returns the document unchanged.",
94
+ inputSchema: {
95
+ type: "object",
96
+ properties: {
97
+ kind: {
98
+ type: "array",
99
+ items: { type: "string" },
100
+ description: "Record kind files whose records join the graph (--kind). The plain graph takes one; intent takes several.",
101
+ },
102
+ intent: { type: "string", description: "The region for the intent graph: a workspace path, path:line or path:start-end (--intent)." },
103
+ composites: { type: "boolean", description: "The composites document instead (--composites). Takes no kind or intent." },
104
+ at: atProp,
105
+ },
106
+ },
107
+ },
108
+ {
109
+ name: "workspace-records",
110
+ description:
111
+ "The records of a kind, validated, with supersession, provenance, quorum and warnings: chant workspace records --json (records.schema.json). With since, what changed since a revision or a review session (records-since.schema.json). Returns the document unchanged; with id, only that record is kept in records. " +
112
+ PROTOCOL,
113
+ inputSchema: {
114
+ type: "object",
115
+ properties: {
116
+ kind: kindProp,
117
+ current: { type: "boolean", description: "Leave out records a closed record supersedes (--current)." },
118
+ since: { type: "string", description: "A revision, or a review session id, to compare with (--since)." },
119
+ id: { type: "string", description: "Keep only the record with this id in the document's records." },
120
+ at: atProp,
121
+ },
122
+ },
123
+ },
124
+ {
125
+ name: "workspace-points",
126
+ description:
127
+ "The workspace's decision points and the questions asked of them: chant workspace points --json (points.schema.json, ws-058). A question is open while it is escalated to people, or proposed by a model and not yet confirmed; each lists any model's answer with its confidence and threshold. Returns the document unchanged. chant calls no model to answer this.",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ open: { type: "boolean", description: "Only the open questions (--open)." },
132
+ kind: { type: "string", description: "One answer kind file, in place of the declared ones (--kind)." },
133
+ at: atProp,
134
+ },
135
+ },
136
+ },
137
+ ];
138
+
139
+ export const workspaceWriteTools: ToolDefinition[] = [
140
+ {
141
+ name: "records-new",
142
+ description:
143
+ "Propose a new record: chant workspace records new. The record opens in the kind's first state (proposed for a decision); another state is refused. Its source block records that it came through MCP (via mcp, and this client's clientInfo). Validated against the kind's schema; nothing is written on refusal, and nothing is committed. " +
144
+ PROTOCOL,
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {
148
+ kind: kindProp,
149
+ record: { type: "object", description: "The record's fields, as the kind's schema describes them. The id is allocated when left out." },
150
+ prefix: { type: "string", description: "The id prefix to allocate under, when the records use several (--prefix)." },
151
+ by: { type: "string", description: "Who decided: written to the kind's decider field, such as decided_by. Required to sign." },
152
+ sign: signProp,
153
+ dryRun: dryRunProp,
154
+ },
155
+ required: ["record"],
156
+ },
157
+ },
158
+ {
159
+ name: "records-amend",
160
+ description:
161
+ "Set top-level fields of a record: chant workspace records amend. A closed record never changes, and an approved one changes only in its state, evidence and reviews: anything else, its reasoning included, is a new record that supersedes it. A source block given in the fields records that the change came through MCP. " +
162
+ PROTOCOL,
163
+ inputSchema: {
164
+ type: "object",
165
+ properties: {
166
+ id: { type: "string", description: "The record's id." },
167
+ kind: kindProp,
168
+ fields: { type: "object", description: "The top-level fields to set; each replaces the whole field." },
169
+ by: { type: "string", description: "Who decided: written to the kind's decider field." },
170
+ sign: signProp,
171
+ dryRun: dryRunProp,
172
+ },
173
+ required: ["id", "fields"],
174
+ },
175
+ },
176
+ {
177
+ name: "records-review",
178
+ description:
179
+ "Give a verdict on a record: chant workspace records review. Appends one entry to its reviews with the digest of the text judged, which moves its quorum. A dissent needs a note. " +
180
+ PROTOCOL,
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ id: { type: "string", description: "The record's id." },
185
+ kind: kindProp,
186
+ verdict: { type: "string", enum: ["agree", "dissent", "abstain"] },
187
+ by: { type: "string", description: "The reviewer: the person or agent giving the verdict (--by)." },
188
+ note: { type: "string", description: "Why; required for a dissent (--note)." },
189
+ session: { type: "string", description: "The open review session the verdict is given in (--session)." },
190
+ sign: signProp,
191
+ dryRun: dryRunProp,
192
+ },
193
+ required: ["id", "verdict", "by"],
194
+ },
195
+ },
196
+ {
197
+ name: "records-close",
198
+ description: "Close an open review session and seal it: chant workspace records close. " + PROTOCOL,
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ id: { type: "string", description: "The session's id." },
203
+ kind: { type: "string", description: "The session kind file. Without it, the one session kind the declaration names." },
204
+ dryRun: dryRunProp,
205
+ },
206
+ required: ["id"],
207
+ },
208
+ },
209
+ {
210
+ name: "points-answer",
211
+ description:
212
+ "Record people's answer to an open decision point question, or confirm a model's proposal: chant workspace points answer. Refused unless the point's quorum is met: distinct people, none holding the agent role, each holding one of the quorum's roles when it names any. An answered question never changes. " +
213
+ PROTOCOL,
214
+ inputSchema: {
215
+ type: "object",
216
+ properties: {
217
+ id: { type: "string", description: "The question's id, as workspace-points lists it." },
218
+ answer: { type: ["string", "boolean"], description: "One of the question's candidates; for a noul, true or false." },
219
+ by: { type: "array", items: { type: "string" }, description: "Each person who answered (--by)." },
220
+ kind: { type: "string", description: "The answer kind file. Without it, the declared answer kinds." },
221
+ dryRun: dryRunProp,
222
+ },
223
+ required: ["id", "answer", "by"],
224
+ },
225
+ },
226
+ ];
227
+
228
+ /** A tool call that can't be made as given: the client gets it as an error result, and nothing runs. */
229
+ class ToolInputError extends Error {}
230
+
231
+ function str(params: Record<string, unknown>, key: string, required = false): string | undefined {
232
+ const v = params[key];
233
+ if (v === undefined || v === null) {
234
+ if (required) throw new ToolInputError(`${key} is required`);
235
+ return undefined;
236
+ }
237
+ if (typeof v !== "string" || v === "") throw new ToolInputError(`${key} must be a non-empty string`);
238
+ // A value is passed as one argument; one that starts with - would read as a flag.
239
+ if (v.startsWith("-")) throw new ToolInputError(`${key} may not start with -: ${JSON.stringify(v)}`);
240
+ return v;
241
+ }
242
+
243
+ function bool(params: Record<string, unknown>, key: string): boolean {
244
+ const v = params[key];
245
+ if (v === undefined || v === null) return false;
246
+ if (typeof v !== "boolean") throw new ToolInputError(`${key} must be true or false`);
247
+ return v;
248
+ }
249
+
250
+ function obj(params: Record<string, unknown>, key: string): Record<string, unknown> {
251
+ const v = params[key];
252
+ if (v === null || typeof v !== "object" || Array.isArray(v)) throw new ToolInputError(`${key} must be a JSON object`);
253
+ return v as Record<string, unknown>;
254
+ }
255
+
256
+ function kinds(params: Record<string, unknown>): string[] {
257
+ const v = params.kind;
258
+ if (v === undefined || v === null) return [];
259
+ const list = typeof v === "string" ? [v] : v;
260
+ if (!Array.isArray(list)) throw new ToolInputError("kind must be a kind file, or a list of them");
261
+ return list.map((k, i) => str({ [`kind[${i}]`]: k }, `kind[${i}]`, true)!);
262
+ }
263
+
264
+ /** The `chant` arguments each read tool runs, from its input. Exported for the conformance suite's MCP transport. */
265
+ export function readArgv(tool: string, params: Record<string, unknown>): string[] {
266
+ const at = str(params, "at");
267
+ const atArgs = at !== undefined ? ["--at", at] : [];
268
+ switch (tool) {
269
+ case "workspace-ls":
270
+ return ["workspace", "ls", ...atArgs, "--json"];
271
+ case "workspace-status": {
272
+ const env = str(params, "env", true)!;
273
+ const compareTo = str(params, "compareTo");
274
+ return ["workspace", "status", env, ...(compareTo !== undefined ? ["--compare-to", compareTo] : []), "--json"];
275
+ }
276
+ case "workspace-graph": {
277
+ const intent = str(params, "intent");
278
+ const composites = bool(params, "composites");
279
+ const kindArgs = kinds(params).flatMap((k) => ["--kind", k]);
280
+ if (composites) return ["workspace", "graph", "--composites", ...kindArgs, ...(intent !== undefined ? ["--intent", intent] : []), ...atArgs, "--json"];
281
+ if (intent !== undefined) return ["workspace", "graph", "--intent", intent, ...kindArgs, ...atArgs, "--json"];
282
+ return ["workspace", "graph", ...kindArgs, ...atArgs, "--json"];
283
+ }
284
+ case "workspace-points": {
285
+ const kind = str(params, "kind");
286
+ return ["workspace", "points", ...(bool(params, "open") ? ["--open"] : []), ...(kind !== undefined ? ["--kind", kind] : []), ...atArgs, "--json"];
287
+ }
288
+ case "workspace-records": {
289
+ const kind = str(params, "kind");
290
+ const since = str(params, "since");
291
+ return [
292
+ "workspace",
293
+ "records",
294
+ ...(kind !== undefined ? ["--kind", kind] : []),
295
+ ...(bool(params, "current") ? ["--current"] : []),
296
+ ...(since !== undefined ? ["--since", since] : []),
297
+ ...atArgs,
298
+ "--json",
299
+ ];
300
+ }
301
+ default:
302
+ throw new ToolInputError(`${tool} is not a workspace read tool`);
303
+ }
304
+ }
305
+
306
+ /** Run chant and parse the one JSON document it printed. The exit code does not matter: an error document is a document. */
307
+ function runRead(command: ChantCommand, argv: string[], cwd: string): Promise<unknown> {
308
+ return new Promise((settle, fail) => {
309
+ const child = spawn(command[0], [...command.slice(1), ...argv], { cwd, env: { ...process.env, NO_COLOR: "1" }, stdio: ["ignore", "pipe", "pipe"] });
310
+ let stdout = "";
311
+ let stderr = "";
312
+ child.stdout.setEncoding("utf-8").on("data", (s: string) => (stdout += s));
313
+ child.stderr.setEncoding("utf-8").on("data", (s: string) => (stderr += s));
314
+ child.on("error", (e) => fail(new Error(`could not run chant ${argv.join(" ")}: ${e.message}`)));
315
+ child.on("close", (status) => {
316
+ try {
317
+ settle(JSON.parse(stdout));
318
+ } catch {
319
+ const why = stderr.trim() || stdout.trim() || `exit ${status}`;
320
+ fail(new Error(`chant ${argv.join(" ")} printed no JSON document: ${why}`));
321
+ }
322
+ });
323
+ });
324
+ }
325
+
326
+ /** Keep only the record with `id` in a records document, or in each kind of a declared set. */
327
+ function onlyRecord(doc: unknown, id: string): unknown {
328
+ if (doc === null || typeof doc !== "object") return doc;
329
+ const d = doc as Record<string, unknown>;
330
+ if (Array.isArray(d.records)) return { ...d, records: (d.records as { id?: unknown }[]).filter((r) => r.id === id) };
331
+ if (Array.isArray(d.kinds)) return { ...d, kinds: d.kinds.map((k) => onlyRecord(k, id)) };
332
+ return doc;
333
+ }
334
+
335
+ /** The source block a write through MCP lays over the record's (#2708). */
336
+ function mcpSource(context: ToolContext | undefined): Record<string, unknown> {
337
+ const c = context?.clientInfo;
338
+ const client =
339
+ c && typeof c.name === "string" && c.name !== ""
340
+ ? {
341
+ name: c.name,
342
+ ...(typeof c.version === "string" && c.version !== "" ? { version: c.version } : {}),
343
+ ...(typeof c.title === "string" && c.title !== "" ? { title: c.title } : {}),
344
+ }
345
+ : undefined;
346
+ return { via: "mcp", ...(client ? { client } : {}) };
347
+ }
348
+
349
+ export interface WorkspaceTool {
350
+ definition: ToolDefinition;
351
+ handler: ToolHandler;
352
+ }
353
+
354
+ /** The workspace tools, reads then writes, bound to the server's directory. */
355
+ export function createWorkspaceTools(options: WorkspaceToolsOptions): WorkspaceTool[] {
356
+ const { cwd } = options;
357
+ const chant = options.chantCommand ?? ownChantCommand();
358
+ const reads: WorkspaceTool[] = workspaceReadTools.map((definition) => ({
359
+ definition,
360
+ handler: async (params) => {
361
+ const argv = readArgv(definition.name, params);
362
+ const id = definition.name === "workspace-records" ? str(params, "id") : undefined;
363
+ const doc = await runRead(chant, argv, cwd);
364
+ return id !== undefined ? onlyRecord(doc, id) : doc;
365
+ },
366
+ }));
367
+
368
+ const write = async () => import("../../workspace/records-write");
369
+ /** The kind file a write goes through: the one named, or the one the declaration names; else the CLI's usage failure. */
370
+ const writeKind = async (params: Record<string, unknown>, schema: string, missing: string): Promise<string | object> => {
371
+ const w = await write();
372
+ const named = str(params, "kind");
373
+ return named !== undefined ? w.resolveWriteKind(named, cwd) : w.declaredWriteKind(schema, cwd, missing);
374
+ };
375
+ const sign = (params: Record<string, unknown>): true | undefined => (bool(params, "sign") ? true : undefined);
376
+
377
+ const handlers: Record<string, ToolHandler> = {
378
+ "records-new": async (params, context) => {
379
+ const w = await write();
380
+ const record = obj(params, "record");
381
+ const kind = await writeKind(params, w.RECORDS_NEW_SCHEMA_ID, "new needs the kind file");
382
+ if (typeof kind !== "string") return kind;
383
+ return w.newRecord({
384
+ kind,
385
+ fields: JSON.stringify(record),
386
+ prefix: str(params, "prefix"),
387
+ by: str(params, "by"),
388
+ sign: sign(params),
389
+ dryRun: bool(params, "dryRun"),
390
+ cwd,
391
+ through: { source: mcpSource(context), opensInitial: true },
392
+ });
393
+ },
394
+ "records-amend": async (params, context) => {
395
+ const w = await write();
396
+ const id = str(params, "id", true)!;
397
+ const fields = obj(params, "fields");
398
+ const kind = await writeKind(params, w.RECORDS_AMEND_SCHEMA_ID, "--kind <kind file> is required");
399
+ if (typeof kind !== "string") return kind;
400
+ return w.amendRecord({
401
+ kind,
402
+ id,
403
+ fields: JSON.stringify(fields),
404
+ by: str(params, "by"),
405
+ sign: sign(params),
406
+ dryRun: bool(params, "dryRun"),
407
+ cwd,
408
+ through: { source: mcpSource(context) },
409
+ });
410
+ },
411
+ "records-review": async (params) => {
412
+ const w = await write();
413
+ const id = str(params, "id", true)!;
414
+ const kind = await writeKind(params, w.RECORDS_REVIEW_SCHEMA_ID, "--kind <kind file> is required");
415
+ if (typeof kind !== "string") return kind;
416
+ return w.reviewRecord({
417
+ kind,
418
+ id,
419
+ verdict: str(params, "verdict", true)!,
420
+ by: str(params, "by", true)!,
421
+ note: typeof params.note === "string" ? params.note : undefined,
422
+ session: str(params, "session"),
423
+ sign: sign(params),
424
+ dryRun: bool(params, "dryRun"),
425
+ cwd,
426
+ });
427
+ },
428
+ "points-answer": async (params) => {
429
+ const { answerPoint } = await import("../../workspace/decide");
430
+ const answer = params.answer;
431
+ if (typeof answer !== "string" && typeof answer !== "boolean") throw new ToolInputError("answer must be a string, or true or false");
432
+ const by = params.by;
433
+ if (!Array.isArray(by) || by.length === 0 || !by.every((b) => typeof b === "string" && b.trim() !== "")) throw new ToolInputError("by must list each person who answered");
434
+ return answerPoint({ cwd, id: str(params, "id", true)!, answer, by: by as string[], kind: str(params, "kind"), dryRun: bool(params, "dryRun") });
435
+ },
436
+ "records-close": async (params) => {
437
+ const w = await write();
438
+ const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("../../workspace/records-close");
439
+ const id = str(params, "id", true)!;
440
+ const named = str(params, "kind");
441
+ const kind = named !== undefined ? w.resolveWriteKind(named, cwd) : await w.declaredSessionKind(RECORDS_CLOSE_SCHEMA_ID, cwd);
442
+ if (typeof kind !== "string") return kind;
443
+ return closeRecord({ kind, id, dryRun: bool(params, "dryRun"), cwd });
444
+ },
445
+ };
446
+
447
+ const writes: WorkspaceTool[] = workspaceWriteTools.map((definition) => ({ definition, handler: handlers[definition.name] }));
448
+ return [...reads, ...writes];
449
+ }
@@ -287,6 +287,18 @@ export interface ParsedArgs {
287
287
  kind?: string;
288
288
  /** Every `--kind` given, in order: `chant workspace graph --intent` reads each (#2651). */
289
289
  kinds?: string[];
290
+ /** Every `--by` given, in order: `chant workspace points answer` counts each person toward the quorum (#2739). */
291
+ bys?: string[];
292
+ /** `chant workspace points --open` (#2739): only the questions still open. */
293
+ open?: boolean;
294
+ /** `chant workspace points ask <point> --inputs <file|->` (#2739): the inputs, as a JSON object. */
295
+ inputs?: string;
296
+ /** `chant workspace points ask <point> --response <file>` (#2739): a POST /v1/systemone response the caller got from a backend. */
297
+ response?: string;
298
+ /** `chant workspace points ask <point> --subject <id>` (#2739): what the question is about. */
299
+ subject?: string;
300
+ /** `chant workspace points answer <id> --answer <value>` (#2739): the people's answer. */
301
+ answer?: string;
290
302
  /** `chant workspace graph --composites` (#2662): print each composite instance with the components that can deploy it. */
291
303
  composites?: boolean;
292
304
  /** `chant workspace graph --intent <path[:start-end]>` (#2651): the region the intent graph is over. */
@@ -369,6 +381,8 @@ export interface ParsedArgs {
369
381
  component?: string;
370
382
  /** `chant components release record --digest <sha256:...>` (#568) — artifact digest to record, joining this release to the build archive/ledger. Also `chant components export --digest <manifestDigest>` (#929) — a build archive manifest digest to export directly, bypassing env/component resolution. */
371
383
  digest?: string;
384
+ /** `chant components release record --release-plan <file>` (ws-055, #2733) — path to a release plan JSON file, persisted content-addressed to `_plans/<digest>.json` on chant/lifecycle and read back through the read contract (`chant workspace status --json`). The plan's own `digest` field supplies `--digest` when it is omitted, and must match it when both are given. Distinct from `--plan` (#2300, below), the Op gate-approval plan digest. */
385
+ releasePlanFile?: string;
372
386
  /** Every `--digest` value, in order (#2602). `chant components promote --digest <component>=<sha256:...>` is repeatable, one per component; the other commands read the single {@link digest}. */
373
387
  digests?: string[];
374
388
  /** `chant components release record --git-sha <sha>` (#568) — git commit the deploy was built from. */
@@ -413,6 +427,23 @@ export interface ParsedArgs {
413
427
  interval?: string;
414
428
  /** `chant operator --lease-ttl <duration>` (#1485) — how long an acquired lease is valid before it's reclaimable by another operator. Default: 5m. */
415
429
  leaseTtl?: string;
430
+ /**
431
+ * `chant operator --steward [<name>]` (#2731) — run a declared steward's
432
+ * local form: its scheduled Ops on their crons, under the steward's own
433
+ * lease. `""` when the flag is given without a name, which picks the
434
+ * project's only steward.
435
+ */
436
+ steward?: string;
437
+ /** `chant workspace work claim|renew|release <id> --holder <name>` (#2732): who holds, or releases, the work lease. */
438
+ holder?: string;
439
+ /** `chant run <op> --work <id>` (#2748): the work item an Op with a work lease runs under. */
440
+ work?: string;
441
+ /** `chant workspace work claim|renew <id> --ttl <seconds|duration>` (#2732): how long the lease lasts unless renewed. */
442
+ ttl?: string;
443
+ /** `chant workspace work renew|release <id> --token <token>` (#2732): the fencing token the caller holds. */
444
+ token?: string;
445
+ /** `chant workspace work release <id> --outcome <text>` (#2732): how the work ended, such as done or not_done. */
446
+ outcome?: string;
416
447
  /** `chant operator --once` (#1485) — run a single round and exit, instead of looping until Ctrl-C. Also the offline test/cron-invoker story. */
417
448
  once?: boolean;
418
449
  /** `chant approve <op> <gate> --note <text>` (#1485) — optional free-text prose recorded on the gate-resolution fact. The PR link belongs in `--url` since #2028; this is for everything that isn't the link. */
@@ -5,7 +5,8 @@
5
5
  * `scan-vulnerabilities` capability over an injected scanner.
6
6
  */
7
7
 
8
- import { readFileSync } from "node:fs";
8
+ import { readFileSync, mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
9
10
  import { join } from "node:path";
10
11
  import { describe, test, expect } from "vitest";
11
12
  import {
@@ -15,8 +16,13 @@ import {
15
16
  createToolVulnScanner,
16
17
  createScanVulnerabilitiesCapability,
17
18
  autoDetectVulnScanner,
19
+ sbomPackages,
20
+ compareVersions,
21
+ scanWithDatabase,
22
+ createDatabaseVulnScanner,
18
23
  type VulnFinding,
19
24
  type VulnScanner,
25
+ type AdvisoryDatabase,
20
26
  } from "./vuln-scan";
21
27
  import { ToolNotAvailableError } from "./process-runner";
22
28
  import type { SbomDocument } from "./sbom-generator";
@@ -178,6 +184,123 @@ describe("exploitability parsing (#1463)", () => {
178
184
  });
179
185
  });
180
186
 
187
+ // ── offline advisory database (ws-056, INTENTIUS/chant#2735) ─────────────────
188
+ // Ported from chud's supply-chain.mjs tests: an SBOM's purl-derived packages
189
+ // matched against a local, offline advisory database — no scanner binary, no
190
+ // network.
191
+
192
+ const SPDX_SBOM: SbomDocument = {
193
+ format: "spdx",
194
+ mediaType: "application/spdx+json",
195
+ bytes: JSON.stringify({
196
+ packages: [
197
+ { name: "lodash", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: "pkg:npm/lodash@4.17.20" }] },
198
+ { name: "left-pad", externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: "pkg:npm/left-pad@1.3.0" }] },
199
+ { name: "no-purl" },
200
+ ],
201
+ }),
202
+ generator: "lockfile",
203
+ };
204
+
205
+ const CYCLONEDX_SBOM: SbomDocument = {
206
+ format: "cyclonedx",
207
+ mediaType: "application/vnd.cyclonedx+json",
208
+ bytes: JSON.stringify({
209
+ components: [{ name: "lodash", purl: "pkg:npm/lodash@4.17.20" }, { name: "left-pad", purl: "pkg:npm/left-pad@1.3.0" }],
210
+ }),
211
+ generator: "lockfile",
212
+ };
213
+
214
+ const DB: AdvisoryDatabase = {
215
+ advisories: [
216
+ { id: "CVE-2024-0001", package: "lodash", severity: "critical", introduced: "4.0.0", fixed: "4.17.21" },
217
+ { id: "CVE-2024-0002", package: "left-pad", severity: "low" },
218
+ { id: "CVE-2024-0003", package: "lodash", ecosystem: "pypi", severity: "high" },
219
+ ],
220
+ };
221
+
222
+ describe("sbomPackages", () => {
223
+ test("reads purls from an SPDX document's externalRefs, skipping entries with none", () => {
224
+ const packages = sbomPackages(SPDX_SBOM);
225
+ expect(packages).toEqual([
226
+ { ecosystem: "npm", name: "lodash", version: "4.17.20" },
227
+ { ecosystem: "npm", name: "left-pad", version: "1.3.0" },
228
+ ]);
229
+ });
230
+
231
+ test("reads purls from a CycloneDX document's components", () => {
232
+ expect(sbomPackages(CYCLONEDX_SBOM)).toEqual([
233
+ { ecosystem: "npm", name: "lodash", version: "4.17.20" },
234
+ { ecosystem: "npm", name: "left-pad", version: "1.3.0" },
235
+ ]);
236
+ });
237
+
238
+ test("deduplicates identical ecosystem/name/version", () => {
239
+ const doc = { bytes: JSON.stringify({ components: [{ purl: "pkg:npm/x@1.0.0" }, { purl: "pkg:npm/x@1.0.0" }] }) };
240
+ expect(sbomPackages(doc)).toHaveLength(1);
241
+ });
242
+ });
243
+
244
+ describe("compareVersions", () => {
245
+ test("compares numerically, not lexically (1.9.0 < 1.10.0)", () => {
246
+ expect(compareVersions("1.9.0", "1.10.0")).toBe(-1);
247
+ expect(compareVersions("1.10.0", "1.9.0")).toBe(1);
248
+ expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
249
+ });
250
+
251
+ test("a pre-release sorts before its release", () => {
252
+ expect(compareVersions("1.2.0-rc.1", "1.2.0")).toBe(-1);
253
+ expect(compareVersions("1.2.0", "1.2.0-rc.1")).toBe(1);
254
+ });
255
+ });
256
+
257
+ describe("scanWithDatabase", () => {
258
+ test("matches an SBOM's packages against the database by ecosystem + name, introduced <= version < fixed", () => {
259
+ const findings = scanWithDatabase(SPDX_SBOM, DB);
260
+ expect(findings).toHaveLength(2);
261
+ const lodash = findings.find((f) => f.cveId === "CVE-2024-0001");
262
+ expect(lodash).toEqual({ cveId: "CVE-2024-0001", severity: "critical", package: "lodash", installedVersion: "4.17.20", fixedVersion: "4.17.21", fixable: true });
263
+ const leftPad = findings.find((f) => f.cveId === "CVE-2024-0002");
264
+ expect(leftPad).toMatchObject({ package: "left-pad", fixable: false });
265
+ expect(leftPad!.fixedVersion).toBeUndefined();
266
+ });
267
+
268
+ test("a version at or after `fixed` is not affected", () => {
269
+ const doc = { bytes: JSON.stringify({ components: [{ purl: "pkg:npm/lodash@4.17.21" }] }) };
270
+ expect(scanWithDatabase(doc, DB)).toHaveLength(0);
271
+ });
272
+
273
+ test("an ecosystem mismatch does not match (pypi advisory, npm package)", () => {
274
+ const findings = scanWithDatabase(SPDX_SBOM, DB);
275
+ expect(findings.some((f) => f.cveId === "CVE-2024-0003")).toBe(false);
276
+ });
277
+
278
+ test("accepts a bare advisories array as well as { advisories: [...] }", () => {
279
+ expect(scanWithDatabase(SPDX_SBOM, DB.advisories)).toHaveLength(2);
280
+ });
281
+ });
282
+
283
+ describe("createDatabaseVulnScanner", () => {
284
+ test("scans by reading the database file at scan time", async () => {
285
+ const dir = mkdtempSync(join(tmpdir(), "chant-vulndb-"));
286
+ const dbFile = join(dir, "advisories.json");
287
+ writeFileSync(dbFile, JSON.stringify(DB));
288
+ const scanner = createDatabaseVulnScanner(dbFile);
289
+ const findings = await scanner.scan({ sbom: SPDX_SBOM });
290
+ expect(findings).toHaveLength(2);
291
+ });
292
+
293
+ test("composes with scan-vulnerabilities like any other VulnScanner", async () => {
294
+ const dir = mkdtempSync(join(tmpdir(), "chant-vulndb-"));
295
+ const dbFile = join(dir, "advisories.json");
296
+ writeFileSync(dbFile, JSON.stringify(DB));
297
+ const cap = createScanVulnerabilitiesCapability(createDatabaseVulnScanner(dbFile));
298
+ const out = await cap.run(ctx, { sbom: SPDX_SBOM, digest: "sha256:abc" });
299
+ expect(out.findings).toHaveLength(2);
300
+ expect(out.digest).toBe("sha256:abc");
301
+ });
302
+ });
303
+
181
304
  describe("createToolVulnScanner (grype, via MockProcessRunner)", () => {
182
305
  test("scans the SBOM with `grype sbom:<file>` and parses the result", async () => {
183
306
  const mock = createMockProcessRunner({ tools: { grype: true }, responses: { "grype sbom:": GRYPE_JSON } });