cool-workflow 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/trust/evidence-grounding.js +13 -1
  27. package/dist/core/util/cli-args.js +22 -0
  28. package/dist/core/version.js +1 -1
  29. package/dist/mcp/dispatch.js +22 -2
  30. package/dist/mcp/server.js +124 -13
  31. package/dist/mcp-server.js +20 -0
  32. package/dist/shell/commit.js +8 -2
  33. package/dist/shell/coordinator-io.js +73 -1
  34. package/dist/shell/drive.js +120 -63
  35. package/dist/shell/error-feedback-io.js +6 -0
  36. package/dist/shell/execution-backend/agent.js +195 -23
  37. package/dist/shell/execution-backend/container.js +44 -10
  38. package/dist/shell/execution-backend/local.js +32 -0
  39. package/dist/shell/fs-atomic.js +93 -12
  40. package/dist/shell/ledger-cli.js +9 -2
  41. package/dist/shell/multi-agent-cli.js +5 -1
  42. package/dist/shell/onramp.js +48 -5
  43. package/dist/shell/pipeline.js +2 -1
  44. package/dist/shell/reclamation-io.js +10 -9
  45. package/dist/shell/run-export.js +52 -4
  46. package/dist/shell/run-store.js +156 -0
  47. package/dist/shell/scheduler-io.js +101 -10
  48. package/dist/shell/telemetry-ledger-io.js +36 -24
  49. package/dist/shell/trust-audit.js +104 -10
  50. package/dist/shell/workbench-host.js +121 -10
  51. package/dist/shell/workbench.js +79 -5
  52. package/dist/shell/worker-isolation.js +1 -1
  53. package/dist/wiring/capability-table/basics.js +5 -0
  54. package/dist/wiring/capability-table/exec-backend.js +40 -22
  55. package/dist/wiring/capability-table/pipeline.js +32 -0
  56. package/dist/wiring/capability-table/registry-core.js +26 -3
  57. package/dist/wiring/capability-table/reporting.js +7 -1
  58. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  59. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  60. package/docs/agent-delegation-drive.7.md +15 -0
  61. package/docs/cli-mcp-parity.7.md +25 -0
  62. package/docs/contract-migration-tooling.7.md +2 -0
  63. package/docs/control-plane-scheduling.7.md +36 -0
  64. package/docs/cross-agent-ledger.7.md +20 -8
  65. package/docs/durable-state-and-locking.7.md +2 -0
  66. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  67. package/docs/execution-backends.7.md +2 -0
  68. package/docs/fix.7.md +4 -4
  69. package/docs/getting-started.md +40 -32
  70. package/docs/index.md +17 -0
  71. package/docs/launch/demo.tape +4 -3
  72. package/docs/mcp-app-surface.7.md +6 -0
  73. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  74. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  75. package/docs/multi-agent-operator-ux.7.md +2 -0
  76. package/docs/node-snapshot-diff-replay.7.md +2 -0
  77. package/docs/observability-cost-accounting.7.md +2 -0
  78. package/docs/project-index.md +39 -9
  79. package/docs/real-execution-backends.7.md +2 -0
  80. package/docs/release-and-migration.7.md +2 -0
  81. package/docs/release-tooling.7.md +2 -0
  82. package/docs/routine.7.md +22 -0
  83. package/docs/run-registry-control-plane.7.md +2 -0
  84. package/docs/run-retention-reclamation.7.md +10 -4
  85. package/docs/state-explosion-management.7.md +2 -0
  86. package/docs/team-collaboration.7.md +2 -0
  87. package/docs/trust-audit-anchor.7.md +2 -0
  88. package/docs/web-desktop-workbench.7.md +24 -2
  89. package/docs/workflow-app-framework.7.md +31 -0
  90. package/manifest/plugin.manifest.json +1 -1
  91. package/manifest/source-context-profiles.json +4 -3
  92. package/package.json +4 -2
  93. package/scripts/agents/agent-adapter-core.js +26 -0
  94. package/scripts/agents/claude-p-agent.js +4 -1
  95. package/scripts/agents/codex-agent.js +4 -0
  96. package/scripts/agents/cw-attest-wrap.js +1 -1
  97. package/scripts/agents/gemini-agent.js +4 -0
  98. package/scripts/agents/opencode-agent.js +5 -0
  99. package/scripts/block-unapproved-tag.js +160 -0
  100. package/scripts/canonical-apps.js +4 -4
  101. package/scripts/children/batch-delegate-child.js +72 -4
  102. package/scripts/children/http-batch-delegate-child.js +132 -0
  103. package/scripts/children/http-delegate-child.js +8 -0
  104. package/scripts/dogfood-release.js +2 -2
  105. package/scripts/fake-date-for-reproduction.js +1 -1
  106. package/scripts/golden-path.js +4 -4
  107. package/scripts/lang-policy-check.js +82 -0
  108. package/scripts/purity-gate.js +16 -3
  109. package/scripts/release-check.js +2 -1
  110. package/scripts/release-flow.js +128 -30
  111. package/scripts/release-gate.js +184 -0
  112. package/scripts/release-oneclick.js +38 -7
  113. package/scripts/release-tags.js +47 -0
  114. package/scripts/verdict-keygen.js +2 -2
  115. package/scripts/verify-bump-reproduction.js +193 -0
  116. package/scripts/verify-verdict-signature.js +1 -1
  117. package/ui/workbench/app.css +41 -9
  118. package/ui/workbench/app.js +160 -27
  119. package/ui/workbench/index.html +2 -2
  120. package/docs/agent-framework.md +0 -177
  121. package/docs/designs/handoff-ledger.md +0 -145
  122. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  123. package/docs/launch/launch-kit.md +0 -195
  124. package/docs/launch/pre-launch-checklist.md +0 -53
  125. package/docs/readme-v0.1.87-full.md +0 -301
  126. package/docs/routines.md +0 -101
  127. package/docs/scheduled-tasks.md +0 -80
  128. package/scripts/agents/claude-p-agent.sh +0 -9
  129. package/scripts/block-unapproved-tag.sh +0 -75
  130. package/scripts/release-gate.sh +0 -94
  131. package/scripts/verify-bump-reproduction.sh +0 -148
  132. package/scripts/verify-container-selfref.js +0 -64
