cool-workflow 0.1.88 → 0.1.90

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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +44 -1
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/capability-core.js +123 -5
  11. package/dist/capability-registry.js +6 -0
  12. package/dist/cli/command-surface.js +99 -7
  13. package/dist/cli.js +27 -1
  14. package/dist/clones.js +162 -0
  15. package/dist/drive.js +35 -1
  16. package/dist/mcp/tool-call.js +4 -0
  17. package/dist/mcp/tool-definitions.js +5 -0
  18. package/dist/orchestrator/report.js +6 -0
  19. package/dist/orchestrator.js +37 -6
  20. package/dist/remote-source.js +444 -0
  21. package/dist/term.js +54 -8
  22. package/dist/version.js +1 -1
  23. package/docs/agent-delegation-drive.7.md +6 -0
  24. package/docs/cli-mcp-parity.7.md +11 -2
  25. package/docs/contract-migration-tooling.7.md +6 -0
  26. package/docs/control-plane-scheduling.7.md +6 -0
  27. package/docs/durable-state-and-locking.7.md +6 -0
  28. package/docs/evidence-adoption-reasoning-chain.7.md +6 -0
  29. package/docs/execution-backends.7.md +6 -0
  30. package/docs/multi-agent-cli-mcp-surface.7.md +6 -0
  31. package/docs/multi-agent-eval-replay-harness.7.md +6 -0
  32. package/docs/multi-agent-operator-ux.7.md +6 -0
  33. package/docs/node-snapshot-diff-replay.7.md +6 -0
  34. package/docs/observability-cost-accounting.7.md +6 -0
  35. package/docs/project-index.md +16 -5
  36. package/docs/real-execution-backends.7.md +6 -0
  37. package/docs/release-and-migration.7.md +6 -0
  38. package/docs/release-tooling.7.md +6 -0
  39. package/docs/remote-source-review.7.md +88 -0
  40. package/docs/run-registry-control-plane.7.md +6 -0
  41. package/docs/run-retention-reclamation.7.md +6 -0
  42. package/docs/state-explosion-management.7.md +6 -0
  43. package/docs/team-collaboration.7.md +6 -0
  44. package/docs/web-desktop-workbench.7.md +6 -0
  45. package/manifest/plugin.manifest.json +1 -1
  46. package/manifest/source-context-profiles.json +1 -1
  47. package/package.json +1 -1
  48. package/scripts/canonical-apps.js +4 -4
  49. package/scripts/dogfood-release.js +1 -1
  50. package/scripts/golden-path.js +4 -4
  51. package/scripts/release-gate.sh +11 -2
