cool-workflow 0.2.4 → 0.2.6
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.gemini-plugin/mcp.json +10 -0
- package/.gemini-plugin/plugin.json +40 -0
- package/.opencode-plugin/mcp.json +10 -0
- package/.opencode-plugin/plugin.json +40 -0
- package/README.md +204 -48
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +32 -4
- package/dist/cli/entry.js +11 -19
- package/dist/cli/global-flags.js +113 -0
- package/dist/cli/parseargv.js +7 -1
- package/dist/core/capability-data.js +337 -8
- package/dist/core/format/help.js +73 -3
- package/dist/core/format/recovery-hint.js +32 -0
- package/dist/core/multi-agent/collaboration.js +35 -6
- package/dist/core/multi-agent/runtime.js +7 -0
- package/dist/core/multi-agent/trust-policy.js +7 -1
- package/dist/core/pipeline/contract.js +7 -0
- package/dist/core/pipeline/error-feedback.js +2 -2
- package/dist/core/state/run-paths.js +3 -30
- package/dist/core/trust/evidence-grounding.js +13 -1
- package/dist/core/util/cli-args.js +22 -0
- package/dist/core/version.js +1 -1
- package/dist/mcp/dispatch.js +22 -2
- package/dist/mcp/server.js +215 -36
- package/dist/mcp/tool-process.js +181 -0
- package/dist/mcp-server.js +28 -1
- package/dist/shell/agent-config.js +11 -2
- package/dist/shell/audit-cli.js +1 -1
- package/dist/shell/commit.js +8 -2
- package/dist/shell/coordinator-io.js +73 -1
- package/dist/shell/drive.js +195 -128
- package/dist/shell/error-feedback-io.js +6 -0
- package/dist/shell/execution-backend/agent.js +201 -26
- package/dist/shell/execution-backend/container.js +44 -10
- package/dist/shell/execution-backend/local.js +33 -0
- package/dist/shell/fs-atomic.js +96 -12
- package/dist/shell/ledger-cli.js +9 -2
- package/dist/shell/metrics-cli.js +1 -1
- package/dist/shell/multi-agent-cli.js +9 -2
- package/dist/shell/multi-agent-operator-ux.js +1 -1
- package/dist/shell/observability.js +7 -4
- package/dist/shell/onramp.js +48 -5
- package/dist/shell/operator-ux.js +1 -1
- package/dist/shell/perf-trace.js +136 -0
- package/dist/shell/pipeline.js +3 -2
- package/dist/shell/reclamation-io.js +10 -9
- package/dist/shell/report-view-cli.js +2 -0
- package/dist/shell/run-export-cli.js +5 -2
- package/dist/shell/run-export.js +293 -31
- package/dist/shell/run-registry-io.js +8 -0
- package/dist/shell/run-store.js +183 -3
- package/dist/shell/scheduler-io.js +101 -10
- package/dist/shell/state-explosion-cli.js +4 -1
- package/dist/shell/telemetry-demo.js +1 -1
- package/dist/shell/telemetry-ledger-io.js +36 -24
- package/dist/shell/trust-audit.js +178 -36
- package/dist/shell/workbench-host.js +121 -10
- package/dist/shell/workbench.js +83 -6
- package/dist/shell/worker-isolation.js +1 -1
- package/dist/wiring/capability-table/basics.js +5 -0
- package/dist/wiring/capability-table/exec-backend.js +40 -22
- package/dist/wiring/capability-table/parity.js +10 -5
- package/dist/wiring/capability-table/pipeline.js +32 -0
- package/dist/wiring/capability-table/registry-core.js +26 -3
- package/dist/wiring/capability-table/reporting.js +7 -1
- package/dist/wiring/capability-table/scheduling-registry.js +8 -2
- package/dist/wiring/capability-table/trust-ledger.js +54 -12
- package/docs/agent-delegation-drive.7.md +17 -0
- package/docs/cli-mcp-parity.7.md +46 -5
- package/docs/contract-migration-tooling.7.md +4 -0
- package/docs/control-plane-scheduling.7.md +38 -0
- package/docs/cross-agent-ledger.7.md +20 -8
- package/docs/durable-state-and-locking.7.md +4 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
- package/docs/execution-backends.7.md +4 -0
- package/docs/fix.7.md +4 -4
- package/docs/getting-started.md +40 -32
- package/docs/index.md +17 -0
- package/docs/launch/demo.tape +4 -3
- package/docs/mcp-app-surface.7.md +27 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
- package/docs/multi-agent-eval-replay-harness.7.md +4 -0
- package/docs/multi-agent-operator-ux.7.md +4 -0
- package/docs/node-snapshot-diff-replay.7.md +4 -0
- package/docs/observability-cost-accounting.7.md +4 -0
- package/docs/project-index.md +53 -9
- package/docs/real-execution-backends.7.md +4 -0
- package/docs/release-and-migration.7.md +4 -0
- package/docs/release-tooling.7.md +12 -0
- package/docs/routine.7.md +22 -0
- package/docs/run-registry-control-plane.7.md +37 -9
- package/docs/run-retention-reclamation.7.md +12 -4
- package/docs/state-explosion-management.7.md +4 -0
- package/docs/team-collaboration.7.md +4 -0
- package/docs/trust-audit-anchor.7.md +4 -0
- package/docs/web-desktop-workbench.7.md +72 -2
- package/docs/workflow-app-framework.7.md +31 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +4 -3
- package/package.json +6 -3
- package/scripts/agents/agent-adapter-core.js +26 -0
- package/scripts/agents/claude-p-agent.js +4 -1
- package/scripts/agents/codex-agent.js +4 -0
- package/scripts/agents/cw-attest-wrap.js +1 -1
- package/scripts/agents/gemini-agent.js +4 -0
- package/scripts/agents/opencode-agent.js +5 -0
- package/scripts/block-unapproved-tag.js +160 -0
- package/scripts/canonical-apps.js +4 -4
- package/scripts/children/batch-delegate-child.js +72 -4
- package/scripts/children/http-batch-delegate-child.js +132 -0
- package/scripts/children/http-delegate-child.js +8 -0
- package/scripts/dogfood-release.js +2 -2
- package/scripts/fake-date-for-reproduction.js +1 -1
- package/scripts/gen-manifests.js +32 -61
- package/scripts/golden-path.js +4 -4
- package/scripts/lang-policy-check.js +82 -0
- package/scripts/parity-check.js +42 -23
- package/scripts/purity-baseline.json +0 -3
- package/scripts/purity-gate.js +16 -3
- package/scripts/release-check.js +2 -1
- package/scripts/release-flow.js +169 -41
- package/scripts/release-gate.js +208 -0
- package/scripts/release-oneclick.js +38 -7
- package/scripts/release-tags.js +47 -0
- package/scripts/schema-version-inventory.json +31 -0
- package/scripts/validate-run-state-schema.js +95 -4
- package/scripts/verdict-keygen.js +2 -2
- package/scripts/verify-bump-reproduction.js +193 -0
- package/scripts/verify-release-verdict.js +139 -0
- package/scripts/verify-verdict-signature.js +1 -1
- package/ui/workbench/app.css +50 -10
- package/ui/workbench/app.js +269 -34
- package/ui/workbench/index.html +4 -2
- package/ui/workbench/inspection.js +51 -0
- package/ui/workbench/navigation.js +44 -0
- package/docs/agent-framework.md +0 -177
- package/docs/designs/handoff-ledger.md +0 -145
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
- package/docs/launch/launch-kit.md +0 -195
- package/docs/launch/pre-launch-checklist.md +0 -53
- package/docs/readme-v0.1.87-full.md +0 -301
- package/docs/routines.md +0 -101
- package/docs/scheduled-tasks.md +0 -80
- package/scripts/agents/claude-p-agent.sh +0 -9
- package/scripts/block-unapproved-tag.sh +0 -75
- package/scripts/release-gate.sh +0 -94
- package/scripts/verify-bump-reproduction.sh +0 -148
- package/scripts/verify-container-selfref.js +0 -64
package/scripts/golden-path.js
CHANGED
|
@@ -33,7 +33,7 @@ function main() {
|
|
|
33
33
|
const appValidation = runJson(["app", "validate", "end-to-end-golden-path"], pluginRoot);
|
|
34
34
|
assert.equal(appValidation.valid, true);
|
|
35
35
|
assert.equal(appValidation.summary.id, "end-to-end-golden-path");
|
|
36
|
-
assert.equal(appValidation.summary.version, "0.2.
|
|
36
|
+
assert.equal(appValidation.summary.version, "0.2.6");
|
|
37
37
|
|
|
38
38
|
const plan = runJson(
|
|
39
39
|
[
|
|
@@ -42,7 +42,7 @@ function main() {
|
|
|
42
42
|
"--repo",
|
|
43
43
|
tmp,
|
|
44
44
|
"--question",
|
|
45
|
-
"Prove the deterministic v0.2.
|
|
45
|
+
"Prove the deterministic v0.2.6 end-to-end golden path."
|
|
46
46
|
],
|
|
47
47
|
pluginRoot
|
|
48
48
|
);
|
|
@@ -52,7 +52,7 @@ function main() {
|
|
|
52
52
|
|
|
53
53
|
let state = readJson(plan.statePath);
|
|
54
54
|
assert.equal(state.workflow.app.id, "end-to-end-golden-path");
|
|
55
|
-
assert.equal(state.workflow.app.version, "0.2.
|
|
55
|
+
assert.equal(state.workflow.app.version, "0.2.6");
|
|
56
56
|
assert.equal(state.loopStage, "interpret");
|
|
57
57
|
|
|
58
58
|
const dispatch = runJson(["dispatch", plan.runId, "--limit", "1", "--sandbox", "readonly"], tmp);
|
|
@@ -195,7 +195,7 @@ function main() {
|
|
|
195
195
|
assert.equal(reportPath, plan.reportPath);
|
|
196
196
|
assert.ok(fs.existsSync(reportPath));
|
|
197
197
|
const report = fs.readFileSync(reportPath, "utf8");
|
|
198
|
-
assert.match(report, /Workflow App: end-to-end-golden-path@0\.2\.
|
|
198
|
+
assert.match(report, /Workflow App: end-to-end-golden-path@0\.2\.6/);
|
|
199
199
|
assert.match(report, /## Candidates/);
|
|
200
200
|
assert.match(report, /## Trust Audit/);
|
|
201
201
|
assert.match(report, /## Acceptance Rationale/);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// lang-policy-check.js — this project is written in JavaScript and
|
|
4
|
+
// TypeScript only (AGENTS.md hard rule, 2026-07-13). Every git-tracked file
|
|
5
|
+
// must be JS/TS, a recognized non-code file (docs, data, config, binary
|
|
6
|
+
// asset), or one of the explicit, path-scoped exceptions below — a real
|
|
7
|
+
// non-JS/TS file the project genuinely needs. Fail closed: an unrecognized
|
|
8
|
+
// extension/basename is a hard stop, forcing a deliberate choice (write it
|
|
9
|
+
// in JS/TS, or add a new exception here with a reason) instead of a silent
|
|
10
|
+
// pass. Mechanism only; the allowlists ARE the policy, kept as plain data
|
|
11
|
+
// in this one file — no separate config to drift from it.
|
|
12
|
+
|
|
13
|
+
const { execFileSync } = require("node:child_process");
|
|
14
|
+
const path = require("node:path");
|
|
15
|
+
|
|
16
|
+
const CODE_EXTENSIONS = new Set(["js", "mjs", "cjs", "ts", "tsx", "jsx"]);
|
|
17
|
+
|
|
18
|
+
// Real, needed files this project cannot write in JS/TS -- the exact same
|
|
19
|
+
// set already carved out of GitHub's Languages stats in .gitattributes, for
|
|
20
|
+
// the same reasons (see that file's comments). Scoped to an EXACT path,
|
|
21
|
+
// never a whole directory, so a new file dropped in the same folder does
|
|
22
|
+
// NOT silently inherit the exception.
|
|
23
|
+
const EXCEPT_PATHS = new Set([
|
|
24
|
+
"Formula/cool-workflow.rb", // Homebrew formula must be Ruby
|
|
25
|
+
"plugins/cool-workflow/ui/workbench/index.html", // served from disk by design
|
|
26
|
+
"plugins/cool-workflow/ui/workbench/app.css" // served from disk by design
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
// Not "code" at all -- docs, data, config, and binary assets. Fine
|
|
30
|
+
// anywhere in the tree; this policy is only about what the project is
|
|
31
|
+
// WRITTEN in, not about every file it ships or tracks.
|
|
32
|
+
const NON_CODE_EXTENSIONS = new Set([
|
|
33
|
+
"md", "mdc", "txt", "json", "jsonl", "yml", "yaml", "toml",
|
|
34
|
+
"verdict", "sig", "pub", "tape",
|
|
35
|
+
"svg", "png", "jpg", "jpeg", "gif", "webp", "ico", "pdf",
|
|
36
|
+
"woff", "woff2", "gz", "tgz", "zip", "lock"
|
|
37
|
+
]);
|
|
38
|
+
// Extensionless / dotfile basenames that are also not code.
|
|
39
|
+
const NON_CODE_BASENAMES = new Set([
|
|
40
|
+
"LICENSE", ".gitignore", ".gitattributes", ".editorconfig", ".windsurfrules", ".npmrc", ".nvmrc"
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// `EXCEPT_PATHS` are written repo-root-relative, so tracked files must be
|
|
44
|
+
// resolved the same way regardless of the invocation cwd (this runs both as
|
|
45
|
+
// `npm run lang:check` from plugins/cool-workflow/ AND from release-check.js
|
|
46
|
+
// at various cwds) -- resolve the real top-level first, never trust the
|
|
47
|
+
// invocation cwd to already BE it.
|
|
48
|
+
function trackedFiles() {
|
|
49
|
+
const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
|
|
50
|
+
return execFileSync("git", ["-C", repoRoot, "ls-files"], { encoding: "utf8" })
|
|
51
|
+
.split("\n")
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function classify(file) {
|
|
56
|
+
if (EXCEPT_PATHS.has(file)) return { ok: true };
|
|
57
|
+
const base = path.basename(file);
|
|
58
|
+
if (NON_CODE_BASENAMES.has(base)) return { ok: true };
|
|
59
|
+
const dot = base.lastIndexOf(".");
|
|
60
|
+
if (dot <= 0) return { ok: false, ext: null };
|
|
61
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
62
|
+
if (CODE_EXTENSIONS.has(ext) || NON_CODE_EXTENSIONS.has(ext)) return { ok: true };
|
|
63
|
+
return { ok: false, ext };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const files = trackedFiles();
|
|
67
|
+
const violations = [];
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const result = classify(file);
|
|
70
|
+
if (!result.ok) violations.push({ file, ext: result.ext });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (violations.length > 0) {
|
|
74
|
+
process.stderr.write("lang-policy-check: this project is JavaScript/TypeScript only. Unrecognized file(s):\n");
|
|
75
|
+
for (const v of violations) {
|
|
76
|
+
process.stderr.write(` - ${v.file}${v.ext ? ` (.${v.ext})` : " (no extension)"}\n`);
|
|
77
|
+
}
|
|
78
|
+
process.stderr.write("Write it in JavaScript/TypeScript, or add a scoped, reasoned exception to scripts/lang-policy-check.js.\n");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
process.stdout.write(`lang-policy-check: ${files.length} tracked files, all JS/TS or an allowed non-code/exception file.\n`);
|
package/scripts/parity-check.js
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
//
|
|
9
9
|
// FAIL CLOSED ON DRIFT (BSD discipline, same shape as gen-manifests --check):
|
|
10
10
|
// 1. STATIC parity — the declared capability registry must exactly match the
|
|
11
|
-
// live MCP tool list (tools/list)
|
|
12
|
-
// the
|
|
13
|
-
//
|
|
11
|
+
// live MCP tool list (tools/list), every declared CLI path must resolve
|
|
12
|
+
// through the live dispatcher, and real `cw help` must match the declared
|
|
13
|
+
// help surface. Any gap is release-blocking.
|
|
14
14
|
// 2. PAYLOAD parity — for every capability declared `payloadIdentical`, the
|
|
15
15
|
// `cw <cmd> --json` payload must equal the `cw_<tool>` MCP result on a real
|
|
16
16
|
// bootstrap run (whitespace + generation-moment ISO timestamps aside).
|
|
@@ -38,7 +38,6 @@ const node = process.execPath;
|
|
|
38
38
|
const cli = path.join(pluginRoot, "dist", "cli.js");
|
|
39
39
|
const mcpServer = path.join(pluginRoot, "dist", "mcp-server.js");
|
|
40
40
|
const registry = require(path.join(pluginRoot, "dist", "core", "capability-table.js"));
|
|
41
|
-
const { formatHelp } = require(path.join(pluginRoot, "dist", "core", "format", "help.js"));
|
|
42
41
|
const { loadRunFromCwd, saveCheckpoint } = require(path.join(pluginRoot, "dist", "shell", "run-store.js"));
|
|
43
42
|
const { appendRunNode, createStateNode } = require(path.join(pluginRoot, "dist", "core", "state", "state-node.js"));
|
|
44
43
|
|
|
@@ -71,15 +70,6 @@ function liveMcpTools() {
|
|
|
71
70
|
return JSON.parse(line).result.tools.map((tool) => tool.name);
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
function cliDispatchTokens() {
|
|
75
|
-
// v2 dispatch is table-driven (findCapabilityByCliPath over REGISTRY), not a
|
|
76
|
-
// switch, so the token set is the union of every CLI capability's accepted
|
|
77
|
-
// first tokens (cli.caseTokens where an alias set is declared, else cli.path).
|
|
78
|
-
// The reachability check below then proves each declared path resolves through
|
|
79
|
-
// the live dispatcher — replacing the old `case "x":` dist source-grep.
|
|
80
|
-
return [...new Set(registry.cliCapabilities().flatMap((cap) => cap.cli.caseTokens ?? cap.cli.path))];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
73
|
// Old-build top-level verbs kept in the frozen help "More commands" index line
|
|
84
74
|
// but folded in v2 into subcommands / the shell layer: init->app.init,
|
|
85
75
|
// search->run.search (declaredCliHelpTokens collapses both to app/run), and
|
|
@@ -87,8 +77,8 @@ function cliDispatchTokens() {
|
|
|
87
77
|
// discovery text, not dispatch-parity tokens — exclude them.
|
|
88
78
|
const HELP_INDEX_ONLY_TOKENS = new Set(["init", "search", "update"]);
|
|
89
79
|
|
|
90
|
-
function cliHelpTokens() {
|
|
91
|
-
const lines =
|
|
80
|
+
function cliHelpTokens(helpText) {
|
|
81
|
+
const lines = helpText.split(/\r?\n/);
|
|
92
82
|
const tokens = new Set();
|
|
93
83
|
for (const line of lines) {
|
|
94
84
|
// Command lines start with exactly 2 spaces; examples are 4-space.
|
|
@@ -104,6 +94,29 @@ function cliHelpTokens() {
|
|
|
104
94
|
return [...tokens].sort();
|
|
105
95
|
}
|
|
106
96
|
|
|
97
|
+
function liveCliHelpTokens() {
|
|
98
|
+
const helpText = execFileSync(node, [cli, "help", "--no-color"], {
|
|
99
|
+
cwd: pluginRoot,
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
env: { ...process.env, NO_COLOR: "1" }
|
|
102
|
+
});
|
|
103
|
+
return cliHelpTokens(helpText);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The CLI dispatcher calls this same lookup. Keep the probe outside the
|
|
107
|
+
* registry report so the live gate does not compare a token list made from the
|
|
108
|
+
* registry with another list made from the same registry. */
|
|
109
|
+
function cliReachabilityIssues(table) {
|
|
110
|
+
const issues = [];
|
|
111
|
+
for (const cap of table.cliCapabilities()) {
|
|
112
|
+
const resolved = table.findCapabilityByCliPath(cap.cli.path);
|
|
113
|
+
if (!resolved || !resolved.cli || typeof resolved.cli.handler !== "function") {
|
|
114
|
+
issues.push(`${cap.capability}: CLI path ${cap.cli.path.join(" ")} does not resolve through the dispatcher`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return issues;
|
|
118
|
+
}
|
|
119
|
+
|
|
107
120
|
// ---- 2. payload identity ---------------------------------------------------
|
|
108
121
|
// Generation-moment ISO timestamps are presentation metadata, not capability
|
|
109
122
|
// data: the same field carries the wall-clock instant of the render. Neutralize
|
|
@@ -907,9 +920,11 @@ async function payloadParity() {
|
|
|
907
920
|
async function main() {
|
|
908
921
|
const check = process.argv.includes("--check");
|
|
909
922
|
const tools = liveMcpTools();
|
|
910
|
-
const
|
|
911
|
-
const
|
|
912
|
-
const
|
|
923
|
+
const helpTokens = liveCliHelpTokens();
|
|
924
|
+
const report = registry.buildParityReport({ mcpTools: tools, helpTokens });
|
|
925
|
+
const reachabilityIssues = cliReachabilityIssues(registry);
|
|
926
|
+
report.registryLint.push(...reachabilityIssues);
|
|
927
|
+
report.ok = report.ok && reachabilityIssues.length === 0;
|
|
913
928
|
const payload = await payloadParity();
|
|
914
929
|
|
|
915
930
|
const ok = report.ok && payload.mismatches.length === 0;
|
|
@@ -940,13 +955,17 @@ async function main() {
|
|
|
940
955
|
if (report.payloadProbeInvalidClassifications.length) lines.push(` - payload probe classifications for invalid capabilities: ${report.payloadProbeInvalidClassifications.join(", ")}`);
|
|
941
956
|
if (report.registryLint.length) lines.push(` - registry lint: ${report.registryLint.join("; ")}`);
|
|
942
957
|
if (payload.mismatches.length) lines.push(` - cw --json != cw_<tool> payload for: ${payload.mismatches.join(", ")}`);
|
|
943
|
-
lines.push("Reconcile src/capability-
|
|
958
|
+
lines.push("Reconcile src/wiring/capability-table, src/cli, and src/mcp so both surfaces render one data source.\n");
|
|
944
959
|
process.stderr.write(lines.join("\n"));
|
|
945
960
|
process.exitCode = 1;
|
|
946
961
|
}
|
|
947
962
|
}
|
|
948
963
|
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
964
|
+
module.exports = { cliHelpTokens, cliReachabilityIssues };
|
|
965
|
+
|
|
966
|
+
if (require.main === module) {
|
|
967
|
+
main().catch((error) => {
|
|
968
|
+
process.stderr.write(`parity-check: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
969
|
+
process.exitCode = 1;
|
|
970
|
+
});
|
|
971
|
+
}
|
package/scripts/purity-gate.js
CHANGED
|
@@ -112,8 +112,12 @@ function stripComments(text) {
|
|
|
112
112
|
|
|
113
113
|
// Every `from "spec"` (import or export-from, type or value — a type-only
|
|
114
114
|
// import still names a module, so it counts the same way a real dependency
|
|
115
|
-
// scan would) plus every `require("spec")
|
|
116
|
-
// each import's own clause without needing a
|
|
115
|
+
// scan would) plus every `require("spec")` and every dynamic `import("spec")`.
|
|
116
|
+
// Non-greedy across `from` finds each import's own clause without needing a
|
|
117
|
+
// real parser. The dynamic-import form matters because a core/ file could
|
|
118
|
+
// otherwise smuggle in an impure or cross-layer module through
|
|
119
|
+
// `await import("node:fs")` — the one import shape the static and require
|
|
120
|
+
// scans did not see, so the ratchet was blind to it.
|
|
117
121
|
function extractSpecifiers(codeText) {
|
|
118
122
|
const specs = [];
|
|
119
123
|
const fromRe = /(?:^|\n)\s*(?:import|export)\s[\s\S]*?from\s+["']([^"']+)["']/g;
|
|
@@ -121,6 +125,8 @@ function extractSpecifiers(codeText) {
|
|
|
121
125
|
while ((m = fromRe.exec(codeText))) specs.push(m[1]);
|
|
122
126
|
const requireRe = /require\(\s*["']([^"']+)["']\s*\)/g;
|
|
123
127
|
while ((m = requireRe.exec(codeText))) specs.push(m[1]);
|
|
128
|
+
const dynamicImportRe = /import\(\s*["']([^"']+)["']\s*\)/g;
|
|
129
|
+
while ((m = dynamicImportRe.exec(codeText))) specs.push(m[1]);
|
|
124
130
|
return specs;
|
|
125
131
|
}
|
|
126
132
|
|
|
@@ -236,4 +242,11 @@ function main() {
|
|
|
236
242
|
process.stdout.write("purity gate: src/ matches the committed baseline (no new core/shell layer breaks, no clock/env drift).\n");
|
|
237
243
|
}
|
|
238
244
|
|
|
239
|
-
|
|
245
|
+
// Run the gate only when invoked directly (node scripts/purity-gate.js). When
|
|
246
|
+
// required by a test, expose the internal helpers so the specifier scan can be
|
|
247
|
+
// unit-checked without shelling out or writing into the real src/ tree.
|
|
248
|
+
if (require.main === module) {
|
|
249
|
+
main();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = { stripComments, extractSpecifiers, layerOf, scan };
|
package/scripts/release-check.js
CHANGED
|
@@ -60,12 +60,13 @@ const checks = [
|
|
|
60
60
|
// from that fresh build — strictly stronger than a bare `npm run build`.
|
|
61
61
|
{ name: "dist freshness", command: ["npm", "run", "dist:check"] },
|
|
62
62
|
{ name: "core/shell purity", command: ["npm", "run", "purity:check"] },
|
|
63
|
+
{ name: "language policy (JS/TS only)", command: ["npm", "run", "lang:check"] },
|
|
63
64
|
{ name: "type check", command: ["npm", "run", "check"] },
|
|
64
65
|
{ name: "onramp contract", command: ["npm", "run", "onramp:check"] },
|
|
65
66
|
{ name: "run-state schema consistency", command: ["node", "scripts/validate-run-state-schema.js"] },
|
|
66
67
|
// Every test path uses --concurrency auto by default (parallel, race-free:
|
|
67
68
|
// each smoke runs in a private cwd + state roots). The release tag-gate
|
|
68
|
-
// (release-gate.
|
|
69
|
+
// (release-gate.js) forces CW_TEST_CONCURRENCY=1 to stay sequential as the
|
|
69
70
|
// deterministic backstop.
|
|
70
71
|
{ name: "tests", command: ["npm", "run", "test:ci"] },
|
|
71
72
|
// Pure core/ unit tests (test/*.test.js) — a separate suite from the smoke
|
package/scripts/release-flow.js
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
// the verdict file's exact bytes, writing a sidecar
|
|
44
44
|
// .cw-release/review-<FULLSHA>.verdict.sig (base64 ed25519). Once
|
|
45
45
|
// .cw-release/verdict-signing.pub is committed to the repo, release-gate.yml,
|
|
46
|
-
// npm-publish.yml, and block-unapproved-tag.
|
|
46
|
+
// npm-publish.yml, and block-unapproved-tag.js all start REQUIRING a valid
|
|
47
47
|
// signature in addition to the APPROVED text check — closing the gap where
|
|
48
48
|
// anyone with shell access could hand-write a passing verdict file. Until
|
|
49
49
|
// that public key is committed, every check stays exactly as before
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
//
|
|
52
52
|
// Test seams (smoke/operator only, never the delegated agent):
|
|
53
53
|
// CW_RELEASE_FLOW_GATE_CMD overrides the deterministic gate command
|
|
54
|
-
// (default: `
|
|
54
|
+
// (default: `node <thisdir>/release-gate.js`) so the smoke can exercise the
|
|
55
55
|
// orchestration layer without re-running the full build/test suite.
|
|
56
56
|
// CW_RELEASE_FLOW_GH_CMD overrides the `gh` binary (single executable token,
|
|
57
57
|
// spawned shell:false) so the smoke can stub GitHub Release calls offline.
|
|
@@ -107,15 +107,25 @@ if (top.status !== 0) die("not inside a git work tree");
|
|
|
107
107
|
const repoRoot = top.stdout.trim();
|
|
108
108
|
|
|
109
109
|
const HEAD = git(["rev-parse", "HEAD"]).out;
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
110
|
+
|
|
111
|
+
// ---- release-tag lookup (semver-ordered, NOT git-ancestry-walked) ---------
|
|
112
|
+
// The lookup itself lives in release-tags.js, SHARED with release-gate.js so
|
|
113
|
+
// the two can never drift (the gate used to carry a bash port of it). See
|
|
114
|
+
// that file's header for why this is a semver question, not `git describe`.
|
|
115
|
+
const releaseTags = require("./release-tags.js");
|
|
116
|
+
const parseSemverTag = releaseTags.parseSemverTag;
|
|
117
|
+
const compareSemver = releaseTags.compareSemver;
|
|
118
|
+
const gitTagOut = (args) => git(args).out;
|
|
119
|
+
function listReleaseTagsDesc() {
|
|
120
|
+
return releaseTags.listReleaseTagsDesc(gitTagOut);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The most recent ALREADY-RELEASED tag as of right now, excluding any tag
|
|
124
|
+
// that already points at HEAD (so this works whether run before tagging or
|
|
125
|
+
// re-run on a freshly tagged commit).
|
|
113
126
|
function resolvePrevTag() {
|
|
114
127
|
if (prevTagArg) return prevTagArg;
|
|
115
|
-
|
|
116
|
-
let prev = git(["describe", "--tags", "--abbrev=0"]).out;
|
|
117
|
-
if (prev && headTags.includes(prev)) prev = git(["describe", "--tags", "--abbrev=0", "HEAD^"]).out;
|
|
118
|
-
return prev || "";
|
|
128
|
+
return releaseTags.resolvePrevReleaseTag(gitTagOut);
|
|
119
129
|
}
|
|
120
130
|
const PREV_TAG = resolvePrevTag();
|
|
121
131
|
|
|
@@ -129,7 +139,7 @@ function runGate() {
|
|
|
129
139
|
// the smoke and operator overrides only, never the delegated agent.
|
|
130
140
|
r = spawnSync(override, { cwd: repoRoot, encoding: "utf8", shell: true, stdio: "inherit" });
|
|
131
141
|
} else {
|
|
132
|
-
r = spawnSync(
|
|
142
|
+
r = spawnSync(process.execPath, [path.join(scriptsDir, "release-gate.js")], {
|
|
133
143
|
cwd: repoRoot,
|
|
134
144
|
encoding: "utf8",
|
|
135
145
|
stdio: "inherit"
|
|
@@ -173,17 +183,28 @@ function runVendorPreflight() {
|
|
|
173
183
|
}
|
|
174
184
|
|
|
175
185
|
// ---- 2. independent reviewer, delegated to the configured agent -------------
|
|
176
|
-
// Default reviewer deadline. The zero-trust reviewer re-runs release-gate.
|
|
186
|
+
// Default reviewer deadline. The zero-trust reviewer re-runs release-gate.js,
|
|
177
187
|
// whose sequential test suite alone is ~12 min, then reads + reasons over the
|
|
178
188
|
// diff — so a 10-min default guaranteed a timeout on a real release. 30 min gives
|
|
179
189
|
// headroom; override with CW_AGENT_TIMEOUT_MS (or --agent-timeout-ms).
|
|
180
190
|
const REVIEWER_TIMEOUT_MS = 1800000;
|
|
181
191
|
function reviewerPromptBody() {
|
|
182
192
|
// Reuse the committed reviewer spec as the prompt; strip YAML frontmatter.
|
|
193
|
+
// Fail closed if the spec is gone or empty: a missing/empty spec used to
|
|
194
|
+
// yield "", which shipped the reviewer an EMPTY review prompt (only the
|
|
195
|
+
// candidate context, no instructions) — a review with no rubric is worse
|
|
196
|
+
// than no review, since it still produces a signable verdict. This project's
|
|
197
|
+
// rule is to refuse the unverifiable, not fabricate a pass.
|
|
183
198
|
const specPath = path.join(pluginRoot, "agents", "release-reviewer.md");
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
199
|
+
const rel = path.relative(repoRoot, specPath);
|
|
200
|
+
if (!fs.existsSync(specPath)) {
|
|
201
|
+
die(`reviewer prompt file is missing at ${rel} — will not run a review with an empty prompt. Restore agents/release-reviewer.md.`);
|
|
202
|
+
}
|
|
203
|
+
const body = fs.readFileSync(specPath, "utf8").replace(/^---[\s\S]*?---\n/, "").trim();
|
|
204
|
+
if (!body) {
|
|
205
|
+
die(`reviewer prompt file at ${rel} is empty after its header is removed — will not run a review with an empty prompt.`);
|
|
206
|
+
}
|
|
207
|
+
return body;
|
|
187
208
|
}
|
|
188
209
|
|
|
189
210
|
function buildReviewerInput(resultPath) {
|
|
@@ -208,12 +229,12 @@ function buildReviewerInput(resultPath) {
|
|
|
208
229
|
" <one concrete sentence: the user-visible capability this ships>",
|
|
209
230
|
"or:",
|
|
210
231
|
" REJECTED",
|
|
211
|
-
"
|
|
232
|
+
" kind: semantic-review",
|
|
233
|
+
" 1. <a semantic code finding with repo-relative file:line evidence>",
|
|
212
234
|
"",
|
|
213
|
-
"
|
|
214
|
-
"
|
|
215
|
-
"
|
|
216
|
-
"lines; prose such as 'Verdict: APPROVED' is not trusted.",
|
|
235
|
+
"The control plane already ran the deterministic gate. Do not run it again",
|
|
236
|
+
"or reject with an unsupported claim that it failed. A rejection without the",
|
|
237
|
+
"semantic-review form above is invalid reviewer output and remains fail closed.",
|
|
217
238
|
""
|
|
218
239
|
].join("\n");
|
|
219
240
|
}
|
|
@@ -222,11 +243,36 @@ function substitute(arg, map) {
|
|
|
222
243
|
return arg.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in map ? String(map[k]) : m));
|
|
223
244
|
}
|
|
224
245
|
|
|
246
|
+
/** True when `line` is a REJECTED verdict line, allowing the markdown
|
|
247
|
+
* decoration a reviewer commonly wraps it in (`**REJECTED**`, `REJECTED:`,
|
|
248
|
+
* `### REJECTED`, `REJECTED.`) — stripped of leading heading/quote/emphasis
|
|
249
|
+
* markers, the line must start with REJECTED not immediately followed by
|
|
250
|
+
* another letter (so "REJECTEDLY speaking..." is still correctly NOT a
|
|
251
|
+
* rejection marker). Must stay at least as permissive as the APPROVED
|
|
252
|
+
* matcher below, or a decorated REJECTED can fall through to it. */
|
|
253
|
+
function isRejectedLine(line) {
|
|
254
|
+
const stripped = line.trim().replace(/^[#>\s]+/, "").replace(/^[*_]+/, "");
|
|
255
|
+
return /^REJECTED(?![A-Za-z])/i.test(stripped);
|
|
256
|
+
}
|
|
257
|
+
|
|
225
258
|
/** Extract verdict lines from agent stdout. Returns the verdict text (first line
|
|
226
259
|
* must be APPROVED <sha> or REJECTED) or null if no valid verdict found.
|
|
227
260
|
* Also logs failures to stderr so the operator can inspect. */
|
|
228
261
|
function extractVerdictFromStdout(stdout, resultPath) {
|
|
229
262
|
const lines = stdout.split(/\r?\n/);
|
|
263
|
+
// Check for REJECTED first, over the WHOLE text, and return right away when
|
|
264
|
+
// found — do not also look for APPROVED in that case. This is the safe,
|
|
265
|
+
// fail-closed order (this project's own rule: fail closed, conservatively).
|
|
266
|
+
// A verbose agent wrapper can echo the required-format example back in its
|
|
267
|
+
// own output (e.g. a report that quotes the prompt's instructions), so the
|
|
268
|
+
// exact text "APPROVED <sha>" can show up even when the real verdict is
|
|
269
|
+
// REJECTED. If BOTH markers are present anywhere in the text, that must
|
|
270
|
+
// read as REJECTED, never as an approval that then gets a real signature.
|
|
271
|
+
const rejectedLine = lines.find((line) => isRejectedLine(line));
|
|
272
|
+
if (rejectedLine) {
|
|
273
|
+
process.stderr.write(`reviewer REJECTED via stdout — full output:\n${stdout.trim()}\n`);
|
|
274
|
+
return lines.slice(lines.indexOf(rejectedLine)).join("\n").trim();
|
|
275
|
+
}
|
|
230
276
|
const approvedLine = lines.find((line) => /^APPROVED\s+\S+/.test(line.trim()));
|
|
231
277
|
if (approvedLine) {
|
|
232
278
|
const idx = lines.indexOf(approvedLine);
|
|
@@ -238,16 +284,25 @@ function extractVerdictFromStdout(stdout, resultPath) {
|
|
|
238
284
|
say(`reviewer verdict captured from stdout → ${resultPath}`);
|
|
239
285
|
return verdict;
|
|
240
286
|
}
|
|
241
|
-
const rejectedLine = lines.find((line) => /^REJECTED/i.test(line.trim()));
|
|
242
|
-
if (rejectedLine) {
|
|
243
|
-
process.stderr.write(`reviewer REJECTED via stdout — full output:\n${stdout.trim()}\n`);
|
|
244
|
-
return rejectedLine.trim();
|
|
245
|
-
}
|
|
246
287
|
// Partial: print the last 20 lines to help the operator diagnose.
|
|
247
288
|
process.stderr.write(`reviewer stdout had no APPROVED/REJECTED line. Last 20 lines:\n${lines.slice(-20).join("\n").trim()}\n`);
|
|
248
289
|
return null;
|
|
249
290
|
}
|
|
250
291
|
|
|
292
|
+
// The reviewer must never be able to read the verdict signing key or other
|
|
293
|
+
// release secrets. The signing key is the ONLY thing that tells a real
|
|
294
|
+
// reviewer's verdict apart from one an agent fabricated by hand — if the
|
|
295
|
+
// reviewer process itself can read that key, it could sign its own made-up
|
|
296
|
+
// approval, which defeats the whole point of signing. So strip these from
|
|
297
|
+
// the env we hand to the reviewer child process, even though the operator's
|
|
298
|
+
// own shell (this script's own process.env) may have them set.
|
|
299
|
+
const REVIEWER_ENV_DENY = ["CW_RELEASE_VERDICT_PRIVKEY", "CW_AGENT_ATTEST_PRIVKEY", "CW_WORKBENCH_TOKEN"];
|
|
300
|
+
function buildReviewerEnv() {
|
|
301
|
+
const env = { ...process.env, CW_RELEASE_REVIEW: "1" };
|
|
302
|
+
for (const key of REVIEWER_ENV_DENY) delete env[key];
|
|
303
|
+
return env;
|
|
304
|
+
}
|
|
305
|
+
|
|
251
306
|
function delegateReview(resultPath, inputPath) {
|
|
252
307
|
// Reuse the canonical agent-config resolver (flags > env > file).
|
|
253
308
|
let resolveAgentConfig;
|
|
@@ -300,6 +355,13 @@ function delegateReview(resultPath, inputPath) {
|
|
|
300
355
|
// Capture stdout so agents that print verdicts (rather than writing the
|
|
301
356
|
// result file) are supported. Agents that DO write the file still work:
|
|
302
357
|
// the file takes precedence. stderr goes to the terminal for live output.
|
|
358
|
+
// A verdict file left over from an earlier run — or planted by anyone with
|
|
359
|
+
// write access to this checkout — must never survive into a fresh review
|
|
360
|
+
// round. Delete it (and any .sig sidecar) BEFORE the reviewer runs, so the
|
|
361
|
+
// "verdict file already exists" check right after spawnSync only ever sees
|
|
362
|
+
// a file the reviewer wrote THIS round, never a stale or planted one.
|
|
363
|
+
try { fs.unlinkSync(resultPath); } catch {}
|
|
364
|
+
try { fs.unlinkSync(`${resultPath}.sig`); } catch {}
|
|
303
365
|
const r = spawnSync(bin, args, {
|
|
304
366
|
cwd: repoRoot,
|
|
305
367
|
// CW_RELEASE_REVIEW=1 is a vendor-agnostic signal that THIS spawn is a
|
|
@@ -308,7 +370,9 @@ function delegateReview(resultPath, inputPath) {
|
|
|
308
370
|
// exec-capable sandbox — a read-only/low-effort reviewer can't execute the
|
|
309
371
|
// gate it judges and degrades to fabricated verdicts. Preflight liveness
|
|
310
372
|
// probes never set it, so they stay fast and read-only.
|
|
311
|
-
|
|
373
|
+
// buildReviewerEnv() also strips the verdict signing key and other release
|
|
374
|
+
// secrets from what the reviewer child process can see — see its comment.
|
|
375
|
+
env: buildReviewerEnv(),
|
|
312
376
|
encoding: "utf8",
|
|
313
377
|
timeout: cfg.timeoutMs || REVIEWER_TIMEOUT_MS,
|
|
314
378
|
shell: false,
|
|
@@ -368,14 +432,19 @@ function verifyVerdict(resultPath) {
|
|
|
368
432
|
const text = fs.readFileSync(resultPath, "utf8");
|
|
369
433
|
const lines = text.split(/\r?\n/);
|
|
370
434
|
const firstLine = lines[0] || "";
|
|
435
|
+
if (isRejectedLine(firstLine)) {
|
|
436
|
+
const rejection = validateSemanticRejection(lines);
|
|
437
|
+
if (!rejection.ok) {
|
|
438
|
+
die(`invalid reviewer output — ${rejection.reason}. Release remains blocked; this is not a verified code rejection.`, text.trim());
|
|
439
|
+
}
|
|
440
|
+
die("reviewer rejected the release with semantic evidence.", text.trim());
|
|
441
|
+
}
|
|
371
442
|
if (firstLine !== `APPROVED ${HEAD}`) {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return (normalizedLines[1] || "").trim();
|
|
378
|
-
}
|
|
443
|
+
const normalized = extractVerdictFromStdout(text, resultPath);
|
|
444
|
+
if (normalized && normalized.split(/\r?\n/)[0] === `APPROVED ${HEAD}`) {
|
|
445
|
+
fs.writeFileSync(resultPath, `${normalized}\n`);
|
|
446
|
+
const normalizedLines = normalized.split(/\r?\n/);
|
|
447
|
+
return (normalizedLines[1] || "").trim();
|
|
379
448
|
}
|
|
380
449
|
die(`verdict first line must be exactly "APPROVED ${HEAD}" — release blocked.`, text.trim());
|
|
381
450
|
}
|
|
@@ -383,12 +452,41 @@ function verifyVerdict(resultPath) {
|
|
|
383
452
|
return cap;
|
|
384
453
|
}
|
|
385
454
|
|
|
455
|
+
function validateSemanticRejection(lines) {
|
|
456
|
+
if (lines[0] !== "REJECTED") {
|
|
457
|
+
return { ok: false, reason: 'a rejection must start with exact "REJECTED"' };
|
|
458
|
+
}
|
|
459
|
+
if (lines[1] !== "kind: semantic-review") {
|
|
460
|
+
return { ok: false, reason: 'a rejection must declare exact "kind: semantic-review"' };
|
|
461
|
+
}
|
|
462
|
+
const findings = lines.slice(2).filter((line) => /^\d+\.\s+/.test(line));
|
|
463
|
+
if (!findings.length) return { ok: false, reason: "a semantic rejection needs a numbered finding" };
|
|
464
|
+
for (const finding of findings) {
|
|
465
|
+
const match = finding.match(/^\d+\.\s+.*?\b([A-Za-z0-9][A-Za-z0-9_./-]*):(\d+)\b/);
|
|
466
|
+
if (!match) return { ok: false, reason: "each semantic finding needs a repo-relative file:line locator" };
|
|
467
|
+
const rel = match[1];
|
|
468
|
+
const line = Number(match[2]);
|
|
469
|
+
if (rel.startsWith("/") || rel.split("/").includes("..") || !Number.isSafeInteger(line) || line < 1) {
|
|
470
|
+
return { ok: false, reason: "a semantic finding has an unsafe file:line locator" };
|
|
471
|
+
}
|
|
472
|
+
const target = path.join(repoRoot, rel);
|
|
473
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
474
|
+
return { ok: false, reason: `semantic evidence path does not exist: ${rel}` };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return { ok: true };
|
|
478
|
+
}
|
|
479
|
+
|
|
386
480
|
// ---- verdict signing (optional, opt-in — see the header comment) -----------
|
|
387
481
|
function resolveVerdictPrivateKey(value) {
|
|
388
482
|
const trimmed = String(value).trim();
|
|
389
483
|
if (trimmed.includes("BEGIN") && trimmed.includes("KEY")) return trimmed;
|
|
390
484
|
if (fs.existsSync(trimmed)) return fs.readFileSync(trimmed, "utf8");
|
|
391
|
-
|
|
485
|
+
// NEVER echo the value: if the operator set the var to inline key material
|
|
486
|
+
// that failed the BEGIN/KEY check (e.g. a raw base64 body), interpolating
|
|
487
|
+
// `trimmed` here would print the secret to stderr — which the oneclick flow
|
|
488
|
+
// tees into the operator's release log. Describe it instead.
|
|
489
|
+
die(`CW_RELEASE_VERDICT_PRIVKEY (length ${trimmed.length}) is neither a readable key file path nor an inline PEM (must contain BEGIN and KEY).`);
|
|
392
490
|
}
|
|
393
491
|
|
|
394
492
|
/** Called only after verifyVerdict() has confirmed resultPath's bytes are a
|
|
@@ -451,10 +549,14 @@ function repoSlug() {
|
|
|
451
549
|
return m ? { owner: m[1], repo: m[2] } : null;
|
|
452
550
|
}
|
|
453
551
|
|
|
454
|
-
// The previous release tag relative to a SPECIFIC
|
|
455
|
-
// compare link in the notes.
|
|
552
|
+
// The previous release tag relative to a SPECIFIC target version (not HEAD's
|
|
553
|
+
// ancestry) — for the compare link in the notes. See listReleaseTagsDesc's
|
|
554
|
+
// header comment above for why this is a semver lookup, not `git describe`.
|
|
456
555
|
function prevTagOf(version) {
|
|
457
|
-
|
|
556
|
+
const target = parseSemverTag(`v${version}`);
|
|
557
|
+
if (!target) return "";
|
|
558
|
+
const found = listReleaseTagsDesc().find((t) => compareSemver(t.semver, target) < 0);
|
|
559
|
+
return found ? found.tag : "";
|
|
458
560
|
}
|
|
459
561
|
|
|
460
562
|
// Full regex escape (same shape as parity-check.js's helper — scripts stay
|
|
@@ -647,7 +749,14 @@ function preflightCut() {
|
|
|
647
749
|
if (PUSH && git(["remote"]).out.split("\n").includes("origin")) {
|
|
648
750
|
// remote tag must not exist (someone/some run already published this version)
|
|
649
751
|
const remoteTag = git(["ls-remote", "origin", `refs/tags/v${cutVersion}`]);
|
|
650
|
-
|
|
752
|
+
// Fail CLOSED on a failed ls-remote: a non-zero exit (network blip, auth
|
|
753
|
+
// error) is NOT evidence the tag is absent. Treating it as "no remote tag"
|
|
754
|
+
// (the old `code === 0 && out` shape) let a transient failure skip the
|
|
755
|
+
// already-published guard and cut a version that may already exist.
|
|
756
|
+
if (remoteTag.code !== 0) {
|
|
757
|
+
die(`could not check origin for tag v${cutVersion} (git ls-remote failed) — refusing to cut blind`, remoteTag.err);
|
|
758
|
+
}
|
|
759
|
+
if (remoteTag.out) {
|
|
651
760
|
die(`tag v${cutVersion} already exists on origin — this version is already published; pick the next version`);
|
|
652
761
|
}
|
|
653
762
|
// HEAD must be the current origin/main tip — cutting a stale HEAD publishes
|
|
@@ -669,6 +778,17 @@ function preflightCut() {
|
|
|
669
778
|
// ---- 3. optional cut (bump + commit verdict + tag + push) ------------------
|
|
670
779
|
function cut(resultPath, capability) {
|
|
671
780
|
if (!cutVersion || !/^\d+\.\d+\.\d+$/.test(cutVersion)) die("--cut requires --version x.y.z");
|
|
781
|
+
// HEAD is captured once at process start (const HEAD) and everything the cut
|
|
782
|
+
// signs/tags is bound to it. The gate + vendor preflight + the ~30-minute
|
|
783
|
+
// reviewer all run in the shared checkout in between, and this repo runs
|
|
784
|
+
// concurrent agent sessions — so re-verify HEAD has not moved before we
|
|
785
|
+
// commit the verdict onto (and tag) it. Tagging an interloper commit would
|
|
786
|
+
// publish an immutable vX.Y.Z that CI then rejects (verdict filename != HEAD),
|
|
787
|
+
// burning the version number. Fail closed and let the operator re-run.
|
|
788
|
+
const headNow = git(["rev-parse", "HEAD"]).out;
|
|
789
|
+
if (headNow !== HEAD) {
|
|
790
|
+
die(`HEAD moved during the release flow (expected ${HEAD.slice(0, 12)}, now ${headNow.slice(0, 12)}) — re-run to restart the flow from the current HEAD`);
|
|
791
|
+
}
|
|
672
792
|
if (DRY_RUN) { say(`[dry-run] would: bump:version ${cutVersion}, commit verdict, tag v${cutVersion}${PUSH ? `, push refs/tags/v${cutVersion} (tag only, no branch)` : ""}`); return; }
|
|
673
793
|
const bump = spawnSync("npm", ["run", "bump:version", "--", cutVersion], { cwd: pluginRoot, encoding: "utf8", stdio: "inherit" });
|
|
674
794
|
if (bump.status !== 0) die("bump:version failed");
|
|
@@ -683,11 +803,19 @@ function cut(resultPath, capability) {
|
|
|
683
803
|
// (that tripped pii-redaction-smoke and red-failed release-gate for v0.1.96).
|
|
684
804
|
// `git add -u` touches tracked files only, so no untracked file can be swept in;
|
|
685
805
|
// the verdict (and its .sig sidecar, when verdict signing is configured) are
|
|
686
|
-
// the only new paths the cut is allowed to add.
|
|
687
|
-
git(
|
|
688
|
-
|
|
806
|
+
// the only new paths the cut is allowed to add. Check every add's exit code:
|
|
807
|
+
// a swallowed git-add failure (a read-only object store, a locked index) used
|
|
808
|
+
// to surface only later as a confusing "verdict commit failed" — a cut that
|
|
809
|
+
// fails to stage the SIGNED verdict must die right here, before the commit.
|
|
810
|
+
const addTracked = git(["add", "-u"]);
|
|
811
|
+
if (addTracked.code !== 0) die("git add -u failed while staging the cut", addTracked.err);
|
|
812
|
+
const addVerdict = git(["add", "--", path.relative(repoRoot, resultPath)]);
|
|
813
|
+
if (addVerdict.code !== 0) die("git add of the verdict file failed", addVerdict.err);
|
|
689
814
|
const sigPath = `${resultPath}.sig`;
|
|
690
|
-
if (fs.existsSync(sigPath))
|
|
815
|
+
if (fs.existsSync(sigPath)) {
|
|
816
|
+
const addSig = git(["add", "--", path.relative(repoRoot, sigPath)]);
|
|
817
|
+
if (addSig.code !== 0) die("git add of the verdict signature failed", addSig.err);
|
|
818
|
+
}
|
|
691
819
|
const commit = git(["commit", "-m", `chore(release): record APPROVED reviewer verdict for v${cutVersion}`]);
|
|
692
820
|
if (commit.code !== 0) die("verdict commit failed", commit.err);
|
|
693
821
|
const tag = git(["tag", "-a", `v${cutVersion}`, "-m", `v${cutVersion}: ${capability || "release"}`]);
|