cool-workflow 0.1.91 → 0.1.93

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 (84) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +111 -434
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/architecture-review-fast/workflow.js +15 -2
  7. package/apps/end-to-end-golden-path/app.json +1 -1
  8. package/apps/pr-review-fix-ci/app.json +1 -1
  9. package/apps/release-cut/app.json +1 -1
  10. package/apps/research-synthesis/app.json +1 -1
  11. package/apps/research-synthesis/workflow.js +1 -1
  12. package/dist/capability-core.js +47 -0
  13. package/dist/capability-registry.js +2 -0
  14. package/dist/cli/command-surface.js +165 -1352
  15. package/dist/cli/format.js +56 -0
  16. package/dist/cli/handlers/audit.js +82 -0
  17. package/dist/cli/handlers/blackboard.js +81 -0
  18. package/dist/cli/handlers/candidate.js +40 -0
  19. package/dist/cli/handlers/clones.js +34 -0
  20. package/dist/cli/handlers/collaboration.js +61 -0
  21. package/dist/cli/handlers/eval.js +40 -0
  22. package/dist/cli/handlers/maintenance.js +107 -0
  23. package/dist/cli/handlers/multi-agent.js +165 -0
  24. package/dist/cli/handlers/node.js +41 -0
  25. package/dist/cli/handlers/operational.js +155 -0
  26. package/dist/cli/handlers/operator.js +146 -0
  27. package/dist/cli/handlers/registry.js +68 -0
  28. package/dist/cli/handlers/run.js +153 -0
  29. package/dist/cli/handlers/scheduling.js +126 -0
  30. package/dist/cli/handlers/workbench.js +41 -0
  31. package/dist/cli/handlers/worker.js +45 -0
  32. package/dist/cli/io.js +27 -0
  33. package/dist/cli/run-summary.js +45 -0
  34. package/dist/commit.js +0 -5
  35. package/dist/doctor.js +1 -0
  36. package/dist/execution-backend.js +0 -11
  37. package/dist/mcp/tool-call.js +2 -0
  38. package/dist/mcp/tool-definitions.js +8 -0
  39. package/dist/orchestrator/app-operations.js +205 -0
  40. package/dist/orchestrator/report.js +6 -1
  41. package/dist/orchestrator.js +41 -153
  42. package/dist/state-explosion.js +0 -7
  43. package/dist/term.js +0 -18
  44. package/dist/validation.js +0 -21
  45. package/dist/version.js +1 -1
  46. package/docs/agent-delegation-drive.7.md +2 -0
  47. package/docs/canonical-workflow-apps.7.md +12 -0
  48. package/docs/cli-mcp-parity.7.md +18 -1
  49. package/docs/contract-migration-tooling.7.md +2 -0
  50. package/docs/control-plane-scheduling.7.md +2 -0
  51. package/docs/durable-state-and-locking.7.md +2 -0
  52. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  53. package/docs/execution-backends.7.md +2 -0
  54. package/docs/mcp-app-surface.7.md +12 -0
  55. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  56. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  57. package/docs/multi-agent-operator-ux.7.md +2 -0
  58. package/docs/node-snapshot-diff-replay.7.md +2 -0
  59. package/docs/observability-cost-accounting.7.md +2 -0
  60. package/docs/project-index.md +15 -3
  61. package/docs/real-execution-backends.7.md +2 -0
  62. package/docs/release-and-migration.7.md +2 -0
  63. package/docs/release-tooling.7.md +11 -0
  64. package/docs/run-registry-control-plane.7.md +26 -2
  65. package/docs/run-retention-reclamation.7.md +2 -0
  66. package/docs/state-explosion-management.7.md +2 -0
  67. package/docs/team-collaboration.7.md +2 -0
  68. package/docs/web-desktop-workbench.7.md +2 -0
  69. package/manifest/plugin.manifest.json +1 -1
  70. package/package.json +3 -1
  71. package/scripts/agents/agent-adapter-core.js +179 -28
  72. package/scripts/agents/claude-p-agent.js +24 -7
  73. package/scripts/agents/codex-agent.js +1 -1
  74. package/scripts/agents/gemini-agent.js +1 -1
  75. package/scripts/agents/opencode-agent.js +1 -1
  76. package/scripts/architecture-review-fast.js +19 -5
  77. package/scripts/bump-version.js +20 -1
  78. package/scripts/canonical-apps.js +4 -4
  79. package/scripts/dogfood-release.js +1 -1
  80. package/scripts/golden-path.js +4 -4
  81. package/scripts/parity-check.js +9 -1
  82. package/scripts/release-check.js +5 -1
  83. package/scripts/sync-readme.js +123 -0
  84. package/scripts/version-sync-check.js +10 -1
