@intentius/chant 0.124.15 → 0.124.16
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.
- package/dist/cli/flush.d.ts +16 -0
- package/dist/cli/flush.d.ts.map +1 -0
- package/dist/cli/flush.js +42 -0
- package/dist/cli/handlers/approve.d.ts +98 -0
- package/dist/cli/handlers/approve.d.ts.map +1 -0
- package/dist/cli/handlers/approve.js +495 -0
- package/dist/cli/handlers/operator.d.ts +1 -95
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/operator.js +4 -488
- package/dist/cli/handlers/override-approve.js +1 -1
- package/dist/cli/main.d.ts +4 -15
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +4 -1062
- package/dist/cli/parse-args.d.ts +6 -0
- package/dist/cli/parse-args.d.ts.map +1 -0
- package/dist/cli/parse-args.js +1028 -0
- package/dist/components/fan-out-record.d.ts +1 -1
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/fan-out-record.js +3 -7
- package/dist/components/fan-out-run.d.ts +5 -3
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +7 -5
- package/dist/gated-waves.d.ts +25 -6
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/gated-waves.js +21 -6
- package/dist/op/activities/activity-contracts.d.ts +4 -4
- package/dist/op/op-waves-run.d.ts +1 -1
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +5 -6
- package/dist/op/op-waves.d.ts.map +1 -1
- package/dist/op/op-waves.js +4 -8
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.d.ts.map +1 -0
- package/dist/package-version.js +10 -0
- package/dist/wave-engine.d.ts +62 -0
- package/dist/wave-engine.d.ts.map +1 -0
- package/dist/wave-engine.js +59 -0
- package/dist/wave-layout.d.ts +29 -0
- package/dist/wave-layout.d.ts.map +1 -0
- package/dist/wave-layout.js +41 -0
- package/dist/wave-runtime.d.ts +29 -0
- package/dist/wave-runtime.d.ts.map +1 -0
- package/dist/wave-runtime.js +103 -0
- package/dist/wave-runtime.mjs +331 -0
- package/dist/workspace/ajv2020.d.ts +5 -0
- package/dist/workspace/ajv2020.d.ts.map +1 -0
- package/dist/workspace/ajv2020.js +14 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +4 -8
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/kinds.js +2 -5
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/points.js +2 -5
- package/dist/workspace/principal-classes.d.ts.map +1 -1
- package/dist/workspace/principal-classes.js +2 -5
- package/package.json +5 -3
- package/src/cli/flush.ts +44 -0
- package/src/cli/handlers/approve.ts +618 -0
- package/src/cli/handlers/operator.ts +10 -608
- package/src/cli/handlers/override-approve.test.ts +1 -1
- package/src/cli/handlers/override-approve.ts +1 -1
- package/src/cli/main.ts +5 -816
- package/src/cli/parse-args.ts +781 -0
- package/src/components/fan-out-record.ts +3 -7
- package/src/components/fan-out-run.ts +7 -5
- package/src/gate-decision-bundle.test.ts +1 -0
- package/src/gated-waves.ts +28 -6
- package/src/op/op-waves-run.ts +5 -6
- package/src/op/op-waves.ts +4 -8
- package/src/package-version.ts +11 -0
- package/src/wave-engine.test.ts +107 -0
- package/src/wave-engine.ts +98 -0
- package/src/wave-layout.ts +44 -0
- package/src/wave-runtime-bundle.test.ts +70 -0
- package/src/wave-runtime.e2e.test.ts +177 -0
- package/src/wave-runtime.ts +104 -0
- package/src/workspace/ajv2020.ts +17 -0
- package/src/workspace/declaration.ts +4 -10
- package/src/workspace/kinds.ts +2 -5
- package/src/workspace/points.ts +2 -5
- package/src/workspace/principal-classes.ts +2 -5
|
@@ -34,25 +34,8 @@ import { readConvergeLedger, type ConvergeTickRecord } from "../../lifecycle/con
|
|
|
34
34
|
import { readRunLedger, runEnvOf } from "../../lifecycle/run-ledger";
|
|
35
35
|
import type { OpRunRecord } from "../../op/runtime";
|
|
36
36
|
import type { GateResolutionRecord, PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
} from "../../lifecycle/gate-origin";
|
|
40
|
-
import {
|
|
41
|
-
appendGateResolution, appendPendingGate, readGateResolutions, readGateLedger,
|
|
42
|
-
latestResolutionSince, latestPendingGate, isPendingGateExpired,
|
|
43
|
-
resolveApprovalUrl, isApprovalUrl,
|
|
44
|
-
} from "../../lifecycle/gate-ledger";
|
|
45
|
-
import { isPlanDigest, samePlanDigest } from "../../lifecycle/plan-digest";
|
|
46
|
-
import {
|
|
47
|
-
gateContextDigest, gatePolicyRequest, loadGatePolicyEvaluator,
|
|
48
|
-
type GateApprover, type GatePolicyDecision, type GatePolicyEvaluator, type ResolvedGateApproval,
|
|
49
|
-
} from "../../op/gate-approval";
|
|
50
|
-
import { approverOf, humanQuorumSince, tallyGateApprovals } from "../../op/gate";
|
|
51
|
-
import { FAN_OUT_GATE_OP, OVERRIDE_GATE_OP, WORKSPACE_UPGRADE_GATE_OP } from "../../op/gate-name";
|
|
52
|
-
import { PR_GATE_OP } from "../../pr-loop";
|
|
53
|
-
import { pushLifecycleReport, requireLifecycleLedger } from "../../lifecycle/git";
|
|
54
|
-
import { LIFECYCLE_LOCAL_NOTE, isLifecycleLocalNote } from "../../lifecycle/local-note";
|
|
55
|
-
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
37
|
+
import { readGateResolutions, readGateLedger, latestResolutionSince, latestPendingGate, isPendingGateExpired } from "../../lifecycle/gate-ledger";
|
|
38
|
+
import { formatError, formatWarning, formatBold, formatInfo } from "../format";
|
|
56
39
|
import type { CommandContext } from "../registry";
|
|
57
40
|
|
|
58
41
|
/** Load the activities a local tick needs, the same way a local run does: core's base activities plus whatever the project's configured lexicons contribute. Best-effort on the lexicon list — an unreadable `chant.config.ts` falls back to base activities only. */
|
|
@@ -799,592 +782,11 @@ export async function runOperatorLog(ctx: CommandContext): Promise<number> {
|
|
|
799
782
|
return 0;
|
|
800
783
|
}
|
|
801
784
|
|
|
802
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
* `chant approve` locally".
|
|
811
|
-
*
|
|
812
|
-
* Since #2119 this closes the loop rather than just narrating it: the next
|
|
813
|
-
* `chant run <op>` reads the resolution, finds it newer than the gate's
|
|
814
|
-
* pending fact, and walks through.
|
|
815
|
-
*
|
|
816
|
-
* `--expire` is the other half — clear a standing pending fact *without*
|
|
817
|
-
* approving anything, for a gate that was recorded against a run nobody
|
|
818
|
-
* intends to finish. It writes no resolution; it appends a pending fact that
|
|
819
|
-
* is already expired, which supersedes the standing one on an append-only
|
|
820
|
-
* ledger, so the next run decides the gate from scratch.
|
|
821
|
-
*/
|
|
822
|
-
/** The ledger branch, named in the warnings below so a reader can go look at it. */
|
|
823
|
-
const LIFECYCLE_BRANCH = "chant/lifecycle";
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* Push the ledger and say so when it does not land (#2309 review, refs #2310).
|
|
827
|
-
*
|
|
828
|
-
* Both write paths here used `pushLifecycle().catch(() => undefined)` and then
|
|
829
|
-
* printed unconditional success and exited 0. A rejected push — a stale lease,
|
|
830
|
-
* no credentials, no network — therefore read as a completed approval, which
|
|
831
|
-
* is the one thing an approval must never do: the operator walks away
|
|
832
|
-
* believing the gate is answered for everybody, while the resolution exists
|
|
833
|
-
* only in their own checkout.
|
|
834
|
-
*
|
|
835
|
-
* The append is still a correct *local* fact, so this is a warning and not a
|
|
836
|
-
* failure; the exit code is unchanged. With no remote at all there is nobody
|
|
837
|
-
* elsewhere to tell, so that case is a quiet suffix and no warning (#3677).
|
|
838
|
-
*/
|
|
839
|
-
async function reportedPush(consequence: string): Promise<{ pushed: boolean; suffix: string }> {
|
|
840
|
-
const { pushed, pushWarning } = await pushLifecycleReport();
|
|
841
|
-
if (pushed) return { pushed, suffix: "" };
|
|
842
|
-
if (isLifecycleLocalNote(pushWarning)) return { pushed, suffix: ` (${LIFECYCLE_LOCAL_NOTE})` };
|
|
843
|
-
console.error(formatWarning({
|
|
844
|
-
message: `The push to the remote did not land: ${pushWarning ?? "no reason given"}`,
|
|
845
|
-
hint: consequence,
|
|
846
|
-
}));
|
|
847
|
-
return { pushed, suffix: " (local only — the push did not land)" };
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
export async function runApprove(ctx: CommandContext): Promise<number> {
|
|
851
|
-
const opName = ctx.args.path;
|
|
852
|
-
const gate = ctx.args.extraPositional;
|
|
853
|
-
if (!opName || opName === "." || !gate) {
|
|
854
|
-
console.error(formatError({ message: "Usage: chant approve <op> <gate>" }));
|
|
855
|
-
return 1;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
if (ctx.args.expire) {
|
|
859
|
-
// Same guard as the approve path below (#2303): `--expire` reads the
|
|
860
|
-
// standing fact and appends beside it, so a clone that never fetched the
|
|
861
|
-
// branch would both fail to see the fact it is expiring and replace the
|
|
862
|
-
// branch with the expiry alone.
|
|
863
|
-
try {
|
|
864
|
-
await requireLifecycleLedger();
|
|
865
|
-
} catch (err) {
|
|
866
|
-
console.error(formatError({
|
|
867
|
-
message: `Cannot expire the gate: ${err instanceof Error ? err.message : String(err)}`,
|
|
868
|
-
}));
|
|
869
|
-
return 1;
|
|
870
|
-
}
|
|
871
|
-
const now = new Date().toISOString();
|
|
872
|
-
const standing = latestPendingGate((await readGateLedger(opName)).pending, gate);
|
|
873
|
-
if (!standing || isPendingGateExpired(standing, now)) {
|
|
874
|
-
console.error(formatWarning({
|
|
875
|
-
message: `Gate "${gate}" on "${opName}" has no standing pending fact — nothing to expire`,
|
|
876
|
-
}));
|
|
877
|
-
return 0;
|
|
878
|
-
}
|
|
879
|
-
await appendPendingGate({
|
|
880
|
-
op: opName,
|
|
881
|
-
gate,
|
|
882
|
-
timestamp: now,
|
|
883
|
-
expiresAt: now,
|
|
884
|
-
...(standing.description ? { description: standing.description } : {}),
|
|
885
|
-
});
|
|
886
|
-
const push = await reportedPush(
|
|
887
|
-
`The expiry is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
|
|
888
|
-
`a run in another checkout still sees the old pending fact.`,
|
|
889
|
-
);
|
|
890
|
-
console.error(formatSuccess(
|
|
891
|
-
`Gate "${gate}" on "${opName}" expired at ${now} — not approved` + push.suffix,
|
|
892
|
-
));
|
|
893
|
-
console.error(formatInfo(
|
|
894
|
-
opName === FAN_OUT_GATE_OP
|
|
895
|
-
? `The next \`chant components fan-out\` decides this gate from scratch and records a fresh pending fact.`
|
|
896
|
-
: PR_GATE_OP.test(opName)
|
|
897
|
-
? `The next \`chant components pr-apply\` decides this gate from scratch and records a fresh pending fact.`
|
|
898
|
-
: opName === WORKSPACE_UPGRADE_GATE_OP
|
|
899
|
-
? `The next \`chant workspace upgrade ${gate}\` decides this gate from scratch and records a fresh pending fact.`
|
|
900
|
-
: `The next \`chant run ${opName}\` decides this gate from scratch and records a fresh pending fact.`,
|
|
901
|
-
));
|
|
902
|
-
return 0;
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
// #3760: an override of a denied plan names its rules and gives a reason.
|
|
906
|
-
if (opName === OVERRIDE_GATE_OP) {
|
|
907
|
-
const { runOverrideApprove } = await import("./override-approve");
|
|
908
|
-
return runOverrideApprove(ctx);
|
|
909
|
-
}
|
|
910
|
-
if (ctx.args.rules !== undefined || ctx.args.reason !== undefined) {
|
|
911
|
-
console.error(formatError({
|
|
912
|
-
message: `--rule and --reason override a denied plan: chant approve ${OVERRIDE_GATE_OP} <subject> --rule <id> --reason <text>`,
|
|
913
|
-
hint: `To approve gate "${gate}" on "${opName}", put any prose in --note.`,
|
|
914
|
-
}));
|
|
915
|
-
return 1;
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// `--approver` wins over `--actor`, the same precedence `chant run approve`
|
|
919
|
-
// has applied since #2126 (`../handlers/run.ts`'s `runOpApprove`). Reading
|
|
920
|
-
// only `--actor` here meant every README that teaches `--approver you`
|
|
921
|
-
// recorded the shell user instead (#2192).
|
|
922
|
-
const outcome = await recordGateApproval(opName, gate, {
|
|
923
|
-
actor: ctx.args.approver ?? ctx.args.actor,
|
|
924
|
-
note: ctx.args.note,
|
|
925
|
-
url: ctx.args.url,
|
|
926
|
-
plan: ctx.args.plan,
|
|
927
|
-
...(ctx.args.env !== undefined ? { environment: ctx.args.env } : {}),
|
|
928
|
-
allowSameOrigin: ctx.args.allowSameOrigin,
|
|
929
|
-
...(ctx.args.roles ? { roles: ctx.args.roles } : {}),
|
|
930
|
-
...(ctx.args.agent ? { agent: true } : {}),
|
|
931
|
-
...(ctx.args.sign !== undefined ? { sign: ctx.args.sign } : {}),
|
|
932
|
-
...(ctx.args.relayedBy !== undefined ? { relayedBy: ctx.args.relayedBy } : {}),
|
|
933
|
-
});
|
|
934
|
-
if (!outcome.ok) return 1;
|
|
935
|
-
|
|
936
|
-
// #3683: start the CI job that waits at this gate again.
|
|
937
|
-
if (ctx.args.resumeRun || ctx.args.resume !== undefined) {
|
|
938
|
-
const { resumeAfterApproval } = await import("./gate-resume");
|
|
939
|
-
return resumeAfterApproval(opName, gate, outcome.record.environment, outcome.pushed);
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
console.error(formatInfo(
|
|
943
|
-
`This records the resolution as a fact; it does not itself re-run anything. ` +
|
|
944
|
-
(opName === FAN_OUT_GATE_OP
|
|
945
|
-
? `Repeat the \`chant components fan-out\` command and it walks through gate "${gate}".`
|
|
946
|
-
: PR_GATE_OP.test(opName)
|
|
947
|
-
? `The pull request's apply (\`chant components pr-apply\`) walks through gate "${gate}" when it plans this same digest.`
|
|
948
|
-
: opName === WORKSPACE_UPGRADE_GATE_OP
|
|
949
|
-
? `Repeat \`chant workspace upgrade ${gate}\` with the same target, and it applies the patch this approval names.`
|
|
950
|
-
: `Run \`chant run ${opName}\` and it walks through gate "${gate}".`),
|
|
951
|
-
));
|
|
952
|
-
return 0;
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
/** What a caller supplies alongside the op and gate names. */
|
|
956
|
-
export interface GateApprovalOptions {
|
|
957
|
-
/**
|
|
958
|
-
* The channel this resolution arrived on (chant#2384). Defaults to the
|
|
959
|
-
* process's own — `"cli"` unless an entry point declared otherwise — and is
|
|
960
|
-
* a parameter only so a caller that knows better can say so.
|
|
961
|
-
*/
|
|
962
|
-
origin?: GateOrigin;
|
|
963
|
-
/**
|
|
964
|
-
* Record a resolution that the same-origin rule would refuse (chant#2384).
|
|
965
|
-
*
|
|
966
|
-
* There is a legitimate case: a single operator driving an agent, who wants
|
|
967
|
-
* the model's run resolved from the same session and accepts what that
|
|
968
|
-
* means. The override exists so that is a deliberate act with a trace on the
|
|
969
|
-
* record, rather than the default.
|
|
970
|
-
*/
|
|
971
|
-
allowSameOrigin?: boolean;
|
|
972
|
-
/** `--actor`/`--approver`; falls back to the CI or shell identity. */
|
|
973
|
-
actor?: string;
|
|
974
|
-
/** `--note` — free-text prose. */
|
|
975
|
-
note?: string;
|
|
976
|
-
/**
|
|
977
|
-
* `--relayed-by <principal>` (#3402): who carried this approval to chant for
|
|
978
|
-
* the approver, such as a follower program relaying a person's approval.
|
|
979
|
-
* Recorded as `relayedBy` on the resolution line and covered by `--sign`.
|
|
980
|
-
* The approver is still `--actor`; the relay is not an approver.
|
|
981
|
-
*/
|
|
982
|
-
relayedBy?: string;
|
|
983
|
-
/** `--url` — the approval surface, validated as absolute http/https. */
|
|
984
|
-
url?: string;
|
|
985
|
-
/**
|
|
986
|
-
* `--plan <digest>` (#2300) — the plan this approval is for. Omitted, the
|
|
987
|
-
* digest is read off the gate's standing pending fact, so the common path
|
|
988
|
-
* stays one command: run, read what it planned, approve it.
|
|
989
|
-
*/
|
|
990
|
-
plan?: string;
|
|
991
|
-
/**
|
|
992
|
-
* `--env <env>` (#2574) — which environment's pending fact a component gate
|
|
993
|
-
* approval answers. A component's gate ledger holds every environment's
|
|
994
|
-
* facts, so without it the newest one is approved. Ignored for a gate whose
|
|
995
|
-
* pending facts record no environment (every Op gate).
|
|
996
|
-
*/
|
|
997
|
-
environment?: string;
|
|
998
|
-
/** `--role` (#2508) — roles the approver claims. */
|
|
999
|
-
roles?: string[];
|
|
1000
|
-
/**
|
|
1001
|
-
* `--agent` (#2508) — record this as an agent's approval. On a
|
|
1002
|
-
* model-authored channel it is one whether or not this is set.
|
|
1003
|
-
*/
|
|
1004
|
-
agent?: boolean;
|
|
1005
|
-
/**
|
|
1006
|
-
* `--sign [<key file>]` (#3163, ws-080): seal the approval with an ssh key,
|
|
1007
|
-
* a key file or true for git's `user.signingkey`. The seal attests the
|
|
1008
|
-
* approver when the signers file at base lists the key for them, and a gate
|
|
1009
|
-
* the declaration's `identity.gates` names counts only sealed approvals.
|
|
1010
|
-
*/
|
|
1011
|
-
sign?: string | true;
|
|
1012
|
-
/** Where the workspace and the key file are looked up from. Defaults to the process's directory. */
|
|
1013
|
-
cwd?: string;
|
|
1014
|
-
/** Replaces the lexicon evaluator a gate policy is loaded from. For tests. */
|
|
1015
|
-
evaluator?: GatePolicyEvaluator;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
export type GateApprovalOutcome =
|
|
1019
|
-
| { ok: true; record: GateResolutionRecord; pushed: boolean }
|
|
1020
|
-
| { ok: false };
|
|
1021
|
-
|
|
1022
|
-
/**
|
|
1023
|
-
* The ledger write behind `chant approve` — extracted so `chant run approve`
|
|
1024
|
-
* (#2121) records the identical fact before waking the runtime that hosts the
|
|
1025
|
-
* gated run, instead of growing a second, drifting writer.
|
|
1026
|
-
*
|
|
1027
|
-
* Prints the same warning for an undiscovered op and the same success line as
|
|
1028
|
-
* `chant approve` always did; the caller adds whatever it does next.
|
|
1029
|
-
*
|
|
1030
|
-
* Since #2300 the resolution names the plan it approves. By default that is
|
|
1031
|
-
* the standing pending fact's plan — the one the gated run produced — so the
|
|
1032
|
-
* common path is still `chant run`, read, `chant approve`. With no pending
|
|
1033
|
-
* fact and no `--plan`, this refuses: there is nothing on the ledger saying
|
|
1034
|
-
* what would be approved, and writing a resolution anyway is what made an
|
|
1035
|
-
* approval mean "the next run" instead of "this plan".
|
|
1036
|
-
*/
|
|
1037
|
-
export async function recordGateApproval(
|
|
1038
|
-
opName: string,
|
|
1039
|
-
gate: string,
|
|
1040
|
-
opts: GateApprovalOptions,
|
|
1041
|
-
): Promise<GateApprovalOutcome> {
|
|
1042
|
-
const { ops } = await discoverOps();
|
|
1043
|
-
// `fan-out` is the op name every `chant components fan-out` gate is recorded
|
|
1044
|
-
// under (../handlers/fan-out.ts). It is a command rather than a declaration,
|
|
1045
|
-
// so there is no `*.op.ts` to find and nothing is wrong when none is there.
|
|
1046
|
-
if (!ops.has(opName) && opName !== FAN_OUT_GATE_OP && opName !== WORKSPACE_UPGRADE_GATE_OP && opName !== OVERRIDE_GATE_OP) {
|
|
1047
|
-
console.error(formatWarning({
|
|
1048
|
-
message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
|
|
1049
|
-
}));
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
// Read the branch before appending to it (#2303 finding 2). Without this,
|
|
1053
|
-
// an approve in a clone that never fetched `chant/lifecycle` builds its
|
|
1054
|
-
// commit from an empty tree with no parent and replaces the branch with a
|
|
1055
|
-
// single commit holding only this resolution — the pending fact it is
|
|
1056
|
-
// answering is discarded rather than appended to.
|
|
1057
|
-
try {
|
|
1058
|
-
await requireLifecycleLedger();
|
|
1059
|
-
} catch (err) {
|
|
1060
|
-
console.error(formatError({
|
|
1061
|
-
message: `Cannot record the approval: ${err instanceof Error ? err.message : String(err)}`,
|
|
1062
|
-
hint: "The pending fact this answers lives on that branch; appending without it would drop it.",
|
|
1063
|
-
}));
|
|
1064
|
-
return { ok: false };
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// #2028: the resolution's link is typed. `--url` wins; otherwise, running
|
|
1068
|
-
// inside the PR/MR job that carries the change is itself the address, the
|
|
1069
|
-
// same env fallback `--actor` uses. `--note` stays free-text prose.
|
|
1070
|
-
const url = opts.url ?? resolveApprovalUrl();
|
|
1071
|
-
if (url && !isApprovalUrl(url)) {
|
|
1072
|
-
console.error(formatError({
|
|
1073
|
-
message: `--url must be an absolute http/https URL (got "${url}")`,
|
|
1074
|
-
hint: "Pass the PR/MR link, or omit --url and put prose in --note.",
|
|
1075
|
-
}));
|
|
1076
|
-
return { ok: false };
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// #2300: an approval is for a plan, so this command has to know which one.
|
|
1080
|
-
// `--plan` names it outright; otherwise it comes off the gate's standing
|
|
1081
|
-
// pending fact — the plan the run that stopped at this gate produced, which
|
|
1082
|
-
// is the plan whoever is typing this just read. There is deliberately no
|
|
1083
|
-
// third fallback: approving with no plan in sight is the behaviour
|
|
1084
|
-
// INTENTIUS/choudoufu#1026 measured, where the resolution authorised the
|
|
1085
|
-
// next run rather than anything anyone had seen.
|
|
1086
|
-
// #2574: a component gate's pending facts carry the environment they were
|
|
1087
|
-
// recorded in, and an approval answers one environment. `--env` picks it;
|
|
1088
|
-
// without it, the newest standing fact decides, and the success line below
|
|
1089
|
-
// names the environment it chose.
|
|
1090
|
-
const ledgerBefore = await readGateLedger(opName);
|
|
1091
|
-
const pendingFor = (all: PendingGateRecord[]): PendingGateRecord[] => {
|
|
1092
|
-
const bound = all.some((p) => p.gate === gate && p.environment !== undefined);
|
|
1093
|
-
return bound && opts.environment !== undefined ? all.filter((p) => p.environment === opts.environment) : all;
|
|
1094
|
-
};
|
|
1095
|
-
const standing = latestPendingGate(pendingFor(ledgerBefore.pending), gate);
|
|
1096
|
-
const environment = standing?.environment;
|
|
1097
|
-
|
|
1098
|
-
let planDigest: string | undefined;
|
|
1099
|
-
if (opts.plan !== undefined) {
|
|
1100
|
-
if (!isPlanDigest(opts.plan)) {
|
|
1101
|
-
console.error(formatError({
|
|
1102
|
-
message: `--plan must be a plan digest ("jcs1-sha256:" or "sha256:", then 64 hex characters), got "${opts.plan}"`,
|
|
1103
|
-
hint: "Copy it from the gated run's `plan :` line, or from the pending-gate summary. It is not a plan file path.",
|
|
1104
|
-
}));
|
|
1105
|
-
return { ok: false };
|
|
1106
|
-
}
|
|
1107
|
-
planDigest = opts.plan;
|
|
1108
|
-
} else {
|
|
1109
|
-
if (!standing) {
|
|
1110
|
-
console.error(formatError({
|
|
1111
|
-
message:
|
|
1112
|
-
opts.environment !== undefined && ledgerBefore.pending.some((p) => p.gate === gate && p.environment !== undefined)
|
|
1113
|
-
? `Gate "${gate}" on "${opName}" has no pending fact in environment "${opts.environment}", so there is no plan to approve`
|
|
1114
|
-
: `Gate "${gate}" on "${opName}" has no pending fact, so there is no plan to approve`,
|
|
1115
|
-
hint:
|
|
1116
|
-
`Run \`chant run ${opName}\` first — it plans, stops at the gate, and records the plan this ` +
|
|
1117
|
-
`approval would be for. To approve a plan you already have the digest for, pass ` +
|
|
1118
|
-
`\`--plan <digest>\`.`,
|
|
1119
|
-
}));
|
|
1120
|
-
return { ok: false };
|
|
1121
|
-
}
|
|
1122
|
-
planDigest = standing.planDigest;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
// chant#2384 — the gate's whole value is that its two halves have different
|
|
1126
|
-
// authors. At a shell they do: a person runs, reads the plan, and approves.
|
|
1127
|
-
// On MCP and ACP the person's only act was launching the server, and every
|
|
1128
|
-
// call after is the model's, so a gate reached over one of those channels and
|
|
1129
|
-
// resolved over the same one has no second party in it. Refused by default,
|
|
1130
|
-
// the way #2300 refuses a plan nobody approved.
|
|
1131
|
-
const origin = opts.origin ?? currentGateOrigin();
|
|
1132
|
-
const ledger = await readGateLedger(opName);
|
|
1133
|
-
const standingForOrigin = latestPendingGate(pendingFor(ledger.pending), gate);
|
|
1134
|
-
|
|
1135
|
-
// chant#3485: a gate that is never resolved over MCP or ACP says so on its
|
|
1136
|
-
// own record, or on its Op's config. No flag overrides this one.
|
|
1137
|
-
if (isModelAuthored(origin) && (standingForOrigin?.neverOverMcp || ops.get(opName)?.config.neverOverMcp)) {
|
|
1138
|
-
console.error(formatError({
|
|
1139
|
-
message: `Gate "${gate}" on "${opName}" is never resolved over ${origin === "acp" ? "ACP" : "MCP"}`,
|
|
1140
|
-
hint: `Approvals belong to people: \`chant approve ${opName} ${gate}\` at a shell.`,
|
|
1141
|
-
}));
|
|
1142
|
-
return { ok: false };
|
|
1143
|
-
}
|
|
1144
|
-
// #3170: a gate that asks a decision point passes on the point's answer,
|
|
1145
|
-
// which the run cites on the ledger. An approval here would count for nothing.
|
|
1146
|
-
if (standingForOrigin?.answer) {
|
|
1147
|
-
console.error(formatError({
|
|
1148
|
-
message: `Gate "${gate}" on "${opName}" asks decision point ${standingForOrigin.answer.point}, so its answer passes the gate, not an approval`,
|
|
1149
|
-
hint: `Answer the question: \`chant workspace points answer ${standingForOrigin.answer.id} --answer <value> --by <name>\`, then run \`chant run ${opName}\` again.`,
|
|
1150
|
-
}));
|
|
1151
|
-
return { ok: false };
|
|
1152
|
-
}
|
|
1153
|
-
const refusal = sameOriginRefusal(standingForOrigin?.origin, origin);
|
|
1154
|
-
if (refusal && !opts.allowSameOrigin) {
|
|
1155
|
-
console.error(formatError({
|
|
1156
|
-
message: `Gate "${gate}" on "${opName}" cannot be resolved from here: ${refusal}`,
|
|
1157
|
-
hint:
|
|
1158
|
-
`Approve it from a channel the run did not come from — \`chant approve ${opName} ${gate}\` ` +
|
|
1159
|
-
`at a shell is the usual one. If resolving from the same channel genuinely is the intent, ` +
|
|
1160
|
-
`pass --allow-same-origin, which records that it was deliberate.`,
|
|
1161
|
-
}));
|
|
1162
|
-
return { ok: false };
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
// #3402: the relay is a principal, on one line (the seal's payload is LF-joined).
|
|
1166
|
-
const relayedBy = opts.relayedBy?.trim();
|
|
1167
|
-
if (opts.relayedBy !== undefined && (!relayedBy || /[\r\n]/.test(relayedBy))) {
|
|
1168
|
-
console.error(formatError({
|
|
1169
|
-
message: `--relayed-by needs one principal on one line (got ${JSON.stringify(opts.relayedBy)})`,
|
|
1170
|
-
hint: "Name who relayed the approval, such as --relayed-by github:<login> or a principal the signers file lists.",
|
|
1171
|
-
}));
|
|
1172
|
-
return { ok: false };
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
const resolvedBy = opts.actor
|
|
1176
|
-
?? (isModelAuthored(origin)
|
|
1177
|
-
// A channel that cannot attest to a name does not get to write one down.
|
|
1178
|
-
// "unattested" reads as what it is; a name the model chose would read in
|
|
1179
|
-
// the ledger exactly like a name a person gave.
|
|
1180
|
-
? UNATTESTED_APPROVER
|
|
1181
|
-
: process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown");
|
|
1182
|
-
|
|
1183
|
-
// #2508: who this is, as the gate's quorum and policy read it. A model on
|
|
1184
|
-
// MCP or ACP is an agent whatever it claims, the same reasoning that gives
|
|
1185
|
-
// it `UNATTESTED_APPROVER` for a name.
|
|
1186
|
-
const approver: GateApprover = {
|
|
1187
|
-
kind: opts.agent || isModelAuthored(origin) ? "agent" : "human",
|
|
1188
|
-
...(opts.roles && opts.roles.length > 0 ? { roles: [...new Set(opts.roles)] } : {}),
|
|
1189
|
-
};
|
|
1190
|
-
|
|
1191
|
-
// #3163, ws-080: the declaration at base may require a checkable approver
|
|
1192
|
-
// (identity.attribution "identified") and a signed approval for this gate
|
|
1193
|
-
// (identity.gates). Both are judged before anything is written.
|
|
1194
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
1195
|
-
const identity = await import("../../workspace/identity");
|
|
1196
|
-
const { scopeSource } = await import("../../workspace/write-scope");
|
|
1197
|
-
const scope = scopeSource(cwd);
|
|
1198
|
-
try {
|
|
1199
|
-
identity.refuseUnidentified(scope, [resolvedBy], opts.actor !== undefined ? "--actor" : "the approver");
|
|
1200
|
-
if (relayedBy) identity.refuseUnidentified(scope, [relayedBy], "--relayed-by");
|
|
1201
|
-
} catch (err) {
|
|
1202
|
-
if (!(err instanceof identity.IdentityError)) throw err;
|
|
1203
|
-
console.error(formatError({
|
|
1204
|
-
message: `Gate "${gate}" on "${opName}" was not approved: ${err.message}`,
|
|
1205
|
-
hint: "Pass --actor <forge identity or signer>, as the surface that signed the person in maps them, for example --actor github:<login>.",
|
|
1206
|
-
}));
|
|
1207
|
-
return { ok: false };
|
|
1208
|
-
}
|
|
1209
|
-
const rule = identity.gateAdmissionFrom(scope, gate);
|
|
1210
|
-
if (rule && opts.sign === undefined) {
|
|
1211
|
-
console.error(formatError({
|
|
1212
|
-
message:
|
|
1213
|
-
`Gate "${gate}" needs a signed approval: chant.workspace.json at base names it in identity.gates` +
|
|
1214
|
-
(rule.requirement.class !== null ? `, from the class ${rule.requirement.class}` : "") +
|
|
1215
|
-
`, and this approval is not signed`,
|
|
1216
|
-
hint: `chant approve ${opName} ${gate} --actor <principal> --sign [<key file>], with a key ${scope.policy.signersPath} at base lists for that principal.`,
|
|
1217
|
-
}));
|
|
1218
|
-
return { ok: false };
|
|
1219
|
-
}
|
|
1220
|
-
const timestamp = new Date().toISOString();
|
|
1221
|
-
let seal: GateResolutionRecord["seal"];
|
|
1222
|
-
if (opts.sign !== undefined) {
|
|
1223
|
-
const { resolveSigningKey, sealGateApproval, SealError } = await import("../../workspace/trust/seal");
|
|
1224
|
-
try {
|
|
1225
|
-
const key = resolveSigningKey(opts.sign, cwd);
|
|
1226
|
-
try {
|
|
1227
|
-
seal = sealGateApproval(key.file, {
|
|
1228
|
-
op: opName,
|
|
1229
|
-
gate,
|
|
1230
|
-
...(environment !== undefined ? { environment } : {}),
|
|
1231
|
-
...(planDigest !== undefined ? { planDigest } : {}),
|
|
1232
|
-
resolvedBy,
|
|
1233
|
-
...(relayedBy ? { relayedBy } : {}),
|
|
1234
|
-
timestamp,
|
|
1235
|
-
});
|
|
1236
|
-
} finally {
|
|
1237
|
-
key.cleanup();
|
|
1238
|
-
}
|
|
1239
|
-
} catch (err) {
|
|
1240
|
-
if (!(err instanceof SealError)) throw err;
|
|
1241
|
-
console.error(formatError({ message: `Gate "${gate}" on "${opName}" was not approved: ${err.message}` }));
|
|
1242
|
-
return { ok: false };
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
if (rule) {
|
|
1246
|
-
const why = rule.refuses({
|
|
1247
|
-
op: opName,
|
|
1248
|
-
gate,
|
|
1249
|
-
...(environment !== undefined ? { environment } : {}),
|
|
1250
|
-
...(planDigest !== undefined ? { planDigest } : {}),
|
|
1251
|
-
resolvedBy,
|
|
1252
|
-
...(relayedBy ? { relayedBy } : {}),
|
|
1253
|
-
timestamp,
|
|
1254
|
-
seal,
|
|
1255
|
-
});
|
|
1256
|
-
if (why !== null) {
|
|
1257
|
-
console.error(formatError({
|
|
1258
|
-
message: `Gate "${gate}" on "${opName}" was not approved: ${why}`,
|
|
1259
|
-
hint: `identity.gates.${gate} in chant.workspace.json at base counts only an approval signed by a key ${scope.policy.signersPath} lists for the approver` +
|
|
1260
|
-
(rule.requirement.class !== null ? `, who holds the role of the class ${rule.requirement.class}` : "") + ".",
|
|
1261
|
-
}));
|
|
1262
|
-
return { ok: false };
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
// #2508: a gate with a policy has it evaluated for this approval now, while
|
|
1267
|
-
// the approver is known, against the context the gated run recorded. The
|
|
1268
|
-
// decision is written next to the approval, so a pass can be traced to the
|
|
1269
|
-
// rule that allowed it.
|
|
1270
|
-
const approval = standingForOrigin?.approval;
|
|
1271
|
-
let policyDecision: GatePolicyDecision | undefined;
|
|
1272
|
-
if (approval?.policy) {
|
|
1273
|
-
try {
|
|
1274
|
-
const evaluator = opts.evaluator ?? (await loadGatePolicyEvaluator(approval.policy.lexicon));
|
|
1275
|
-
const answer = await evaluator.evaluateGatePolicy(
|
|
1276
|
-
approval.policy,
|
|
1277
|
-
gatePolicyRequest({
|
|
1278
|
-
op: opName,
|
|
1279
|
-
gate,
|
|
1280
|
-
resolvedBy,
|
|
1281
|
-
approver,
|
|
1282
|
-
...(planDigest !== undefined ? { planDigest } : {}),
|
|
1283
|
-
...(approval.context ? { context: approval.context } : {}),
|
|
1284
|
-
}),
|
|
1285
|
-
);
|
|
1286
|
-
policyDecision = {
|
|
1287
|
-
policy: approval.policy.name,
|
|
1288
|
-
version: approval.policy.version,
|
|
1289
|
-
mode: approval.mode,
|
|
1290
|
-
contextDigest: gateContextDigest(approval.context),
|
|
1291
|
-
decision: answer.decision,
|
|
1292
|
-
determining: answer.determining,
|
|
1293
|
-
errors: answer.errors,
|
|
1294
|
-
};
|
|
1295
|
-
} catch (err) {
|
|
1296
|
-
console.error(formatError({
|
|
1297
|
-
message: `Gate "${gate}" on "${opName}" declares policy "${approval.policy.name}", which could not be evaluated: ${err instanceof Error ? err.message : String(err)}`,
|
|
1298
|
-
hint: `Install the ${approval.policy.lexicon} lexicon in this project, or approve from a checkout that has it.`,
|
|
1299
|
-
}));
|
|
1300
|
-
return { ok: false };
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
const { record } = await appendGateResolution({
|
|
1305
|
-
op: opName,
|
|
1306
|
-
gate,
|
|
1307
|
-
resolvedBy,
|
|
1308
|
-
...(relayedBy ? { relayedBy } : {}),
|
|
1309
|
-
timestamp,
|
|
1310
|
-
...(seal ? { seal } : {}),
|
|
1311
|
-
approver,
|
|
1312
|
-
...(policyDecision ? { policyDecision } : {}),
|
|
1313
|
-
...(opts.note ? { note: opts.note } : {}),
|
|
1314
|
-
...(url ? { url } : {}),
|
|
1315
|
-
...(planDigest !== undefined ? { planDigest } : {}),
|
|
1316
|
-
...(environment !== undefined ? { environment } : {}),
|
|
1317
|
-
origin,
|
|
1318
|
-
...(refusal && opts.allowSameOrigin ? { sameOriginOverride: true } : {}),
|
|
1319
|
-
});
|
|
1320
|
-
const push = await reportedPush(
|
|
1321
|
-
`The resolution is recorded locally on ${LIFECYCLE_BRANCH}. Until it reaches the remote, ` +
|
|
1322
|
-
`a run in another checkout will not see it.`,
|
|
1323
|
-
);
|
|
1324
|
-
|
|
1325
|
-
console.error(formatSuccess(
|
|
1326
|
-
`Gate "${gate}" on "${opName}" resolved by ${record.resolvedBy}` +
|
|
1327
|
-
(record.relayedBy ? `, relayed by ${record.relayedBy},` : "") +
|
|
1328
|
-
` at ${record.timestamp}` +
|
|
1329
|
-
(record.url ? ` (${record.url})` : "") +
|
|
1330
|
-
(record.seal ? `, signed with ${record.seal.key}` : "") +
|
|
1331
|
-
push.suffix,
|
|
1332
|
-
));
|
|
1333
|
-
if (record.planDigest) {
|
|
1334
|
-
console.error(formatInfo(
|
|
1335
|
-
`This approves the plan ${record.planDigest}` +
|
|
1336
|
-
(record.environment !== undefined ? ` in environment "${record.environment}"` : "") +
|
|
1337
|
-
", and only that plan. A run whose fresh plan differs refuses rather than applying it.",
|
|
1338
|
-
));
|
|
1339
|
-
}
|
|
1340
|
-
if (approval) {
|
|
1341
|
-
for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin, ledger.pending)) {
|
|
1342
|
-
console.error(formatInfo(line));
|
|
1343
|
-
}
|
|
1344
|
-
}
|
|
1345
|
-
return { ok: true, record, pushed: push.pushed };
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
/**
|
|
1349
|
-
* What a just-recorded approval did to a gate with an `approval` block
|
|
1350
|
-
* (#2508): the policy's decision and whether it binds, then the quorum count
|
|
1351
|
-
* for this plan. The run decides the gate from the ledger; these lines only
|
|
1352
|
-
* say what it will find.
|
|
1353
|
-
*/
|
|
1354
|
-
export function describeApprovalProgress(
|
|
1355
|
-
approval: ResolvedGateApproval,
|
|
1356
|
-
record: GateResolutionRecord,
|
|
1357
|
-
prior: GateResolutionRecord[],
|
|
1358
|
-
standing: PendingGateRecord | undefined,
|
|
1359
|
-
pending: readonly PendingGateRecord[] = standing ? [standing] : [],
|
|
1360
|
-
): string[] {
|
|
1361
|
-
const lines: string[] = [];
|
|
1362
|
-
const decision = record.policyDecision;
|
|
1363
|
-
if (decision) {
|
|
1364
|
-
const rules = decision.determining.length > 0 ? ` (${decision.determining.join(", ")})` : "";
|
|
1365
|
-
const binds =
|
|
1366
|
-
approval.mode === "enforce"
|
|
1367
|
-
? decision.decision === "allow"
|
|
1368
|
-
? "In enforce mode this permit passes the gate on its own."
|
|
1369
|
-
: "In enforce mode only a permit passes the gate on its own, so this approval counts only toward the quorum."
|
|
1370
|
-
: "The gate is in log-only mode, so the decision is recorded and does not change the outcome.";
|
|
1371
|
-
lines.push(`Policy "${decision.policy}" ${decision.version}: ${decision.decision}${rules}. ${binds}`);
|
|
1372
|
-
for (const error of decision.errors) lines.push(`Policy evaluation error: ${error}`);
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
const since = standing?.timestamp ?? new Date(0).toISOString();
|
|
1376
|
-
// #3206: count the humans the way the run will, back past facts re-recorded for this plan.
|
|
1377
|
-
const humansSince = standing && samePlanDigest(standing.planDigest, record.planDigest) ? humanQuorumSince(pending, standing) : since;
|
|
1378
|
-
const tally = tallyGateApprovals([...prior, record], record.gate, since, record.planDigest, approval, humansSince);
|
|
1379
|
-
const roles = approval.quorum?.roles ? ` with role ${approval.quorum.roles.join(" or ")}` : "";
|
|
1380
|
-
const who = tally.counted.map((r) => r.resolvedBy).join(", ") || "none yet";
|
|
1381
|
-
lines.push(`Quorum: ${tally.counted.length} of ${tally.need} human approval(s)${roles} for this plan (${who}).`);
|
|
1382
|
-
if (!tally.counted.includes(record)) {
|
|
1383
|
-
lines.push(
|
|
1384
|
-
approverOf(record).kind === "agent"
|
|
1385
|
-
? "An agent's approval does not count toward the quorum."
|
|
1386
|
-
: `This approval does not count toward the quorum: the approver claims none of its roles (${approval.quorum?.roles?.join(", ")}).`,
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
return lines;
|
|
1390
|
-
}
|
|
785
|
+
// `chant approve` lives in ./approve.ts (#3896); these re-exports keep its importers unchanged.
|
|
786
|
+
export {
|
|
787
|
+
runApprove,
|
|
788
|
+
recordGateApproval,
|
|
789
|
+
describeApprovalProgress,
|
|
790
|
+
type GateApprovalOptions,
|
|
791
|
+
type GateApprovalOutcome,
|
|
792
|
+
} from "./approve";
|
|
@@ -12,7 +12,7 @@ const readOverrideLedgerMock = vi.fn();
|
|
|
12
12
|
const requireLifecycleLedgerMock = vi.fn();
|
|
13
13
|
const originMock = vi.fn(() => "cli");
|
|
14
14
|
|
|
15
|
-
vi.mock("./
|
|
15
|
+
vi.mock("./approve", () => ({ recordGateApproval: (...a: unknown[]) => recordGateApprovalMock(...a) }));
|
|
16
16
|
vi.mock("../../lifecycle/override-ledger", () => ({ readOverrideLedger: (...a: unknown[]) => readOverrideLedgerMock(...a) }));
|
|
17
17
|
vi.mock("../../lifecycle/git", () => ({ requireLifecycleLedger: (...a: unknown[]) => requireLifecycleLedgerMock(...a) }));
|
|
18
18
|
vi.mock("../../lifecycle/gate-origin", async () => {
|
|
@@ -22,7 +22,7 @@ import { samePlanDigest } from "../../lifecycle/plan-digest";
|
|
|
22
22
|
import { OVERRIDE_OP, OVERRIDERS_PATH, overrideCommand, overridersAt, recordedDenials, sortedRules } from "../../override-gate";
|
|
23
23
|
import { formatError, formatInfo, formatWarning } from "../format";
|
|
24
24
|
import type { CommandContext } from "../registry";
|
|
25
|
-
import { recordGateApproval } from "./
|
|
25
|
+
import { recordGateApproval } from "./approve";
|
|
26
26
|
|
|
27
27
|
const norm = (s: string): string => s.normalize("NFKC").trim().toLowerCase();
|
|
28
28
|
|