@@ -120,6 +120,28 @@ function alreadyCut() {
120
120
  return remote.code === 0 && Boolean(remote.out);
121
121
  }
122
122
 
123
+ // A cut tags the verdict commit LOCALLY and only then pushes the tag. If that
124
+ // push dies (a network drop, an auth expiry) the tag is left local-only:
125
+ // alreadyCut() still sees it and resumes at stage 3, but stage 3 would then
126
+ // wait 45 minutes for a release-gate run CI never started, because the tag
127
+ // never reached origin. So on resume, before the CI wait, make sure the tag is
128
+ // actually on origin — re-push it (the same tag-only refspec cut() uses) when
129
+ // it is only local. This keeps the tag-only-push contract: no branch is pushed,
130
+ // and the tag commit's first parent is untouched (it is exactly what was cut).
131
+ function ensureTagPushedOnResume() {
132
+ const remote = git(["ls-remote", "origin", `refs/tags/v${version}`]);
133
+ if (remote.code !== 0) {
134
+ die("could not reach origin to check the tag — fix network/auth and re-run to resume.", remote.err);
135
+ }
136
+ if (remote.out) return; // already on origin — nothing to do
137
+ say(`tag v${version} is local-only (the cut's tag push must have failed) — re-pushing refs/tags/v${version}.`);
138
+ const push = git(["push", "origin", `refs/tags/v${version}`]);
139
+ if (push.code !== 0) {
140
+ die("re-push of the local-only tag failed (nothing else was done) — fix the issue and re-run to resume.", push.err);
141
+ }
142
+ say(`re-pushed refs/tags/v${version} to origin.`);
143
+ }
144
+
123
145
  // ---- stage 0: preflight -----------------------------------------------------
