@intentius/chant 0.124.24 → 0.124.26
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/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +4 -2
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/lint.js +4 -2
- package/dist/cli/handlers/approve.d.ts.map +1 -1
- package/dist/cli/handlers/approve.js +39 -4
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +21 -7
- package/dist/cli/mcp/workspace-tools.d.ts +3 -3
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.js +27 -4
- package/dist/effect-receipt.d.ts +13 -0
- package/dist/effect-receipt.d.ts.map +1 -1
- package/dist/effect-receipt.js +19 -0
- package/dist/gate-decision.d.ts +1 -1
- package/dist/gate-decision.d.ts.map +1 -1
- package/dist/gate-decision.js +1 -1
- package/dist/lexicon-module.d.ts +7 -1
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +7 -2
- package/dist/lifecycle/gate-audit.d.ts +7 -1
- package/dist/lifecycle/gate-audit.d.ts.map +1 -1
- package/dist/lifecycle/gate-audit.js +40 -3
- package/dist/lifecycle/gate-checkpoint.d.ts +78 -0
- package/dist/lifecycle/gate-checkpoint.d.ts.map +1 -0
- package/dist/lifecycle/gate-checkpoint.js +103 -0
- package/dist/lifecycle/gate-ledger.d.ts +41 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.js +18 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.js +4 -2
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/run-ledger.js +2 -0
- package/dist/lint/rules/op/index.d.ts +6 -2
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.js +7 -3
- package/dist/lint/rules/op/ops016-gate-resume.d.ts +14 -0
- package/dist/lint/rules/op/ops016-gate-resume.d.ts.map +1 -0
- package/dist/lint/rules/op/ops016-gate-resume.js +31 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts +30 -0
- package/dist/lint/rules/op/ops017-gate-alias.d.ts.map +1 -0
- package/dist/lint/rules/op/ops017-gate-alias.js +67 -0
- package/dist/op/activity-contract.d.ts +14 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-contract.js +16 -0
- package/dist/op/builders.d.ts +7 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/builders.js +13 -0
- package/dist/op/gate-alias.d.ts +57 -0
- package/dist/op/gate-alias.d.ts.map +1 -0
- package/dist/op/gate-alias.js +127 -0
- package/dist/op/gate-checkpoint.d.ts +123 -0
- package/dist/op/gate-checkpoint.d.ts.map +1 -0
- package/dist/op/gate-checkpoint.js +280 -0
- package/dist/op/gate.d.ts +54 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +142 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/index.js +2 -1
- package/dist/op/local-executor.d.ts +57 -2
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +200 -11
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/local-output.js +20 -2
- package/dist/op/op-ir.d.ts +4 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-ir.js +4 -0
- package/dist/op/receipt-store.d.ts +5 -0
- package/dist/op/receipt-store.d.ts.map +1 -1
- package/dist/op/receipt-store.js +4 -3
- package/dist/op/runtime.d.ts +15 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/runtimes/local.js +5 -0
- package/dist/op/step-output-ref.d.ts +26 -18
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/step-output-ref.js +86 -63
- package/dist/op/types.d.ts +31 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/wave-runtime.mjs +223 -221
- package/dist/workspace/agent-cli.d.ts.map +1 -1
- package/dist/workspace/agent-cli.js +3 -0
- package/dist/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/dist/workspace/conformance/writer.js +1 -1
- package/dist/workspace/decide.d.ts +51 -9
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decide.js +125 -14
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.js +20 -11
- package/dist/workspace/point-answer.schema.json +16 -1
- package/dist/workspace/points-cli.d.ts +5 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points-cli.js +16 -7
- package/dist/workspace/points-write.schema.json +39 -4
- package/dist/workspace/points.schema.json +42 -3
- package/dist/workspace/reason-codes.d.ts +2 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +2 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-cli.js +27 -7
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records-write.js +2 -3
- package/dist/workspace/records.d.ts +10 -4
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/records.js +16 -3
- package/dist/workspace/records.schema.json +2 -1
- package/dist/workspace/trust/gate-seal.d.ts +32 -1
- package/dist/workspace/trust/gate-seal.d.ts.map +1 -1
- package/dist/workspace/trust/gate-seal.js +41 -2
- package/dist/workspace/trust/seal.d.ts +1 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/seal.js +1 -1
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.js +2 -1
- package/dist/workspace/trust/verify.d.ts +22 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/trust/verify.js +94 -14
- package/dist/workspace/write-scope.d.ts +3 -1
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -4
- package/package.json +1 -1
- package/src/cli/commands/build.ts +9 -2
- package/src/cli/commands/lint.ts +4 -2
- package/src/cli/compiled-cli.e2e.test.ts +45 -1
- package/src/cli/handlers/approve.ts +46 -4
- package/src/cli/handlers/operator.test.ts +91 -0
- package/src/cli/main.test.ts +10 -0
- package/src/cli/main.ts +21 -7
- package/src/cli/mcp/workspace-tools.e2e.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +27 -4
- package/src/effect-receipt.ts +22 -0
- package/src/gate-decision.ts +2 -0
- package/src/lexicon-module.ts +9 -2
- package/src/lifecycle/gate-audit.ts +41 -4
- package/src/lifecycle/gate-checkpoint.test.ts +128 -0
- package/src/lifecycle/gate-checkpoint.ts +133 -0
- package/src/lifecycle/gate-ledger.ts +61 -0
- package/src/lifecycle/receipt-plan.test.ts +9 -0
- package/src/lifecycle/receipt-plan.ts +4 -1
- package/src/lifecycle/run-ledger.ts +4 -0
- package/src/lint/rules/op/index.ts +9 -3
- package/src/lint/rules/op/ops016-gate-resume.test.ts +53 -0
- package/src/lint/rules/op/ops016-gate-resume.ts +48 -0
- package/src/lint/rules/op/ops017-gate-alias.test.ts +85 -0
- package/src/lint/rules/op/ops017-gate-alias.ts +89 -0
- package/src/op/activity-contract.ts +22 -1
- package/src/op/builders.ts +21 -1
- package/src/op/effect-step.test.ts +95 -1
- package/src/op/gate-alias.e2e.test.ts +78 -0
- package/src/op/gate-alias.test.ts +350 -0
- package/src/op/gate-alias.ts +137 -0
- package/src/op/gate-checkpoint.test.ts +284 -0
- package/src/op/gate-checkpoint.ts +322 -0
- package/src/op/gate.ts +198 -4
- package/src/op/index.ts +4 -1
- package/src/op/local-executor.test.ts +65 -0
- package/src/op/local-executor.ts +248 -12
- package/src/op/local-output.ts +18 -2
- package/src/op/op-ir.test.ts +25 -0
- package/src/op/op-ir.ts +8 -0
- package/src/op/receipt-store.ts +13 -3
- package/src/op/runtime.ts +8 -0
- package/src/op/runtimes/local.ts +5 -0
- package/src/op/step-output-ref.test.ts +68 -24
- package/src/op/step-output-ref.ts +85 -65
- package/src/op/types.ts +32 -0
- package/src/workspace/agent-cli.ts +2 -0
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +16 -1
- package/src/workspace/conformance/writer.ts +1 -1
- package/src/workspace/decide.test.ts +87 -3
- package/src/workspace/decide.ts +142 -17
- package/src/workspace/lineage-upgrade.test.ts +21 -0
- package/src/workspace/lineage-upgrade.ts +19 -11
- package/src/workspace/point-answer.schema.json +16 -1
- package/src/workspace/points-cli.ts +16 -8
- package/src/workspace/points-write.schema.json +39 -4
- package/src/workspace/points.schema.json +42 -3
- package/src/workspace/reason-codes.ts +2 -1
- package/src/workspace/record-assets.test.ts +14 -0
- package/src/workspace/records-cli.ts +28 -7
- package/src/workspace/records-write-commands.e2e.test.ts +32 -0
- package/src/workspace/records-write.ts +2 -3
- package/src/workspace/records.schema.json +2 -1
- package/src/workspace/records.ts +17 -4
- package/src/workspace/trust/gate-seal.test.ts +29 -2
- package/src/workspace/trust/gate-seal.ts +46 -2
- package/src/workspace/trust/seal.ts +1 -1
- package/src/workspace/trust/trust.test.ts +105 -0
- package/src/workspace/trust/verify-cli.ts +2 -1
- package/src/workspace/trust/verify.ts +106 -13
- package/src/workspace/write-scope.test.ts +18 -0
- package/src/workspace/write-scope.ts +16 -5
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* Each run is traced through a load hook that NODE_OPTIONS hands to the CLI's
|
|
8
8
|
* node, so the test can say what loaded: none of chant's TypeScript source,
|
|
9
9
|
* ever, and tsx only for a command that imports TypeScript (here the
|
|
10
|
-
* lexicon and the project source).
|
|
10
|
+
* lexicon and the project source). tsx's ESM hooks run on a thread of their
|
|
11
|
+
* own, about 200 MB of the process (#3276), so a workspace read must not
|
|
12
|
+
* register it either.
|
|
11
13
|
*
|
|
12
14
|
* End to end because it packs, installs from the registry and spawns the CLI.
|
|
13
15
|
*/
|
|
@@ -20,12 +22,14 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
|
20
22
|
|
|
21
23
|
const coreRoot = resolve(import.meta.dirname, "..", "..");
|
|
22
24
|
const renderRoot = resolve(coreRoot, "..", "..", "lexicons", "render");
|
|
25
|
+
const referenceDecisions = resolve(coreRoot, "..", "..", "reference-workspace", "decisions");
|
|
23
26
|
const version = (JSON.parse(readFileSync(join(coreRoot, "package.json"), "utf-8")) as { version: string }).version;
|
|
24
27
|
|
|
25
28
|
let scratch: string;
|
|
26
29
|
let project: string;
|
|
27
30
|
let cjsProject: string;
|
|
28
31
|
let waves: string;
|
|
32
|
+
let workspace: string;
|
|
29
33
|
let chant: string;
|
|
30
34
|
let tracer: string;
|
|
31
35
|
|
|
@@ -126,6 +130,31 @@ beforeAll(() => {
|
|
|
126
130
|
writeFileSync(join(waves, ".gitignore"), ".chant/\n");
|
|
127
131
|
git("add", "-A");
|
|
128
132
|
git("commit", "-qm", "ignore");
|
|
133
|
+
|
|
134
|
+
// A workspace whose declaration names a .mjs record kind, with a member
|
|
135
|
+
// that has a chant.config.ts, for the workspace reads below (#3276).
|
|
136
|
+
workspace = join(scratch, "workspace");
|
|
137
|
+
mkdirSync(join(workspace, "decisions"), { recursive: true });
|
|
138
|
+
mkdirSync(join(workspace, "app"));
|
|
139
|
+
for (const file of ["decision.kind.mjs", "decision.schema.json", "ref-001-how-the-app-is-deployed.md"]) {
|
|
140
|
+
writeFileSync(join(workspace, "decisions", file), readFileSync(join(referenceDecisions, file)));
|
|
141
|
+
}
|
|
142
|
+
writeFileSync(
|
|
143
|
+
join(workspace, "chant.workspace.json"),
|
|
144
|
+
JSON.stringify({
|
|
145
|
+
name: "e2e",
|
|
146
|
+
schema: 1,
|
|
147
|
+
members: [{ name: "app", dir: "app", kind: "chant" }],
|
|
148
|
+
records: [{ kind: "decisions/decision.kind.mjs" }],
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
writeFileSync(join(workspace, "app", "chant.config.ts"), `export default { lexicons: [] as string[] };\n`);
|
|
152
|
+
symlinkSync(join(project, "node_modules"), join(workspace, "node_modules"), "dir");
|
|
153
|
+
const wsGit = (...args: string[]) => execFileSync("git", ["-c", "user.email=e2e@chant", "-c", "user.name=e2e", ...args], { cwd: workspace, stdio: "ignore" });
|
|
154
|
+
wsGit("init", "-q", "-b", "main");
|
|
155
|
+
writeFileSync(join(workspace, ".gitignore"), "node_modules\n");
|
|
156
|
+
wsGit("add", "-A");
|
|
157
|
+
wsGit("commit", "-qm", "workspace");
|
|
129
158
|
}, 600_000);
|
|
130
159
|
|
|
131
160
|
afterAll(() => {
|
|
@@ -148,6 +177,21 @@ describe("the installed chant CLI (#3878)", () => {
|
|
|
148
177
|
expect(r.loaded).toMatchObject({ chantSrc: 0, tsx: 0 });
|
|
149
178
|
});
|
|
150
179
|
|
|
180
|
+
test("workspace ls reads a .mjs record kind from a member with a chant.config.ts without loading tsx", () => {
|
|
181
|
+
const r = run(join(workspace, "app"), ["workspace", "ls", "--json"]);
|
|
182
|
+
expect(r.status, r.stderr).toBe(0);
|
|
183
|
+
const ls = JSON.parse(r.stdout) as { workspace: { records: Array<{ name: string; kind: string | null }> } };
|
|
184
|
+
expect(ls.workspace.records.map((k) => k.kind)).toEqual(["decision"]);
|
|
185
|
+
expect(r.loaded).toMatchObject({ chantSrc: 0, tsx: 0 });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("workspace records reads the declared .mjs kind's records without loading tsx", () => {
|
|
189
|
+
const r = run(join(workspace, "app"), ["workspace", "records", "--json"]);
|
|
190
|
+
expect(r.status, r.stderr).toBe(0);
|
|
191
|
+
expect(r.stdout).toContain("ref-001");
|
|
192
|
+
expect(r.loaded).toMatchObject({ chantSrc: 0, tsx: 0 });
|
|
193
|
+
});
|
|
194
|
+
|
|
151
195
|
test("build loads the TypeScript lexicon and project through tsx, against the one compiled core", () => {
|
|
152
196
|
const r = run(project, ["build", "src", "-o", "out/render.json"]);
|
|
153
197
|
expect(r.status, r.stderr).toBe(0);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* re-exports everything here.
|
|
6
6
|
*/
|
|
7
7
|
import { discoverOps } from "../../op/discover";
|
|
8
|
+
import { aliasingGate } from "../../op/gate-alias";
|
|
8
9
|
import type { GateResolutionRecord, PendingGateRecord } from "../../lifecycle/gate-ledger";
|
|
9
10
|
import {
|
|
10
11
|
currentGateOrigin, isModelAuthored, sameOriginRefusal, UNATTESTED_APPROVER, type GateOrigin,
|
|
@@ -280,7 +281,9 @@ export async function recordGateApproval(
|
|
|
280
281
|
// `fan-out` is the op name every `chant components fan-out` gate is recorded
|
|
281
282
|
// under (../handlers/fan-out.ts). It is a command rather than a declaration,
|
|
282
283
|
// so there is no `*.op.ts` to find and nothing is wrong when none is there.
|
|
283
|
-
|
|
284
|
+
// #3949: a gate that lists this one as an alias, after an Op was restructured.
|
|
285
|
+
const successor = aliasingGate([...ops.values()].map((o) => o.config), opName, gate);
|
|
286
|
+
if (!successor && !ops.has(opName) && opName !== FAN_OUT_GATE_OP && opName !== WORKSPACE_UPGRADE_GATE_OP && opName !== OVERRIDE_GATE_OP) {
|
|
284
287
|
console.error(formatWarning({
|
|
285
288
|
message: `Op "${opName}" was not found among discovered *.op.ts declarations — recording the resolution anyway`,
|
|
286
289
|
}));
|
|
@@ -301,6 +304,26 @@ export async function recordGateApproval(
|
|
|
301
304
|
return { ok: false };
|
|
302
305
|
}
|
|
303
306
|
|
|
307
|
+
// #3949: an approval under an alias counts toward the new gate only while
|
|
308
|
+
// the new gate has no standing fact of its own. Once it has one, approve
|
|
309
|
+
// the gate the runs now stop at.
|
|
310
|
+
if (successor) {
|
|
311
|
+
const theirs = latestPendingGate((await readGateLedger(successor.op)).pending, successor.gate);
|
|
312
|
+
if (theirs && !isPendingGateExpired(theirs, new Date().toISOString())) {
|
|
313
|
+
console.error(formatError({
|
|
314
|
+
message:
|
|
315
|
+
`Gate "${gate}" on "${opName}" is now gate "${successor.gate}" on "${successor.op}", which lists it as an alias, ` +
|
|
316
|
+
`and "${successor.gate}" has a standing pending fact`,
|
|
317
|
+
hint: `Approve that gate instead: chant approve ${successor.op} ${successor.gate}`,
|
|
318
|
+
}));
|
|
319
|
+
return { ok: false };
|
|
320
|
+
}
|
|
321
|
+
console.error(formatInfo(
|
|
322
|
+
`Gate "${gate}" on "${opName}" is an alias of gate "${successor.gate}" on "${successor.op}": ` +
|
|
323
|
+
`the next run of ${successor.op} counts this approval there while the pending fact it answers stands and the plan is the same.`,
|
|
324
|
+
));
|
|
325
|
+
}
|
|
326
|
+
|
|
304
327
|
// #2028: the resolution's link is typed. `--url` wins; otherwise, running
|
|
305
328
|
// inside the PR/MR job that carries the change is itself the address, the
|
|
306
329
|
// same env fallback `--actor` uses. `--note` stays free-text prose.
|
|
@@ -358,6 +381,14 @@ export async function recordGateApproval(
|
|
|
358
381
|
}
|
|
359
382
|
planDigest = standing.planDigest;
|
|
360
383
|
}
|
|
384
|
+
// #3947: a gate that resumes recorded a checkpoint with its pending fact.
|
|
385
|
+
// An approval of that fact's plan copies the checkpoint's digest, the way it
|
|
386
|
+
// copies the plan's, so the run that resumes checks the file against what
|
|
387
|
+
// was approved here. An approval of another plan copies none.
|
|
388
|
+
const checkpointDigest =
|
|
389
|
+
standing?.checkpointDigest !== undefined && samePlanDigest(planDigest, standing.planDigest)
|
|
390
|
+
? standing.checkpointDigest
|
|
391
|
+
: undefined;
|
|
361
392
|
|
|
362
393
|
// chant#2384 — the gate's whole value is that its two halves have different
|
|
363
394
|
// authors. At a shell they do: a person runs, reads the plan, and approves.
|
|
@@ -457,11 +488,11 @@ export async function recordGateApproval(
|
|
|
457
488
|
const timestamp = new Date().toISOString();
|
|
458
489
|
let seal: GateResolutionRecord["seal"];
|
|
459
490
|
if (opts.sign !== undefined) {
|
|
460
|
-
const { resolveSigningKey, sealGateApproval, SealError } = await import("../../workspace/trust/seal");
|
|
491
|
+
const { resolveSigningKey, sealGateApproval, gateSealVersion, SealError } = await import("../../workspace/trust/seal");
|
|
461
492
|
try {
|
|
462
493
|
const key = resolveSigningKey(opts.sign, cwd);
|
|
463
494
|
try {
|
|
464
|
-
|
|
495
|
+
const sealed = {
|
|
465
496
|
op: opName,
|
|
466
497
|
gate,
|
|
467
498
|
...(environment !== undefined ? { environment } : {}),
|
|
@@ -469,7 +500,10 @@ export async function recordGateApproval(
|
|
|
469
500
|
resolvedBy,
|
|
470
501
|
...(relayedBy ? { relayedBy } : {}),
|
|
471
502
|
timestamp,
|
|
472
|
-
|
|
503
|
+
...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
|
|
504
|
+
};
|
|
505
|
+
// #3947: a seal over a checkpoint digest is seal version 2.
|
|
506
|
+
seal = { ...sealGateApproval(key.file, sealed), ...(gateSealVersion(sealed) === 2 ? { version: 2 as const } : {}) };
|
|
473
507
|
} finally {
|
|
474
508
|
key.cleanup();
|
|
475
509
|
}
|
|
@@ -488,6 +522,7 @@ export async function recordGateApproval(
|
|
|
488
522
|
resolvedBy,
|
|
489
523
|
...(relayedBy ? { relayedBy } : {}),
|
|
490
524
|
timestamp,
|
|
525
|
+
...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
|
|
491
526
|
seal,
|
|
492
527
|
});
|
|
493
528
|
if (why !== null) {
|
|
@@ -550,6 +585,7 @@ export async function recordGateApproval(
|
|
|
550
585
|
...(opts.note ? { note: opts.note } : {}),
|
|
551
586
|
...(url ? { url } : {}),
|
|
552
587
|
...(planDigest !== undefined ? { planDigest } : {}),
|
|
588
|
+
...(checkpointDigest !== undefined ? { checkpointDigest } : {}),
|
|
553
589
|
...(environment !== undefined ? { environment } : {}),
|
|
554
590
|
origin,
|
|
555
591
|
...(refusal && opts.allowSameOrigin ? { sameOriginOverride: true } : {}),
|
|
@@ -574,6 +610,12 @@ export async function recordGateApproval(
|
|
|
574
610
|
", and only that plan. A run whose fresh plan differs refuses rather than applying it.",
|
|
575
611
|
));
|
|
576
612
|
}
|
|
613
|
+
if (record.checkpointDigest) {
|
|
614
|
+
console.error(formatInfo(
|
|
615
|
+
`The next run resumes at this gate from run ${standing?.runId ?? "(unknown)"}'s checkpoint ${record.checkpointDigest}, ` +
|
|
616
|
+
"after it plans again. A moved plan or another commit starts it at the first phase.",
|
|
617
|
+
));
|
|
618
|
+
}
|
|
577
619
|
if (approval) {
|
|
578
620
|
for (const line of describeApprovalProgress(approval, record, ledger.resolutions, standingForOrigin, ledger.pending)) {
|
|
579
621
|
console.error(formatInfo(line));
|
|
@@ -929,6 +929,97 @@ describe("runApprove — the resolution names the plan it approves (#2300)", ()
|
|
|
929
929
|
});
|
|
930
930
|
});
|
|
931
931
|
|
|
932
|
+
/** #3947 — a gate that resumes recorded a checkpoint, and its approval copies the digest. */
|
|
933
|
+
describe("runApprove — the approval copies the checkpoint a resumed run carries on from (#3947)", () => {
|
|
934
|
+
const CHECKPOINT = `jcs1-sha256:${"c".repeat(64)}`;
|
|
935
|
+
const seedCheckpoint = () =>
|
|
936
|
+
readGateLedgerMock.mockResolvedValue({
|
|
937
|
+
resolutions: [],
|
|
938
|
+
pending: [{
|
|
939
|
+
version: 1, kind: "pending", op: "fountain-apply", gate: "rollout-gate", runId: "local-1",
|
|
940
|
+
timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z",
|
|
941
|
+
planDigest: PLAN_A, checkpointDigest: CHECKPOINT,
|
|
942
|
+
}],
|
|
943
|
+
malformed: 0,
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("approving the standing fact copies its checkpoint digest and says the next run resumes", async () => {
|
|
947
|
+
seedCheckpoint();
|
|
948
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
949
|
+
commit: "sha",
|
|
950
|
+
record: {
|
|
951
|
+
version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex",
|
|
952
|
+
timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A, checkpointDigest: CHECKPOINT,
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
956
|
+
|
|
957
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex" }))).toBe(0);
|
|
958
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ planDigest: PLAN_A, checkpointDigest: CHECKPOINT }));
|
|
959
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain(`resumes at this gate from run local-1's checkpoint ${CHECKPOINT}`);
|
|
960
|
+
errSpy.mockRestore();
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
test("an approval of another plan copies no checkpoint", async () => {
|
|
964
|
+
seedCheckpoint();
|
|
965
|
+
const other = `sha256:${"b".repeat(64)}`;
|
|
966
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
967
|
+
commit: "sha",
|
|
968
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: other },
|
|
969
|
+
});
|
|
970
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
971
|
+
|
|
972
|
+
expect(await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", plan: other }))).toBe(0);
|
|
973
|
+
expect(appendGateResolutionMock.mock.calls[0][0]).not.toHaveProperty("checkpointDigest");
|
|
974
|
+
errSpy.mockRestore();
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
/** #3949 — once a gate that aliases this one has a standing fact, approve that gate. */
|
|
979
|
+
describe("runApprove — an approval under a gate's alias (#3949)", () => {
|
|
980
|
+
const merged = {
|
|
981
|
+
config: {
|
|
982
|
+
name: "upgrade",
|
|
983
|
+
overview: "upgrade every tier",
|
|
984
|
+
phases: [{ name: "Wave 1", steps: [{ kind: "gate", gate: "approve-tier-1", plan: PLAN_A, aliases: [{ op: "upgrade-tier-1", gate: "approve" }] }] }],
|
|
985
|
+
},
|
|
986
|
+
};
|
|
987
|
+
const fact = (op: string, gate: string) => ({
|
|
988
|
+
version: 1, kind: "pending", op, gate, timestamp: "2026-01-01T00:00:00.000Z", expiresAt: "2099-01-01T00:00:00.000Z", planDigest: PLAN_A,
|
|
989
|
+
});
|
|
990
|
+
const ledgers = (byOp: Record<string, unknown[]>) =>
|
|
991
|
+
readGateLedgerMock.mockImplementation(async (op: string) => ({ resolutions: [], pending: byOp[op] ?? [], malformed: 0 }));
|
|
992
|
+
|
|
993
|
+
test("refuses and names the new gate once it has a standing pending fact", async () => {
|
|
994
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([["upgrade", merged]]), errors: [] });
|
|
995
|
+
ledgers({ "upgrade-tier-1": [fact("upgrade-tier-1", "approve")], upgrade: [fact("upgrade", "approve-tier-1")] });
|
|
996
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
997
|
+
|
|
998
|
+
expect(await runApprove(ctx({ path: "upgrade-tier-1", extraPositional: "approve", actor: "alex" }))).toBe(1);
|
|
999
|
+
const out = errSpy.mock.calls.map((c) => String(c[0])).join("\n");
|
|
1000
|
+
expect(out).toContain('is now gate "approve-tier-1" on "upgrade"');
|
|
1001
|
+
expect(out).toContain("chant approve upgrade approve-tier-1");
|
|
1002
|
+
expect(out).not.toContain("was not found among discovered");
|
|
1003
|
+
expect(appendGateResolutionMock).not.toHaveBeenCalled();
|
|
1004
|
+
errSpy.mockRestore();
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
test("records the approval while the new gate has none, and says where it counts", async () => {
|
|
1008
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([["upgrade", merged]]), errors: [] });
|
|
1009
|
+
ledgers({ "upgrade-tier-1": [fact("upgrade-tier-1", "approve")] });
|
|
1010
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
1011
|
+
commit: "sha",
|
|
1012
|
+
record: { version: 1, op: "upgrade-tier-1", gate: "approve", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z", planDigest: PLAN_A },
|
|
1013
|
+
});
|
|
1014
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
1015
|
+
|
|
1016
|
+
expect(await runApprove(ctx({ path: "upgrade-tier-1", extraPositional: "approve", actor: "alex" }))).toBe(0);
|
|
1017
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(expect.objectContaining({ op: "upgrade-tier-1", gate: "approve", planDigest: PLAN_A }));
|
|
1018
|
+
expect(errSpy.mock.calls.map((c) => String(c[0])).join("\n")).toContain('is an alias of gate "approve-tier-1" on "upgrade"');
|
|
1019
|
+
errSpy.mockRestore();
|
|
1020
|
+
});
|
|
1021
|
+
});
|
|
1022
|
+
|
|
932
1023
|
/**
|
|
933
1024
|
* #2574 — a component's gate ledger holds every environment's pending facts.
|
|
934
1025
|
* The approval records the environment it answers, and `--env` picks it.
|
package/src/cli/main.test.ts
CHANGED
|
@@ -1003,3 +1003,13 @@ describe("chant workspace check --generated (#2641)", () => {
|
|
|
1003
1003
|
expect(() => parseArgs(["workspace", "check", "--generated=yes"])).toThrow();
|
|
1004
1004
|
});
|
|
1005
1005
|
});
|
|
1006
|
+
|
|
1007
|
+
describe("chant --help", () => {
|
|
1008
|
+
test("lists every top-level command in the registry", () => {
|
|
1009
|
+
const source = readFileSync(join(import.meta.dirname, "main.ts"), "utf-8");
|
|
1010
|
+
const help = source.slice(source.indexOf("function printHelp("), source.indexOf("Options:", source.indexOf("function printHelp(")));
|
|
1011
|
+
const listed = new Set([...help.matchAll(/^ {2}([a-z][a-z-]*)\b/gm)].map((m) => m[1]));
|
|
1012
|
+
const commands = new Set(commandRegistry.map((def) => def.name.split(" ")[0]));
|
|
1013
|
+
expect([...commands].filter((name) => !listed.has(name))).toEqual([]);
|
|
1014
|
+
});
|
|
1015
|
+
});
|
package/src/cli/main.ts
CHANGED
|
@@ -167,7 +167,12 @@ Commands:
|
|
|
167
167
|
okf emits an OKF v0.2 knowledge bundle — one markdown
|
|
168
168
|
concept per entity + index.md; -o <dir> writes the
|
|
169
169
|
bundle tree, otherwise JSON path→content on stdout)
|
|
170
|
-
|
|
170
|
+
search "<query>" Answer an estate question with a few matching rows
|
|
171
|
+
instead of a graph dump (kind:, tag:, attr:, ->/<- edge
|
|
172
|
+
terms; --live or --at <ref> with --env <name> reads the
|
|
173
|
+
estate or a recorded snapshot; --show <attr>,
|
|
174
|
+
--check-live, --check-snapshot, --fail-on-drift)
|
|
175
|
+
generate Generate typed code from sources chant.config declares
|
|
171
176
|
(k8s.crds, helm.charts) into src/generated/<lexicon>/
|
|
172
177
|
(codegen.outDir); --check exits 1 when the committed
|
|
173
178
|
output no longer matches its sources, as build does
|
|
@@ -402,9 +407,11 @@ Workspace (level 1, #2524):
|
|
|
402
407
|
state transitions, new verdicts, new supersessions
|
|
403
408
|
and changed pins. A session id compares the commits
|
|
404
409
|
the session opened and closed at
|
|
405
|
-
workspace records pin <path>
|
|
410
|
+
workspace records pin <path> [--kind <kind file>]
|
|
406
411
|
Print the path from the workspace root and the
|
|
407
|
-
sha256 of a file, for a decision's evidence pin
|
|
412
|
+
sha256 of a file, for a decision's evidence pin. The
|
|
413
|
+
root is the kind file's workspace with --kind, else
|
|
414
|
+
the workspace above the current directory
|
|
408
415
|
workspace records new [<kind file>] --from <file|-> [--prefix <prefix>] [--sign [<key file>]] [--dry-run]
|
|
409
416
|
Write one new record in the kind's directory from
|
|
410
417
|
the JSON fields given, after validating them as
|
|
@@ -559,6 +566,11 @@ Workspace (level 1, #2524):
|
|
|
559
566
|
workspace points retract <id> --by <name>... [--note <text>] [--kind <kind file>] [--dry-run]
|
|
560
567
|
Take an answer back: the question is open for people
|
|
561
568
|
again, and the answer stays in its retractions
|
|
569
|
+
workspace points withdraw <id> --by <name>... [--note <text>] [--kind <kind file>] [--dry-run]
|
|
570
|
+
Close an open question that no longer applies, such as
|
|
571
|
+
one about a work item its author changed: points --open
|
|
572
|
+
leaves it out, nobody answers it, and asking it again
|
|
573
|
+
opens it again (#2888)
|
|
562
574
|
workspace pin <path> [--json]
|
|
563
575
|
Print the integrity value that pins the plugin at
|
|
564
576
|
<path>, to put in a path pin of chant.workspace.json.
|
|
@@ -1226,16 +1238,18 @@ export const commandRegistry: CommandDef[] = [
|
|
|
1226
1238
|
|
|
1227
1239
|
// Workspace reads (#2524). Imported on first use, so a level-0 command never
|
|
1228
1240
|
// loads anything under workspace/ (#2525 rule 5, pinned by #2526's goldens).
|
|
1229
|
-
|
|
1241
|
+
// #3276 — records, points, ls and status read the declaration and never the
|
|
1242
|
+
// config of the member they run in, so they skip its load (and tsx with it).
|
|
1243
|
+
{ name: "workspace records", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/records-cli")).runWorkspaceRecords(ctx) },
|
|
1230
1244
|
// ws-058 (#2739) — decision points and the questions asked of them; never calls a model.
|
|
1231
|
-
{ name: "workspace points", handler: async (ctx) => (await import("../workspace/points-cli")).runWorkspacePoints(ctx) },
|
|
1245
|
+
{ name: "workspace points", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/points-cli")).runWorkspacePoints(ctx) },
|
|
1232
1246
|
{ name: "workspace init", handler: async (ctx) => (await import("../workspace/init")).runWorkspaceInit(ctx) },
|
|
1233
|
-
{ name: "workspace ls", handler: async (ctx) => (await import("../workspace/ls")).runWorkspaceLs(ctx) },
|
|
1247
|
+
{ name: "workspace ls", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/ls")).runWorkspaceLs(ctx) },
|
|
1234
1248
|
// #2732 — the work lease: claim, renew and release a work item.
|
|
1235
1249
|
{ name: "workspace work", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/work-cli")).runWorkspaceWork(ctx) },
|
|
1236
1250
|
// #3033 — the agent run record: what each run cost and which commits it made. chant records runs and never starts one.
|
|
1237
1251
|
{ name: "workspace runs", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/runs-cli")).runWorkspaceRuns(ctx) },
|
|
1238
|
-
{ name: "workspace status", handler: async (ctx) => (await import("../workspace/status")).runWorkspaceStatus(ctx) },
|
|
1252
|
+
{ name: "workspace status", runsNoConfig: true, handler: async (ctx) => (await import("../workspace/status")).runWorkspaceStatus(ctx) },
|
|
1239
1253
|
{ name: "workspace lineage", handler: async (ctx) => (await import("../workspace/lineage-cli")).runWorkspaceLineage(ctx) },
|
|
1240
1254
|
{ name: "workspace upgrade", handler: async (ctx) => (await import("../workspace/lineage-upgrade-cli")).runWorkspaceUpgrade(ctx) },
|
|
1241
1255
|
// #2551 — adopt a lineage, the hash index it matches against, and the versions of a family of workspaces.
|
|
@@ -85,7 +85,7 @@ describe("which servers have the workspace tools", () => {
|
|
|
85
85
|
const { tools } = (await rpc(server(cwd), "tools/list")) as { tools: { name: string }[] };
|
|
86
86
|
expect(tools.map((t) => t.name)).toEqual(expect.arrayContaining(names));
|
|
87
87
|
}
|
|
88
|
-
expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-changes", "workspace-records", "workspace-points", "workspace-agent", "workspace-work-history", "workspace-runs", "records-new", "records-amend", "records-review", "records-close", "points-answer", "points-retract"]);
|
|
88
|
+
expect(names).toEqual(["workspace-ls", "workspace-status", "workspace-graph", "workspace-changes", "workspace-records", "workspace-points", "workspace-agent", "workspace-work-history", "workspace-runs", "records-new", "records-amend", "records-review", "records-close", "points-answer", "points-retract", "points-withdraw"]);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
91
|
test("a server outside any workspace, or given none, does not", async () => {
|
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
* read to the workspace root's pinned chant included (ws-021), and keeps
|
|
16
16
|
* anything a command prints away from the protocol on stdout.
|
|
17
17
|
* - The writes (`records-new`, `records-amend`, `records-review`,
|
|
18
|
-
* `records-close`, `points-answer`, `points-retract`)
|
|
19
|
-
* `chant workspace records new|amend|review|close` and
|
|
20
|
-
* answer|retract` call, and return the
|
|
18
|
+
* `records-close`, `points-answer`, `points-retract`, `points-withdraw`)
|
|
19
|
+
* call the functions `chant workspace records new|amend|review|close` and
|
|
20
|
+
* `points answer|retract|withdraw` call, and return the
|
|
21
21
|
* same JSON result. They keep every rule
|
|
22
22
|
* the CLI keeps: the kind's schema, a closed record never changing, an
|
|
23
23
|
* approved one changing only as its approval rule allows, a dissent needing
|
|
@@ -165,7 +165,7 @@ export const workspaceReadTools: ToolDefinition[] = [
|
|
|
165
165
|
{
|
|
166
166
|
name: "workspace-points",
|
|
167
167
|
description:
|
|
168
|
-
"The workspace's decision points and the questions asked of them: chant workspace points --json (points.schema.json, ws-058). A question is open while it is escalated to people, or proposed by a model and not yet confirmed; each lists any model's answer with its confidence and threshold. Returns the document unchanged. chant calls no model to answer this.",
|
|
168
|
+
"The workspace's decision points and the questions asked of them: chant workspace points --json (points.schema.json, ws-058). A question is open while it is escalated to people, or proposed by a model and not yet confirmed, and not withdrawn; each lists any model's answer with its confidence and threshold. Returns the document unchanged. chant calls no model to answer this.",
|
|
169
169
|
inputSchema: {
|
|
170
170
|
type: "object",
|
|
171
171
|
properties: {
|
|
@@ -326,6 +326,23 @@ export const workspaceWriteTools: ToolDefinition[] = [
|
|
|
326
326
|
required: ["id", "by"],
|
|
327
327
|
},
|
|
328
328
|
},
|
|
329
|
+
{
|
|
330
|
+
name: "points-withdraw",
|
|
331
|
+
description:
|
|
332
|
+
"Close an open decision point question that no longer applies, such as one about a work item its author changed, which a run asks about anew: chant workspace points withdraw (#2888). The question keeps its state, with who withdrew it, when and why; workspace-points open leaves it out, and points-answer refuses it. Asking it again opens it again. Withdrawing answers nothing, so it needs no quorum. An answered question is not withdrawn: points-retract takes the answer back. " +
|
|
333
|
+
PROTOCOL,
|
|
334
|
+
inputSchema: {
|
|
335
|
+
type: "object",
|
|
336
|
+
properties: {
|
|
337
|
+
id: { type: "string", description: "The question's id, as workspace-points lists it." },
|
|
338
|
+
by: { type: "array", items: { type: "string" }, description: "Who withdraws the question (--by)." },
|
|
339
|
+
note: { type: "string", description: "Why, such as the question that asks about the subject now (--note)." },
|
|
340
|
+
kind: { type: "string", description: "The answer kind file. Without it, the declared answer kinds." },
|
|
341
|
+
dryRun: dryRunProp,
|
|
342
|
+
},
|
|
343
|
+
required: ["id", "by"],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
329
346
|
];
|
|
330
347
|
|
|
331
348
|
/** A tool call that can't be made as given: the client gets it as an error result, and nothing runs. */
|
|
@@ -581,6 +598,12 @@ export function createWorkspaceTools(options: WorkspaceToolsOptions): WorkspaceT
|
|
|
581
598
|
if (!Array.isArray(by) || by.length === 0 || !by.every((b) => typeof b === "string" && b.trim() !== "")) throw new ToolInputError("by must list each person who takes the answer back");
|
|
582
599
|
return retractAnswer({ cwd, id: str(params, "id", true)!, by: by as string[], note: str(params, "note"), kind: str(params, "kind"), dryRun: bool(params, "dryRun") });
|
|
583
600
|
},
|
|
601
|
+
"points-withdraw": async (params) => {
|
|
602
|
+
const { withdrawQuestion } = await import("../../workspace/decide");
|
|
603
|
+
const by = params.by;
|
|
604
|
+
if (!Array.isArray(by) || by.length === 0 || !by.every((b) => typeof b === "string" && b.trim() !== "")) throw new ToolInputError("by must name who withdraws the question");
|
|
605
|
+
return withdrawQuestion({ cwd, id: str(params, "id", true)!, by: by as string[], note: str(params, "note"), kind: str(params, "kind"), dryRun: bool(params, "dryRun") });
|
|
606
|
+
},
|
|
584
607
|
"records-close": async (params) => {
|
|
585
608
|
const w = await write();
|
|
586
609
|
const { closeRecord, RECORDS_CLOSE_SCHEMA_ID } = await import("../../workspace/records-close");
|
package/src/effect-receipt.ts
CHANGED
|
@@ -66,6 +66,28 @@ export const EFFECT_RECEIPT_ENTITY_TYPE = "Chant::EffectReceipt";
|
|
|
66
66
|
*/
|
|
67
67
|
export const EXISTENCE_EXPECTATION = "chant.effect-receipt:exists";
|
|
68
68
|
|
|
69
|
+
const OUTPUTS_SEPARATOR = "+outputs=";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A receipt expectation with the digest of the step outputs its effect read
|
|
73
|
+
* appended (#3948). The effect step writes this value, so the next run fires
|
|
74
|
+
* again when one of those outputs changed, though the declared inputs did not.
|
|
75
|
+
*/
|
|
76
|
+
export function withStepOutputs(expectation: string, outputs: string): string {
|
|
77
|
+
return `${expectation}${OUTPUTS_SEPARATOR}${outputs}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The part of a stored receipt value that the declared inputs determine: the
|
|
82
|
+
* value without any step-output digest {@link withStepOutputs} appended. The
|
|
83
|
+
* plan and `receiptStaleness` compare this part, since step outputs exist only
|
|
84
|
+
* while the Op runs.
|
|
85
|
+
*/
|
|
86
|
+
export function declaredExpectation(value: string): string {
|
|
87
|
+
const at = value.indexOf(OUTPUTS_SEPARATOR);
|
|
88
|
+
return at < 0 ? value : value.slice(0, at);
|
|
89
|
+
}
|
|
90
|
+
|
|
69
91
|
/**
|
|
70
92
|
* An effect receipt declaration — a Declarable, so discovery collects it,
|
|
71
93
|
* `chant list` shows it, and (unlike a secret declaration) serialization
|
package/src/gate-decision.ts
CHANGED
package/src/lexicon-module.ts
CHANGED
|
@@ -176,12 +176,19 @@ export function resetLexiconModules(): void {
|
|
|
176
176
|
* Import the module a path-declared lexicon names. Returns `undefined` when
|
|
177
177
|
* `name` is package-backed, so a caller falls through to its package import.
|
|
178
178
|
* An import failure is thrown with the declared path in the message.
|
|
179
|
+
*
|
|
180
|
+
* `typeScript: false` imports it with plain node, without registering tsx
|
|
181
|
+
* (#3276): tsx's ESM hooks run on a thread of their own, which costs the
|
|
182
|
+
* compiled CLI about 200 MB, so a caller whose module is JavaScript says so.
|
|
179
183
|
*/
|
|
180
|
-
export async function importLexiconModule(
|
|
184
|
+
export async function importLexiconModule(
|
|
185
|
+
name: string,
|
|
186
|
+
options: { typeScript?: boolean } = {},
|
|
187
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
181
188
|
const path = modulePaths.get(name);
|
|
182
189
|
if (path === undefined) return undefined;
|
|
183
190
|
try {
|
|
184
|
-
ensureTypeScriptLoader();
|
|
191
|
+
if (options.typeScript !== false) ensureTypeScriptLoader();
|
|
185
192
|
return esModuleNamespace((await import(path)) as Record<string, unknown>);
|
|
186
193
|
} catch (err) {
|
|
187
194
|
throw new Error(
|
|
@@ -48,6 +48,13 @@
|
|
|
48
48
|
* - `override`: an apply cites an override that no line holds.
|
|
49
49
|
* - `malformed-line`: a line that is not a gate record. Its content is lost
|
|
50
50
|
* to the trail, so it is said.
|
|
51
|
+
* - `carried-without-source`: an approval carried from a gate's alias
|
|
52
|
+
* (#3949, `carriedFrom`) whose cited line is not in the cited op's
|
|
53
|
+
* ledger. Checked only when that ledger is passed in.
|
|
54
|
+
*
|
|
55
|
+
* A carried approval's request is the pending fact of the name it was
|
|
56
|
+
* carried from, in that op's ledger. When that ledger is not passed in,
|
|
57
|
+
* neither gap is looked for, and the entry's `carriedFrom` says where to look.
|
|
51
58
|
*
|
|
52
59
|
* ## Sources and ids
|
|
53
60
|
*
|
|
@@ -211,13 +218,15 @@ export interface GateAuditEntry {
|
|
|
211
218
|
subject?: "approval" | "override";
|
|
212
219
|
/** An applied entry: the approval entry it ran under, if one is held. An override-spent entry: the override entry. */
|
|
213
220
|
under?: string;
|
|
221
|
+
/** An approval a run carried from one of the gate's aliases (#3949): the op, gate and time of the line it came from. */
|
|
222
|
+
carriedFrom?: { op: string; gate: string; at: string };
|
|
214
223
|
/** Everything else the line carries, such as `members`, `via` or a product's own fields. */
|
|
215
224
|
detail: Record<string, unknown>;
|
|
216
225
|
source: GateAuditSource;
|
|
217
226
|
}
|
|
218
227
|
|
|
219
228
|
export interface GateAuditGap {
|
|
220
|
-
kind: "approval-without-request" | "unsealed" | "approval" | "override" | "malformed-line";
|
|
229
|
+
kind: "approval-without-request" | "unsealed" | "approval" | "override" | "malformed-line" | "carried-without-source";
|
|
221
230
|
op?: string;
|
|
222
231
|
gate?: string;
|
|
223
232
|
digest?: string;
|
|
@@ -284,7 +293,7 @@ type Row = Record<string, unknown>;
|
|
|
284
293
|
|
|
285
294
|
const KNOWN = new Set([
|
|
286
295
|
"version", "kind", "op", "gate", "resolvedBy", "relayedBy", "timestamp", "note", "url", "planDigest", "environment",
|
|
287
|
-
"approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers",
|
|
296
|
+
"approver", "seal", "review", "runId", "expiresAt", "description", "wave", "heldBy", "approvalMode", "rules", "pr", "head", "reviewers", "carriedFrom",
|
|
288
297
|
]);
|
|
289
298
|
|
|
290
299
|
const waveOf = (v: unknown): { number: number; name: string; op?: string } | undefined => {
|
|
@@ -335,6 +344,11 @@ function approverOf(r: Row): GateAuditEntry["approver"] {
|
|
|
335
344
|
return { kind: a.kind, ...(roles ? { roles } : {}) };
|
|
336
345
|
}
|
|
337
346
|
|
|
347
|
+
const carriedOf = (v: unknown): GateAuditEntry["carriedFrom"] => {
|
|
348
|
+
const c = v as { op?: unknown; gate?: unknown; timestamp?: unknown } | null;
|
|
349
|
+
return c && typeof c === "object" && typeof c.op === "string" && typeof c.gate === "string" && typeof c.timestamp === "string" ? { op: c.op, gate: c.gate, at: c.timestamp } : undefined;
|
|
350
|
+
};
|
|
351
|
+
|
|
338
352
|
const drop = <T extends object>(o: T): T => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
|
|
339
353
|
|
|
340
354
|
const idOf = (...parts: unknown[]): string => contentDigest(canonicalJson(parts));
|
|
@@ -375,6 +389,19 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
|
|
|
375
389
|
const entries: GateAuditEntry[] = [];
|
|
376
390
|
const gaps: GateAuditGap[] = [];
|
|
377
391
|
|
|
392
|
+
// #3949: every ledger's requests and approvals by op, for an approval carried from another op's gate.
|
|
393
|
+
const byOp = new Map<string, { requests: Seen[]; approvals: Row[] }>();
|
|
394
|
+
for (const ledger of input.ledgers) {
|
|
395
|
+
const held = byOp.get(ledger.op) ?? { requests: [], approvals: [] };
|
|
396
|
+
byOp.set(ledger.op, held);
|
|
397
|
+
for (const line of ledger.lines) {
|
|
398
|
+
const r = rowOf(line);
|
|
399
|
+
if (!r || line.removed || typeof r.gate !== "string" || typeof r.timestamp !== "string") continue;
|
|
400
|
+
if (r.kind === "pending") held.requests.push({ op: ledger.op, gate: r.gate, at: time(r.timestamp), ...(str(r.planDigest) ? { digest: str(r.planDigest) } : {}) });
|
|
401
|
+
else held.approvals.push(r);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
378
405
|
for (const ledger of input.ledgers) {
|
|
379
406
|
const path = ledger.path ?? `_gates/${ledger.op}.jsonl`;
|
|
380
407
|
const parsed: { line: GateLedgerLine; n: number; r: Row; pending: boolean }[] = [];
|
|
@@ -453,12 +480,22 @@ export function deriveGateAudit(input: GateAuditInput): { entries: GateAuditEntr
|
|
|
453
480
|
at: String(r.timestamp),
|
|
454
481
|
by: String(r.resolvedBy),
|
|
455
482
|
seal,
|
|
456
|
-
...drop({ sealed, signer, committedBy: line.added?.by, relayedBy: str(r.relayedBy), approver: approverOf(r), note: str(r.note), url: str(r.url), review: reviewOf(r), rules }),
|
|
483
|
+
...drop({ sealed, signer, committedBy: line.added?.by, relayedBy: str(r.relayedBy), approver: approverOf(r), note: str(r.note), url: str(r.url), review: reviewOf(r), rules, carriedFrom: carriedOf(r.carriedFrom) }),
|
|
457
484
|
detail: extra,
|
|
458
485
|
});
|
|
459
486
|
entries.push(entry);
|
|
460
487
|
|
|
461
|
-
|
|
488
|
+
const carried = entry.carriedFrom;
|
|
489
|
+
if (!override && carried) {
|
|
490
|
+
const cited = byOp.get(carried.op);
|
|
491
|
+
const from = `${carried.op}/${carried.gate}`;
|
|
492
|
+
if (cited && !cited.approvals.some((a) => a.gate === carried.gate && time(String(a.timestamp)) === time(carried.at) && a.resolvedBy === r.resolvedBy)) {
|
|
493
|
+
gaps.push({ kind: "carried-without-source", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds an approval of ${gate} by ${entry.by} carried from ${from} at ${carried.at}, and _gates/${carried.op}.jsonl holds no such approval` });
|
|
494
|
+
}
|
|
495
|
+
if (cited && !cited.requests.some((q) => q.gate === carried.gate && q.at <= time(carried.at) && compatible(q.digest, digest))) {
|
|
496
|
+
gaps.push({ kind: "approval-without-request", op: ledger.op, gate, ...(digest ? { digest } : {}), detail: `${path} holds an approval of ${gate} by ${entry.by} carried from ${from} at ${carried.at} with no pending fact of ${from}${digest ? " and digest" : ""} before it` });
|
|
497
|
+
}
|
|
498
|
+
} else if (!override) {
|
|
462
499
|
const at = time(entry.at);
|
|
463
500
|
const env = str(r.environment);
|
|
464
501
|
const asked = requests.some((q) => q.gate === gate && q.at <= at && compatible(q.digest, digest) && (q.environment === undefined || env === undefined || q.environment === env));
|