@intentius/chant 0.124.1 → 0.124.3

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 (80) hide show
  1. package/dist/cli/handlers/override-approve.d.ts +3 -1
  2. package/dist/cli/handlers/override-approve.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/workspace-tools.d.ts +5 -1
  5. package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +2 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/lifecycle/gate-audit.d.ts +37 -13
  9. package/dist/lifecycle/gate-audit.d.ts.map +1 -1
  10. package/dist/lifecycle/gate-ledger.d.ts +1 -33
  11. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  12. package/dist/lifecycle/gate-origin.d.ts +14 -2
  13. package/dist/lifecycle/gate-origin.d.ts.map +1 -1
  14. package/dist/lifecycle/gate-pending.d.ts +41 -0
  15. package/dist/lifecycle/gate-pending.d.ts.map +1 -0
  16. package/dist/op/activities/activity-contracts.d.ts +1 -0
  17. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  18. package/dist/op/activities/decide.d.ts +2 -0
  19. package/dist/op/activities/decide.d.ts.map +1 -1
  20. package/dist/op/gate-point-run.d.ts +5 -77
  21. package/dist/op/gate-point-run.d.ts.map +1 -1
  22. package/dist/op/gate-point.d.ts +18 -0
  23. package/dist/op/gate-point.d.ts.map +1 -1
  24. package/dist/op/gate.d.ts +8 -0
  25. package/dist/op/gate.d.ts.map +1 -1
  26. package/dist/op/index.d.ts +4 -4
  27. package/dist/op/index.d.ts.map +1 -1
  28. package/dist/op/op-waves-run.d.ts +37 -6
  29. package/dist/op/op-waves-run.d.ts.map +1 -1
  30. package/dist/op/op-waves.d.ts +38 -4
  31. package/dist/op/op-waves.d.ts.map +1 -1
  32. package/dist/op/steward-points.d.ts +7 -0
  33. package/dist/op/steward-points.d.ts.map +1 -1
  34. package/dist/override-gate.d.ts +8 -2
  35. package/dist/override-gate.d.ts.map +1 -1
  36. package/dist/point-decision.d.ts +134 -0
  37. package/dist/point-decision.d.ts.map +1 -0
  38. package/dist/pr-forge.d.ts +37 -2
  39. package/dist/pr-forge.d.ts.map +1 -1
  40. package/dist/workspace/decide.d.ts +14 -2
  41. package/dist/workspace/decide.d.ts.map +1 -1
  42. package/dist/workspace/reason-codes.d.ts +2 -1
  43. package/dist/workspace/reason-codes.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/cli/handlers/override-approve.test.ts +16 -0
  46. package/src/cli/handlers/override-approve.ts +6 -2
  47. package/src/cli/main.test.ts +3 -0
  48. package/src/cli/main.ts +9 -2
  49. package/src/cli/mcp/workspace-tools.ts +7 -3
  50. package/src/cli/registry.ts +2 -0
  51. package/src/lifecycle/gate-audit.test.ts +42 -0
  52. package/src/lifecycle/gate-audit.ts +102 -34
  53. package/src/lifecycle/gate-ledger.ts +3 -53
  54. package/src/lifecycle/gate-origin.ts +20 -3
  55. package/src/lifecycle/gate-pending.ts +62 -0
  56. package/src/op/activities/activity-contracts.ts +1 -0
  57. package/src/op/activities/decide.ts +3 -0
  58. package/src/op/gate-point-ci.e2e.test.ts +171 -0
  59. package/src/op/gate-point-mcp.e2e.test.ts +163 -0
  60. package/src/op/gate-point-run.ts +18 -149
  61. package/src/op/gate-point.e2e.test.ts +2 -0
  62. package/src/op/gate-point.test.ts +1 -1
  63. package/src/op/gate-point.ts +27 -3
  64. package/src/op/gate.ts +10 -0
  65. package/src/op/index.ts +10 -1
  66. package/src/op/op-waves-run.ts +84 -19
  67. package/src/op/op-waves.test.ts +96 -0
  68. package/src/op/op-waves.ts +60 -4
  69. package/src/op/steward-points.ts +8 -0
  70. package/src/override-gate.test.ts +5 -0
  71. package/src/override-gate.ts +8 -2
  72. package/src/point-decision-bundle.test.ts +62 -0
  73. package/src/point-decision.e2e.test.ts +95 -0
  74. package/src/point-decision.ts +280 -0
  75. package/src/pr-forge.test.ts +62 -0
  76. package/src/pr-forge.ts +60 -9
  77. package/src/workspace/decide.ts +92 -8
  78. package/src/workspace/points-cli.ts +1 -1
  79. package/src/workspace/points-write.schema.json +1 -0
  80. package/src/workspace/reason-codes.ts +2 -1