@@ -34,7 +34,7 @@ const { spawn, spawnSync } = require("node:child_process");
34
34
  // wrappers instead of carrying a private copy. A drifted inline copy (ASCII
35
35
  // hyphens silently became em-dashes here) meant claude was sent a different
36
36
  // instruction text than the other providers for the same contract.
37
- const { buildPrompt, createRenderer } = require("./agent-adapter-core");
37
+ const { buildPrompt, createRenderer, toolLabel, summarizeToolResult } = require("./agent-adapter-core");
38
38
 
39
39
  const inputPath = process.argv[2];
40
40
  const resultPath = process.argv[3];
@@ -78,17 +78,17 @@ if (!streamEnabled) {
78
78
  process.exit(0);
79
79
  }
80
80
 
81
- function shortInput(tool, input) {
81
+ // The shared `toolLabel` (from the core) renders `ToolName(basename)` — the Claude-tree label format
82
+ // every vendor uses, so the look can't drift between wrappers.
83
+ function pickInput(input) {
82
84
  if (!input || typeof input !== "object") return "";
83
- const v = input.file_path || input.path || input.pattern || input.command || input.query || input.url || "";
84
- const s = String(v).replace(/\s+/g, " ").trim();
85
- return s ? ` ${s.length > 80 ? s.slice(0, 77) + "…" : s}` : "";
85
+ return input.file_path || input.path || input.pattern || input.command || input.query || input.url || "";
86
86
  }
87
87
 
88
88
  // The live view (spinner + folding actions on a TTY, plain append-only when piped, silent when
89
89
  // CW_AGENT_STREAM=0) + cursor hygiene + an always-on-disk transcript live in the shared core.
90
90
  void traceEnabled; // superseded by the renderer (which does its own TTY/stream gating)
91
- const render = createRenderer({ env: process.env, stderr: process.stderr });
91
+ const render = createRenderer({ env: process.env, stderr: process.stderr, label: "claude" });
92
92
  const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
93
93
 
94
94
  // stream-json so claude emits incremental NDJSON events we render live. We CAPTURE claude's
@@ -128,6 +128,13 @@ child.stdout.on("data", (chunk) => {
128
128
  }
129
129
  });
130
130
 