@@ -0,0 +1,444 @@
1
+ "use strict";
2
+ // src/remote-source.ts — materialize a REMOTE repository (a URL) into a LOCAL checkout
3
+ // so the existing review pipeline can run against it unchanged.
4
+ //
5
+ // This lives in the CAPABILITY layer and is imported ONLY by capability-core (+ smokes),
6
+ // never by the orchestrator/drive core — cloning is non-deterministic network I/O and the
7
+ // core must stay replay-deterministic. After materialize(), the caller points `args.repo`
8
+ // at the returned local path and everything downstream is identical to a local run.
9
+ //
10
+ // Zero runtime deps: `git` via spawnSync (the gitLines/gitOne shape from onramp.ts), plus
11
+ // node:crypto for the cache key. Fail closed: a bad URL / blocked scheme / network / auth
12
+ // failure throws an explicit error (never a fabricated success). All diagnostics are in the
13
+ // thrown message (the caller routes them to stderr); this module writes nothing to stdout.
14
+ var __importDefault = (this && this.__importDefault) || function (mod) {
15
+ return (mod && mod.__esModule) ? mod : { "default": mod };
16
+ };
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.classifyRemote = classifyRemote;
19
+ exports.isRemoteUrl = isRemoteUrl;
20
+ exports.sanitizeUrl = sanitizeUrl;
21
+ exports.validateRemoteUrl = validateRemoteUrl;
22
+ exports.gitAvailable = gitAvailable;
23
+ exports.redactCredentials = redactCredentials;
24
+ exports.materializeRemote = materializeRemote;
25
+ const node_child_process_1 = require("node:child_process");
26
+ const node_crypto_1 = require("node:crypto");
27
+ const node_fs_1 = __importDefault(require("node:fs"));
28
+ const node_path_1 = __importDefault(require("node:path"));
29
+ const node_url_1 = require("node:url");
30
+ const run_registry_1 = require("./run-registry");
31
+ /** Upper bound on a downloaded archive (200 MiB) — a review target should be source, not a
32
+ * binary blob; a giant download fails closed rather than exhausting disk/memory. */
33
+ const MAX_ARCHIVE_BYTES = 200 * 1024 * 1024;
34
+ /** Upper bound on the EXTRACTED tree (1 GiB) — a small archive can decompress to terabytes
35
+ * (a "zip bomb"). We reject by declared size before extracting AND by actual size after. */
36
+ const MAX_EXTRACTED_BYTES = 1024 * 1024 * 1024;
37
+ /** git transport schemes we will hand to `git clone`. http is permitted (some internal
38
+ * servers), but `ext::`/`fd::` remote-helpers and anything else are rejected. */
39
+ const ALLOWED_SCHEMES = new Set(["https", "http", "ssh", "git", "file"]);
40
+ const ARCHIVE_EXT = /\.(tar\.gz|tgz|tar|zip)$/i;
41
+ const SCP_LIKE = /^[^/@\s]+@[^/@\s:]+:/; // git@host:owner/repo (no scheme)
42
+ const HELPER_LIKE = /^[a-z][a-z0-9+.-]*::/i; // ext::, fd::, transport::address
43
+ /** Classify a flag value as a git URL, an archive URL, or a local path. Conservative:
44
+ * a value with NO remote marker is "local", so a real directory is never mis-fetched. */
45
+ function classifyRemote(value) {
46
+ if (!value)
47
+ return "local";
48
+ const v = value.trim();
49
+ const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(v);
50
+ const isScp = SCP_LIKE.test(v) && !v.includes("://");
51
+ const isHelper = HELPER_LIKE.test(v);
52
+ if (!hasScheme && !isScp && !isHelper)
53
+ return "local";
54
+ // Remote-ish. Archive by path extension (helpers/scp are always git-ish).
55
+ const pathPart = hasScheme ? safePathname(v) : v;
56
+ if (ARCHIVE_EXT.test(pathPart))
57
+ return "archive";
58
+ return "git";
59
+ }
60
+ /** Convenience: is this value something we should materialize (vs treat as a local path)? */
61
+ function isRemoteUrl(value) {
62
+ return classifyRemote(value) !== "local";
63
+ }
64
+ /** Strip credentials (userinfo) from a URL so it is safe to print/persist/record. scp-style
65
+ * (`git@host:…`) and local paths pass through unchanged (the `git@` user is not a secret). */
66
+ function sanitizeUrl(value) {
67
+ const v = value.trim();
68
+ if (SCP_LIKE.test(v) && !v.includes("://"))
69
+ return v;
70
+ try {
71
+ const u = new URL(v);
72
+ u.username = "";
73
+ u.password = "";
74
+ return u.toString();
75
+ }
76
+ catch {
77
+ return v;
78
+ }
79
+ }
80
+ /** Fail-closed validation BEFORE any subprocess: reject option-injection, blocked transport
81
+ * helpers, and unknown schemes. Throws with an explicit reason; never returns a verdict. */
82
+ function assertSafeUrl(value) {
83
+ const v = value.trim();
84
+ if (v.startsWith("-"))
85
+ throw new Error(`refusing a URL that begins with '-' (option injection): ${v}`);
86
+ if (HELPER_LIKE.test(v))
87
+ throw new Error(`blocked git transport helper in URL (e.g. ext::/fd::): ${v}`);
88
+ if (SCP_LIKE.test(v) && !v.includes("://"))
89
+ return; // scp-style is ssh
90
+ let scheme;
91
+ try {
92
+ scheme = new URL(v).protocol.replace(/:$/, "").toLowerCase();
93
+ }
94
+ catch {
95
+ throw new Error(`unparseable remote URL: ${v}`);
96
+ }
97
+ if (!ALLOWED_SCHEMES.has(scheme)) {
98
+ throw new Error(`unsupported URL scheme '${scheme}:' (allowed: https, http, ssh, git, file): ${v}`);
99
+ }
100
+ }
101
+ /** Validate a remote URL's shape WITHOUT any network I/O (used by `--check`): recognized,
102
+ * scheme-allowlisted, no option-injection or blocked transport helper. Never fetches. */
103
+ function validateRemoteUrl(value) {
104
+ const kind = classifyRemote(value);
105
+ const url = sanitizeUrl(value);
106
+ if (kind === "local")
107
+ return { ok: false, kind, url, reason: "not a recognized remote URL (expected https/ssh/git/file or git@host:repo)" };
108
+ try {
109
+ assertSafeUrl(value);
110
+ }
111
+ catch (error) {
112
+ return { ok: false, kind, url, reason: error.message };
113
+ }
114
+ return { ok: true, kind, url };
115
+ }
116
+ /** True when `git` is on PATH (used by `--check`; materialize asserts this too). */
117
+ function gitAvailable(env) {
118
+ return (0, node_child_process_1.spawnSync)("git", ["--version"], { encoding: "utf8", env: env || process.env }).status === 0;
119
+ }
120
+ function safePathname(value) {
121
+ try {
122
+ return new URL(value).pathname;
123
+ }
124
+ catch {
125
+ return value;
126
+ }
127
+ }
128
+ function cacheRoot(opts) {
129
+ return node_path_1.default.join(opts.home || (0, run_registry_1.resolveCwHome)(opts.env), "clones");
130
+ }
131
+ function cacheDirFor(root, sanitizedUrl, ref) {
132
+ const key = (0, node_crypto_1.createHash)("sha256").update(`${sanitizedUrl}\0${ref || ""}`).digest("hex").slice(0, 24);
133
+ return node_path_1.default.join(root, key);
134
+ }
135
+ /** Defense in depth: strip `user[:pass]@` userinfo from ANY URL in arbitrary text. git's own
136
+ * diagnostics can echo a credential-bearing URL on auth failure (version/transport dependent),
137
+ * so we never relay git's stderr/stdout verbatim — we redact first. Exported for testing. */
138
+ function redactCredentials(text) {
139
+ return String(text).replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1");
140
+ }
141
+ /** Run a git subprocess, returning trimmed stdout; throws with a credential-REDACTED stderr
142
+ * tail on failure (never relays git output verbatim). */
143
+ function git(args, opts, cwd) {
144
+ const env = {
145
+ ...(opts.env || process.env),
146
+ GIT_TERMINAL_PROMPT: "0", // fail closed instead of hanging on an auth prompt
147
+ GIT_ASKPASS: "",
148
+ GCM_INTERACTIVE: "never"
149
+ };
150
+ const result = (0, node_child_process_1.spawnSync)("git", args, {
151
+ cwd,
152
+ env,
153
+ encoding: "utf8",
154
+ timeout: opts.timeoutMs || 120000,
155
+ stdio: ["ignore", "pipe", "pipe"]
156
+ });
157
+ if (result.error)
158
+ throw new Error(`git ${args[0]} could not run: ${redactCredentials(result.error.message)}`);
159
+ if (result.status !== 0) {
160
+ const raw = String(result.stderr || result.stdout || "").trim();
161
+ const tail = redactCredentials(raw).split(/\r?\n/).slice(-3).join("; ");
162
+ throw new Error(`git ${args[0]} failed: ${tail || `exit ${result.status}`}`);
163
+ }
164
+ return String(result.stdout || "").trim();
165
+ }
166
+ function assertGitAvailable(opts) {
167
+ const result = (0, node_child_process_1.spawnSync)("git", ["--version"], { encoding: "utf8", env: opts.env || process.env });
168
+ if (result.status !== 0)
169
+ throw new Error("git is required to review a remote repository but was not found on PATH");
170
+ }
171
+ function writeCloneMeta(dir, meta) {
172
+ try {
173
+ node_fs_1.default.writeFileSync(node_path_1.default.join(dir, ".cw-clone-meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
174
+ }
175
+ catch {
176
+ /* meta is advisory (used by `cw clones`); never fail a clone over it */
177
+ }
178
+ }
179
+ function cloneGit(rawUrl, sanitizedUrl, opts) {
180
+ const root = cacheRoot(opts);
181
+ const dir = cacheDirFor(root, sanitizedUrl, opts.ref);
182
+ // Containment: the target MUST be inside clones/ (the key is hex, so this always holds —
183
+ // assert anyway so a future change can never write or, later, gc outside the cache root).
184
+ if (!node_path_1.default.resolve(dir).startsWith(node_path_1.default.resolve(root) + node_path_1.default.sep)) {
185
+ throw new Error(`refusing clone target outside the cache root: ${dir}`);
186
+ }
187
+ if (node_fs_1.default.existsSync(node_path_1.default.join(dir, ".git")) && !opts.refresh) {
188
+ const commit = git(["-C", dir, "rev-parse", "HEAD"], opts);
189
+ if (commit)
190
+ return { localPath: dir, url: sanitizedUrl, commit, kind: "git", ref: opts.ref, cached: true };
191
+ // Corrupt cache entry — fall through to a fresh clone.
192
+ }
193
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
194
+ node_fs_1.default.mkdirSync(root, { recursive: true });
195
+ // Array argv + `--` before the URL (never a shell string); shallow + single-branch; repo
196
+ // hooks disabled. The raw URL (which may carry credentials) is passed ONLY as an argv
197
+ // element — it never reaches a shell and never gets persisted (we store the sanitized one).
198
+ const args = [
199
+ "clone",
200
+ "--depth",
201
+ "1",
202
+ "--single-branch",
203
+ ...(opts.ref ? ["--branch", opts.ref] : []),
204
+ "-c",
205
+ "core.hooksPath=",
206
+ "--",
207
+ rawUrl,
208
+ dir
209
+ ];
210
+ try {
211
+ git(args, opts);
212
+ }
213
+ catch (error) {
214
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
215
+ throw new Error(`could not clone ${sanitizedUrl}: ${error.message}`);
216
+ }
217
+ const commit = git(["-C", dir, "rev-parse", "HEAD"], opts);
218
+ if (!commit) {
219
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
220
+ throw new Error(`cloned ${sanitizedUrl} but could not resolve HEAD`);
221
+ }
222
+ writeCloneMeta(dir, { url: sanitizedUrl, kind: "git", ref: opts.ref || null, commit, fetchedAt: new Date().toISOString() });
223
+ return { localPath: dir, url: sanitizedUrl, commit, kind: "git", ref: opts.ref, cached: false };
224
+ }
225
+ /** Materialize a remote URL into a local checkout. Throws (fail closed) on any bad URL,
226
+ * blocked scheme, missing git, or fetch failure. The caller must have already decided the
227
+ * value is remote (classifyRemote !== "local"). */
228
+ function materializeRemote(value, opts = {}) {
229
+ const kind = classifyRemote(value);
230
+ if (kind === "local")
231
+ throw new Error(`not a remote URL: ${value}`);
232
+ assertSafeUrl(value);
233
+ assertGitAvailable(opts);
234
+ const sanitized = sanitizeUrl(value);
235
+ if (kind === "archive")
236
+ return downloadArchive(value, sanitized, opts);
237
+ return cloneGit(value, sanitized, opts);
238
+ }
239
+ // ---- archive download + extract (.tar.gz / .tgz / .tar / .zip) --------------------------
240
+ /** Read a cached archive checkout's content sha from its meta, or undefined to re-fetch. */
241
+ function cachedArchive(dir, sanitizedUrl, opts) {
242
+ if (opts.refresh || !node_fs_1.default.existsSync(node_path_1.default.join(dir, ".git")))
243
+ return undefined;
244
+ try {
245
+ const meta = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, ".cw-clone-meta.json"), "utf8"));
246
+ if (meta && typeof meta.commit === "string" && meta.commit) {
247
+ return { localPath: dir, url: sanitizedUrl, commit: meta.commit, kind: "archive", ref: opts.ref, cached: true };
248
+ }
249
+ }
250
+ catch {
251
+ /* corrupt/absent meta — fall through to a fresh download */
252
+ }
253
+ return undefined;
254
+ }
255
+ /** Fetch the archive bytes to a temp file: `file://` is read directly; http(s) is fetched in a
256
+ * short node subprocess (Node's built-in fetch — zero deps — run synchronously via spawnSync,
257
+ * since the whole CLI flow is synchronous). The URL is an argv element, never a shell string.
258
+ * SSRF-hardened: redirects are followed MANUALLY and each hop is re-validated (http(s) scheme
259
+ * only, no private/loopback/link-local host) BEFORE we connect to it — so a public URL cannot
260
+ * redirect us into an internal service. The operator's ORIGINAL host is their own choice. */
261
+ function fetchArchiveBytes(rawUrl, sanitizedUrl, dest, opts) {
262
+ if (rawUrl.startsWith("file://")) {
263
+ const src = (0, node_url_1.fileURLToPath)(rawUrl);
264
+ const size = node_fs_1.default.statSync(src).size;
265
+ if (size > MAX_ARCHIVE_BYTES)
266
+ throw new Error(`archive ${sanitizedUrl} is too large (${size} bytes > ${MAX_ARCHIVE_BYTES})`);
267
+ node_fs_1.default.copyFileSync(src, dest);
268
+ return;
269
+ }
270
+ const child = [
271
+ "const fs=require('fs');",
272
+ "const [url,out,cap]=[process.argv[1],process.argv[2],Number(process.argv[3])];",
273
+ "const priv=(h)=>{h=String(h).replace(/^\\[|\\]$/g,'').toLowerCase();",
274
+ "if(h==='localhost'||h.endsWith('.localhost')||h.endsWith('.local'))return true;",
275
+ "if(h==='::1'||h==='0.0.0.0'||h==='::'||h.startsWith('fe80:')||h.startsWith('fc')||h.startsWith('fd'))return true;",
276
+ "const m=h.match(/^(\\d+)\\.(\\d+)\\.(\\d+)\\.(\\d+)$/);",
277
+ "if(m){const a=+m[1],b=+m[2];if(a===127||a===10||a===0||(a===192&&b===168)||(a===172&&b>=16&&b<=31)||(a===169&&b===254))return true;}",
278
+ "return false;};",
279
+ "(async()=>{let u=url;",
280
+ "for(let i=0;i<6;i++){",
281
+ "const r=await fetch(u,{redirect:'manual'});",
282
+ "if(r.status>=300&&r.status<400&&r.headers.get('location')){",
283
+ "const nx=new URL(r.headers.get('location'),u);",
284
+ "if(!/^https?:$/.test(nx.protocol)){process.stderr.write('redirect to disallowed scheme '+nx.protocol);process.exit(5);}",
285
+ "if(priv(nx.hostname)){process.stderr.write('redirect to a private/internal host was blocked');process.exit(5);}",
286
+ "u=nx.href;continue;}",
287
+ "if(!r.ok){process.stderr.write('HTTP '+r.status+' '+r.statusText);process.exit(2);}",
288
+ "const len=Number(r.headers.get('content-length')||0);",
289
+ "if(cap&&len>cap){process.stderr.write('archive too large');process.exit(3);}",
290
+ "const buf=Buffer.from(await r.arrayBuffer());",
291
+ "if(cap&&buf.length>cap){process.stderr.write('archive too large');process.exit(3);}",
292
+ "fs.writeFileSync(out,buf);return;}",
293
+ "process.stderr.write('too many redirects');process.exit(6);",
294
+ "})().catch(e=>{process.stderr.write(String((e&&e.message)||e));process.exit(4);});"
295
+ ].join("");
296
+ const result = (0, node_child_process_1.spawnSync)(process.execPath, ["-e", child, rawUrl, dest, String(MAX_ARCHIVE_BYTES)], {
297
+ encoding: "utf8",
298
+ timeout: opts.timeoutMs || 120000,
299
+ env: opts.env || process.env,
300
+ stdio: ["ignore", "ignore", "pipe"]
301
+ });
302
+ if (result.status !== 0) {
303
+ node_fs_1.default.rmSync(dest, { force: true });
304
+ throw new Error(`could not download ${sanitizedUrl}: ${redactCredentials(String(result.stderr || "").trim()) || `exit ${result.status}`}`);
305
+ }
306
+ }
307
+ /** List an archive's entry NAMES WITHOUT extracting (the zip-slip/tar-slip name guard runs on
308
+ * this). Symlinks/specials and decompression bombs are caught separately (below). */
309
+ function listArchive(file, isZip) {
310
+ const cmd = isZip ? ["unzip", "-Z1", "--", file] : ["tar", "-tf", file];
311
+ const result = (0, node_child_process_1.spawnSync)(cmd[0], cmd.slice(1), { encoding: "utf8" });
312
+ if (result.status !== 0) {
313
+ // Distinguish "unzip not installed" (ENOENT) from "archive is corrupt" — never conflate the
314
+ // two (a corrupt .tar mislabeled .zip used to surface a bogus "unzip not found").
315
+ if (isZip && result.error?.code === "ENOENT") {
316
+ throw new Error("unzip is required to review a .zip link but was not found on PATH (use a .tar.gz or a git URL)");
317
+ }
318
+ throw new Error(`could not read archive: ${String(result.stderr || "").trim() || `exit ${result.status}`}`);
319
+ }
320
+ return String(result.stdout || "").split(/\r?\n/).filter(Boolean);
321
+ }
322
+ /** Reject any entry that would escape the extraction dir (absolute path or a `..` segment). */
323
+ function assertNoTraversal(entries, sanitizedUrl) {
324
+ for (const entry of entries) {
325
+ const normalized = entry.replace(/\\/g, "/");
326
+ if (node_path_1.default.isAbsolute(normalized) || normalized.startsWith("/") || /(^|\/)\.\.(\/|$)/.test(normalized)) {
327
+ throw new Error(`refusing archive ${sanitizedUrl}: unsafe path escapes the extraction dir: ${entry}`);
328
+ }
329
+ }
330
+ }
331
+ /** Declared uncompressed size, read WITHOUT extracting — gzip's ISIZE trailer for `.tar.gz`/
332
+ * `.tgz`, or `unzip -l`'s total for `.zip`. Best-effort (undefined when unknown); the
333
+ * post-extraction walk is authoritative. Lets us reject a bomb BEFORE it fills the disk. */
334
+ function declaredUncompressedSize(file, isZip) {
335
+ try {
336
+ if (isZip) {
337
+ const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8" });
338
+ if (r.status !== 0)
339
+ return undefined;
340
+ const lines = String(r.stdout || "").trim().split(/\r?\n/);
341
+ const m = (lines[lines.length - 1] || "").match(/^\s*(\d+)\s+\d+\s+files?/);
342
+ return m ? Number(m[1]) : undefined;
343
+ }
344
+ const fd = node_fs_1.default.openSync(file, "r");
345
+ try {
346
+ const head = Buffer.alloc(2);
347
+ node_fs_1.default.readSync(fd, head, 0, 2, 0);
348
+ if (head[0] !== 0x1f || head[1] !== 0x8b)
349
+ return undefined; // not gzip (plain .tar — its file size already ≤ the download cap)
350
+ const size = node_fs_1.default.fstatSync(fd).size;
351
+ const tail = Buffer.alloc(4);
352
+ node_fs_1.default.readSync(fd, tail, 0, 4, size - 4);
353
+ return tail.readUInt32LE(0); // ISIZE (mod 2^32)
354
+ }
355
+ finally {
356
+ node_fs_1.default.closeSync(fd);
357
+ }
358
+ }
359
+ catch {
360
+ return undefined;
361
+ }
362
+ }
363
+ /** Walk the EXTRACTED tree (without following symlinks) and fail closed on anything a reviewed
364
+ * source archive must not contain: a symlink or other non-regular entry (defends the symlink
365
+ * traversal class regardless of tar/unzip version), or a total size over the bomb cap. */
366
+ function assertSafeTree(root, sanitizedUrl) {
367
+ let total = 0;
368
+ const walk = (dir) => {
369
+ for (const name of node_fs_1.default.readdirSync(dir)) {
370
+ const p = node_path_1.default.join(dir, name);
371
+ const st = node_fs_1.default.lstatSync(p); // lstat: do NOT follow symlinks
372
+ if (st.isSymbolicLink()) {
373
+ throw new Error(`refusing archive ${sanitizedUrl}: contains a symlink (${node_path_1.default.relative(root, p)}); symlinks are not allowed in a reviewed source archive`);
374
+ }
375
+ if (st.isDirectory()) {
376
+ walk(p);
377
+ continue;
378
+ }
379
+ if (!st.isFile()) {
380
+ throw new Error(`refusing archive ${sanitizedUrl}: contains a non-regular entry (${node_path_1.default.relative(root, p)})`);
381
+ }
382
+ total += st.size;
383
+ if (total > MAX_EXTRACTED_BYTES) {
384
+ throw new Error(`refusing archive ${sanitizedUrl}: extracted size exceeds ${MAX_EXTRACTED_BYTES} bytes (possible decompression bomb)`);
385
+ }
386
+ }
387
+ };
388
+ walk(root);
389
+ }
390
+ function gitSnapshot(dir, message, opts) {
391
+ const base = ["-c", "user.email=cw@local", "-c", "user.name=cw", "-c", "commit.gpgsign=false", "-c", "core.hooksPath="];
392
+ git([...base, "-C", dir, "init", "-q"], opts);
393
+ git([...base, "-C", dir, "add", "-A"], opts);
394
+ git([...base, "-C", dir, "commit", "-q", "--allow-empty", "-m", message], opts);
395
+ }
396
+ function downloadArchive(rawUrl, sanitizedUrl, opts) {
397
+ assertGitAvailable(opts); // we snapshot the extracted tree into a local git repo
398
+ const root = cacheRoot(opts);
399
+ const dir = cacheDirFor(root, sanitizedUrl, opts.ref);
400
+ if (!node_path_1.default.resolve(dir).startsWith(node_path_1.default.resolve(root) + node_path_1.default.sep)) {
401
+ throw new Error(`refusing extract target outside the cache root: ${dir}`);
402
+ }
403
+ const reuse = cachedArchive(dir, sanitizedUrl, opts);
404
+ if (reuse)
405
+ return reuse;
406
+ node_fs_1.default.mkdirSync(root, { recursive: true });
407
+ // Detect zip from the URL path only (consistent with classifyRemote); the staging dir is
408
+ // mkdtemp-unique, so the temp download file can never collide with a concurrent same-URL run.
409
+ const isZip = /\.zip$/i.test(safePathname(rawUrl));
410
+ const staging = node_fs_1.default.mkdtempSync(node_path_1.default.join(root, ".stage-"));
411
+ const tmpFile = node_path_1.default.join(staging, isZip ? "archive.zip" : "archive.tar");
412
+ try {
413
+ fetchArchiveBytes(rawUrl, sanitizedUrl, tmpFile, opts);
414
+ const commit = (0, node_crypto_1.createHash)("sha256").update(node_fs_1.default.readFileSync(tmpFile)).digest("hex"); // content address
415
+ // Bomb defense (BEFORE extracting, to avoid filling the disk): reject a declared
416
+ // uncompressed size over the cap. assertSafeTree re-checks the ACTUAL size afterward.
417
+ const declared = declaredUncompressedSize(tmpFile, isZip);
418
+ if (declared !== undefined && declared > MAX_EXTRACTED_BYTES) {
419
+ throw new Error(`refusing archive ${sanitizedUrl}: declared uncompressed size ${declared} exceeds ${MAX_EXTRACTED_BYTES} bytes (possible decompression bomb)`);
420
+ }
421
+ assertNoTraversal(listArchive(tmpFile, isZip), sanitizedUrl);
422
+ const extractDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(staging, "x-"));
423
+ const ex = isZip
424
+ ? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8" })
425
+ : (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8" });
426
+ if (ex.status !== 0)
427
+ throw new Error(`could not extract archive ${sanitizedUrl}: ${String(ex.stderr || "").trim() || `exit ${ex.status}`}`);
428
+ // Fail closed on symlinks/specials and on an over-cap actual extracted size.
429
+ assertSafeTree(extractDir, sanitizedUrl);
430
+ // Many archives (e.g. GitHub tarballs) wrap everything in a single top-level dir; descend
431
+ // into it so the review sees the project root, not a one-entry wrapper. lstat (NOT stat) so
432
+ // a top-level SYMLINK is never treated as a directory (assertSafeTree already rejected one).
433
+ const top = node_fs_1.default.readdirSync(extractDir);
434
+ const contentRoot = top.length === 1 && node_fs_1.default.lstatSync(node_path_1.default.join(extractDir, top[0])).isDirectory() ? node_path_1.default.join(extractDir, top[0]) : extractDir;
435
+ node_fs_1.default.rmSync(dir, { recursive: true, force: true });
436
+ node_fs_1.default.renameSync(contentRoot, dir); // same filesystem (both under clones/) — atomic move
437
+ gitSnapshot(dir, `snapshot of ${sanitizedUrl}`, opts); // make it a real local repo at HEAD
438
+ writeCloneMeta(dir, { url: sanitizedUrl, kind: "archive", ref: opts.ref || null, commit, fetchedAt: new Date().toISOString() });
439
+ return { localPath: dir, url: sanitizedUrl, commit, kind: "archive", ref: opts.ref, cached: false };
440
+ }
441
+ finally {
442
+ node_fs_1.default.rmSync(staging, { recursive: true, force: true });
443
+ }
444
+ }
package/dist/term.js CHANGED
@@ -16,6 +16,11 @@ exports.cyan = cyan;
16
16
  exports.doctorGlyph = doctorGlyph;