@@ -48,6 +48,9 @@ import type { GateApprover, GatePolicyDecision, ResolvedGateApproval } from "../
48
48
  import type { GateRunLocator } from "../op/gate-resume";
49
49
  import { readBlobFromPath, readPathSha, readBlobBySha, writeBlobToPath, RefCASConflictError } from "./git";
50
50
 
51
+ // The facts a pending record is written with live apart, so a bundle can import them without git (#3816).
52
+ export { DEFAULT_GATE_EXPIRY, isApprovalUrl, resolveApprovalUrl } from "./gate-pending";
53
+
51
54
  // The selection rules a gate decides by live apart, so a bundle can import them without git (#3763).
52
55
  export {
53
56
  isPendingGateExpired,
@@ -63,57 +66,6 @@ const DIR = "_gates";
63
66
  export const GATES_DIR = DIR;
64
67
  const APPEND_RETRY_ATTEMPTS = 5;
65
68
 
66
- /**
67
- * The address of the approval surface for a gate, resolved from the CI
68
- * environment (#2028).
69
- *
70
- * #1485's argument for gate-as-fact was that approval gets an address:
71
- * "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
72
- * authorization model." What shipped recorded the gate and not the address,
73
- * so a pending-approval card had nothing to link to and gate-as-PR stayed a
74
- * convention. This is the narrow, honest half of that: when a tick (or a
75
- * `chant approve`) runs inside the PR/MR job that carries the change, the
76
- * loop genuinely knows where approval happens, and says so. Anywhere else it
77
- * returns `undefined` and the field is simply absent — never a guess, never a
78
- * synthesized link.
79
- *
80
- * The env-var fallback chain is the same one `--actor` and `--run-id` already
81
- * use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
82
- *
83
- * - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
84
- * `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
85
- * `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
86
- * - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
87
- * `CI_MERGE_REQUEST_IID`.
88
- */
89
- export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
90
- const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
91
- if (prNumber && env.GITHUB_REPOSITORY) {
92
- const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
93
- return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
94
- }
95
-
96
- if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
97
- return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
98
- }
99
-
100
- return undefined;
101
- }
102
-
103
- /**
104
- * Whether `raw` is an address worth recording as one: an absolute `http`/
105
- * `https` URL. A gate's address is a link a reader is expected to follow, so
106
- * a relative path or a `file:`/`javascript:` scheme is refused at the CLI
107
- * boundary rather than written into an immutable record.
108
- */
109
- export function isApprovalUrl(raw: string): boolean {
110
- try {
111
- const parsed = new URL(raw);
112
- return parsed.protocol === "http:" || parsed.protocol === "https:";
113
- } catch {
114
- return false;
115
- }
116
- }
117
69
 
118
70
  /**
119
71
  * The answer record a gate that asks a decision point cites (#3170): the
@@ -342,8 +294,6 @@ export type PendingGateInput = Omit<PendingGateRecord, "version" | "kind">;
342
294
  /** Either kind of line in `_gates/<op>.jsonl`. */
343
295
  export type GateLedgerRecord = GateResolutionRecord | PendingGateRecord;
344
296
 
345
- /** The default a gate's pending fact expires after when its `GateStep` declares no `timeout` — the same 48h `GateStep.timeout` documents as its own default. */
346
- export const DEFAULT_GATE_EXPIRY = "48h";
347
297
 