124
146
  function runPreflight() {
125
147
  stage(0, "preflight");
@@ -318,12 +340,12 @@ function runRecordAndWaitStage() {
318
340
  }
319
341
 
320
342
  say(`waiting for release-gate on tag v${version}...`);
321
- const gateRunId = poll({
343
+ const gate = poll({
322
344
  label: "release-gate wait",
323
345
  timeoutMs: 45 * 60 * 1000,
324
346
  intervalMs: 20000,
325
347
  probe: () => {
326
- const list = gh(["run", "list", "--workflow", "release-gate", "--limit", "10", "--json", "databaseId,headBranch,status,conclusion"]);
348
+ const list = gh(["run", "list", "--workflow", "release-gate", "--limit", "10", "--json", "databaseId,headBranch,status,conclusion,updatedAt"]);
327
349
  if (list.code !== 0) return { error: true };
328
350
  let runs;
329
351
  try {
@@ -333,22 +355,29 @@ function runRecordAndWaitStage() {
333
355
  }
334
356
  const gateRun = runs.find((r) => r.headBranch === `v${version}`);
335
357
  if (gateRun && gateRun.status === "completed") {
336
- if (gateRun.conclusion === "success") return { done: true, value: gateRun.databaseId };
358
+ // Carry the gate's OWN completion time out of the poll — the npm-publish
359
+ // cutoff below must key off when the gate actually finished, not off the
360
+ // wall clock now (a resume can start long after the gate completed).
361
+ if (gateRun.conclusion === "success") return { done: true, value: { id: gateRun.databaseId, completedMs: Date.parse(gateRun.updatedAt) } };
337
362
  die(`release-gate FAILED — inspect: gh run view ${gateRun.databaseId} --log-failed`);
338
363
  }
339
364
  return {};
340
365
  }
341
366
  });
342
- say(`release-gate: SUCCESS (run ${gateRunId})`);
367
+ say(`release-gate: SUCCESS (run ${gate.id})`);
343
368
 
344
369
  // npm-publish is created only AFTER release-gate completes (workflow_run
345
370
  // trigger), so the run to wait for may not exist yet — and the newest
346
371
  // completed npm-publish run at this moment is usually the PREVIOUS
347
372
  // release's. Only accept a run created at-or-after the moment the gate
348
- // finished (small clock-skew allowance), and keep waiting until that run
349
- // exists and completes.
373
+ // FINISHED (small clock-skew allowance), and keep waiting until that run
374
+ // exists and completes. The cutoff is the gate run's own completion time,
375
+ // NOT Date.now(): on a resume minutes/days later, a now-based cutoff sits in
376
+ // the future relative to an already-completed npm-publish run and would drop
377
+ // it, hanging the wait for a publish that is already done. Fall back to now
378
+ // only if the gate run reported no parseable completion time.
350
379
  say("waiting for npm-publish...");
351
- const gateDoneMs = Date.now();
380
+ const gateDoneMs = Number.isFinite(gate.completedMs) ? gate.completedMs : Date.now();
352
381
  poll({
353
382
  label: "npm-publish wait",
354
383
  timeoutMs: 30 * 60 * 1000,
@@ -396,7 +425,9 @@ if (alreadyCut()) {
396
425
  say(`tag v${version} already exists — the cut already happened; resuming at stage 3 (CI wait + record + confirm).`);
397
426
  if (DRY_RUN) {
398
427
  say("[dry-run] would resume at stage 3: record PR + wait for release-gate/npm-publish + npm view confirmation.");
428
+ say("[dry-run] would first re-push refs/tags/v" + version + " if it is only local (the cut's tag push had failed).");
399
429
  } else {
430
+ ensureTagPushedOnResume();
400
431
  runRecordAndWaitStage();
401
432
  }
402
433
  } else {
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ // release-tags.js — the ONE semver-ordered release-tag lookup, shared by
3
+ // release-flow.js and release-gate.js so the two can never drift (the gate
4
+ // used to carry a bash port of this logic; a mirror copy is exactly the kind
5
+ // of thing that rots).
6
+ //
7
+ // "What was the last release" is a SEMVER question, not an ancestry question:
8
+ // a cut's own tag commit lives on an ephemeral line that is NEVER merged as
9
+ // an ancestor of `main` (cut() tags the verdict-record commit directly), so
10
+ // `git describe --tags --abbrev=0`, which walks ONLY ancestors, always skips
11
+ // the true previous release tag and silently lands one release further back
12
+ // (verified live: `git describe --tags v0.2.4^` -> v0.2.2, skipping v0.2.3).
13
+ // List every vX.Y.Z tag and pick by version order instead.
14
+
15
+ function parseSemverTag(tag) {
16
+ const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
17
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
18
+ }
19
+
20
+ function compareSemver(a, b) {
21
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
22
+ return 0;
23
+ }
24
+
25
+ // gitOut: (args: string[]) => string — the caller's own git runner (each
26
+ // caller binds its own repo root), returning trimmed stdout, "" on failure.
27
+ function listReleaseTagsDesc(gitOut) {
28
+ const out = gitOut(["tag", "-l", "v*"]);
29
+ if (!out) return [];
30
+ return out
31
+ .split("\n")
32
+ .filter(Boolean)
33
+ .map((tag) => ({ tag, semver: parseSemverTag(tag) }))
34
+ .filter((t) => t.semver)
35
+ .sort((a, b) => compareSemver(b.semver, a.semver));
36
+ }
37
+
38
+ // The most recent ALREADY-RELEASED tag as of right now: the highest vX.Y.Z
39
+ // tag that does NOT already point at HEAD — so this works whether run before
40
+ // tagging or re-run on a freshly tagged commit.
41
+ function resolvePrevReleaseTag(gitOut) {
42
+ const headTags = new Set(gitOut(["tag", "--points-at", "HEAD"]).split("\n").filter(Boolean));
43
+ const found = listReleaseTagsDesc(gitOut).find((t) => !headTags.has(t.tag));
44
+ return found ? found.tag : "";
45
+ }
46
+
47
+ module.exports = { parseSemverTag, compareSemver, listReleaseTagsDesc, resolvePrevReleaseTag };
@@ -9,7 +9,7 @@
9
9
  // The PRIVATE key stays with whoever runs `release-flow.js --cut`/`--check`
10
10
  // (CW_RELEASE_VERDICT_PRIVKEY) and NEVER gets committed. The PUBLIC key is
11
11
  // committed to the repo at .cw-release/verdict-signing.pub so CI's
12
- // release-gate.yml, npm-publish.yml, and the local block-unapproved-tag.sh
12
+ // release-gate.yml, npm-publish.yml, and the local block-unapproved-tag.js
13
13
  // hook can all verify a committed verdict without holding any secret.
14
14
  //
15
15
  // Verification is opt-in and backward compatible: as long as
@@ -76,7 +76,7 @@ process.stdout.write(
76
76
  ` git -C "$(git rev-parse --show-toplevel)" commit -m "chore: add release-verdict signing public key"`,
77
77
  ``,
78
78
  `Once .cw-release/verdict-signing.pub is committed, release-gate.yml, npm-publish.yml,`,
79
- `and block-unapproved-tag.sh all start REQUIRING a valid signature on top of the`,
79
+ `and block-unapproved-tag.js all start REQUIRING a valid signature on top of the`,
80
80
  `existing APPROVED text check — an unsigned or hand-written verdict fails closed.`,
81
81
  ``
82
82
  ].join("\n")
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // verify-bump-reproduction.js — closes the HEAD~1 verdict-replay bypass.
4
+ //
5
+ // release-gate.yml / npm-publish.yml tolerate a verdict written for the tag
6
+ // commit's PARENT (release-flow.js's cut() reviews content, then adds a
7
+ // mechanical version-bump + the verdict as a NEW child commit that actually
8
+ // gets tagged). A validly-signed verdict at the parent proves someone once
9
+ // approved THAT commit — it does NOT prove the child (the thing actually being
10
+ // tagged) is the deterministic bump cut() would have produced, rather than an
11
+ // attacker's own commit smuggling in arbitrary changes on top of an old,
12
+ // genuinely-approved parent (a real signature on a REPLAYED verdict).
13
+ //
14
+ // This script independently reproduces the bump: checks out the approved
15
+ // parent into a scratch worktree, runs the SAME bump:version/sync:project-index
16
+ // steps cut() runs, stages the SAME way cut() stages (git add -u + the
17
+ // explicit verdict/.sig paths, never -A), and requires the resulting tree to
18
+ // match the ACTUAL tagged commit's tree byte-for-byte. Any difference —
19
+ // anywhere, including a single added file — fails closed.
20
+ //
21
+ // (Node port of the former verify-bump-reproduction.sh; behavior kept line
22
+ // for line — same steps, same messages, same exit map.)
23
+ //
24
+ // Usage: verify-bump-reproduction.js <approved-parent-sha> <tagged-sha> <verdict-repo-relative-path> [sig-repo-relative-path]
25
+ //
26
+ // Exit 0: the tagged commit's tree matches the reproduced bump exactly.
27
+ // Exit 1: mismatch, or any step failed (install, bump, sync, worktree, etc).
28
+
29
+ const fs = require("node:fs");
30
+ const os = require("node:os");
31
+ const path = require("node:path");
32
+ const { spawnSync } = require("node:child_process");
33
+
34
+ // Where THIS SCRIPT ITSELF lives — distinct from REPO_ROOT (the git repo being
35
+ // operated ON, resolved below from the invocation cwd). These coincide in
36
+ // ordinary production use (the script runs from within the same checkout it
37
+ // verifies), but conflating them is wrong: REPO_ROOT could be an ARBITRARY git
38
+ // context (e.g. a test harness's throwaway clone of old history, which never
39
+ // has this script's own sibling files, since they postdate whatever was
40
+ // cloned). Sibling files this script depends on must be found relative to
41
+ // where it is installed, never relative to the repo it happens to be pointed at.
42
+ const SCRIPT_DIR = __dirname;
43
+
44
+ function err(msg) {
45
+ process.stderr.write(`${msg}\n`);
46
+ }
47
+
48
+ const [, , PARENT, TAGGED, VERDICT_REL, SIG_REL = ""] = process.argv;
49
+ if (!PARENT || !TAGGED || !VERDICT_REL) {
50
+ err("usage: verify-bump-reproduction.js <parent-sha> <tagged-sha> <verdict-rel-path> [sig-rel-path]");
51
+ process.exit(1);
52
+ }
53
+
54
+ function run(cmd, args, opts = {}) {
55
+ return spawnSync(cmd, args, { encoding: "utf8", ...opts });
56
+ }
57
+ function gitIn(dir, args, opts = {}) {
58
+ return run("git", ["-C", dir, ...args], opts);
59
+ }
60
+
61
+ const top = run("git", ["rev-parse", "--show-toplevel"]);
62
+ if (top.status !== 0) process.exit(1);
63
+ const REPO_ROOT = top.stdout.trim();
64
+
65
+ const SCRATCH_PARENT = fs.mkdtempSync(path.join(os.tmpdir(), "cw-bump-repro-"));
66
+ const SCRATCH = path.join(SCRATCH_PARENT, "wt");
67
+ function cleanup() {
68
+ gitIn(REPO_ROOT, ["worktree", "remove", "--force", SCRATCH], { stdio: "ignore" });
69
+ fs.rmSync(SCRATCH_PARENT, { recursive: true, force: true });
70
+ }
71
+
72
+ function main() {
73
+ const wt = gitIn(REPO_ROOT, ["worktree", "add", "--quiet", "--detach", SCRATCH, PARENT], { stdio: "ignore" });
74
+ if (wt.status !== 0) {
75
+ err(`verify-bump-reproduction: could not create a scratch worktree at ${PARENT}`);
76
+ return 1;
77
+ }
78
+
79
+ // The target version comes from the TAGGED commit (not the parent, which may
80
+ // still be pre-bump) — read via git plumbing, no separate checkout needed.
81
+ const pkgShow = gitIn(REPO_ROOT, ["show", `${TAGGED}:plugins/cool-workflow/package.json`]);
82
+ let VERSION = "";
83
+ if (pkgShow.status === 0) {
84
+ try {
85
+ VERSION = JSON.parse(pkgShow.stdout).version || "";
86
+ } catch {
87
+ VERSION = "";
88
+ }
89
+ }
90
+ if (!VERSION) {
91
+ err(`verify-bump-reproduction: could not read package.json version from tagged commit ${TAGGED}`);
92
+ return 1;
93
+ }
94
+
95
+ // sync-project-index.js embeds a wall-clock "Generated on <date>" line. Pin it
96
+ // to the TAGGED commit's own committer date (UTC) via a Node --require preload
97
+ // that overrides the global Date constructor (fake-date-for-reproduction.js),
98
+ // NOT an application-level env var: the scratch worktree runs the APPROVED
99
+ // PARENT's OWN checked-out copy of sync-project-index.js, which for every
100
+ // release cut before this mechanism existed has no idea any such env var is
101
+ // meant to be read — an app-level opt-in is a no-op against code that doesn't
102
+ // know to opt in, so it can only ever "work" for releases cut after the
103
+ // mechanism landed (confirmed empirically: re-running an already-shipped
104
+ // release's real approved-parent/tagged pair on a later calendar day produced
105
+ // a tree mismatch whose ONLY diff was that one date line). A global override
106
+ // of the Date constructor, injected before any application code runs, is
107
+ // transparent to the code being executed regardless of which version it is.
108
+ const dateShow = gitIn(REPO_ROOT, ["show", "-s", "--format=%cd", "--date=format-local:%Y-%m-%d", TAGGED], {
109
+ env: { ...process.env, TZ: "UTC" },
110
+ });
111
+ const CUT_DATE = dateShow.status === 0 ? dateShow.stdout.trim() : "";
112
+ if (!CUT_DATE) {
113
+ err("verify-bump-reproduction: could not read the tagged commit's committer date");
114
+ return 1;
115
+ }
116
+ const FAKE_DATE_PRELOAD = path.join(SCRIPT_DIR, "fake-date-for-reproduction.js");
117
+
118
+ // Each step's exit code is checked EXPLICITLY and immediately (not via one
119
+ // combined chain) — a chain's overall success can silently survive an earlier
120
+ // step's failure if a future edit ever loosens it, which would flip this
121
+ // fail-closed gate to fail-open without changing its outward shape.
122
+ // stdout/stderr from each step are captured (not discarded) so a real
123
+ // failure — a transient npm registry hiccup vs. an actual reproduction
124
+ // mismatch — is distinguishable in the workflow log instead of both
125
+ // collapsing into the same generic message.
126
+ const stepCwd = path.join(SCRATCH, "plugins", "cool-workflow");
127
+ const stepEnv = {
128
+ ...process.env,
129
+ NODE_OPTIONS: `--require ${FAKE_DATE_PRELOAD}`,
130
+ CW_FAKE_DATE: CUT_DATE,
131
+ };
132
+ function runStep(label, cmd, args) {
133
+ const r = run(cmd, args, { cwd: stepCwd, env: stepEnv });
134
+ const out = (r.stdout || "") + (r.stderr || "");
135
+ const code = r.error ? 1 : r.status;
136
+ if (code !== 0) {
137
+ err(`verify-bump-reproduction: ${label} failed (exit ${code}):`);
138
+ err(out);
139
+ return false;
140
+ }
141
+ return true;
142
+ }
143
+
144
+ const bumpOk =
145
+ runStep("npm install", "npm", ["install", "--no-package-lock", "--ignore-scripts"]) &&
146
+ runStep("bump:version", "npm", ["run", "bump:version", "--", VERSION]) &&
147
+ runStep("sync:project-index", "npm", ["run", "sync:project-index", "--", "--repo-only"]);
148
+ if (!bumpOk) {
149
+ err(`verify-bump-reproduction: reproducing the bump for ${PARENT} -> v${VERSION} failed`);
150
+ return 1;
151
+ }
152
+
153
+ fs.mkdirSync(path.join(SCRATCH, ".cw-release"), { recursive: true });
154
+ try {
155
+ fs.copyFileSync(path.join(REPO_ROOT, VERDICT_REL), path.join(SCRATCH, VERDICT_REL));
156
+ } catch {
157
+ return 1;
158
+ }
159
+ if (SIG_REL && fs.existsSync(path.join(REPO_ROOT, SIG_REL))) {
160
+ try {
161
+ fs.copyFileSync(path.join(REPO_ROOT, SIG_REL), path.join(SCRATCH, SIG_REL));
162
+ } catch {
163
+ return 1;
164
+ }
165
+ }
166
+
167
+ // Mirror cut()'s OWN staging exactly (git-add -u + the explicit verdict/.sig
168
+ // paths) — never -A, so an untracked stray left by npm/tooling in the scratch
169
+ // worktree can never silently ride into what we compare.
170
+ gitIn(SCRATCH, ["add", "-u"]);
171
+ gitIn(SCRATCH, ["add", "--", VERDICT_REL]);
172
+ if (SIG_REL && fs.existsSync(path.join(SCRATCH, SIG_REL))) {
173
+ gitIn(SCRATCH, ["add", "--", SIG_REL]);
174
+ }
175
+
176
+ const EXPECTED_TREE = gitIn(SCRATCH, ["write-tree"]).stdout.trim();
177
+ const ACTUAL_TREE = gitIn(REPO_ROOT, ["rev-parse", `${TAGGED}^{tree}`]).stdout.trim();
178
+
179
+ if (EXPECTED_TREE !== ACTUAL_TREE) {
180
+ err(`verify-bump-reproduction: ${TAGGED}'s tree (${ACTUAL_TREE}) does not match the deterministic bump reproduced from approved parent ${PARENT} (${EXPECTED_TREE}) — refusing`);
181
+ return 1;
182
+ }
183
+
184
+ return 0;
185
+ }
186
+
187
+ let code = 1;
188
+ try {
189
+ code = main();
190
+ } finally {
191
+ cleanup();
192
+ }
193
+ process.exit(code);
@@ -4,7 +4,7 @@
4
4
  // verify-verdict-signature.js — verify an ed25519 signature over a committed
5
5
  // release verdict file, against the committed public key
6
6
  // (.cw-release/verdict-signing.pub). Portable (node + node:crypto only) so
7
- // CI (release-gate.yml, npm-publish.yml) and the local block-unapproved-tag.sh
7
+ // CI (release-gate.yml, npm-publish.yml) and the local block-unapproved-tag.js
8
8
  // hook can all shell out to the SAME check instead of three drifting copies.
9
9
  //
10
10
  // Exit 0: signature verifies. Exit 1: missing file, unreadable key, or a
@@ -27,21 +27,31 @@
27
27
  }
28
28
  }
29
29
  * { box-sizing: border-box; }
30
+ /* The body is a flex column and main takes the leftover height (flex: 1 +
31
+ min-height: 0 so the inner panes can scroll) — no hardcoded header
32
+ height, so a header that wraps to two lines no longer pushes the panes
33
+ past the bottom of the window. */
30
34
  body {
31
35
  margin: 0;
32
36
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
33
37
  background: var(--bg);
34
38
  color: var(--ink);
35
39
  font-size: 14px;
40
+ display: flex;
41
+ flex-direction: column;
42
+ height: 100vh;
36
43
  }
37
44
  header { padding: 14px 20px; border-bottom: 1px solid var(--line); }
38
45
  header h1 { margin: 0; font-size: 18px; font-weight: 600; }
39
46
  header .muted, .muted { color: var(--muted); font-weight: 400; }
40
47
  header .trust { margin: 4px 0 0; font-size: 12px; color: var(--muted); }
41
48
  code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
42
- main { display: grid; grid-template-columns: 320px 1fr; height: calc(100vh - 64px); }
49
+ main { display: grid; grid-template-columns: 320px 1fr; flex: 1; min-height: 0; }
43
50
  .sidebar { border-right: 1px solid var(--line); overflow-y: auto; padding: 12px; }
44
51
  .bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
52
+ /* The sidebar heading is a real <h2> (document outline / screen-reader
53
+ navigation) styled to the size the old <strong> label had. */
54
+ .bar h2 { margin: 0; font-size: 14px; font-weight: 600; }
45
55
  button {
46
56
  background: var(--panel-2); color: var(--ink); border: 1px solid var(--line);
47
57
  border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 12px;
@@ -51,16 +61,28 @@ input[type="search"] {
51
61
  width: 100%; background: var(--panel); color: var(--ink);
52
62
  border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; margin-bottom: 8px;
53
63
  }
54
- .freshness { font-size: 11px; color: var(--muted); margin-bottom: 8px; }
64
+ .freshness { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
55
65
  .run-list { list-style: none; margin: 0; padding: 0; }
56
- .run-list li {
66
+ .run-list li,
67
+ .run-list button {
57
68
  padding: 8px; border: 1px solid var(--line); border-radius: 6px;
58
69
  margin-bottom: 6px; cursor: pointer; background: var(--panel);
59
70
  }
60
- .run-list li:hover { border-color: var(--accent); }
61
- .run-list li.active { border-color: var(--accent); background: var(--panel-2); }
71
+ /* Only the run-row <button> needs a plain-element reset: a <button> has
72
+ its own default display/width/font/color, a plain <li> (used only for
73
+ the error and empty-state rows) does not and must keep its own color
74
+ (.err red, .muted gray) — see app.js. */
75
+ .run-list button {
76
+ display: block; width: 100%; text-align: left; font: inherit; color: inherit;
77
+ }
78
+ .run-list li:hover, .run-list button:hover { border-color: var(--accent); }
79
+ .run-list li.active, .run-list button.active { border-color: var(--accent); background: var(--panel-2); }
80
+ /* Run rows are now real <button> elements (for Tab/Enter/Space); make the
81
+ keyboard focus ring visible — there is no :focus-visible rule for this
82
+ element otherwise. */
83
+ .run-list button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
62
84
  .run-list .rid { font-family: ui-monospace, monospace; font-size: 12px; display: flex; align-items: center; gap: 6px; }
63
- .run-list .meta { color: var(--muted); font-size: 11px; margin-top: 2px; }
85
+ .run-list .meta { color: var(--muted); font-size: 12px; margin-top: 2px; }
64
86
  .status-dot {
65
87
  display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none;
66
88
  background: var(--muted);
@@ -81,13 +103,23 @@ input[type="search"] {
81
103
  padding: 8px 12px; border-bottom: 1px solid var(--line);
82
104
  }
83
105
  .panel-card > .head .title { font-weight: 600; }
84
- .panel-card > .head .src { font-size: 11px; color: var(--muted); font-family: ui-monospace, monospace; }
85
- .badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); }
106
+ .panel-card > .head .src { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; }
107
+ .badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); }
86
108
  .badge.present { color: var(--present); border-color: var(--present); }
87
109
  .badge.absent { color: var(--absent); border-color: var(--absent); }
88
110
  .badge.stale { color: var(--absent); border-color: var(--absent); }
89
111
  .badge.valid { color: var(--present); border-color: var(--present); }
90
112
  .badge.missing, .badge.bad { color: var(--bad); border-color: var(--bad); }
113
+ /* Lifecycle states shown as a badge in the run detail header (the other
114
+ lifecycle values reuse the neutral/present/absent colors above). */
115
+ .badge.blocked { color: var(--absent); border-color: var(--absent); }
116
+ .badge.failed { color: var(--bad); border-color: var(--bad); }
117
+ .badge.completed { color: var(--present); border-color: var(--present); }
118
+ .badge.running { color: var(--accent); border-color: var(--accent); }
119
+ /* One plain next-step line under the header when a run is blocked/failed. */
120
+ .recovery-hint { margin: 0 0 12px; font-size: 12px; color: var(--absent); }
121
+ /* Second line of the empty-list state: the suggested next command. */
122
+ .run-list li .hint { margin-top: 4px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
91
123
  pre.json {
92
124
  margin: 0; padding: 12px; overflow-x: auto; font-size: 12px;
93
125
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height: 1.5;
@@ -99,7 +131,7 @@ pre.json {
99
131
  .err { color: var(--bad); }
100
132
  .struct-block { padding: 10px 12px; border-top: 1px solid var(--line); }
101
133
  .struct-block:first-child { border-top: none; }
102
- .struct-block .struct-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin-bottom: 6px; }
134
+ .struct-block .struct-title { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin-bottom: 6px; }
103
135
  .struct-table { width: 100%; border-collapse: collapse; font-size: 12px; }
104
136
  .struct-table th, .struct-table td {
105
137
  text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line);