compound-workflow 1.4.4 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compound-workflow",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Clarify → plan → execute → verify → capture. One Install action for Cursor, Claude, and OpenCode.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,8 @@
19
19
  ],
20
20
  "scripts": {
21
21
  "postinstall": "node scripts/postinstall.mjs",
22
- "check:pack-readme": "node scripts/check-pack-readme.mjs"
22
+ "check:pack-readme": "node scripts/check-pack-readme.mjs",
23
+ "test:install": "node --test tests/install-cli.test.mjs"
23
24
  },
24
25
  "engines": {
25
26
  "node": ">=18"
@@ -87,6 +87,16 @@ const requiredChecks = [
87
87
  pattern: "HARD GATE - WORKTREE FIRST",
88
88
  description: "worktree hard-gate wording in work command",
89
89
  },
90
+ {
91
+ file: "src/.agents/commands/workflow/work.md",
92
+ pattern: "required prompt/create gate",
93
+ description: "mandatory worktree decision prompt/create gate in work command",
94
+ },
95
+ {
96
+ file: "src/.agents/commands/workflow/work.md",
97
+ pattern: "Do not infer or assume an answer when the user has not answered.",
98
+ description: "worktree decision cannot be silently assumed",
99
+ },
90
100
  {
91
101
  file: "src/.agents/commands/workflow/work.md",
92
102
  pattern:
@@ -14,29 +14,32 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
14
  function usage(exitCode = 0) {
15
15
  const msg = `
16
16
  Usage:
17
- npx compound-workflow install [all|--all] [--root <projectDir>] [--dry-run] [--no-config] [--cursor]
17
+ npx compound-workflow install [all|--all] [--root <projectDir>] [--dry-run] [--no-config]
18
18
 
19
19
  One action: writes opencode.json (loads from package), merges AGENTS.md, creates dirs,
20
20
  and prompts for Repo Config Block (unless --no-config).
21
21
 
22
- all, --all Full install shortcut (enables Cursor integration; creates .cursor if needed)
22
+ all, --all Kept for compatibility
23
23
  --root <dir> Project directory (default: cwd)
24
24
  --dry-run Print planned changes only
25
25
  --no-config Skip Repo Config Block prompt (only write opencode.json + AGENTS.md + dirs)
26
- --cursor Force Cursor integration (create .cursor if missing, then wire links)
27
26
  `;
28
27
  (exitCode === 0 ? console.log : console.error)(msg.trimStart());
29
28
  process.exit(exitCode);
30
29
  }
31
30
 
32
31
  function parseArgs(argv) {
33
- const out = { root: process.cwd(), dryRun: false, noConfig: false, cursor: false };
32
+ const out = { root: process.cwd(), dryRun: false, noConfig: false };
34
33
  for (let i = 2; i < argv.length; i++) {
35
34
  const a = argv[i];
36
35
  if (a === "--dry-run") out.dryRun = true;
37
36
  else if (a === "--no-config") out.noConfig = true;
38
- else if (a === "--cursor") out.cursor = true;
39
- else if (a === "--all" || a === "all") out.cursor = true;
37
+ else if (a === "--cursor") {
38
+ // Deprecated compatibility alias; install now auto-detects .cursor.
39
+ }
40
+ else if (a === "--all" || a === "all") {
41
+ // Deprecated compatibility alias; install now auto-detects .cursor.
42
+ }
40
43
  else if (a === "--root") {
41
44
  const v = argv[i + 1];
42
45
  if (!v) usage(1);
@@ -58,7 +61,10 @@ function realpathSafe(p) {
58
61
 
59
62
  const packageRoot = realpathSafe(path.join(__dirname, ".."));
60
63
  const packageAgents = path.join(packageRoot, "src", ".agents");
61
- const PKG_PREFIX = "node_modules/compound-workflow";
64
+ const LOCAL_RUNTIME_ROOT = ".agents/compound-workflow";
65
+ const LOCAL_COMMANDS_ROOT = `${LOCAL_RUNTIME_ROOT}/commands`;
66
+ const LOCAL_AGENTS_ROOT = `${LOCAL_RUNTIME_ROOT}/agents`;
67
+ const LOCAL_REFERENCES_ROOT = `${LOCAL_RUNTIME_ROOT}/references`;
62
68
 
63
69
  function walkFiles(dirAbs, predicate) {
64
70
  const out = [];
@@ -101,14 +107,14 @@ function discoverCommands(agentsRoot) {
101
107
  const files = walkFiles(commandsDir, (p) => p.endsWith(".md"));
102
108
  const map = new Map();
103
109
  for (const fileAbs of files) {
104
- const rel = path.relative(packageRoot, fileAbs).replaceAll(path.sep, "/");
110
+ const relWithinCommands = path.relative(commandsDir, fileAbs).replaceAll(path.sep, "/");
105
111
  const md = fs.readFileSync(fileAbs, "utf8");
106
112
  const fm = parseFrontmatter(md);
107
113
  const id = (fm.invocation || fm.name || path.basename(fileAbs, ".md")).trim();
108
114
  const description = (fm.description || id).trim();
109
115
  if (!id) continue;
110
- const pkgRel = `${PKG_PREFIX}/${rel}`;
111
- map.set(id, { id, rel: pkgRel, description });
116
+ const localRel = `${LOCAL_COMMANDS_ROOT}/${relWithinCommands}`;
117
+ map.set(id, { id, rel: localRel, description });
112
118
  }
113
119
  return map;
114
120
  }
@@ -118,18 +124,65 @@ function discoverAgents(agentsRoot) {
118
124
  const files = walkFiles(agentsDir, (p) => p.endsWith(".md"));
119
125
  const map = new Map();
120
126
  for (const fileAbs of files) {
121
- const rel = path.relative(packageRoot, fileAbs).replaceAll(path.sep, "/");
127
+ const relWithinAgents = path.relative(agentsDir, fileAbs).replaceAll(path.sep, "/");
122
128
  const md = fs.readFileSync(fileAbs, "utf8");
123
129
  const fm = parseFrontmatter(md);
124
130
  const id = (fm.name || path.basename(fileAbs, ".md")).trim();
125
131
  const description = (fm.description || id).trim();
126
132
  if (!id) continue;
127
- const pkgRel = `${PKG_PREFIX}/${rel}`;
128
- map.set(id, { id, rel: pkgRel, description });
133
+ const localRel = `${LOCAL_AGENTS_ROOT}/${relWithinAgents}`;
134
+ map.set(id, { id, rel: localRel, description });
129
135
  }
130
136
  return map;
131
137
  }
132
138
 
139
+ function copyDirContents(sourceDir, targetDir) {
140
+ if (!fs.existsSync(sourceDir)) return;
141
+ fs.mkdirSync(targetDir, { recursive: true });
142
+ const entries = fs.readdirSync(sourceDir, { withFileTypes: true });
143
+ for (const entry of entries) {
144
+ const src = path.join(sourceDir, entry.name);
145
+ const dst = path.join(targetDir, entry.name);
146
+ if (entry.isDirectory()) {
147
+ copyDirContents(src, dst);
148
+ continue;
149
+ }
150
+ if (entry.isFile()) {
151
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
152
+ fs.copyFileSync(src, dst);
153
+ }
154
+ if (entry.isSymbolicLink()) {
155
+ const real = realpathSafe(src);
156
+ const realStat = fs.statSync(real);
157
+ if (realStat.isDirectory()) {
158
+ copyDirContents(real, dst);
159
+ } else if (realStat.isFile()) {
160
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
161
+ fs.copyFileSync(real, dst);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+ function syncRuntimeAssets(targetRoot, dryRun) {
168
+ const mappings = [
169
+ { label: "commands", src: path.join(packageAgents, "commands"), dst: path.join(targetRoot, LOCAL_COMMANDS_ROOT) },
170
+ { label: "agents", src: path.join(packageAgents, "agents"), dst: path.join(targetRoot, LOCAL_AGENTS_ROOT) },
171
+ { label: "references", src: path.join(packageAgents, "references"), dst: path.join(targetRoot, LOCAL_REFERENCES_ROOT) },
172
+ ];
173
+
174
+ for (const mapping of mappings) {
175
+ if (!fs.existsSync(mapping.src)) continue;
176
+ if (dryRun) {
177
+ console.log("[dry-run] Would sync", mapping.label, "to", path.relative(targetRoot, mapping.dst));
178
+ continue;
179
+ }
180
+ fs.rmSync(mapping.dst, { recursive: true, force: true });
181
+ copyDirContents(mapping.src, mapping.dst);
182
+ console.log("Synced", mapping.label + ":", path.relative(targetRoot, mapping.dst));
183
+ }
184
+ }
185
+
133
186
  function ensureObject(v) {
134
187
  return v && typeof v === "object" && !Array.isArray(v) ? v : {};
135
188
  }
@@ -237,39 +290,29 @@ function ensureSkillsSymlink(targetRoot, dryRun) {
237
290
  }
238
291
  }
239
292
 
240
- function ensureCursorDirSymlink(targetRoot, cursorSubdir, pkgSubdir, dryRun, label, cursorReady) {
293
+ function ensureCursorDirSync(targetRoot, cursorSubdir, pkgSubdir, dryRun, label, cursorReady) {
241
294
  const cursorDir = path.join(targetRoot, ".cursor");
242
295
  if (!cursorReady) return { status: "skipped-missing-cursor" };
243
296
  const pkgPath = path.join(packageRoot, "src", ".agents", pkgSubdir);
244
297
  if (!fs.existsSync(pkgPath)) return { status: "skipped-missing-package-path" };
245
298
 
246
- const linkPath = path.join(cursorDir, cursorSubdir);
247
- const targetRel = path.join("..", "node_modules", "compound-workflow", "src", ".agents", pkgSubdir);
248
- const targetAbs = path.resolve(path.dirname(linkPath), targetRel);
249
-
299
+ const targetPath = path.join(cursorDir, cursorSubdir);
250
300
  if (dryRun) {
251
- console.log("[dry-run] Would create .cursor/" + cursorSubdir, "symlink (Cursor)");
301
+ console.log("[dry-run] Would sync .cursor/" + cursorSubdir, "from", label || pkgSubdir, "(Cursor)");
252
302
  return { status: "dry-run" };
253
303
  }
254
304
 
255
- let needCreate = true;
256
- try {
257
- const stat = fs.lstatSync(linkPath);
258
- if (stat.isSymbolicLink() && symlinkPointsTo(linkPath, targetAbs)) needCreate = false;
259
- else if (!stat.isSymbolicLink()) {
260
- console.warn("Skipped", ".cursor/" + cursorSubdir, "because it exists and is not a symlink");
261
- return { status: "blocked-nonsymlink", path: linkPath };
305
+ if (fs.existsSync(targetPath)) {
306
+ const stat = fs.lstatSync(targetPath);
307
+ if (!stat.isDirectory()) {
308
+ return { status: "blocked-nondirectory", path: targetPath };
262
309
  }
263
- } catch (_) {}
264
-
265
- if (needCreate) {
266
- removePathIfExists(linkPath);
267
- const type = process.platform === "win32" ? "dir" : "dir";
268
- fs.symlinkSync(targetRel, linkPath, type);
269
- console.log("Created", ".cursor/" + cursorSubdir, "->", label || pkgSubdir, "(Cursor)");
270
- return { status: "created" };
271
310
  }
272
- return { status: "ok" };
311
+
312
+ fs.rmSync(targetPath, { recursive: true, force: true });
313
+ copyDirContents(pkgPath, targetPath);
314
+ console.log("Synced", ".cursor/" + cursorSubdir, "from", label || pkgSubdir, "(Cursor)");
315
+ return { status: "synced" };
273
316
  }
274
317
 
275
318
  function ensureCursorSkills(targetRoot, dryRun, cursorReady) {
@@ -278,29 +321,13 @@ function ensureCursorSkills(targetRoot, dryRun, cursorReady) {
278
321
 
279
322
  const packageSkillsDir = path.join(packageRoot, "src", ".agents", "skills");
280
323
  if (!fs.existsSync(packageSkillsDir)) return { blocked: [] };
281
-
282
- const skillNames = [];
283
- try {
284
- for (const name of fs.readdirSync(packageSkillsDir)) {
285
- const skillPath = path.join(packageSkillsDir, name);
286
- if (fs.statSync(skillPath).isDirectory() && fs.existsSync(path.join(skillPath, "SKILL.md"))) {
287
- skillNames.push(name);
288
- }
289
- }
290
- } catch (_) {
291
- return;
292
- }
293
-
294
324
  const skillsDir = path.join(cursorDir, "skills");
295
- const type = process.platform === "win32" ? "dir" : "dir";
296
-
297
325
  if (dryRun) {
298
- console.log("[dry-run] Would create .cursor/skills/<skill> symlinks for:", skillNames.join(", "));
326
+ console.log("[dry-run] Would sync .cursor/skills from package skills (Cursor)");
299
327
  return { blocked: [] };
300
328
  }
301
329
 
302
- if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });
303
- else {
330
+ if (fs.existsSync(skillsDir)) {
304
331
  const stat = fs.lstatSync(skillsDir);
305
332
  if (!stat.isDirectory()) {
306
333
  console.warn("Skipped .cursor/skills because it exists and is not a directory");
@@ -308,50 +335,42 @@ function ensureCursorSkills(targetRoot, dryRun, cursorReady) {
308
335
  }
309
336
  }
310
337
 
311
- const blocked = [];
312
- for (const name of skillNames) {
313
- const linkPath = path.join(skillsDir, name);
314
- const targetRel = path.join("..", "..", "node_modules", "compound-workflow", "src", ".agents", "skills", name);
315
- const targetAbs = path.resolve(path.dirname(linkPath), targetRel);
316
- let needCreate = true;
317
- try {
318
- const stat = fs.lstatSync(linkPath);
319
- if (stat.isSymbolicLink() && symlinkPointsTo(linkPath, targetAbs)) needCreate = false;
320
- else if (!stat.isSymbolicLink()) {
321
- console.warn("Skipped", ".cursor/skills/" + name, "because it exists and is not a symlink");
322
- blocked.push(linkPath);
323
- continue;
324
- }
325
- } catch (_) {}
338
+ fs.rmSync(skillsDir, { recursive: true, force: true });
339
+ copyDirContents(packageSkillsDir, skillsDir);
340
+ console.log("Synced .cursor/skills from package skills (Cursor)");
341
+ return { blocked: [] };
342
+ }
326
343
 
327
- if (needCreate) {
328
- removePathIfExists(linkPath);
329
- fs.symlinkSync(targetRel, linkPath, type);
330
- console.log("Created", ".cursor/skills/" + name, "-> package skill (Cursor)");
331
- }
344
+ function hasCursorPluginCommands(targetRoot) {
345
+ const pluginPath = path.join(targetRoot, ".cursor-plugin", "plugin.json");
346
+ if (!fs.existsSync(pluginPath)) return false;
347
+ try {
348
+ const parsed = JSON.parse(fs.readFileSync(pluginPath, "utf8"));
349
+ return typeof parsed?.commands === "string" && parsed.commands.trim().length > 0;
350
+ } catch {
351
+ return false;
332
352
  }
333
- return { blocked };
334
353
  }
335
354
 
336
- function verifyCursorIntegration(targetRoot) {
355
+ function verifyCursorIntegration(targetRoot, options = {}) {
337
356
  const cursorDir = path.join(targetRoot, ".cursor");
338
357
  if (!fs.existsSync(cursorDir)) return [];
358
+ const skipCommands = options.skipCommands === true;
339
359
 
340
360
  const checks = [
341
361
  { name: ".cursor/agents", rel: path.join("agents") },
342
- { name: ".cursor/commands", rel: path.join("commands") },
343
362
  { name: ".cursor/references", rel: path.join("references") },
344
363
  ];
364
+ if (!skipCommands) checks.splice(1, 0, { name: ".cursor/commands", rel: path.join("commands") });
345
365
  const issues = [];
346
366
 
347
367
  for (const check of checks) {
348
- const linkPath = path.join(cursorDir, check.rel);
349
- const expectedAbs = path.join(targetRoot, "node_modules", "compound-workflow", "src", ".agents", check.rel);
350
- if (!fs.existsSync(linkPath)) issues.push(`${check.name} is missing`);
368
+ const dirPath = path.join(cursorDir, check.rel);
369
+ if (!fs.existsSync(dirPath)) issues.push(`${check.name} is missing`);
351
370
  else {
352
- const stat = fs.lstatSync(linkPath);
353
- if (stat.isSymbolicLink() && !symlinkPointsTo(linkPath, expectedAbs)) {
354
- issues.push(`${check.name} symlink points to unexpected target`);
371
+ const stat = fs.lstatSync(dirPath);
372
+ if (!stat.isDirectory()) {
373
+ issues.push(`${check.name} exists but is not a directory`);
355
374
  }
356
375
  }
357
376
  }
@@ -374,10 +393,16 @@ function verifyCursorIntegration(targetRoot) {
374
393
  }
375
394
 
376
395
  const packageRoots = [
377
- { label: "commands", pkgDir: path.join(packageRoot, "src", ".agents", "commands"), cursorSubdir: "commands" },
378
396
  { label: "agents", pkgDir: path.join(packageRoot, "src", ".agents", "agents"), cursorSubdir: "agents" },
379
397
  { label: "references", pkgDir: path.join(packageRoot, "src", ".agents", "references"), cursorSubdir: "references" },
380
398
  ];
399
+ if (!skipCommands) {
400
+ packageRoots.unshift({
401
+ label: "commands",
402
+ pkgDir: path.join(packageRoot, "src", ".agents", "commands"),
403
+ cursorSubdir: "commands",
404
+ });
405
+ }
381
406
  for (const item of packageRoots) {
382
407
  if (!fs.existsSync(item.pkgDir)) continue;
383
408
  const pkgFiles = walkFiles(item.pkgDir, () => true);
@@ -401,6 +426,7 @@ function verifyCursorIntegration(targetRoot) {
401
426
  function ensureCursorIntegration(targetRoot, dryRun, forceCursor) {
402
427
  const cursorDir = path.join(targetRoot, ".cursor");
403
428
  let cursorReady = fs.existsSync(cursorDir);
429
+ const skipCommands = hasCursorPluginCommands(targetRoot);
404
430
  if (!fs.existsSync(cursorDir)) {
405
431
  if (!forceCursor) return { issues: [], status: "skipped-no-cursor" };
406
432
  if (dryRun) console.log("[dry-run] Would create .cursor directory (Cursor)");
@@ -413,25 +439,27 @@ function ensureCursorIntegration(targetRoot, dryRun, forceCursor) {
413
439
 
414
440
  const skillReport = ensureCursorSkills(targetRoot, dryRun, cursorReady);
415
441
  const dirReports = [
416
- ensureCursorDirSymlink(targetRoot, "agents", "agents", dryRun, "package agents", cursorReady),
417
- ensureCursorDirSymlink(targetRoot, "commands", "commands", dryRun, "package commands", cursorReady),
418
- ensureCursorDirSymlink(targetRoot, "references", "references", dryRun, "package references", cursorReady),
442
+ ensureCursorDirSync(targetRoot, "agents", "agents", dryRun, "package agents", cursorReady),
443
+ ensureCursorDirSync(targetRoot, "references", "references", dryRun, "package references", cursorReady),
419
444
  ];
445
+ if (!skipCommands) {
446
+ dirReports.splice(1, 0, ensureCursorDirSync(targetRoot, "commands", "commands", dryRun, "package commands", cursorReady));
447
+ }
420
448
 
421
449
  const issues = [];
422
450
  if (skillReport?.blocked?.length) {
423
451
  for (const p of skillReport.blocked) issues.push(`${path.relative(targetRoot, p)} blocks symlink creation (not a symlink)`);
424
452
  }
425
453
  for (const report of dirReports) {
426
- if (report?.status === "blocked-nonsymlink") {
427
- issues.push(`${path.relative(targetRoot, report.path)} blocks symlink creation (not a symlink)`);
454
+ if (report?.status === "blocked-nondirectory") {
455
+ issues.push(`${path.relative(targetRoot, report.path)} blocks sync (not a directory)`);
428
456
  }
429
457
  }
430
458
 
431
459
  if (!dryRun) {
432
- for (const issue of verifyCursorIntegration(targetRoot)) issues.push(issue);
460
+ for (const issue of verifyCursorIntegration(targetRoot, { skipCommands })) issues.push(issue);
433
461
  }
434
- return { issues, status: "configured" };
462
+ return { issues, status: "configured", skipCommands };
435
463
  }
436
464
 
437
465
  function writeOpenCodeJson(targetRoot, dryRun) {
@@ -579,14 +607,20 @@ function main() {
579
607
  console.log("Package root:", packageRoot);
580
608
  console.log("OpenCode CLI detected:", hasCommand("opencode") ? "yes" : "no");
581
609
 
610
+ syncRuntimeAssets(targetRoot, args.dryRun);
582
611
  writeOpenCodeJson(targetRoot, args.dryRun);
583
612
  ensureSkillsSymlink(targetRoot, args.dryRun);
584
613
  reportOpenCodeIntegration(targetRoot, args.dryRun);
585
- const cursorReport = ensureCursorIntegration(targetRoot, args.dryRun, args.cursor);
614
+ const cursorExists = fs.existsSync(path.join(targetRoot, ".cursor"));
615
+ const cursorReport = ensureCursorIntegration(targetRoot, args.dryRun, cursorExists);
586
616
  if (cursorReport.status === "skipped-no-cursor") {
587
- console.log("Cursor integration: skipped (.cursor missing). Run install with `all` or `--cursor` to create it.");
617
+ console.log("Cursor integration: skipped (.cursor not found).");
588
618
  } else {
589
- console.log("Cursor integration: verified skills, agents, commands, and references.");
619
+ if (cursorReport.skipCommands) {
620
+ console.log("Cursor integration: verified skills, agents, references (commands supplied by .cursor-plugin).");
621
+ } else {
622
+ console.log("Cursor integration: verified skills, agents, commands, and references.");
623
+ }
590
624
  }
591
625
  writeAgentsMd(targetRoot, args.dryRun);
592
626
  ensureDirs(targetRoot, args.dryRun);
@@ -138,16 +138,23 @@ The input must be a plan file path.
138
138
  - If you are already on a branch that clearly matches this plan, continue.
139
139
  - Otherwise, continue anyway — the current active branch remains the reference/base for a new worktree unless the user explicitly requests a different base.
140
140
 
141
- 2) Ask the user (required decision prompt):
141
+ 2) Resolve the user decision (required prompt/create gate):
142
142
 
143
- - "Use a worktree for this work? (default: Yes; required unless you explicitly opt out)"
143
+ - If the user already gave an explicit instruction in this run:
144
+ - "create a worktree" / "yes use a worktree" => use worktree path
145
+ - "do not use a worktree" / "no worktree" => opt-out path
146
+ - Otherwise, you MUST ask this exact decision before proceeding:
147
+ - "Use a worktree for this work? (Yes/No; default recommendation: Yes)"
144
148
  - Options:
145
149
  - Yes (worktree)
146
150
  - No (stay in current checkout; create/switch to a feature branch)
147
151
 
148
- If Yes: ask for the new branch name (e.g., `feat/<slug>`, `fix/<slug>`).
152
+ Mandatory behavior:
149
153
 
150
- If the user does not explicitly choose "No", proceed with "Yes" by default.
154
+ - Do not infer or assume an answer when the user has not answered.
155
+ - Do not run `skill: git-worktree` until the user has answered Yes (or already explicitly requested worktree creation).
156
+ - If Yes: ask for the new branch name when missing (e.g., `feat/<slug>`, `fix/<slug>`), then continue.
157
+ - If No: require explicit opt-out confirmation, then continue with the non-worktree path.
151
158
 
152
159
  3) If worktree is chosen, run:
153
160