348
298
  function filename(op: string): string {
349
299
  return `${op}.jsonl`;
@@ -66,23 +66,40 @@ export const UNATTESTED_APPROVER = "unattested";
66
66
 
67
67
  let ambient: GateOrigin = "cli";
68
68
 
69
+ /**
70
+ * The environment variable a model-authored channel carries into child
71
+ * processes (chant#3817), as a steward's turn carries `CHANT_STEWARD`: a
72
+ * `chant workspace points answer` or `chant approve` a process on MCP or ACP
73
+ * starts is that channel's too. Only `mcp` and `acp` are read from it, so it
74
+ * can make a process model-authored and never make one a person's.
75
+ */
76
+ export const GATE_ORIGIN_ENV = "CHANT_GATE_ORIGIN";
77
+
69
78
  /**
70
79
  * Declare the channel this process serves. Called once at an entry point, not
71
80
  * per call: `chant mcp` sets `"mcp"` before it serves anything, `chant acp`
72
- * sets `"acp"`, and the CLI leaves the default.
81
+ * sets `"acp"`, and the CLI leaves the default. A model-authored channel is
82
+ * also exported as {@link GATE_ORIGIN_ENV}, so a child process knows it too.
73
83
  */
74
84
  export function setGateOrigin(origin: GateOrigin): void {
75
85
  ambient = origin;
86
+ if (isModelAuthored(origin)) process.env[GATE_ORIGIN_ENV] = origin;
76
87
  }
77
88
 
78
- /** The channel this process serves. `"cli"` unless an entry point said otherwise. */
89
+ /**
90
+ * The channel this process serves. `"cli"` unless an entry point said
91
+ * otherwise, or this process was started by one on MCP or ACP.
92
+ */
79
93
  export function currentGateOrigin(): GateOrigin {
80
- return ambient;
94
+ if (ambient !== "cli") return ambient;
95
+ const inherited = process.env[GATE_ORIGIN_ENV];
96
+ return inherited === "mcp" || inherited === "acp" ? inherited : ambient;
81
97
  }
82
98
 
83
99
  /** Restore the default. For tests, which must not leak a channel into each other. */
84
100
  export function resetGateOrigin(): void {
85
101
  ambient = "cli";
102
+ delete process.env[GATE_ORIGIN_ENV];
86
103
  }
87
104
 
88
105
  /**
@@ -0,0 +1,62 @@
1
+ /**
2
+ * What a pending gate fact is written with, apart from the ledger's git code
3
+ * (#3816): its default expiry and the address of its approval surface. A
4
+ * bundle that records a pending fact through its own port, such as
5
+ * `@intentius/chant/point-decision`, imports these without git.
6
+ * `./gate-ledger.ts` re-exports them.
7
+ */
8
+
9
+ /** The default a gate's pending fact expires after when its `GateStep` declares no `timeout` — the same 48h `GateStep.timeout` documents as its own default. */
10
+ export const DEFAULT_GATE_EXPIRY = "48h";
11
+
12
+ /**
13
+ * The address of the approval surface for a gate, resolved from the CI
14
+ * environment (#2028).
15
+ *
16
+ * #1485's argument for gate-as-fact was that approval gets an address:
17
+ * "gate-as-PR gives approval a URL, a review surface, and CODEOWNERS as the
18
+ * authorization model." What shipped recorded the gate and not the address,
19
+ * so a pending-approval card had nothing to link to and gate-as-PR stayed a
20
+ * convention. This is the narrow, honest half of that: when a tick (or a
21
+ * `chant approve`) runs inside the PR/MR job that carries the change, the
22
+ * loop genuinely knows where approval happens, and says so. Anywhere else it
23
+ * returns `undefined` and the field is simply absent — never a guess, never a
24
+ * synthesized link.
25
+ *
26
+ * The env-var fallback chain is the same one `--actor` and `--run-id` already
27
+ * use (`../cli/handlers/components.ts`): GitHub Actions first, then GitLab CI.
28
+ *
29
+ * - GitHub Actions on a `pull_request` event: `GITHUB_SERVER_URL` +
30
+ * `GITHUB_REPOSITORY` + the PR number, which `GITHUB_REF_NAME` carries as
31
+ * `<n>/merge`. A push-event run has no PR, so it resolves to nothing.
32
+ * - GitLab CI on a merge-request pipeline: `CI_MERGE_REQUEST_PROJECT_URL` +
33
+ * `CI_MERGE_REQUEST_IID`.
34
+ */
35
+ export function resolveApprovalUrl(env: NodeJS.ProcessEnv = process.env): string | undefined {
36
+ const prNumber = /^(\d+)\/(merge|head)$/.exec(env.GITHUB_REF_NAME ?? "")?.[1];
37
+ if (prNumber && env.GITHUB_REPOSITORY) {
38
+ const server = (env.GITHUB_SERVER_URL ?? "https://github.com").replace(/\/$/, "");
39
+ return `${server}/${env.GITHUB_REPOSITORY}/pull/${prNumber}`;
40
+ }
41
+
42
+ if (env.CI_MERGE_REQUEST_PROJECT_URL && env.CI_MERGE_REQUEST_IID) {
43
+ return `${env.CI_MERGE_REQUEST_PROJECT_URL.replace(/\/$/, "")}/-/merge_requests/${env.CI_MERGE_REQUEST_IID}`;
44
+ }
45
+
46
+ return undefined;
47
+ }
48
+
49
+ /**
50
+ * Whether `raw` is an address worth recording as one: an absolute `http`/
51
+ * `https` URL. A gate's address is a link a reader is expected to follow, so
52
+ * a relative path or a `file:`/`javascript:` scheme is refused at the CLI
53
+ * boundary rather than written into an immutable record.
54
+ */
55
+ export function isApprovalUrl(raw: string): boolean {
56
+ try {
57
+ const parsed = new URL(raw);
58
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
@@ -320,6 +320,7 @@ export const decideContract = activityContract(
320
320
  cwd: z.string().optional(),
321
321
  backends: z.record(z.string(), backendSchema).optional(),
322
322
  dryRun: z.boolean().optional(),
323
+ store: z.enum(["tree", "ledger"]).optional(),
323
324
  }),
324
325
  z.strictObject({
325
326
  id: z.string(),
@@ -65,6 +65,8 @@ export interface DecideArgs {
65
65
  backends?: Record<string, DecideBackend>;
66
66
  /** Ask, but write nothing, and return the question whatever its state: an open one does not stop the run. */
67
67
  dryRun?: boolean;
68
+ /** Where a new question goes (#3815): `"ledger"` holds it on chant/lifecycle, for a run whose checkout does not outlive it. Default: the ledger in a steward's turn, the tree otherwise. */
69
+ store?: "tree" | "ledger";
68
70
  }
69
71
 
70
72
  export interface DecideResult {
@@ -234,6 +236,7 @@ export async function runDecide(args: DecideArgs, deps: DecideDeps = {}, signal?
234
236
  inputs,
235
237
  ...(args.subject !== undefined ? { subject: args.subject } : {}),
236
238
  ...(args.kind !== undefined ? { kind: args.kind } : {}),
239
+ ...(args.store !== undefined ? { store: args.store } : {}),
237
240
  ...(deps.on ? { on: deps.on } : {}),
238
241
  };
239
242
 
@@ -0,0 +1,171 @@
1
+ /**
2
+ * #3815: a gate's decision-point question survives a CI job. Outside a
3
+ * steward's turn a question used to go to the working tree, which a CI
4
+ * checkout throws away, so `points answer` elsewhere found nothing and the
5
+ * next run asked again. Asked into the ledger (`--store ledger`, a gate's
6
+ * `point.store`, or by default when `CI` is set), it lands on
7
+ * `chant/lifecycle`, is pushed, and is found and answered from another clone;
8
+ * the job's next run, in a fresh clone, passes on that answer.
9
+ *
10
+ * Three clones of one bare remote stand for the CI job's checkout, the
11
+ * person's clone the answer comes from, and the next job's checkout. The gate
12
+ * ledger the runs write is the in-memory port, so only the question and its
13
+ * answer travel through the remote.
14
+ */
15
+
16
+ import { execFileSync } from "node:child_process";
17
+ import { existsSync, mkdtempSync, readdirSync, realpathSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
21
+ import { cleanScratch, git, workspace } from "../workspace/__fixtures__/decide-workspace";
22
+ import { runWorkspacePoints } from "../workspace/points-cli";
23
+ import type { CommandContext } from "../cli/registry";
24
+ import { ApplyOp } from "./composites/apply-op";
25
+ import { memoryGateLedgerPort } from "./gate";
26
+ import { defaultGatePointStore, type GatePoint } from "./gate-point";
27
+ import { runOpLocally } from "./local-executor";
28
+ import type { ActivityFn } from "./activity-registry";
29
+ import type { OpConfig } from "./types";
30
+
31
+ const PLAN = `jcs1-sha256:${"4".repeat(64)}`;
32
+
33
+ const POINTS = {
34
+ points: {
35
+ "prod-apply": {
36
+ title: "Apply this plan to prod",
37
+ question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
38
+ inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
39
+ deciders: [{ kind: "quorum", count: 1 }],
40
+ },
41
+ "ship-it": {
42
+ title: "Ship it",
43
+ question: { type: "noul", instructions: "Ship it?", criteria: { true: "Ship.", false: "Hold." } },
44
+ inputs: { "release.version": "the version" },
45
+ deciders: [{ kind: "quorum", count: 1 }],
46
+ },
47
+ },
48
+ };
49
+
50
+ const applied: string[] = [];
51
+ const activities = new Map<string, ActivityFn>([
52
+ ["chantBuild", async () => ({ ok: true })],
53
+ ["lifecycleDiff", async () => ({ planDigest: PLAN, drifted: true })],
54
+ ["nativeApply", async () => {
55
+ applied.push(PLAN);
56
+ return { ok: true };
57
+ }],
58
+ ]);
59
+
60
+ const config = (name: string, point: string | GatePoint): OpConfig =>
61
+ (ApplyOp({ name, env: "prod", target: "kubectl", gate: { gate: "approve-apply", point } }).op as unknown as { props: OpConfig }).props;
62
+
63
+ let home: string;
64
+ let remote: string;
65
+
66
+ /** A fresh clone of the remote: a CI job's checkout, or a person's. */
67
+ function clone(name: string): string {
68
+ const dir = join(home, name);
69
+ execFileSync("git", ["clone", "-q", remote, dir], { stdio: "ignore" });
70
+ return dir;
71
+ }
72
+
73
+ /** `chant workspace points <argv>` through the command's handler, in `cwd`. Returns the document it prints. */
74
+ async function points(cwd: string, args: Record<string, unknown>): Promise<Record<string, unknown> & { error?: { code: string } }> {
75
+ const printed: string[] = [];
76
+ const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
77
+ printed.push(String(line));
78
+ });
79
+ const was = process.cwd();
80
+ process.chdir(cwd);
81
+ try {
82
+ await runWorkspacePoints({ args, plugins: [], serializers: [] } as unknown as CommandContext);
83
+ } finally {
84
+ process.chdir(was);
85
+ log.mockRestore();
86
+ }
87
+ return JSON.parse(printed.join("\n"));
88
+ }
89
+
90
+ const answersIn = (dir: string): string[] => (existsSync(join(dir, "answers")) ? readdirSync(join(dir, "answers")).filter((f) => f.endsWith(".md")) : []);
91
+ const run = (op: OpConfig, cwd: string) => runOpLocally(op, activities, {}, undefined, { gates: memoryGateLedgerPort(), cwd, now: "2026-10-11T12:00:00.000Z" });
92
+
93
+ beforeAll(() => {
94
+ home = realpathSync(mkdtempSync(join(tmpdir(), "chant-gate-ci-")));
95
+ const root = workspace(POINTS);
96
+ remote = join(home, "remote.git");
97
+ execFileSync("git", ["clone", "-q", "--bare", root, remote], { stdio: "ignore" });
98
+ });
99
+
100
+ afterEach(() => {
101
+ vi.unstubAllEnvs();
102
+ });
103
+
104
+ afterAll(() => {
105
+ cleanScratch();
106
+ rmSync(home, { recursive: true, force: true });
107
+ });
108
+
109
+ describe("a gate's question outlives the CI job that asked it (#3815)", () => {
110
+ test("a gate with point.store ledger: asked in one clone, answered from another, and the next job's clone passes on it", async () => {
111
+ vi.stubEnv("CI", "");
112
+ const op = config("prod-apply", { name: "prod-apply", store: "ledger" });
113
+
114
+ const job = clone("job-1");
115
+ const first = await run(op, job);
116
+ expect(first.status).toBe("waiting");
117
+ const id = first.point!.id;
118
+ // Nothing was written to the job's working tree, which goes away with the job.
119
+ expect(answersIn(job)).toEqual([]);
120
+ expect(git(job, "status", "--porcelain")).toBe("");
121
+
122
+ // A person's clone, which never ran the job, finds the question and answers it.
123
+ const person = clone("person");
124
+ const answered = await points(person, { extraPositional: "answer", extraPositional2: id, answer: "yes", bys: ["alice"] });
125
+ expect(answered).toMatchObject({ verb: "answer", id, written: true, question: { state: "answered", answer: true, ledger: expect.stringContaining("chant/lifecycle:") } });
126
+
127
+ // The next job, in a fresh checkout, passes on that answer.
128
+ const next = await run(op, clone("job-2"));
129
+ expect(next.status).toBe("ok");
130
+ expect(applied).toEqual([PLAN]);
131
+ });
132
+
133
+ test("with CI set, a gate that names no store asks into the ledger", async () => {
134
+ expect(defaultGatePointStore({ CI: "true" })).toBe("ledger");
135
+ expect(defaultGatePointStore({ CI: "false" })).toBeUndefined();
136
+ expect(defaultGatePointStore({})).toBeUndefined();
137
+
138
+ vi.stubEnv("CI", "true");
139
+ const op = config("ci-apply", "prod-apply");
140
+ const job = clone("job-3");
141
+ const first = await run(op, job);
142
+ expect(first.status).toBe("waiting");
143
+ expect(answersIn(job)).toEqual([]);
144
+
145
+ const person = clone("person-2");
146
+ expect(await points(person, { extraPositional: "answer", extraPositional2: first.point!.id, answer: "yes", bys: ["alice"] })).toMatchObject({ written: true });
147
+ expect((await run(op, clone("job-4"))).status).toBe("ok");
148
+ });
149
+
150
+ test("chant workspace points ask --store ledger holds the question on chant/lifecycle, and another clone answers it", async () => {
151
+ vi.stubEnv("CI", "");
152
+ const job = clone("ask-job");
153
+ const asked = await points(job, { extraPositional: "ask", extraPositional2: "ship-it", inputs: JSON.stringify({ "release.version": "1.2.3" }), store: "ledger" });
154
+ expect(asked).toMatchObject({ verb: "ask", written: true, question: { state: "escalated", ledger: expect.stringContaining("chant/lifecycle:") } });
155
+ expect(answersIn(job)).toEqual([]);
156
+
157
+ const id = asked.id as string;
158
+ const person = clone("ask-person");
159
+ expect(await points(person, { extraPositional: "answer", extraPositional2: id, answer: "no", bys: ["bob"] })).toMatchObject({ written: true, question: { state: "answered", answer: false } });
160
+
161
+ // Asked again from a fresh checkout, the question is the answered one.
162
+ const again = await points(clone("ask-job-2"), { extraPositional: "ask", extraPositional2: "ship-it", inputs: JSON.stringify({ "release.version": "1.2.3" }), store: "ledger" });
163
+ expect(again).toMatchObject({ reused: true, question: { state: "answered", answer: false } });
164
+
165
+ // Without --store ledger the same ask in a clone with no ledger history goes to the tree, as before.
166
+ const tree = clone("ask-tree");
167
+ const local = await points(tree, { extraPositional: "ask", extraPositional2: "ship-it", inputs: JSON.stringify({ "release.version": "9.9.9" }) });
168
+ expect(local).toMatchObject({ written: true, question: { ledger: null } });
169
+ expect(answersIn(tree)).toHaveLength(1);
170
+ });
171
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * #3817: a gate that asks a decision point is not passed by an answer sent
3
+ * over MCP or ACP. The answer there is an agent's, so nobody it names counts
4
+ * toward a human quorum and the gate keeps waiting; a question whose gate is
5
+ * `neverOverMcp` is refused outright. The same answer from a shell answers
6
+ * it, and the next run passes.
7
+ *
8
+ * The workspace is real (a git repository with the reference workspace's
9
+ * answer kind, a points file and a trust policy at base). The MCP answer goes
10
+ * through the `points-answer` tool's own handler with this process serving
11
+ * MCP, the shell answer through `runWorkspacePoints`. The gate ledger the run
12
+ * writes is the in-memory port; the `neverOverMcp` pending fact is on the
13
+ * workspace's own `chant/lifecycle`, where `points answer` reads it.
14
+ */
15
+
16
+ import { writeFileSync, mkdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest";
19
+ import { cleanScratch, git, workspace } from "../workspace/__fixtures__/decide-workspace";
20
+ import { runWorkspacePoints } from "../workspace/points-cli";
21
+ import type { CommandContext } from "../cli/registry";
22
+ import { createWorkspaceTools } from "../cli/mcp/workspace-tools";
23
+ import { GATE_ORIGIN_ENV, resetGateOrigin, setGateOrigin } from "../lifecycle/gate-origin";
24
+ import { appendPendingGate } from "../lifecycle/gate-ledger";
25
+ import { ApplyOp } from "./composites/apply-op";
26
+ import { memoryGateLedgerPort } from "./gate";
27
+ import { runOpLocally } from "./local-executor";
28
+ import type { ActivityFn } from "./activity-registry";
29
+ import type { OpConfig } from "./types";
30
+
31
+ const PLAN = `jcs1-sha256:${"3".repeat(64)}`;
32
+
33
+ const POINTS = {
34
+ points: {
35
+ "prod-apply": {
36
+ title: "Apply this plan to prod",
37
+ question: { type: "noul", instructions: "Should this plan be applied to prod?", criteria: { true: "Apply it.", false: "Do not apply it." } },
38
+ inputs: { "gate.component": "the Op the gate belongs to", "gate.planDigest": "the plan the gate binds" },
39
+ deciders: [{ kind: "quorum", count: 2, roles: ["lead"] }],
40
+ },
41
+ },
42
+ };
43
+
44
+ let root: string;
45
+ const applied: string[] = [];
46
+ const activities = new Map<string, ActivityFn>([
47
+ ["chantBuild", async () => ({ ok: true })],
48
+ ["lifecycleDiff", async () => ({ planDigest: PLAN, drifted: true })],
49
+ ["nativeApply", async () => {
50
+ applied.push(PLAN);
51
+ return { ok: true };
52
+ }],
53
+ ]);
54
+
55
+ const config = (name: string): OpConfig =>
56
+ (ApplyOp({ name, env: "prod", target: "kubectl", gate: { gate: "approve-apply", point: "prod-apply" } }).op as unknown as { props: OpConfig }).props;
57
+
58
+ type Doc = Record<string, unknown> & { error?: { code: string; message: string } };
59
+
60
+ /** The `points-answer` and `points-retract` tools, as `chant serve mcp` serves them in the workspace. */
61
+ function mcpTool(name: "points-answer" | "points-retract"): (params: Record<string, unknown>) => Promise<Doc> {
62
+ const tool = createWorkspaceTools({ cwd: root }).find((t) => t.definition.name === name)!;
63
+ return async (params) => (await tool.handler(params, {} as never)) as Doc;
64
+ }
65
+
66
+ /** `chant workspace points <verb> <id> ...` through the command's handler, in the workspace. Returns the document it prints. */
67
+ async function shell(verb: "answer" | "retract", id: string, by: string[], answer?: string): Promise<Doc> {
68
+ const printed: string[] = [];
69
+ const log = vi.spyOn(console, "log").mockImplementation((line: unknown) => {
70
+ printed.push(String(line));
71
+ });
72
+ const err = vi.spyOn(console, "error").mockImplementation(() => {});
73
+ const was = process.cwd();
74
+ process.chdir(root);
75
+ try {
76
+ const ctx = { args: { extraPositional: verb, extraPositional2: id, ...(answer !== undefined ? { answer } : {}), bys: by, json: true }, plugins: [], serializers: [] } as unknown as CommandContext;
77
+ await runWorkspacePoints(ctx);
78
+ } finally {
79
+ process.chdir(was);
80
+ log.mockRestore();
81
+ err.mockRestore();
82
+ }
83
+ return JSON.parse(printed.join("\n")) as Doc;
84
+ }
85
+
86
+ beforeAll(() => {
87
+ root = workspace(POINTS);
88
+ // The questions go to the working tree, as outside CI: a CI job asks into the ledger by default (#3815).
89
+ vi.stubEnv("CI", "");
90
+ mkdirSync(join(root, ".chant"), { recursive: true });
91
+ writeFileSync(join(root, ".chant", "trust.json"), JSON.stringify({ schema: 1, roles: { agent: ["bot"], lead: ["alice", "bob"] } }));
92
+ git(root, "add", "-A");
93
+ git(root, "commit", "-q", "-m", "trust");
94
+ });
95
+
96
+ afterEach(() => resetGateOrigin());
97
+ afterAll(cleanScratch);
98
+
99
+ describe("an answer over MCP or ACP never passes a gate that needs a human quorum (#3817)", () => {
100
+ test("points-answer over MCP naming the quorum's people does not answer; the gate keeps waiting; the same answer from a shell passes it", async () => {
101
+ const port = memoryGateLedgerPort();
102
+ const op = config("prod-apply");
103
+
104
+ const first = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-11T10:00:00.000Z" });
105
+ expect(first.status).toBe("waiting");
106
+ const id = first.point!.id;
107
+
108
+ // This process now serves MCP: alice and bob both hold lead, and neither counts.
109
+ setGateOrigin("mcp");
110
+ const overMcp = await mcpTool("points-answer")({ id, answer: true, by: ["alice", "bob"] });
111
+ expect(overMcp.error?.code).toBe("quorum-not-met");
112
+ expect(overMcp.error?.message).toContain("alice, who was named over MCP");
113
+ expect(overMcp.error?.message).toContain("bob, who was named over MCP");
114
+
115
+ // ACP is the same, and so is `chant workspace points answer` in a process a session on either started.
116
+ setGateOrigin("acp");
117
+ expect((await mcpTool("points-answer")({ id, answer: true, by: ["alice", "bob"] })).error?.code).toBe("quorum-not-met");
118
+ resetGateOrigin();
119
+ process.env[GATE_ORIGIN_ENV] = "mcp";
120
+ expect((await shell("answer", id, ["alice", "bob"], "yes")).error?.code).toBe("quorum-not-met");
121
+ resetGateOrigin();
122
+
123
+ const still = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-11T10:05:00.000Z" });
124
+ expect(still.status).toBe("waiting");
125
+ expect(still.point?.id).toBe(id);
126
+ expect(applied).toEqual([]);
127
+ expect(port.resolved).toHaveLength(0);
128
+
129
+ // From a shell the same people answer it, and the next run passes on the answer.
130
+ const answered = await shell("answer", id, ["alice", "bob"], "yes");
131
+ expect(answered).toMatchObject({ verb: "answer", id, written: true, question: { state: "answered", answer: true, answeredBy: ["alice", "bob"] } });
132
+ const passed = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-11T10:10:00.000Z" });
133
+ expect(passed.status).toBe("ok");
134
+ expect(applied).toEqual([PLAN]);
135
+
136
+ // Taking the answer back follows the same rule.
137
+ setGateOrigin("mcp");
138
+ expect((await mcpTool("points-retract")({ id, by: ["alice", "bob"] })).error?.code).toBe("quorum-not-met");
139
+ });
140
+
141
+ test("a question whose gate is neverOverMcp is refused over MCP outright, and answered from a shell", async () => {
142
+ const port = memoryGateLedgerPort();
143
+ const op = config("tf-apply");
144
+
145
+ const first = await runOpLocally(op, activities, {}, undefined, { gates: port, cwd: root, now: "2026-10-11T11:00:00.000Z" });
146
+ expect(first.status).toBe("waiting");
147
+ const id = first.point!.id;
148
+ // The gate's pending fact on chant/lifecycle says it is never resolved over MCP, as terragucci's tf-apply records it.
149
+ await appendPendingGate({ op: "tf-apply", gate: "approve-apply", timestamp: "2026-10-11T11:00:00.000Z", expiresAt: "2026-10-13T11:00:00.000Z", planDigest: PLAN, neverOverMcp: true, answer: { point: "prod-apply", id, path: first.point!.path } }, { cwd: root });
150
+
151
+ setGateOrigin("mcp");
152
+ const overMcp = await mcpTool("points-answer")({ id, answer: true, by: ["alice", "bob"] });
153
+ expect(overMcp.error?.code).toBe("answer-never-over-mcp");
154
+ expect(overMcp.error?.message).toContain('gate "approve-apply" on "tf-apply"');
155
+ resetGateOrigin();
156
+
157
+ const answered = await shell("answer", id, ["alice", "bob"], "yes");
158
+ expect(answered).toMatchObject({ verb: "answer", id, written: true, question: { state: "answered" } });
159
+
160
+ setGateOrigin("acp");
161
+ expect((await mcpTool("points-retract")({ id, by: ["alice", "bob"] })).error?.code).toBe("answer-never-over-mcp");
162
+ });
163
+ });