131
+ const toolIds = new Map(); // tool_use id -> tool name, so a later tool_result can name its tool
132
+ function toolResultText(content) {
133
+ if (typeof content === "string") return content;
134
+ if (Array.isArray(content)) return content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("\n");
135
+ return "";
136
+ }
137
+
131
138
  function renderEvent(ev) {
132
139
  if (ev.type === "assistant" && ev.message) {
133
140
  if (!model && typeof ev.message.model === "string") model = ev.message.model;
@@ -135,7 +142,17 @@ function renderEvent(ev) {
135
142
  if (part.type === "text" && part.text && part.text.trim()) {
136
143
  render.text(part.text.trim());
137
144
  } else if (part.type === "tool_use") {
138
- render.action(`${part.name}${shortInput(part.name, part.input)}`);
145
+ if (part.id) toolIds.set(part.id, part.name);
146
+ render.action(toolLabel(part.name, pickInput(part.input)), part.id);
147
+ }
148
+ }
149
+ } else if (ev.type === "user" && ev.message) {
150
+ // claude runs the tools itself (headless -p) and echoes each result back as a tool_result block;
151
+ // summarize it into the `⎿` tree line for the tool it belongs to.
152
+ for (const part of ev.message.content || []) {
153
+ if (part && part.type === "tool_result") {
154
+ const name = toolIds.get(part.tool_use_id) || "";
155
+ render.result(summarizeToolResult(name, toolResultText(part.content), part.is_error), part.is_error, part.tool_use_id);
139
156
  }
140
157
  }
141
158
  } else if (ev.type === "system" && ev.subtype === "post_turn_summary" && ev.status_detail) {
@@ -42,7 +42,7 @@ try {
42
42
  }
43
43
 
44
44
  const prompt = buildPrompt(inputPath);
45
- const render = createRenderer({ env: process.env, stderr: process.stderr });
45
+ const render = createRenderer({ env: process.env, stderr: process.stderr, label: "codex" });
46
46
  const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
47
47
  const state = { provider: "codex", buffer: "", model: undefined, usage: undefined, renderer: render };
48
48
  const capturedStdout = [];
@@ -33,7 +33,7 @@ if (!inputPath || !resultPath) {
33
33
  }
34
34
 
35
35
  const prompt = buildPrompt(inputPath);
36
- const render = createRenderer({ env: process.env, stderr: process.stderr });
36
+ const render = createRenderer({ env: process.env, stderr: process.stderr, label: "gemini" });
37
37
  const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
38
38
  const state = { provider: "gemini", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined, renderer: render };
39
39
  let childStderr = "";
@@ -38,7 +38,7 @@ if (!inputPath || !resultPath) {
38
38
  }
39
39
 
40
40
  const prompt = buildPrompt(inputPath);
41
- const render = createRenderer({ env: process.env, stderr: process.stderr });
41
+ const render = createRenderer({ env: process.env, stderr: process.stderr, label: "opencode" });
42
42
  const transcriptPath = path.join(path.dirname(resultPath), "transcript.md");
43
43
  const state = { provider: "opencode", buffer: "", model: undefined, usage: undefined, textFragments: [], finalResult: undefined, renderer: render };
44
44
  let childStderr = "";
@@ -38,13 +38,20 @@ function main() {
38
38
  const fastModel = stringArg(args.fastModel || args["fast-model"]);
39
39
  const strongModel = stringArg(args.strongModel || args["strong-model"]);
40
40
  const modelEnv = modelPolicyEnv(fastModel, strongModel);
41
+ // Opt-in incremental overlay: scope the exported source context to files
42
+ // changed since REF (e.g. origin/main). The source-context export resolves
43
+ // the ref, keys its cache separately, and records the base. The fast review
44
+ // then runs over the narrower context; the scheduled full review stays a
45
+ // complete audit (see scheduleFullReview).
46
+ const changedFrom = stringArg(args.changedFrom || args["changed-from"]);
41
47
 
42
48
  const contextExport = timed(() => exportSourceContext({
43
49
  repo,
44
50
  profile,
45
51
  ref,
46
52
  profileFile,
47
- cacheDir
53
+ cacheDir,
54
+ changedFrom
48
55
  }));
49
56
  const contextText = contextExport.value;
50
57
  assertNonEmptySourceContext(contextText, profile, repo);
@@ -87,7 +94,8 @@ function main() {
87
94
  digest,
88
95
  profile,
89
96
  ref,
90
- cacheDir
97
+ cacheDir,
98
+ ...(changedFrom ? { changedFrom } : {})
91
99
  };
92
100
  const fullReviewScheduleRun = truthy(args.scheduleFull || args["schedule-full"])
93
101
  ? timed(() => scheduleFullReview(repo, question, args, fastReview, sourceContextMeta))
@@ -101,7 +109,7 @@ function main() {
101
109
  fastReview,
102
110
  ...(fastModel || strongModel ? { modelPolicy: { ...(fastModel ? { fastModel } : {}), ...(strongModel ? { strongModel } : {}) } } : {}),
103
111
  ...(fullReviewSchedule ? { fullReviewSchedule } : {}),
104
- ...(includeMetrics ? { metrics: buildMetrics(started, contextText, contextExport.elapsedMs, fastReview, fastReviewRun.elapsedMs, fullReviewScheduleRun?.elapsedMs) } : {})
112
+ ...(includeMetrics ? { metrics: buildMetrics(started, contextText, contextExport.elapsedMs, fastReview, fastReviewRun.elapsedMs, fullReviewScheduleRun?.elapsedMs, changedFrom) } : {})
105
113
  });
106
114
  }
107
115
 
@@ -119,6 +127,7 @@ function exportSourceContext(options) {
119
127
  options.cacheDir
120
128
  ];
121
129
  if (options.profileFile) argv.push("--profile-file", path.resolve(options.profileFile));
130
+ if (options.changedFrom) argv.push("--changed-from", options.changedFrom);
122
131
  const result = spawnSync(node, argv, {
123
132
  cwd: repoRoot,
124
133
  encoding: "utf8",
@@ -194,6 +203,9 @@ function scheduleFullReview(repo, question, args, fastReview, sourceContextMeta)
194
203
  fastReview?.reportPath ? `Fast review report: ${fastReview.reportPath}.` : "",
195
204
  `Fast review status: ${fastReview?.status || "unknown"} (${fastReview?.completedWorkers || 0}/${fastReview?.plannedWorkers || 0} workers completed).`,
196
205
  `Source context: ${sourceContextMeta.path} (${sourceContextMeta.digest}, profile ${sourceContextMeta.profile}, ref ${sourceContextMeta.ref}).`,
206
+ sourceContextMeta.changedFrom
207
+ ? `Fast review used an incremental overlay (changed-from ${sourceContextMeta.changedFrom}); this full review must audit the complete source, not only the changed files.`
208
+ : "",
197
209
  "Use the completed architecture-review-fast report as the foreground triage result; write the full review report path and digest when the background review finishes."
198
210
  ].filter(Boolean).join(" ");
199
211
  return runCwJson([
@@ -304,7 +316,7 @@ function timed(fn) {
304
316
  return { value, elapsedMs: elapsedMs(started) };
305
317
  }
306
318
 
307
- function buildMetrics(started, contextText, sourceContextElapsedMs, fastReview, fastReviewElapsedMs, fullReviewScheduleElapsedMs) {
319
+ function buildMetrics(started, contextText, sourceContextElapsedMs, fastReview, fastReviewElapsedMs, fullReviewScheduleElapsedMs, changedFrom) {
308
320
  const steps = Array.isArray(fastReview?.steps) ? fastReview.steps : [];
309
321
  const handleKinds = countBy(steps.map((step) => step && step.handleKind).filter(Boolean));
310
322
  const actions = countBy(steps.map((step) => step && step.action).filter(Boolean));
@@ -314,7 +326,8 @@ function buildMetrics(started, contextText, sourceContextElapsedMs, fastReview,
314
326
  sourceContext: {
315
327
  elapsedMs: sourceContextElapsedMs,
316
328
  bytes: Buffer.byteLength(contextText, "utf8"),
317
- digest: `sha256:${crypto.createHash("sha256").update(contextText, "utf8").digest("hex")}`
329
+ digest: `sha256:${crypto.createHash("sha256").update(contextText, "utf8").digest("hex")}`,
330
+ ...(changedFrom ? { changedFrom } : {})
318
331
  },
319
332
  fastReview: {
320
333
  elapsedMs: fastReviewElapsedMs,
@@ -388,6 +401,7 @@ function usage(code) {
388
401
  "",
389
402
  "options:",
390
403
  " --profile core --ref HEAD --profile-file PATH --cache-dir DIR --context-out PATH",
404
+ " --changed-from REF (incremental overlay: scope context to files changed since REF)",
391
405
  " --fast-model MODEL --strong-model MODEL",
392
406
  " --invariant TEXT --focus TEXT --preview --once",
393
407
  " --schedule-full [--full-delay-minutes N]",
@@ -143,6 +143,22 @@ function main() {
143
143
  note(rel);
144
144
  }
145
145
 
146
+ // 5c. Homebrew formula (repo root, not under pluginRoot). Structured surface:
147
+ // a git-tag formula with no sha256. Homebrew reads the version from the
148
+ // tag, so the ONLY literal that moves is `tag: "v<version>"` (an explicit
149
+ // `version` line would be redundant and brew audit rejects it). A targeted
150
+ // exact-string swap keeps the file byte-formatting intact;
151
+ // version-sync-check.js asserts the tag at HEAD, so it can never drift.
152
+ const formula = path.join(repoRoot, "Formula", "cool-workflow.rb");
153
+ if (fs.existsSync(formula)) {
154
+ const text = fs.readFileSync(formula, "utf8");
155
+ const updated = text.split(`tag: "v${current}"`).join(`tag: "v${next}"`);
156
+ if (updated !== text) {
157
+ fs.writeFileSync(formula, updated);
158
+ note("Formula/cool-workflow.rb");
159
+ }
160
+ }
161
+
146
162
  process.stdout.write(`bump:version ${current} -> ${next}\n`);
147
163
  for (const rel of touched) process.stdout.write(` updated ${rel}\n`);
148
164
 
@@ -167,7 +183,10 @@ function contentSurfaceFiles(next) {
167
183
  // All files the version-sync-check script validates for VERSION or vX.Y.Z.
168
184
  // Keep in sync with scripts/version-sync-check.js.
169
185
  return [
170
- { path: "plugins/cool-workflow/README.md", needle: `v${next}`, desc: "README version tag" },
186
+ // The npm package README (plugins/cool-workflow/README.md) is GENERATED from the GitHub
187
+ // README.md by scripts/sync-readme.js and shows the version via the live npm/release shields
188
+ // badges, not a hand-maintained literal — so it is NOT a version-bump surface. After a bump,
189
+ // `npm run sync:readme` keeps it byte-identical to the GitHub README (guarded by readme:check).
171
190
  { path: "plugins/cool-workflow/docs/multi-agent-cli-mcp-surface.7.md", needle: next, desc: "multi-agent CLI/MCP surface doc" },
172
191
  { path: "plugins/cool-workflow/docs/multi-agent-operator-ux.7.md", needle: next, desc: "multi-agent operator UX doc" },
173
192
  { path: "plugins/cool-workflow/docs/multi-agent-eval-replay-harness.7.md", needle: next, desc: "multi-agent eval/replay doc" },
@@ -83,7 +83,7 @@ const canonicalApps = [
83
83
  "--source",
84
84
  "plugins/cool-workflow/docs/workflow-app-framework.7.md",
85
85
  "--scope",
86
- "Cool Workflow v0.1.91",
86
+ "Cool Workflow v0.1.93",
87
87
  "--freshness",
88
88
  "as of release preparation"
89
89
  ]
@@ -117,14 +117,14 @@ function main() {
117
117
  assert.ok(summary, `${app.id} must appear in app list`);
118
118
  assert.equal(summary.sourceKind, "app-directory");
119
119
  assert.equal(summary.legacy, false);
120
- assert.equal(summary.version, "0.1.91");
120
+ assert.equal(summary.version, "0.1.93");
121
121
 
122
122
  const validation = runJson(["app", "validate", manifestPath]);
123
123
  assert.equal(validation.valid, true, `${app.id} manifest must validate`);
124
124
 
125
125
  const shown = runJson(["app", "show", app.id]);
126
126
  assert.equal(shown.app.id, app.id);
127
- assert.equal(shown.app.version, "0.1.91");
127
+ assert.equal(shown.app.version, "0.1.93");
128
128
  assert.ok(shown.app.metadata.canonical, `${app.id} must be marked canonical`);
129
129
  assert.ok(shown.app.sandboxProfiles.length > 0, `${app.id} must declare sandbox profiles`);
130
130
  assertTaskIdsUnique(shown);
@@ -135,7 +135,7 @@ function main() {
135
135
  const plan = runJson(["plan", app.id, ...app.args(workspace)]);
136
136
  const state = JSON.parse(fs.readFileSync(plan.statePath, "utf8"));
137
137
  assert.equal(state.workflow.app.id, app.id);
138
- assert.equal(state.workflow.app.version, "0.1.91");
138
+ assert.equal(state.workflow.app.version, "0.1.93");
139
139
  assert.equal(state.workflow.app.metadata.canonical, true);
140
140
  assert.ok(state.tasks.some((task) => task.requiresEvidence), `${app.id} plan must include evidence gates`);
141
141
  assert.ok(state.tasks.every((task) => task.sandboxProfileId), `${app.id} plan must include sandbox hints`);
@@ -6,7 +6,7 @@ const fs = require("node:fs");
6
6
  const path = require("node:path");
7
7
  const { CoolWorkflowRunner } = require("../dist/orchestrator.js");
8
8
 
9
- const TARGET_VERSION = "0.1.91";
9
+ const TARGET_VERSION = "0.1.93";
10
10
  const PREVIOUS_VERSION = "0.1.31";
11
11
  const pluginRoot = path.resolve(__dirname, "..");
12
12
  const repoRoot = path.resolve(pluginRoot, "..", "..");
@@ -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.1.91");
36
+ assert.equal(appValidation.summary.version, "0.1.93");
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.1.91 end-to-end golden path."
45
+ "Prove the deterministic v0.1.93 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.1.91");
55
+ assert.equal(state.workflow.app.version, "0.1.93");
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\.1\.91/);
198
+ assert.match(report, /Workflow App: end-to-end-golden-path@0\.1\.93/);
199
199
  assert.match(report, /## Candidates/);
200
200
  assert.match(report, /## Trust Audit/);
201
201
  assert.match(report, /## Acceptance Rationale/);
@@ -69,7 +69,15 @@ function cliDispatchTokens() {
69
69
  }
70
70
 
71
71
  function cliDispatchSources() {
72
- return [cli, path.join(pluginRoot, "dist", "cli", "command-surface.js")].filter((file) => fs.existsSync(file));
72
+ // The dispatcher + every per-command handler module carved out of it (the
73
+ // command-surface god-object decomposition). A verb's subcommand `case`s may
74
+ // live in dist/cli/handlers/<group>.js, so scan those too or their tokens read
75
+ // as "missing from dist/cli.js".
76
+ const handlersDir = path.join(pluginRoot, "dist", "cli", "handlers");
77
+ const handlerFiles = fs.existsSync(handlersDir)
78
+ ? fs.readdirSync(handlersDir).filter((name) => name.endsWith(".js")).map((name) => path.join(handlersDir, name))
79
+ : [];
80
+ return [cli, path.join(pluginRoot, "dist", "cli", "command-surface.js"), ...handlerFiles].filter((file) => fs.existsSync(file));
73
81
  }
74
82
 
75
83
  function cliHelpTokens() {
@@ -74,7 +74,11 @@ const checks = [
74
74
  // drifted from a fresh source scan. The teeth-on-the-gate live in
75
75
  // test/project-index-sync-smoke.js (run by `npm test`); this entry gives the
76
76
  // real-doc check its own visible PASS/FAIL line in the release summary.
77
- { name: "project index sync", command: ["npm", "run", "index:check"] }
77
+ { name: "project index sync", command: ["npm", "run", "index:check"] },
78
+ // Fail closed if the npm README (plugins/cool-workflow/README.md) has drifted
79
+ // from the GitHub README.md it is generated from. Keeps the two pages identical
80
+ // by construction; teeth live in test/readme-sync-smoke.js (run by `npm test`).
81
+ { name: "readme sync", command: ["npm", "run", "readme:check"] }
78
82
  ];
79
83
 
80
84
  function main() {
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // sync-readme — keep the npm package README in lockstep with the GitHub README.
5
+ //
6
+ // The repo-root README.md is the SINGLE SOURCE OF TRUTH. This generates the npm
7
+ // page (plugins/cool-workflow/README.md) from it, changing ONLY what npm needs to
8
+ // render: relative URLs become absolute, because npm cannot resolve a repo's
9
+ // relative `docs/assets/...` image paths or `](LICENSE)` links the way GitHub can.
10
+ //
11
+ // <img src="docs/assets/x.svg"> -> raw.githubusercontent.com/<owner>/<repo>/main/docs/assets/x.svg
12
+ // ](LICENSE) / ](plugins/...) -> github.com/<owner>/<repo>/blob/main/...
13
+ //
14
+ // The text content is otherwise identical, so the two pages can never drift.
15
+ //
16
+ // --check : regenerate in memory, compare to the committed npm README, write
17
+ // nothing, exit non-zero on drift (the release/CI gate). Mirrors
18
+ // sync-project-index.js. Fork-safe: the owner/repo in the absolute URLs
19
+ // is normalized out of the compare, so a fork clone never false-REDs.
20
+
21
+ const fs = require("node:fs");
22
+ const path = require("node:path");
23
+ const { spawnSync } = require("node:child_process");
24
+
25
+ const pluginRoot = path.resolve(__dirname, "..");
26
+ const repoRoot = path.resolve(pluginRoot, "..", "..");
27
+ const sourceReadme = path.join(repoRoot, "README.md");
28
+ // CW_README_PATH overrides the npm README that --check compares against (used by
29
+ // the smoke to point --check at a throwaway drifted fixture). Default: the real one.
30
+ const targetReadme = process.env.CW_README_PATH
31
+ ? path.resolve(process.env.CW_README_PATH)
32
+ : path.join(pluginRoot, "README.md");
33
+
34
+ const repoUrl = normalizeGitRemote(git(["config", "--get", "remote.origin.url"]).trim())
35
+ || "https://github.com/coo1white/cool-workflow";
36
+ const ownerRepo = repoUrl.replace(/^https:\/\/github\.com\//, "");
37
+ const RAW_BASE = `https://raw.githubusercontent.com/${ownerRepo}/main`;
38
+ const BLOB_BASE = `${repoUrl}/blob/main`;
39
+
40
+ const MARKER =
41
+ "<!-- AUTO-GENERATED from /README.md by scripts/sync-readme.js — edit the root README, " +
42
+ "then run `npm run sync:readme`. DO NOT edit this file directly. -->";
43
+
44
+ const CHECK = process.argv.includes("--check");
45
+
46
+ function render() {
47
+ if (!fs.existsSync(sourceReadme)) {
48
+ process.stderr.write(`sync-readme FAILED: source ${path.relative(repoRoot, sourceReadme)} does not exist.\n`);
49
+ process.exit(1);
50
+ }
51
+ const source = fs.readFileSync(sourceReadme, "utf8");
52
+ // NOTE: a relative path must be free of a literal "(", ")", or '"' — the [^)]+ / [^"]+
53
+ // captures below stop at those. The repo README has none; readme:check catches any drift.
54
+ const absolutized = source
55
+ // HTML image sources: <img src="docs/assets/x.svg"> -> raw URL (skip already-absolute / anchors)
56
+ .replace(/\bsrc="(?!https?:|#|data:)([^"]+)"/g, (_m, rel) => `src="${RAW_BASE}/${rel}"`)
57
+ // Markdown links: ](LICENSE) / ](plugins/...) -> blob URL (skip absolute / in-page anchors / mailto)
58
+ .replace(/\]\((?!https?:|#|mailto:)([^)]+)\)/g, (_m, rel) => `](${BLOB_BASE}/${rel})`);
59
+ return `${MARKER}\n${absolutized}`;
60
+ }
61
+
62
+ function normalizeForCompare(text) {
63
+ // Strip the owner/repo from the generated absolute URLs so a fork (whose git
64
+ // remote differs) does not false-RED against the canonical committed README.
65
+ // The CONTENT is what this gate guards; the owner/repo is environment-derived.
66
+ return text
67
+ .replace(/\r\n/g, "\n")
68
+ .replace(/https:\/\/raw\.githubusercontent\.com\/[^/]+\/[^/]+\/main/g, "<RAW>")
69
+ .replace(/https:\/\/github\.com\/[^/]+\/[^/]+\/blob\/main/g, "<BLOB>");
70
+ }
71
+
72
+ function main() {
73
+ const rendered = render();
74
+ const rel = path.relative(repoRoot, targetReadme);
75
+
76
+ if (CHECK) {
77
+ if (!fs.existsSync(targetReadme)) {
78
+ process.stderr.write(`readme check FAILED: ${rel} does not exist.\nRun: (cd plugins/cool-workflow && npm run sync:readme)\n`);
79
+ process.exit(1);
80
+ }
81
+ const committed = normalizeForCompare(fs.readFileSync(targetReadme, "utf8"));
82
+ const fresh = normalizeForCompare(rendered);
83
+ if (committed === fresh) {
84
+ process.stdout.write(`${JSON.stringify({ ok: true, check: true, source: "README.md", doc: rel }, null, 2)}\n`);
85
+ return;
86
+ }
87
+ const a = committed.split("\n");
88
+ const b = fresh.split("\n");
89
+ let i = 0;
90
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
91
+ process.stderr.write(
92
+ `readme check FAILED: ${rel} is stale (does not match the GitHub README.md).\n` +
93
+ `First difference at line ${i + 1}:\n` +
94
+ ` committed: ${JSON.stringify(a[i] ?? "<missing>")}\n` +
95
+ ` expected: ${JSON.stringify(b[i] ?? "<missing>")}\n` +
96
+ `Regenerate with: (cd plugins/cool-workflow && npm run sync:readme)\n`
97
+ );
98
+ process.exit(1);
99
+ }
100
+
101
+ fs.writeFileSync(targetReadme, rendered, "utf8");
102
+ process.stdout.write(`${JSON.stringify({
103
+ ok: true,
104
+ source: "README.md",
105
+ output: rel,
106
+ repoUrl
107
+ }, null, 2)}\n`);
108
+ }
109
+
110
+ function git(args) {
111
+ const result = spawnSync("git", args, { cwd: repoRoot, encoding: "utf8" });
112
+ return result.status === 0 ? result.stdout : "";
113
+ }
114
+
115
+ function normalizeGitRemote(remote) {
116
+ if (!remote) return "";
117
+ if (remote.endsWith(".git")) remote = remote.slice(0, -4);
118
+ const sshMatch = remote.match(/^git@github\.com:(.+)$/);
119
+ if (sshMatch) return `https://github.com/${sshMatch[1]}`;
120
+ return remote;
121
+ }
122
+
123
+ main();
@@ -92,7 +92,11 @@ function main() {
92
92
  checkIncludes("plugins/cool-workflow/dist/mcp-server.js", "CURRENT_COOL_WORKFLOW_VERSION", checks);
93
93
  checkIncludes("plugins/cool-workflow/dist/workflow-app-framework.js", "CURRENT_COOL_WORKFLOW_VERSION", checks);
94
94
 
95
- checkIncludes("plugins/cool-workflow/README.md", `v${VERSION}`, checks);
95
+ // The npm package README (plugins/cool-workflow/README.md) is GENERATED from the GitHub
96
+ // README.md (scripts/sync-readme.js) and conveys the version via the live npm/release shields
97
+ // badges — not a hand-maintained literal — exactly like the GitHub page. It is no longer
98
+ // version-pinned here; readme:check guards that the two pages stay identical, and the surfaces
99
+ // above (version.ts/dist/package.json/manifests/docs) still pin the release.
96
100
  checkIncludes("plugins/cool-workflow/docs/index.md", "release and migration", checks);
97
101
  checkIncludes("plugins/cool-workflow/docs/multi-agent-topologies.7.md", "Multi-Agent Topologies", checks);
98
102
  checkIncludes("plugins/cool-workflow/docs/multi-agent-cli-mcp-surface.7.md", VERSION, checks);
@@ -192,6 +196,11 @@ function main() {
192
196
  checkIncludes("plugins/cool-workflow/docs/release-and-migration.7.md", VERSION, checks);
193
197
  checkIncludes("CHANGELOG.md", `## ${VERSION}`, checks);
194
198
  checkIncludes("RELEASE.md", VERSION, checks);
199
+ // Homebrew formula (repo root): git-tag formula. Homebrew scans the version
200
+ // from the tag, so the tag is the single version surface to gate (an explicit
201
+ // `version` line would be redundant and brew audit rejects it). bump-version.js
202
+ // moves this tag.
203
+ checkIncludes("Formula/cool-workflow.rb", `tag: "v${VERSION}"`, checks);
195
204
  checkIncludes("plugins/cool-workflow/skills/cool-workflow/SKILL.md", "release:check", checks);
196
205
 
197
206
  process.stdout.write(