17
17
  exports.cwLabel = cwLabel;
18
18
  exports.indent = indent;
19
+ exports.nextHint = nextHint;
20
+ exports.tryHint = tryHint;
21
+ exports.sectionHeader = sectionHeader;
22
+ exports.phaseProgressLine = phaseProgressLine;
23
+ exports.formatDuration = formatDuration;
19
24
  exports.printSuccessSummary = printSuccessSummary;
20
25
  function isTTY(stream = process.stderr) {
21
26
  return Boolean(stream.isTTY);
@@ -74,20 +79,61 @@ function indent(text, spaces = 2) {
74
79
  const prefix = " ".repeat(spaces);
75
80
  return text.split("\n").map((line) => `${prefix}${line}`).join("\n");
76
81
  }
77
- /** Print a success summary to stderr (TTY-gated). Shows the report path and a
78
- * suggested next command. Pipe-friendly: silent when stderr is not a TTY. */
82
+ /** A `Next: <cmd>` hint line (the command stays plain so it is copy-pasteable). */
83
+ function nextHint(cmd, stream) {
84
+ return `${dim("Next:", stream)} ${cmd}`;
85
+ }
86
+ /** A `Try: <cmd>` recovery hint (brew-style; the command stays plain to copy). */
87
+ function tryHint(cmd, stream) {
88
+ return `${dim("Try:", stream)} ${cmd}`;
89
+ }
90
+ /** A `==> Title` section header (brew-style). */
91
+ function sectionHeader(title, stream) {
92
+ return `${bold("==>", stream)} ${title}`;
93
+ }
94
+ /** A phase-progress line: `==> Map ✓ (6/6)` / `==> Assess … (3/6)`. Parallel phases
95
+ * use ⇉, sequential use …; a finished phase uses a green ✓. */
96
+ function phaseProgressLine(name, done, total, mode, stream) {
97
+ const complete = total > 0 && done >= total;
98
+ const glyph = complete ? green("✓", stream) : (mode === "parallel" ? "⇉" : "…");
99
+ const count = total > 0 ? ` (${done}/${total})` : "";
100
+ return `${sectionHeader(name, stream)} ${glyph}${count}`;
101
+ }
102
+ /** Format a DURATION in ms as `850ms` / `5.2s` / `1m02s`. Pure (no clock) — the
103
+ * caller measures elapsed via process.hrtime, so this never reads wall-clock time. */
104
+ function formatDuration(ms) {
105
+ if (ms < 1000)
106
+ return `${Math.max(0, Math.round(ms))}ms`;
107
+ const s = Math.round(ms / 100) / 10;
108
+ if (s < 60)
109
+ return `${s}s`;
110
+ const m = Math.floor(s / 60);
111
+ const rem = Math.round(s % 60);
112
+ return `${m}m${String(rem).padStart(2, "0")}s`;
113
+ }
114
+ /** Print a success summary to stderr (TTY-gated). Shows the report path, a one-line
115
+ * status (with N/N worker counts when known), and a copy-pasteable next/recovery
116
+ * command. Pipe-friendly: silent when stderr is not a TTY, so it never pollutes
117
+ * piped/`--json` stdout. A non-complete run with no agent configured gets a brew-style
118
+ * `Try: cw doctor` recovery line; otherwise `Next: cw status <id>` to inspect. */
79
119
  function printSuccessSummary(fields, stream) {
80
120
  if (!isTTY(stream))
81
121
  return;
82
122
  const s = stream || process.stderr;
83
- s.write(`\n${green("")} Report: ${fields.reportPath}\n`);
123
+ const counts = (typeof fields.completedWorkers === "number" && typeof fields.plannedWorkers === "number")
124
+ ? ` — ${fields.completedWorkers}/${fields.plannedWorkers}` : "";
125
+ s.write(`\n${green("✓", s)} Report: ${fields.reportPath}\n`);
84
126
  if (fields.status === "complete") {
85
- s.write(` Next: cw status ${fields.runId} --brief\n`);
86
- if (fields.bundle !== false) {
87
- s.write(` Bundle: cw report bundle ${fields.runId}\n`);
88
- }
127
+ s.write(` ${green("✓", s)} Status: complete${counts}\n`);
128
+ s.write(` ${nextHint(`cw report ${fields.runId} --show`, s)}\n`);
89
129
  }
90
130
  else {
91
- s.write(` ${yellow("!")} Status: ${fields.status}. Next: cw status ${fields.runId}\n`);
131
+ s.write(` ${yellow("!", s)} Status: ${fields.status}${counts}\n`);
132
+ // No agent backend is the #1 first-run blocker — point at the one command that
133
+ // diagnoses and prints the fix, brew-style. Otherwise inspect the run's state.
134
+ if (fields.agentConfigured === false)
135
+ s.write(` ${tryHint("cw doctor", s)}\n`);
136
+ else
137
+ s.write(` ${nextHint(`cw status ${fields.runId}`, s)}\n`);
92
138
  }
93
139
  }
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA_VERSION = exports.CURRENT_RUN_STATE_SCHEMA_VERSION = exports.WORKFLOW_APP_SCHEMA_VERSION = exports.CURRENT_COOL_WORKFLOW_VERSION = void 0;
4
- exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.88";
4
+ exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.90";
5
5
  exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
6
6
  exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;
7
7
  exports.LEGACY_RUN_STATE_SCHEMA_VERSION = 0;
@@ -313,3 +313,9 @@ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-st
313
313
  ## 0.1.88 (v0.1.88)
314
314
 
315
315
  Orchestration-parity for the agent drive: `run --drive --incremental` step-level resume (unchanged-input tasks replay from a content-addressed cache, zero re-spawns), inline `subWorkflow()` nesting (a task runs a child app and binds its verified report back, bounded depth + cycle guard, no telemetry fabricated), bounded dynamic `loop()` phases that expand at runtime under a static replay-stable cap, and a `claude -p` wrapper now on the canonical result contract.
316
+
317
+ ## 0.1.89 (v0.1.89)
318
+
319
+ The one-command `cw -q` headline now routes the question and defaults the repo to the caller cwd before driving the agent; the delegation contract, drive, and accept path are unchanged.
320
+
321
+ 0.1.90
@@ -82,7 +82,7 @@ relationship. `identical` means `cw <cmd> --json` is equal to the `cw_<tool>`
82
82
  payload; `projected` means a declared divergence with a reason; `cli-only` marks
83
83
  a surface-specific capability with a recorded reason. The matrix is
84
84
  <!-- gen:parity:count -->
85
- machine-complete by design: 199 capabilities, 186 MCP tools.
85
+ machine-complete by design: 201 capabilities, 188 MCP tools.
86
86
  <!-- /gen:parity:count -->
87
87
 
88
88
  <!-- gen:parity:table -->
@@ -272,6 +272,8 @@ machine-complete by design: 199 capabilities, 186 MCP tools.
272
272
  | `gc.plan` | `cw gc plan` | `cw_gc_plan` | `gcPlan` | both | identical |
273
273
  | `gc.run` | `cw gc run` | `cw_gc_run` | `gcRun` | both | projected |
274
274
  | `gc.verify` | `cw gc verify` | `cw_gc_verify` | `gcVerify` | both | identical |
275
+ | `clones.list` | `cw clones list` | `cw_clones_list` | `listClones` | both | identical |
276
+ | `clones.gc` | `cw clones gc` | `cw_clones_gc` | `gcClones` | both | projected |
275
277
  | `telemetry.verify` | `cw telemetry verify` | `cw_telemetry_verify` | `telemetryVerify` | both | identical |
276
278
  | `demo.tamper` | `cw demo tamper` | `—` | `demoTamper` | cli-only | cli-only |
277
279
  | `demo.bundle` | `cw demo bundle` | `—` | `demoBundle` | cli-only | cli-only |
@@ -319,12 +321,13 @@ carry a recorded reason in the registry.
319
321
  <!-- /gen:parity:cliOnly -->
320
322
 
321
323
  <!-- gen:parity:projected -->
322
- Five capabilities are payload-divergent on purpose (`projected`):
324
+ Six capabilities are payload-divergent on purpose (`projected`):
323
325
 
324
326
  - `commit` — Both surfaces route through the single core entry runner.commit. The CLI emits the raw StateCommitResult for scripting (commit.id, commit.evidence, commit.gate, commit.acceptanceRationale); cw_commit emits the operator commit envelope (commitId, verifierGated, checkpoint, evidenceCount, snapshotPath, nextActions, plus the raw result under `commit`). Declared projection via capability-core.commitEnvelope, not drift.
325
327
  - `backend.agent.config.set` — Mutating: persists $CW_HOME/agent-config.json (secret-stripped) before returning the effective config; both surfaces perform the same write — it is a surface-mutating verb, not a read probe.
326
328
  - `run.drive.step` — Mutating: advances the run by spawning the external agent per worker and recording attested output — not a read probe. CLI (--drive/--step) and MCP route through the same drive() core.
327
329
  - `gc.run` — Mutating: frees disk and appends a tombstone; both surfaces perform the identical transaction but the payload reports now-derived bytesFreed/tombstone.
330
+ - `clones.gc` — Mutating: removes cache directories and reports now-derived freedBytes/removed; both surfaces perform the identical reclamation.
328
331
  - `workbench.serve` — Both surfaces route through the single core entry buildWorkbenchServeDescriptor and return the IDENTICAL serve descriptor under `cw workbench serve --json`/`--once` and `cw_workbench_serve`. They diverge only in side effect, not payload: the CLI's default `cw workbench serve` (no --once) additionally STARTS the blocking localhost host (like `schedule daemon`), which an MCP stdio host cannot do, so cw_workbench_serve only ever returns the descriptor. Declared divergence, not drift.
329
332
  <!-- /gen:parity:projected -->
330
333
 
@@ -517,3 +520,9 @@ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-st
517
520
  ## 0.1.88 (v0.1.88)
518
521
 
519
522
  CLI surface simplified to 6 commands with agent stderr streaming on by default and vendor agent flags; the drive gains a `--incremental` flag (added to DRIVE_RUNTIME_KEYS so it never poisons run.inputs or the cache key). MCP tools stay the derived mirror of the same capabilities.
523
+
524
+ ## 0.1.89 (v0.1.89)
525
+
526
+ CLI golden-path fixes: `cw -q "…"` routes the question (was read as an app id → "Workflow app not found"), auto-detects the cwd as the repo (run anywhere, no `--repo`), and `cw help` wraps its command list with a trailing newline; the CLI↔MCP parity contract and the help-token parser are unchanged.
527
+
528
+ 0.1.90
@@ -153,3 +153,9 @@ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-st
153
153
  ## 0.1.88 (v0.1.88)
154
154
 
155
155
  _No behavioral change in v0.1.88 (no schema-migration edge was added; the incremental result cache uses a self-contained schemaVersion:2 key that never collides with the opt-in v1 cache and is not a run-state or app-schema migration)._
156
+
157
+ ## 0.1.89 (v0.1.89)
158
+
159
+ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
160
+
161
+ 0.1.90
@@ -137,3 +137,9 @@ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-st
137
137
  ## 0.1.88 (v0.1.88)
138
138
 
139
139
  _No behavioral change in v0.1.88 (the `sched` priority/readiness selection, concurrency ceiling, leases, backoff retry, and fail-closed park state are unchanged)._
140
+
141
+ ## 0.1.89 (v0.1.89)
142
+
143
+ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
144
+
145
+ 0.1.90
@@ -136,3 +136,9 @@ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-st
136
136
  ## 0.1.88 (v0.1.88)
137
137
 
138
138
  _No behavioral change in v0.1.88 (atomic writes, fsync-durability for audit-essential state, and lock-serialized cross-process stores are unchanged; the in-place `appendRunNode` optimization keeps `writeRunNode` and the persisted bytes identical)._
139
+
140
+ ## 0.1.89 (v0.1.89)
141
+
142
+ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
143
+
144
+ 0.1.90
@@ -297,3 +297,9 @@ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-st
297
297
  ## 0.1.88 (v0.1.88)
298
298
 
299
299
  _No behavioral change in v0.1.88 (the evidence adoption reasoning chain and its fingerprinted, fail-closed derivation are unchanged)._
300
+
301
+ ## 0.1.89 (v0.1.89)
302
+
303
+ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
304
+
305
+ 0.1.90
@@ -327,3 +327,9 @@ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-st
327
327
  ## 0.1.88 (v0.1.88)
328
328
 
329
329
  Agent stderr live-streaming is now on by default when stderr is a TTY (CW_AGENT_STREAM=0 / CW_NO_STREAM=1 force it off; CI and pipes stay silent); stdout is still always captured as data and the driver model / sandbox contract are unchanged.
330
+
331
+ ## 0.1.89 (v0.1.89)
332
+
333
+ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes only; this subsystem is unchanged)._
334
+
335
+ 0.1.90