@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
package/src/cli/main.ts CHANGED
@@ -54,6 +54,7 @@ import type { LexiconPlugin } from "../lexicon";
54
54
  */
55
55
  const BOOLEAN_FLAGS = new Set([
56
56
  "--help",
57
+ "--open",
57
58
  "--version",
58
59
  "--agents",
59
60
  "--agent",
@@ -392,6 +393,27 @@ export function parseArgs(args: string[]): ParsedArgs {
392
393
  // `chant workspace records review <id> --by <principal>` (#2670): whoever the caller says.
393
394
  result.by = args[++i];
394
395
  if (!result.by || result.by.startsWith("-")) throw new Error("--by needs the reviewer: --by <principal>");
396
+ // Repeatable for `workspace points answer` (#2739): each person who answered.
397
+ (result.bys ??= []).push(result.by);
398
+ } else if (arg === "--open") {
399
+ // `chant workspace points --open` (#2739): only the questions still open.
400
+ result.open = true;
401
+ } else if (arg === "--inputs") {
402
+ // `chant workspace points ask <point> --inputs <file|->` (#2739)
403
+ result.inputs = args[++i];
404
+ if (!result.inputs || (result.inputs.startsWith("-") && result.inputs !== "-")) throw new Error("--inputs needs a JSON file, or - for standard input: --inputs <file|->");
405
+ } else if (arg === "--response") {
406
+ // `chant workspace points ask <point> --response <file>` (#2739): a POST /v1/systemone response the caller got.
407
+ result.response = args[++i];
408
+ if (!result.response || result.response.startsWith("-")) throw new Error("--response needs a JSON file: --response <file>");
409
+ } else if (arg === "--subject") {
410
+ // `chant workspace points ask <point> --subject <id>` (#2739): what the question is about.
411
+ result.subject = args[++i];
412
+ if (!result.subject || result.subject.startsWith("-")) throw new Error("--subject needs what the question is about, such as a work item id: --subject <id>");
413
+ } else if (arg === "--answer") {
414
+ // `chant workspace points answer <id> --answer <value>` (#2739)
415
+ result.answer = args[++i];
416
+ if (result.answer === undefined || result.answer === "") throw new Error("--answer needs one of the question's candidates: --answer <value>");
395
417
  } else if (arg === "--sign") {
396
418
  // `chant workspace records review <id> --sign [<key file>]` (#2687): seal the verdict.
397
419
  // `records new` and `records amend` take it too, to seal the record's author (#2688).
@@ -431,6 +453,8 @@ export function parseArgs(args: string[]): ParsedArgs {
431
453
  } else if (arg === "--digest") {
432
454
  result.digest = args[++i];
433
455
  if (result.digest !== undefined) (result.digests ??= []).push(result.digest);
456
+ } else if (arg === "--release-plan") {
457
+ result.releasePlanFile = args[++i];
434
458
  } else if (arg === "--git-sha") {
435
459
  result.gitSha = args[++i];
436
460
  } else if (arg === "--run-id") {
@@ -483,8 +507,28 @@ export function parseArgs(args: string[]): ParsedArgs {
483
507
  result.interval = args[++i];
484
508
  } else if (arg === "--lease-ttl") {
485
509
  result.leaseTtl = args[++i];
510
+ } else if (arg === "--steward") {
511
+ // #2731 — the name is optional: a project with one steward runs it
512
+ // with a bare `--steward`.
513
+ const next = args[i + 1];
514
+ if (next !== undefined && !next.startsWith("-")) {
515
+ result.steward = next;
516
+ i++;
517
+ } else {
518
+ result.steward = "";
519
+ }
486
520
  } else if (arg === "--once") {
487
521
  result.once = true;
522
+ } else if (arg === "--work") {
523
+ // `chant run <op> --work <id>` (#2748)
524
+ const value = args[++i];
525
+ if (!value || value.startsWith("-")) throw new Error("--work needs a work item id: --work <id>");
526
+ result.work = value;
527
+ } else if (arg === "--holder" || arg === "--ttl" || arg === "--token" || arg === "--outcome") {
528
+ // `chant workspace work claim|renew|release <id>` (#2732)
529
+ const value = args[++i];
530
+ if (!value || value.startsWith("-")) throw new Error(`${arg} needs a value: ${arg} <${arg.slice(2)}>`);
531
+ result[arg.slice(2) as "holder" | "ttl" | "token" | "outcome"] = value;
488
532
  } else if (arg === "--note") {
489
533
  result.note = args[++i];
490
534
  } else if (arg === "--expire") {
@@ -664,6 +708,8 @@ Commands:
664
708
 
665
709
  Ops:
666
710
  run <name> Run an Op on the resolved runtime (--on; local by default)
711
+ [--work <id>] [--holder <name>]: the work item an Op
712
+ with a work lease runs under, and who holds it
667
713
  run list List all Ops with the runtime's state for each
668
714
  run status <name> Show the runtime's state for one Op's latest run
669
715
  run approve <op> <gate> Record a gate's resolution and wake the runtime
@@ -691,7 +737,10 @@ Ops:
691
737
  environment; --interval <dur> (default 60s) and
692
738
  --lease-ttl <dur> (default 5m) tune cadence; --once
693
739
  runs a single round and exits (cron/systemd-timer/
694
- CronJob invokers use this instead of the daemon)
740
+ CronJob invokers use this instead of the daemon).
741
+ --steward [<name>] runs a declared steward's local
742
+ form instead (#2731): its scheduled Ops on their
743
+ crons, under the steward's own lease
695
744
  operator status Last tick, outcomes, and pending gates per
696
745
  ConvergeOp, read from the chant/lifecycle orphan
697
746
  branch alone — no daemon needs to be running
@@ -802,6 +851,25 @@ Workspace (level 1, #2524):
802
851
  closing commit and seal, in one write. Without
803
852
  --kind, the one session kind the declaration names.
804
853
  Prints {path, id, changed, seal, closedRev}
854
+ workspace work claim|renew|release <id> --holder <name> [--kind <kind file>] [--ttl <seconds|duration>] [--token <token>] [--outcome <text>] [--note <text>] [--json]
855
+ Take, heartbeat or give back the lease on a work item:
856
+ refs/chant/lease/work/<id>, a compare-and-set ref with a
857
+ fencing token and an expiry, pushed to the remote so
858
+ separate clones coordinate. A claim is refused, exit 2,
859
+ while anyone holds it live. Each change appends to
860
+ _leases/<id>.jsonl on chant/lifecycle
861
+ workspace points [--open] [--kind <kind file>] [--at <rev>] [--json]
862
+ List the decision points the declared answer kinds'
863
+ points files declare, and the questions asked of them;
864
+ --open keeps the escalated and proposed ones (ws-058)
865
+ workspace points ask <point> --inputs <file|-> [--response <file>] [--subject <id>] [--kind <kind file>] [--dry-run]
866
+ Ask a point's table, model and quorum deciders and
867
+ record the answer: proposed from a model, escalated to
868
+ people below its threshold. --response is a POST
869
+ /v1/systemone response the caller got; chant calls no model
870
+ workspace points answer <id> --answer <value> --by <name>... [--kind <kind file>] [--dry-run]
871
+ Record people's answer to an open question, or confirm
872
+ a model's proposal, once the point's quorum is met
805
873
  workspace verify [--base <rev>] [--head <rev>] [--require attested]
806
874
  Check the commits in base..head against the signers
807
875
  and roles read from base. A change to the signers file
@@ -904,7 +972,11 @@ Component release ledger + status:
904
972
  <file> finishes an attempt that stopped)
905
973
  components release <env> Append one immutable release record
906
974
  (--component <name> --digest <sha256:...>
907
- [--git-sha <sha>] [--run-id <id>] [--actor <name>])
975
+ [--git-sha <sha>] [--run-id <id>] [--actor <name>]);
976
+ --release-plan <file> takes --digest's place,
977
+ persisting the release plan the file holds to
978
+ _plans/<digest>.json (ws-055) and recording its
979
+ own digest
908
980
  components export <env> Materialize a persisted build archive manifest to
909
981
  a portable directory (--component <name>
910
982
  [--digest <manifestDigest>] -o <dir> [--json]);
@@ -1328,8 +1400,12 @@ export const commandRegistry: CommandDef[] = [
1328
1400
  // Workspace reads (#2524). Imported on first use, so a level-0 command never
1329
1401
  // loads anything under workspace/ (#2525 rule 5, pinned by #2526's goldens).
1330
1402
  { name: "workspace records", handler: async (ctx) => (await import("../workspace/records-cli")).runWorkspaceRecords(ctx) },
1403
+ // ws-058 (#2739) — decision points and the questions asked of them; never calls a model.
1404
+ { name: "workspace points", handler: async (ctx) => (await import("../workspace/points-cli")).runWorkspacePoints(ctx) },
1331
1405
  { name: "workspace init", handler: async (ctx) => (await import("../workspace/init")).runWorkspaceInit(ctx) },
1332
1406
  { name: "workspace ls", handler: async (ctx) => (await import("../workspace/ls")).runWorkspaceLs(ctx) },
1407
+ // #2732 — the work lease: claim, renew and release a work item.
1408
+ { name: "workspace work", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/work-cli")).runWorkspaceWork(ctx) },
1333
1409
  { name: "workspace status", handler: async (ctx) => (await import("../workspace/status")).runWorkspaceStatus(ctx) },
1334
1410
  { name: "workspace lineage", handler: async (ctx) => (await import("../workspace/lineage-cli")).runWorkspaceLineage(ctx) },
1335
1411
  { name: "workspace upgrade", handler: async (ctx) => (await import("../workspace/lineage-upgrade-cli")).runWorkspaceUpgrade(ctx) },
@@ -39,8 +39,9 @@ const guideDoc = readFileSync(join(repoRoot, GUIDE_DOC), "utf8");
39
39
  const serverSrc = readFileSync(join(repoRoot, SERVER_SRC), "utf8");
40
40
 
41
41
  /** What a client is told when it asks, built from a server with no plugins loaded. */
42
- async function servedListing(method: "tools/list" | "resources/list"): Promise<string[]> {
43
- const response = await new McpServer().handleRequest({ jsonrpc: "2.0", id: 1, method });
42
+ async function servedListing(method: "tools/list" | "resources/list", inWorkspace = false): Promise<string[]> {
43
+ const server = inWorkspace ? new McpServer([], { workspace: { cwd: repoRoot } }) : new McpServer();
44
+ const response = await server.handleRequest({ jsonrpc: "2.0", id: 1, method });
44
45
  const result = response.result as { tools?: ToolDefinition[]; resources?: ResourceDefinition[] };
45
46
  const names = result.tools?.map((t) => t.name) ?? result.resources?.map((r) => r.uri);
46
47
  if (!names || names.length === 0) {
@@ -82,6 +83,23 @@ describe("the MCP docs describe the server that ships (#2385)", () => {
82
83
  ).toEqual(sorted(registered));
83
84
  });
84
85
 
86
+ test("cli/mcp.mdx's Workspace tools table lists exactly the tools a server inside a workspace adds (#2707)", async () => {
87
+ const core = await servedListing("tools/list");
88
+ // This repository declares a workspace, so a server started in it has them.
89
+ const registered = (await servedListing("tools/list", true)).filter((n) => !core.includes(n));
90
+ expect(registered.length).toBeGreaterThan(0);
91
+ const listed = firstColumnCells(section(mcpDoc, "## Workspace tools", MCP_DOC));
92
+ expect(sorted(listed), `${MCP_DOC}'s "Workspace tools" table and the workspace tools disagree`).toEqual(sorted(registered));
93
+ });
94
+
95
+ test("guide/agent-integration.mdx lists exactly the workspace tools (#2707)", async () => {
96
+ const core = await servedListing("tools/list");
97
+ const registered = (await servedListing("tools/list", true)).filter((n) => !core.includes(n));
98
+ expect(registered.length).toBeGreaterThan(0);
99
+ const listed = firstColumnCells(section(guideDoc, "### Workspace Tools", GUIDE_DOC));
100
+ expect(sorted(listed), `${GUIDE_DOC}'s "Workspace Tools" table and the workspace tools a server inside a workspace registers disagree`).toEqual(sorted(registered));
101
+ });
102
+
85
103
  test("guide/agent-integration.mdx lists exactly the tools the server registers", async () => {
86
104
  const registered = await servedListing("tools/list");
87
105
  const listed = firstColumnCells(section(guideDoc, "### Available Tools", GUIDE_DOC));
@@ -8,7 +8,9 @@ import { scaffoldTool, createScaffoldHandler } from "./tools/scaffold";
8
8
  import { searchTool, createSearchHandler } from "./tools/search";
9
9
  import { compositesTool, createCompositesHandler } from "./tools/composites";
10
10
  import type { LexiconPlugin } from "../../lexicon";
11
- import type { McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
11
+ import type { McpClientInfo, McpRequest, McpResponse, McpRequestMeta, ToolDefinition, ToolHandler, ResourceDefinition } from "./types";
12
+ import { findWorkspaceRoot } from "../../project-root";
13
+ import { createWorkspaceTools, type WorkspaceToolsOptions } from "./workspace-tools";
12
14
  import { createSnapshotTool, createDiffTool } from "./lifecycle-tools";
13
15
  import { setGateOrigin } from "../../lifecycle/gate-origin";
14
16
  import { createOpListTool, createOpRunTool, createOpStatusTool, createOpApproveTool, createOpReportTool } from "./op-tools";
@@ -47,14 +49,14 @@ export function negotiateProtocolVersion(requested: string | undefined): string
47
49
  * sends on `initialize`. Read-side only — the server holds no handshake
48
50
  * state to update (#1194).
49
51
  */
50
- export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?: { name: string; version?: string } } {
52
+ export function parseMeta(params: Record<string, unknown>): { protocolVersion?: string; clientInfo?: McpClientInfo } {
51
53
  const meta = (params._meta ?? {}) as McpRequestMeta;
52
54
  const protocolVersion =
53
55
  (typeof meta.protocolVersion === "string" ? meta.protocolVersion : undefined) ??
54
56
  (typeof params.protocolVersion === "string" ? params.protocolVersion : undefined);
55
57
  const clientInfo =
56
58
  meta["io.modelcontextprotocol/clientInfo"] ??
57
- (params.clientInfo as { name: string; version?: string } | undefined);
59
+ (params.clientInfo as McpClientInfo | undefined);
58
60
  return { protocolVersion, clientInfo };
59
61
  }
60
62
 
@@ -94,14 +96,20 @@ export class McpServer {
94
96
  private pluginResources: Map<string, { definition: ResourceDefinition; handler: () => Promise<string> }> = new Map();
95
97
  private plugins: LexiconPlugin[];
96
98
  private instructions: string | undefined;
99
+ /** The `clientInfo` the client gave on `initialize`, for a request that carries none in `_meta` (#2707). */
100
+ private clientInfo: McpClientInfo | undefined;
97
101
 
98
102
  /**
99
103
  * `options.instructions` is sent as the `initialize` result's
100
104
  * `instructions`, the text a client may give its model about this server.
101
105
  * Only a workspace root with no lexicon of its own sets it (#2700), to say
102
106
  * which members' lexicons were loaded.
107
+ *
108
+ * `options.workspace` names the directory the server serves (#2707). When
109
+ * it is at or inside a declared workspace, the workspace read-contract and
110
+ * record-write tools are served too.
103
111
  */
104
- constructor(plugins?: LexiconPlugin[], options: { instructions?: string } = {}) {
112
+ constructor(plugins?: LexiconPlugin[], options: { instructions?: string; workspace?: WorkspaceToolsOptions } = {}) {
105
113
  this.plugins = plugins ?? [];
106
114
  this.instructions = options.instructions;
107
115
  // Register core tools
@@ -126,6 +134,11 @@ export class McpServer {
126
134
  this.registerTool(t.definition, t.handler);
127
135
  }
128
136
 
137
+ // Workspace reads and record writes (#2707), inside a declared workspace only.
138
+ if (options.workspace && findWorkspaceRoot(options.workspace.cwd)) {
139
+ for (const t of createWorkspaceTools(options.workspace)) this.registerTool(t.definition, t.handler);
140
+ }
141
+
129
142
  // Register plugin contributions
130
143
  if (plugins) {
131
144
  for (const plugin of plugins) {
@@ -244,6 +257,8 @@ export class McpServer {
244
257
  private async dispatch(method: string, params: Record<string, unknown>): Promise<unknown> {
245
258
  switch (method) {
246
259
  case "initialize":
260
+ // Kept for a write's source block (#2707): a prior-revision client names itself only here.
261
+ this.clientInfo = parseMeta(params).clientInfo ?? this.clientInfo;
247
262
  // Answered for prior-revision clients too — negotiated, not hard-coded (#1194).
248
263
  return this.buildInitializeResult(params);
249
264
 
@@ -290,7 +305,9 @@ export class McpServer {
290
305
  }
291
306
 
292
307
  try {
293
- const result = await handler(toolParams);
308
+ // The client, from this request's _meta (2026-07-28) or else from initialize.
309
+ const clientInfo = parseMeta(params).clientInfo ?? this.clientInfo;
310
+ const result = await handler(toolParams, clientInfo ? { clientInfo } : {});
294
311
  const isStructured = typeof result === "object" && result !== null;
295
312
  return {
296
313
  content: [
@@ -374,6 +391,6 @@ export async function startMcpServer(): Promise<void> {
374
391
  // Start without plugins if resolution fails
375
392
  }
376
393
 
377
- const server = new McpServer(plugins);
394
+ const server = new McpServer(plugins, { workspace: { cwd: process.cwd() } });
378
395
  server.start();
379
396
  }
@@ -32,7 +32,7 @@ export interface McpResponse {
32
32
  */
33
33
  export interface McpRequestMeta {
34
34
  protocolVersion?: string;
35
- "io.modelcontextprotocol/clientInfo"?: { name: string; version?: string };
35
+ "io.modelcontextprotocol/clientInfo"?: McpClientInfo;
36
36
  }
37
37
 
38
38
  /**
@@ -68,4 +68,17 @@ export interface ResourceDefinition {
68
68
  mimeType?: string;
69
69
  }
70
70
 
71
- export type ToolHandler = (params: Record<string, unknown>) => Promise<unknown>;
71
+ /** The MCP client's `clientInfo`, as it gave it on `initialize` or in a request's `_meta`. */
72
+ export interface McpClientInfo {
73
+ name: string;
74
+ version?: string;
75
+ title?: string;
76
+ }
77
+
78
+ /** What a handler knows about the call beyond its arguments (#2707). */
79
+ export interface ToolContext {
80
+ /** The client that made the call, when it said. */
81
+ clientInfo?: McpClientInfo;
82
+ }
83
+
84
+ export type ToolHandler = (params: Record<string, unknown>, context?: ToolContext) => Promise<unknown>;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * #2707 — chant serve mcp's workspace tools: served at or inside a declared
3
+ * workspace, reads that return the CLI's documents, and writes that keep the
4
+ * CLI's rules and say they came through MCP in the record's source block
5
+ * (#2708). Run against the workspace the reader conformance suite generates.
6
+ */
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
13
+ import { createConformanceWorkspace, defaultChantCommand, mcpToolCall, type ConformanceWorkspace } from "../../workspace/conformance";
14
+ import { McpServer } from "./server";
15
+ import { workspaceReadTools, workspaceWriteTools } from "./workspace-tools";
16
+
17
+ const KIND = "decisions/decision.kind.mjs";
18
+ const chant = defaultChantCommand();
19
+
20
+ let ws: ConformanceWorkspace;
21
+ beforeAll(() => {
22
+ ws = createConformanceWorkspace({ chantCommand: chant });
23
+ }, 300_000);
24
+ afterAll(() => ws?.dispose());
25
+
26
+ type Result = { isError?: boolean; structuredContent?: Record<string, unknown>; content: { text: string }[] };
27
+
28
+ function server(cwd = ws.dir): McpServer {
29
+ return new McpServer([], { workspace: { cwd, chantCommand: chant } });
30
+ }
31
+
32
+ let nextId = 1;
33
+ async function rpc(s: McpServer, method: string, params: Record<string, unknown> = {}): Promise<unknown> {
34
+ const res = await s.handleRequest({ jsonrpc: "2.0", id: nextId++, method, params });
35
+ if (res.error) throw new Error(res.error.message);
36
+ return res.result;
37
+ }
38
+
39
+ async function call(s: McpServer, name: string, args: Record<string, unknown>, meta?: Record<string, unknown>): Promise<Result> {
40
+ return (await rpc(s, "tools/call", { name, arguments: args, ...(meta ? { _meta: meta } : {}) })) as Result;
41
+ }
42
+
43
+ function cli(argv: string[], env: NodeJS.ProcessEnv = {}): unknown {
44
+ let out: string;
45
+ try {
46
+ out = execFileSync(chant[0], [...chant.slice(1), ...argv], { cwd: ws.dir, encoding: "utf-8", env: { ...process.env, NO_COLOR: "1", ...env }, stdio: ["ignore", "pipe", "pipe"] });
47
+ } catch (e) {
48
+ out = String((e as { stdout?: string }).stdout ?? "");
49
+ }
50
+ return JSON.parse(out);
51
+ }
52
+
53
+ /** A proposed decision's fields, with no id, state or source. */
54
+ function proposal(title: string): Record<string, unknown> {
55
+ return {
56
+ schema: 1,
57
+ title,
58
+ area: "delivery",
59
+ question: "Where do the app's logs go?",
60
+ options: [
61
+ { id: "a", label: "stdout", how: "The app writes to stdout and the runtime collects it.", tradeoff: "Nothing to configure." },
62
+ { id: "b", label: "a file", how: "The app writes a file.", tradeoff: "A volume to manage." },
63
+ ],
64
+ choice: null,
65
+ rejected: [],
66
+ supersedes: [],
67
+ evidence: [],
68
+ decided_by: null,
69
+ decided_on: null,
70
+ reviews: [],
71
+ constrains: ["member:app"],
72
+ };
73
+ }
74
+
75
+ type RecordView = { id: string; state: string; valid: boolean; data: Record<string, unknown>; quorum?: { agreed: number; met: boolean } };
76
+ function recordsOf(doc: unknown): RecordView[] {
77
+ return (doc as { records: RecordView[] }).records;
78
+ }
79
+
80
+ describe("which servers have the workspace tools", () => {
81
+ const names = [...workspaceReadTools, ...workspaceWriteTools].map((t) => t.name);
82
+
83
+ test("a server at or inside a declared workspace lists them", async () => {
84
+ for (const cwd of [ws.dir, join(ws.dir, "decisions")]) {
85
+ const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
86
+ expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
87
+ }
88
+ expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-records", "workspace-points", "records-new", "records-amend", "records-review", "records-close", "points-answer"]);
89
+ });
90
+
91
+ test("a server outside any workspace, or given none, does not", async () => {
92
+ const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-mcp-no-ws-")));
93
+ try {
94
+ for (const s of [server(outside), new McpServer([])]) {
95
+ const { tools } = (await rpc(s, "tools/list")) as { tools: { name: string }[] };
96
+ expect(tools.map((t) => t.name).filter((n) => names.includes(n))).toEqual([]);
97
+ }
98
+ } finally {
99
+ rmSync(outside, { recursive: true, force: true });
100
+ }
101
+ });
102
+
103
+ test("the descriptions say records are proposals and by names who decided", () => {
104
+ for (const t of workspaceWriteTools) expect(t.description).toMatch(/proposals until they are reviewed.*by must name the person or agent that actually decided/s);
105
+ });
106
+ });
107
+
108
+ describe("reads", () => {
109
+ test("workspace-records returns the document chant workspace records --json prints", async () => {
110
+ const s = server();
111
+ const res = await call(s, "workspace-records", { kind: KIND });
112
+ expect(res.isError).toBeUndefined();
113
+ expect(res.structuredContent).toEqual(cli(["workspace", "records", "--kind", KIND, "--json"]));
114
+ const only = await call(s, "workspace-records", { kind: KIND, id: "fix-001" });
115
+ expect(recordsOf(only.structuredContent).map((r) => r.id)).toEqual(["fix-001"]);
116
+ }, 120_000);
117
+
118
+ test("workspace-points returns the document chant workspace points --json prints, and points-answer the write's (ws-058)", async () => {
119
+ const s = server();
120
+ const res = await call(s, "workspace-points", { open: true });
121
+ expect(res.isError).toBeUndefined();
122
+ expect(res.structuredContent).toEqual(cli(["workspace", "points", "--open", "--json"]));
123
+ expect(res.structuredContent).toMatchObject({ open: true, sources: [], points: [], questions: [] });
124
+ // The generated workspace declares no answer kind, so there is nothing to answer.
125
+ const answered = await call(s, "points-answer", { id: "slice-tier-000000000000", answer: "small", by: ["alice"] });
126
+ expect(answered.structuredContent).toMatchObject({ verb: "answer", error: { code: "points-undeclared" } });
127
+ const bad = await call(s, "points-answer", { id: "slice-tier-000000000000", answer: "small", by: [] });
128
+ expect(bad.isError).toBe(true);
129
+ }, 120_000);
130
+
131
+ test("an error document is returned as a document, and a flag-shaped value is refused before chant runs", async () => {
132
+ const s = server();
133
+ const missing = await call(s, "workspace-records", { kind: "nowhere/none.kind.mjs" });
134
+ expect(missing.structuredContent).toMatchObject({ error: { code: "kind-unreadable" } });
135
+ const flag = await call(s, "workspace-ls", { at: "--output=/tmp/x" });
136
+ expect(flag.isError).toBe(true);
137
+ expect(flag.content[0].text).toMatch(/at may not start with -/);
138
+ }, 120_000);
139
+
140
+ test("the conformance suite's argv maps to the tool that answers it", () => {
141
+ expect(mcpToolCall(["workspace", "records", "--kind", KIND, "--json"])).toEqual({ name: "workspace-records", arguments: { kind: KIND } });
142
+ expect(mcpToolCall(["workspace", "status", "dev", "--json"])).toEqual({ name: "workspace-status", arguments: { env: "dev" } });
143
+ expect(mcpToolCall(["workspace", "graph", "--intent", "a.mjs:1", "--kind", KIND, "--json"])).toEqual({ name: "workspace-graph", arguments: { intent: "a.mjs:1", kind: [KIND] } });
144
+ expect(mcpToolCall(["workspace", "graph", "--composites", "--json"])).toEqual({ name: "workspace-graph", arguments: { composites: true } });
145
+ expect(mcpToolCall(["workspace", "check", "--format", "json"])).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe("writes", () => {
150
+ test("records-new proposes a decision with MCP in its source, records reads it, and reviews move its quorum", async () => {
151
+ const s = server();
152
+ await rpc(s, "initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "claude-code", version: "2.1.0" } });
153
+ const made = await call(s, "records-new", { kind: KIND, record: proposal("Where the logs go") });
154
+ expect(made.structuredContent).toMatchObject({ id: "fix-002", dryRun: false });
155
+ const read = recordsOf(cli(["workspace", "records", "--kind", KIND, "--json"])).find((r) => r.id === "fix-002")!;
156
+ expect(read).toMatchObject({ state: "proposed", valid: true });
157
+ expect(read.data.source).toEqual({ via: "mcp", client: { name: "claude-code", version: "2.1.0" } });
158
+
159
+ for (const by of ["bob", "carol"]) {
160
+ const res = await call(s, "records-review", { kind: KIND, id: "fix-002", verdict: "agree", by });
161
+ expect(res.structuredContent).toMatchObject({ review: { reviewer: by, verdict: "agree" } });
162
+ }
163
+ const after = await call(s, "workspace-records", { kind: KIND, id: "fix-002" });
164
+ expect(recordsOf(after.structuredContent)[0].quorum).toMatchObject({ agreed: 2, met: true });
165
+ }, 180_000);
166
+
167
+ test("a second client is recorded as itself, from the request's _meta", async () => {
168
+ const s = server();
169
+ await rpc(s, "initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "claude-code", version: "2.1.0" } });
170
+ const made = await call(s, "records-new", { kind: KIND, record: { ...proposal("Where the metrics go"), source: { kind: "workspace", member: "app" } } }, {
171
+ "io.modelcontextprotocol/clientInfo": { name: "codex", version: "0.40.0" },
172
+ });
173
+ const id = (made.structuredContent as { id: string }).id;
174
+ const read = recordsOf(cli(["workspace", "records", "--kind", KIND, "--json"])).find((r) => r.id === id)!;
175
+ expect(read.data.source).toEqual({ kind: "workspace", member: "app", via: "mcp", client: { name: "codex", version: "0.40.0" } });
176
+ }, 120_000);
177
+
178
+ test("keeps the CLI's rules: a record opens proposed, a decided record's reasoning stays, by and sign", async () => {
179
+ const s = server();
180
+ const decided = await call(s, "records-new", {
181
+ kind: KIND,
182
+ record: { ...proposal("Decided at once"), state: "decided", choice: { option: "a", reason: "Simplest." }, decided_by: "lex00", decided_on: "2026-09-25" },
183
+ });
184
+ expect(decided.structuredContent).toMatchObject({ error: { code: "record-state-not-initial", message: expect.stringContaining("opens proposed") } });
185
+
186
+ const reasoning = await call(s, "records-amend", { kind: KIND, id: "fix-001", fields: { question: "Something else?" } });
187
+ expect(reasoning.structuredContent).toMatchObject({ error: { code: "amend-supersede-instead" } });
188
+
189
+ const both = await call(s, "records-new", { kind: KIND, record: { ...proposal("Two authors"), decided_by: "alice" }, by: "bob", dryRun: true });
190
+ expect(both.structuredContent).toMatchObject({ error: { code: "write-input-invalid" } });
191
+ const by = await call(s, "records-new", { kind: KIND, record: proposal("One author"), by: "alice", dryRun: true });
192
+ expect(by.structuredContent, JSON.stringify(by.structuredContent)).toHaveProperty("text");
193
+ expect((by.structuredContent as { text: string }).text).toContain('decided_by: "alice"');
194
+
195
+ // No key configured on this host: the CLI's refusal and remedy.
196
+ const home = { GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
197
+ const saved = { ...process.env };
198
+ Object.assign(process.env, home);
199
+ try {
200
+ const signed = await call(s, "records-new", { kind: KIND, record: proposal("Signed"), by: "alice", sign: true, dryRun: true });
201
+ expect(signed.structuredContent).toMatchObject({ error: { code: "record-sign-failed", message: expect.stringContaining("pass --sign <key file>") } });
202
+ } finally {
203
+ for (const k of Object.keys(home)) {
204
+ if (saved[k] === undefined) delete process.env[k];
205
+ else process.env[k] = saved[k];
206
+ }
207
+ }
208
+ // Nothing above wrote a file.
209
+ expect(readFileSync(join(ws.dir, "decisions", "fix-001-how-the-app-is-deployed.md"), "utf-8")).toContain('question: "What declares the app\'s deployment?"');
210
+ }, 180_000);
211
+ });