@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
@@ -93,6 +93,7 @@ import { IdentityError, refuseUnidentified } from "./identity";
93
93
  import { scopeSource } from "./write-scope";
94
94
  import { RefCASConflictError } from "../lifecycle/git";
95
95
  import { readLedgerAnswers, refreshLedger, withLedgerAnswers, writeLedgerAnswer, type LedgerAnswers } from "./answers-ledger";
96
+ import { currentGateOrigin, isModelAuthored, type GateOrigin } from "../lifecycle/gate-origin";
96
97
 
97
98
  /** The version of the write documents `points ask`, `points answer` and `points retract` print. */
98
99
  export const POINTS_WRITE_CONTRACT_VERSION = 1;
@@ -122,6 +123,8 @@ export const POINTS_WRITE_ERROR_CODES = [
122
123
  "record-id-taken",
123
124
  /** The answer was given during a steward's turn (#2749): a steward never answers a question, its own or another's. */
124
125
  "answer-in-steward-turn",
126
+ /** The answer or retraction came over MCP or ACP, and the question constrains a gate that is never resolved there (#3817). */
127
+ "answer-never-over-mcp",
125
128
  /** An answerer is named by a bare name, and identity.attribution at base is identified (#3163). */
126
129
  "principal-unidentified",
127
130
  /** `points retract` names a question that has no answer: escalated or proposed (#3351). */
@@ -442,7 +445,9 @@ export interface AskPointOptions {
442
445
  * or the lifecycle ledger (#2786). Defaults to the ledger in a steward's
443
446
  * turn (`steward` given, or this process a steward's turn), since a steward
444
447
  * never writes the checkout, and to the tree otherwise. A question already
445
- * held on the ledger is rewritten there whatever this says.
448
+ * held on the ledger is rewritten there whatever this says. Outside a
449
+ * steward's turn, `points ask --store ledger` and a gate's `point.store`
450
+ * reach it (#3815), for a checkout that does not outlive the run.
446
451
  */
447
452
  store?: "tree" | "ledger";
448
453
  /** The date written as asked_on, and answered_on for a table's answer, YYYY-MM-DD. Defaults to today, in UTC. */
@@ -715,6 +720,13 @@ export interface AnswerPointOptions {
715
720
  kind?: string;
716
721
  on?: string;
717
722
  dryRun?: boolean;
723
+ /**
724
+ * The channel the answer came through (#3817). Defaults to this process's
725
+ * (`currentGateOrigin`). Over MCP or ACP nobody `by` names counts toward
726
+ * the quorum, and a question that constrains a `neverOverMcp` gate is
727
+ * refused outright.
728
+ */
729
+ origin?: GateOrigin;
718
730
  }
719
731
 
720
732
  /**
@@ -813,7 +825,10 @@ async function findQuestion(cwd: string, kind: string | undefined, id: string):
813
825
  if (kinds.length === 0) throw new PointsWriteError("points-undeclared", "no record kind with an answers block is declared: name one with --kind, or declare one in chant.workspace.json");
814
826
  for (const k of kinds) {
815
827
  const opened = await openAnswers(k, cwd);
816
- // A question a steward asked is on the ledger (#2786), and people's answer goes back there.
828
+ // A question a steward asked is on the ledger (#2786), and people's answer goes back there. So is
829
+ // one a CI job asked with --store ledger (#3815), from a checkout that is gone: fetch the
830
+ // branch first, so a clone that never ran the job finds the question.
831
+ await refreshLedger(opened.o.loaded.file);
817
832
  const read = await readWithLedger(opened.o);
818
833
  const target = read.before.find((e) => e.id === id);
819
834
  if (target && target.data !== null) return { opened, before: read.before, read, target: target as FoundQuestion["target"] };
@@ -821,20 +836,83 @@ async function findQuestion(cwd: string, kind: string | undefined, id: string):
821
836
  throw new PointsWriteError("record-not-found", `no answer record has id ${id}`);
822
837
  }
823
838
 
824
- /** Count `by` toward the point's quorum, or refuse with `quorum-not-met`. */
825
- function meetQuorum(name: string, by: string[], quorum: { count: number; roles?: string[] }, root: string, steward: string | undefined, verb: "answer" | "retract"): { counted: string[] } {
826
- const tally = tallyQuorum(by, quorum, policyFor(root), steward);
839
+ /** A model-authored channel's name, as a person reads it. */
840
+ const channelName = (origin: GateOrigin): string => (origin === "acp" ? "ACP" : "MCP");
841
+
842
+ /**
843
+ * Count `by` toward the point's quorum, or refuse with `quorum-not-met`.
844
+ *
845
+ * Over MCP or ACP (#3817) the names are the model's to choose, as a gate's
846
+ * approver is (`lifecycle/gate-origin.ts`), so the answer is an agent's and
847
+ * nobody it names counts: a person answers at a shell or through hud.
848
+ */
849
+ function meetQuorum(name: string, by: string[], quorum: { count: number; roles?: string[] }, root: string, steward: string | undefined, verb: "answer" | "retract", origin: GateOrigin): { counted: string[] } {
850
+ const overModel = isModelAuthored(origin);
851
+ const tally = overModel
852
+ ? { counted: [] as string[], left: by.map((n) => ({ name: n, why: `was named over ${channelName(origin)}, where an answer is an agent's` })), met: false }
853
+ : tallyQuorum(by, quorum, policyFor(root), steward);
827
854
  if (!tally.met) {
828
855
  const got = tally.counted.length;
829
856
  const left = tally.left.length ? `; not counted: ${tally.left.map((l) => `${l.name}, who ${l.why}`).join("; ")}` : "";
857
+ const remedy = overModel ? `: a person ${verb === "answer" ? "answers" : "retracts it"} at a shell with \`chant workspace points ${verb}\`, or through hud` : "";
830
858
  throw new PointsWriteError(
831
859
  "quorum-not-met",
832
- `${name} needs ${quorum.count} ${quorum.count === 1 ? "person" : "people"}${quorum.roles ? ` holding ${quorum.roles.join(" or ")}` : ""} to ${verb === "answer" ? "answer" : "retract an answer"}, and ${got} ${got === 1 ? "counts" : "count"}${got ? ` (${tally.counted.join(", ")})` : ""}${left}`,
860
+ `${name} needs ${quorum.count} ${quorum.count === 1 ? "person" : "people"}${quorum.roles ? ` holding ${quorum.roles.join(" or ")}` : ""} to ${verb === "answer" ? "answer" : "retract an answer"}, and ${got} ${got === 1 ? "counts" : "count"}${got ? ` (${tally.counted.join(", ")})` : ""}${left}${remedy}`,
833
861
  );
834
862
  }
835
863
  return tally;
836
864
  }
837
865
 
866
+ /** The gates a question's `constrains` names, as `gate:<op>/<gate>` with an optional `@<env>` (`op/gate-point.ts`'s `gateSubject`). */
867
+ function constrainedGates(d: Record<string, unknown>): { op: string; gate: string }[] {
868
+ const out: { op: string; gate: string }[] = [];
869
+ for (const c of Array.isArray(d.constrains) ? d.constrains : []) {
870
+ if (typeof c !== "string" || !c.startsWith("gate:")) continue;
871
+ const rest = c.slice("gate:".length);
872
+ const at = rest.lastIndexOf("@");
873
+ const named = at > 0 ? rest.slice(0, at) : rest;
874
+ const slash = named.indexOf("/");
875
+ if (slash > 0 && slash < named.length - 1) out.push({ op: named.slice(0, slash), gate: named.slice(slash + 1) });
876
+ }
877
+ return out;
878
+ }
879
+
880
+ /**
881
+ * Refuse an answer or retraction over MCP or ACP to a question that
882
+ * constrains a gate never resolved there (#3817, #3485): the gate-only op
883
+ * names chant knows, a pending fact on the gate ledger that says so (as
884
+ * terragucci's records do), or the Op's own `neverOverMcp`. `op-approve`
885
+ * refuses the same gates.
886
+ */
887
+ async function refuseNeverOverMcp(id: string, d: Record<string, unknown>, root: string, origin: GateOrigin, does: "answers" | "retracts"): Promise<void> {
888
+ if (!isModelAuthored(origin)) return;
889
+ const gates = constrainedGates(d);
890
+ if (gates.length === 0) return;
891
+ const { isNeverOverMcpGateOp } = await import("../op/gate-name");
892
+ const { readGateLedger } = await import("../lifecycle/gate-ledger");
893
+ let configs: Map<string, { config: { neverOverMcp?: boolean } }> | undefined;
894
+ for (const { op, gate } of gates) {
895
+ let never = isNeverOverMcpGateOp(op);
896
+ if (!never) {
897
+ const { pending } = await readGateLedger(op, { cwd: root }).catch(() => ({ pending: [] as { gate: string; neverOverMcp?: true }[] }));
898
+ never = pending.some((p) => p.gate === gate && p.neverOverMcp === true);
899
+ }
900
+ if (!never) {
901
+ if (configs === undefined) {
902
+ const { discoverOps } = await import("../op/discover");
903
+ configs = await discoverOps({ cwd: root }).then((r) => r.ops as Map<string, { config: { neverOverMcp?: boolean } }>).catch(() => new Map());
904
+ }
905
+ never = Boolean(configs.get(op)?.config.neverOverMcp);
906
+ }
907
+ if (never) {
908
+ throw new PointsWriteError(
909
+ "answer-never-over-mcp",
910
+ `${id} constrains gate "${gate}" on "${op}", which is never resolved over ${channelName(origin)}, so its question is not ${does === "answers" ? "answered" : "retracted"} there either: a person ${does === "answers" ? "answers" : "retracts the answer to"} it at a shell with \`chant workspace points ${does === "answers" ? "answer" : "retract"} ${id}\`, or through hud`,
911
+ );
912
+ }
913
+ }
914
+ }
915
+
838
916
  /**
839
917
  * People answer an open question: `points answer`. A proposed question the
840
918
  * people answer as the model did is confirmed, and keeps the model as its
@@ -884,8 +962,10 @@ async function answerPointLocked(opts: AnswerPointOptions): Promise<PointsWriteD
884
962
  if (value === undefined || !allowed.includes(value)) {
885
963
  throw new PointsWriteError("answer-not-candidate", `${opts.id} takes one of ${allowed.map((c) => JSON.stringify(c)).join(", ")}, not ${JSON.stringify(opts.answer)}`);
886
964
  }
965
+ const origin = opts.origin ?? currentGateOrigin();
966
+ await refuseNeverOverMcp(opts.id, d, o.root, origin, "answers");
887
967
  const quorum = quorumOf(point);
888
- const tally = meetQuorum(name, opts.by, quorum, o.root, askedByOf(d)?.steward, "answer");
968
+ const tally = meetQuorum(name, opts.by, quorum, o.root, askedByOf(d)?.steward, "answer", origin);
889
969
  const on = opts.on ?? today();
890
970
  const decider = d.decider as Record<string, unknown>;
891
971
  const confirmed = target.state === "proposed" && decider.kind === "model" && d.answer === value;
@@ -960,6 +1040,8 @@ export interface RetractAnswerOptions {
960
1040
  kind?: string;
961
1041
  on?: string;
962
1042
  dryRun?: boolean;
1043
+ /** The channel the retraction came through, counted as for an answer (#3817). */
1044
+ origin?: GateOrigin;
963
1045
  }
964
1046
 
965
1047
  /**
@@ -998,8 +1080,10 @@ async function retractAnswerLocked(opts: RetractAnswerOptions): Promise<PointsWr
998
1080
  const name = String(d.point);
999
1081
  const point = pointOf(opened.points, name);
1000
1082
  if (!point) throw new PointsWriteError("point-unknown", `${opts.id} answers ${name}, which ${opened.pointsFile} no longer declares, so no quorum can take the answer back: ask the point as it is declared now`);
1083
+ const origin = opts.origin ?? currentGateOrigin();
1084
+ await refuseNeverOverMcp(opts.id, d, o.root, origin, "retracts");
1001
1085
  const quorum = quorumOf(point);
1002
- const tally = meetQuorum(name, opts.by, quorum, o.root, askedByOf(d)?.steward, "retract");
1086
+ const tally = meetQuorum(name, opts.by, quorum, o.root, askedByOf(d)?.steward, "retract", origin);
1003
1087
  const on = opts.on ?? today();
1004
1088
  const type = String(d.question_type);
1005
1089
  const decider = d.decider !== null && typeof d.decider === "object" && !Array.isArray(d.decider) ? (d.decider as Record<string, unknown>) : { kind: "quorum" };
@@ -304,7 +304,7 @@ export async function runWorkspacePoints(ctx: CommandContext): Promise<number> {
304
304
  if (!("value" in response)) return print(response);
305
305
  ask = responseAsk(response.value);
306
306
  }
307
- return print(await askPoint({ cwd, point, inputs: inputs.value, candidates: given, subject: args.subject, kind: args.kind, ask, dryRun: args.dryRun }));
307
+ return print(await askPoint({ cwd, point, inputs: inputs.value, candidates: given, subject: args.subject, kind: args.kind, ask, ...(args.store ? { store: args.store } : {}), dryRun: args.dryRun }));
308
308
  }
309
309
  if (verb === "answer") {
310
310
  const id = args.extraPositional2;
@@ -626,6 +626,7 @@
626
626
  "answer-not-candidate",
627
627
  "quorum-not-met",
628
628
  "answer-in-steward-turn",
629
+ "answer-never-over-mcp",
629
630
  "principal-unidentified",
630
631
  "answer-not-answered",
631
632
  "answer-field-unsupported",
@@ -157,8 +157,9 @@ export const REASONS = {
157
157
  "point-inputs-invalid": "The inputs given to an ask are not a JSON object of the point's declared inputs.",
158
158
  "point-decider-failed": "A model decider that fails closed (unreachable: fail) could not answer, so the ask wrote nothing.",
159
159
  "answer-not-candidate": "The people's answer is not one of the question's candidates.",
160
- "quorum-not-met": "Too few of the people who answered count toward the point's quorum: distinct, not holding the agent role, not the steward that asked, and holding one of its roles when it names any.",
160
+ "quorum-not-met": "Too few of the people who answered count toward the point's quorum: distinct, not holding the agent role, not the steward that asked, and holding one of its roles when it names any. Nobody named over MCP or ACP counts (#3817).",
161
161
  "answer-in-steward-turn": "The answer was given during a steward's turn, or by a process it started: a steward never answers a decision point, and a person answers it through hud or at a shell.",
162
+ "answer-never-over-mcp": "The answer or retraction came over MCP or ACP, or from a process a session on one started, and the question constrains a gate that is never resolved there: a person answers it at a shell or through hud (#3817).",
162
163
  "answer-not-answered": "points retract names a question that has no answer to retract: it is escalated or proposed, and people answer it instead (#3351).",
163
164
  "answer-field-unsupported": "The answer kind's copy of point-answer.schema.json predates a field the write needs, a note, a relay, a retraction or an ad-hoc question's asked: copy the schema anew to turn it on (#3351, #3402, #3403).",
164
165
  "point-candidates-invalid": "points ask names an ad-hoc point without --candidates, a declared point with them, or candidates that are not a question and criteria of the point's question type (#3403).",