cclaw-cli 0.51.28 → 0.51.29

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 (41) hide show
  1. package/dist/cli.d.ts +17 -1
  2. package/dist/cli.js +185 -49
  3. package/dist/codex-feature-flag.d.ts +1 -1
  4. package/dist/codex-feature-flag.js +1 -1
  5. package/dist/config.js +3 -0
  6. package/dist/content/cancel-command.d.ts +2 -0
  7. package/dist/content/cancel-command.js +25 -0
  8. package/dist/content/finish-command.d.ts +2 -0
  9. package/dist/content/finish-command.js +26 -0
  10. package/dist/content/harness-doc.js +1 -1
  11. package/dist/content/hooks.js +32 -9
  12. package/dist/content/ideate-command.js +12 -7
  13. package/dist/content/next-command.js +17 -13
  14. package/dist/content/node-hooks.js +22 -6
  15. package/dist/content/opencode-plugin.js +1 -1
  16. package/dist/content/stages/review.js +1 -1
  17. package/dist/content/stages/tdd.js +1 -1
  18. package/dist/content/start-command.js +6 -5
  19. package/dist/content/status-command.js +4 -3
  20. package/dist/content/track-render-context.d.ts +1 -0
  21. package/dist/content/track-render-context.js +2 -0
  22. package/dist/doctor-registry.d.ts +2 -0
  23. package/dist/doctor-registry.js +37 -10
  24. package/dist/doctor.d.ts +2 -1
  25. package/dist/doctor.js +183 -2
  26. package/dist/fs-utils.js +6 -0
  27. package/dist/harness-adapters.js +29 -5
  28. package/dist/install.d.ts +4 -1
  29. package/dist/install.js +37 -4
  30. package/dist/internal/advance-stage.js +6 -6
  31. package/dist/managed-resources.d.ts +53 -0
  32. package/dist/managed-resources.js +289 -0
  33. package/dist/run-archive.d.ts +8 -0
  34. package/dist/run-archive.js +19 -5
  35. package/dist/runs.d.ts +1 -1
  36. package/dist/runs.js +1 -1
  37. package/dist/tdd-cycle.js +10 -10
  38. package/dist/tdd-verification-evidence.js +4 -4
  39. package/dist/track-heuristics.d.ts +2 -0
  40. package/dist/track-heuristics.js +11 -3
  41. package/package.json +1 -1
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import type { FlowTrack, HarnessId } from "./types.js";
3
+ import type { ArchiveDisposition } from "./runs.js";
3
4
  type CommandName = "init" | "sync" | "doctor" | "upgrade" | "uninstall" | "archive" | "internal";
4
5
  interface ParsedArgs {
5
6
  command?: CommandName;
@@ -15,6 +16,8 @@ interface ParsedArgs {
15
16
  archiveName?: string;
16
17
  archiveSkipRetro?: boolean;
17
18
  archiveSkipRetroReason?: string;
19
+ archiveDisposition?: ArchiveDisposition;
20
+ archiveDispositionReason?: string;
18
21
  /** Hidden plumbing command (`cclaw internal ...`) arguments. */
19
22
  internalArgs?: string[];
20
23
  showHelp?: boolean;
@@ -23,5 +26,18 @@ interface ParsedArgs {
23
26
  export declare function usage(): string;
24
27
  declare function parseHarnesses(raw: string): HarnessId[];
25
28
  declare function parseTrack(raw: string): FlowTrack;
29
+ declare function parseArchiveDisposition(raw: string): ArchiveDisposition;
30
+ export type HarnessSelectionAnswer = {
31
+ kind: "accept";
32
+ } | {
33
+ kind: "all";
34
+ } | {
35
+ kind: "toggle";
36
+ indexes: number[];
37
+ } | {
38
+ kind: "invalid";
39
+ message: string;
40
+ };
41
+ export declare function parseHarnessSelectionAnswer(raw: string): HarnessSelectionAnswer;
26
42
  declare function parseArgs(argv: string[]): ParsedArgs;
27
- export { parseArgs, parseHarnesses, parseTrack };
43
+ export { parseArgs, parseArchiveDisposition, parseHarnesses, parseTrack };
package/dist/cli.js CHANGED
@@ -8,9 +8,9 @@ import { doctorChecks, doctorSucceeded } from "./doctor.js";
8
8
  import { initCclaw, syncCclaw, uninstallCclaw, upgradeCclaw } from "./install.js";
9
9
  import { error, info } from "./logger.js";
10
10
  import { FLOW_TRACKS, HARNESS_IDS } from "./types.js";
11
- import { archiveRun } from "./runs.js";
11
+ import { ARCHIVE_DISPOSITIONS, archiveRun } from "./runs.js";
12
12
  import { CCLAW_VERSION, RUNTIME_ROOT } from "./constants.js";
13
- import { createDefaultConfig } from "./config.js";
13
+ import { createDefaultConfig, readConfig } from "./config.js";
14
14
  import { detectHarnesses } from "./init-detect.js";
15
15
  import { HARNESS_ADAPTERS } from "./harness-adapters.js";
16
16
  import { classifyCodexHooksFlag, codexConfigPath, patchCodexHooksFlag, readCodexConfig, writeCodexConfig } from "./codex-feature-flag.js";
@@ -38,17 +38,21 @@ Commands:
38
38
  Flags: --harnesses=<list> Comma list of harnesses (claude,cursor,opencode,codex).
39
39
  --no-interactive Skip interactive prompts even on TTY (for CI/scripts).
40
40
  sync Reconcile generated runtime files with the current config.
41
+ Flags: --harnesses=<list> Update configured harnesses before syncing.
42
+ --interactive Pick harnesses from a numbered TTY menu.
41
43
  doctor Check install/runtime wiring and print concrete fixes for failures.
42
44
  Flags: --explain Include docs pointers for every check.
43
45
  --json Emit machine-readable check results.
44
46
  --quiet Show only failing checks.
45
47
  --only=<filter> Limit displayed checks (error,warning,hook:,state:,...).
46
- --reconcile-gates Refresh derived gate status before checking.
48
+ --reconcile-gates Refresh derived gate status before checking; does not repair missing artifacts/tests.
47
49
  upgrade Refresh generated files in .cclaw. Preserves your config.yaml.
48
50
  archive Archive the active run and reset flow state for the next run.
49
51
  Flags: --name=<slug> Override archive folder suffix.
50
52
  --skip-retro Skip retro gate only when runtime allows it.
51
53
  --retro-reason=<txt> Required rationale with --skip-retro.
54
+ --disposition=<completed|cancelled|abandoned>
55
+ --reason=<txt> Required for cancelled/abandoned archives.
52
56
  uninstall Remove .cclaw runtime and the generated harness shim files.
53
57
 
54
58
  Global flags:
@@ -58,17 +62,18 @@ Global flags:
58
62
  Examples:
59
63
  npx cclaw-cli
60
64
  npx cclaw-cli init --harnesses=claude,cursor --no-interactive
61
- npx cclaw-cli sync
65
+ npx cclaw-cli sync --interactive
62
66
  npx cclaw-cli archive --name=my-run
67
+ npx cclaw-cli archive --disposition=cancelled --reason="deprioritized"
63
68
  npx cclaw-cli upgrade
64
69
 
65
70
  Happy-path work happens inside your harness via /cc, /cc-next,
66
- /cc-ideate, and /cc-view. Doctor is an operator/support surface:
71
+ /cc-ideate, /cc-view, /cc-finish, and /cc-cancel. Doctor is an operator/support surface:
67
72
  it verifies install/runtime wiring, but a real harness smoke test is
68
73
  still needed to prove provider auth and model execution.
69
74
 
70
75
  Docs: https://github.com/zuevrs/cclaw
71
- Local: docs/config.md and docs/harnesses.md
76
+ Local: README.md and generated .cclaw/skills/*.md
72
77
  Issues: https://github.com/zuevrs/cclaw/issues
73
78
  `;
74
79
  }
@@ -77,6 +82,9 @@ function parseHarnesses(raw) {
77
82
  .split(",")
78
83
  .map((item) => item.trim())
79
84
  .filter(Boolean);
85
+ if (requested.length === 0) {
86
+ throw new Error("Select at least one harness.");
87
+ }
80
88
  const invalid = requested.filter((item) => !HARNESS_IDS.includes(item));
81
89
  if (invalid.length > 0) {
82
90
  throw new Error(`Unknown harnesses: ${invalid.join(", ")}`);
@@ -90,6 +98,13 @@ function parseTrack(raw) {
90
98
  }
91
99
  return trimmed;
92
100
  }
101
+ function parseArchiveDisposition(raw) {
102
+ const trimmed = raw.trim();
103
+ if (!ARCHIVE_DISPOSITIONS.includes(trimmed)) {
104
+ throw new Error(`Unknown archive disposition: ${trimmed}. Supported: ${ARCHIVE_DISPOSITIONS.join(", ")}`);
105
+ }
106
+ return trimmed;
107
+ }
93
108
  function isInitPromptAllowed(ctx) {
94
109
  return Boolean(process.stdin.isTTY && ctx.stdout.isTTY);
95
110
  }
@@ -156,39 +171,95 @@ function buildInitSurfacePreview(harnesses) {
156
171
  }
157
172
  return lines;
158
173
  }
159
- async function promptInitConfig(defaults, ctx) {
174
+ function harnessLabel(harness) {
175
+ const adapter = HARNESS_ADAPTERS[harness];
176
+ const tier = adapter ? `${adapter.reality.declaredSupport}, ${adapter.capabilities.hookSurface} hooks` : "supported";
177
+ return `${harness} (${tier})`;
178
+ }
179
+ function selectedHarnessPreview(harnesses) {
180
+ return harnesses.length > 0 ? harnesses.join(", ") : "none";
181
+ }
182
+ export function parseHarnessSelectionAnswer(raw) {
183
+ const answer = raw.trim().toLowerCase();
184
+ if (answer.length === 0)
185
+ return { kind: "accept" };
186
+ if (answer === "all")
187
+ return { kind: "all" };
188
+ if (answer === "none") {
189
+ return { kind: "invalid", message: "Zero harnesses is not supported. Select at least one harness." };
190
+ }
191
+ const parts = answer.split(",").map((part) => part.trim()).filter(Boolean);
192
+ const indexes = parts.map((part) => Number.parseInt(part, 10));
193
+ if (indexes.some((value) => !Number.isInteger(value) || value < 1 || value > HARNESS_IDS.length)) {
194
+ return { kind: "invalid", message: `Invalid selection. Use numbers 1-${HARNESS_IDS.length}, comma-separated.` };
195
+ }
196
+ return { kind: "toggle", indexes };
197
+ }
198
+ async function promptHarnessSelection(defaults, ctx, label = "Harness selection") {
160
199
  const rl = createInterface({
161
200
  input: process.stdin,
162
201
  output: ctx.stdout
163
202
  });
164
- const pickHarnesses = async (fallback) => {
165
- const fallbackText = fallback.join(",");
203
+ const defaultSet = new Set(defaults.harnesses);
204
+ const selected = new Set(defaults.harnesses.length > 0 ? defaults.harnesses : HARNESS_IDS);
205
+ const detected = new Set(defaults.detectedHarnesses ?? []);
206
+ const current = new Set(defaults.currentHarnesses ?? []);
207
+ const printMenu = () => {
208
+ ctx.stdout.write(`\n${label}\n`);
209
+ ctx.stdout.write(`Detected: ${selectedHarnessPreview(defaults.detectedHarnesses ?? [])}\n`);
210
+ ctx.stdout.write(`Current: ${selectedHarnessPreview(defaults.currentHarnesses ?? [])}\n`);
211
+ ctx.stdout.write(`Supported harnesses and target paths:\n`);
212
+ HARNESS_IDS.forEach((harness, index) => {
213
+ const adapter = HARNESS_ADAPTERS[harness];
214
+ const markers = [
215
+ detected.has(harness) ? "detected" : "",
216
+ current.has(harness) ? "current" : "",
217
+ defaultSet.has(harness) ? "default" : ""
218
+ ].filter(Boolean).join(", ");
219
+ const checked = selected.has(harness) ? "x" : " ";
220
+ ctx.stdout.write(` ${index + 1}. [${checked}] ${harnessLabel(harness)} -> ${adapter.commandDir}${markers ? ` (${markers})` : ""}\n`);
221
+ });
222
+ ctx.stdout.write("Enter numbers to toggle (for example 1,3), 'all', or press Enter to accept.\n");
223
+ };
224
+ try {
166
225
  while (true) {
167
- const answer = (await rl.question(`\nHarnesses (comma list from ${HARNESS_IDS.join(", ")}) [${fallbackText}]: `)).trim();
168
- if (answer.length === 0) {
169
- return fallback;
170
- }
171
- try {
172
- const parsed = parseHarnesses(answer);
173
- if (parsed.length === 0) {
226
+ printMenu();
227
+ const answer = await rl.question(`Selected [${[...selected].join(",") || "select at least one"}]: `);
228
+ const parsedAnswer = parseHarnessSelectionAnswer(answer);
229
+ if (parsedAnswer.kind === "accept") {
230
+ if (selected.size === 0) {
174
231
  ctx.stdout.write("Select at least one harness.\n");
175
232
  continue;
176
233
  }
177
- return parsed;
234
+ return HARNESS_IDS.filter((harness) => selected.has(harness));
235
+ }
236
+ if (parsedAnswer.kind === "all") {
237
+ HARNESS_IDS.forEach((harness) => selected.add(harness));
238
+ continue;
239
+ }
240
+ if (parsedAnswer.kind === "invalid") {
241
+ ctx.stdout.write(`${parsedAnswer.message}\n`);
242
+ continue;
178
243
  }
179
- catch (err) {
180
- ctx.stdout.write(`${err instanceof Error ? err.message : "Invalid harness list"}\n`);
244
+ for (const index of parsedAnswer.indexes) {
245
+ const harness = HARNESS_IDS[index - 1];
246
+ if (!harness)
247
+ continue;
248
+ if (selected.has(harness))
249
+ selected.delete(harness);
250
+ else
251
+ selected.add(harness);
181
252
  }
182
253
  }
183
- };
184
- try {
185
- const harnesses = await pickHarnesses(defaults.harnesses);
186
- return { harnesses };
187
254
  }
188
255
  finally {
189
256
  rl.close();
190
257
  }
191
258
  }
259
+ async function promptInitConfig(defaults, ctx) {
260
+ const harnesses = await promptHarnessSelection(defaults, ctx, "Initial cclaw harnesses");
261
+ return { harnesses };
262
+ }
192
263
  /**
193
264
  * When Codex is one of the installed harnesses, check the Codex CLI
194
265
  * config file for the `codex_hooks` feature flag. If it is missing or
@@ -293,13 +364,41 @@ async function resolveInitInputs(parsed, ctx) {
293
364
  const defaults = {
294
365
  harnesses: autoHarnesses ?? HARNESS_IDS.slice()
295
366
  };
296
- const prompted = await promptInitConfig(defaults, ctx);
367
+ const prompted = await promptInitConfig({ ...defaults, detectedHarnesses }, ctx);
297
368
  return {
298
369
  track: parsed.track,
299
370
  harnesses: prompted.harnesses,
300
371
  detectedHarnesses
301
372
  };
302
373
  }
374
+ async function resolveSyncInputs(parsed, ctx) {
375
+ const explicitHarnesses = parsed.harnesses;
376
+ if (explicitHarnesses && explicitHarnesses.length > 0) {
377
+ return { harnesses: explicitHarnesses };
378
+ }
379
+ if (parsed.interactive !== true) {
380
+ return {};
381
+ }
382
+ if (!isInitPromptAllowed(ctx)) {
383
+ throw new Error("Interactive sync requires a TTY. Remove --interactive or run in a terminal.");
384
+ }
385
+ let currentHarnesses = [];
386
+ try {
387
+ currentHarnesses = (await readConfig(ctx.cwd)).harnesses;
388
+ }
389
+ catch {
390
+ currentHarnesses = [];
391
+ }
392
+ const detectedHarnesses = await detectHarnesses(ctx.cwd);
393
+ const defaults = detectedHarnesses.length > 0 ? detectedHarnesses : currentHarnesses.length > 0 ? currentHarnesses : HARNESS_IDS.slice();
394
+ return {
395
+ harnesses: await promptHarnessSelection({
396
+ harnesses: defaults,
397
+ detectedHarnesses,
398
+ currentHarnesses
399
+ }, ctx, "Sync harness reconfiguration")
400
+ };
401
+ }
303
402
  function parseDoctorOnly(raw) {
304
403
  return raw
305
404
  .split(",")
@@ -335,24 +434,47 @@ function doctorCountsBySeverity(checks) {
335
434
  }
336
435
  return result;
337
436
  }
437
+ const DOCTOR_ACTION_GROUP_LABELS = {
438
+ sync: "Can fix with cclaw sync",
439
+ "user-action": "Requires user action",
440
+ "stage-work": "Requires stage work",
441
+ informational: "Informational warning"
442
+ };
443
+ function doctorActionGroupOrder(group) {
444
+ return group === "sync" ? 0 : group === "user-action" ? 1 : group === "stage-work" ? 2 : 3;
445
+ }
338
446
  function printDoctorText(ctx, checks, options) {
339
447
  const orderedSeverities = ["error", "warning", "info"];
340
448
  const view = options.quiet ? checks.filter((check) => !check.ok) : checks;
341
- for (const severity of orderedSeverities) {
342
- const inBucket = view.filter((check) => check.severity === severity);
343
- if (inBucket.length === 0)
344
- continue;
345
- ctx.stdout.write(`\n[${severity.toUpperCase()}]\n`);
346
- for (const check of inBucket) {
347
- const status = check.ok ? "PASS" : "FAIL";
348
- ctx.stdout.write(`${status} ${check.name} :: ${check.summary}\n`);
349
- if (!options.quiet) {
350
- ctx.stdout.write(` details: ${check.details}\n`);
351
- }
352
- if (!check.ok || options.explain) {
353
- ctx.stdout.write(` fix: ${check.fix}\n`);
354
- if (check.docRef) {
355
- ctx.stdout.write(` docs: ${check.docRef}\n`);
449
+ const actionGroups = [...new Set(view.map((check) => check.actionGroup))]
450
+ .sort((left, right) => doctorActionGroupOrder(left) - doctorActionGroupOrder(right));
451
+ for (const actionGroup of actionGroups) {
452
+ const groupChecks = view.filter((check) => check.actionGroup === actionGroup);
453
+ const failingInGroup = groupChecks.filter((check) => !check.ok).length;
454
+ ctx.stdout.write(`
455
+ [${DOCTOR_ACTION_GROUP_LABELS[actionGroup]}] ${failingInGroup}/${groupChecks.length} failing
456
+ `);
457
+ for (const severity of orderedSeverities) {
458
+ const inBucket = groupChecks.filter((check) => check.severity === severity);
459
+ if (inBucket.length === 0)
460
+ continue;
461
+ ctx.stdout.write(` ${severity.toUpperCase()}
462
+ `);
463
+ for (const check of inBucket) {
464
+ const status = check.ok ? "PASS" : "FAIL";
465
+ ctx.stdout.write(` ${status} ${check.name} :: ${check.summary}
466
+ `);
467
+ if (!options.quiet) {
468
+ ctx.stdout.write(` details: ${check.details}
469
+ `);
470
+ }
471
+ if (!check.ok || options.explain) {
472
+ ctx.stdout.write(` next action: ${check.fix}
473
+ `);
474
+ if (check.docRef) {
475
+ ctx.stdout.write(` reference: ${check.docRef}
476
+ `);
477
+ }
356
478
  }
357
479
  }
358
480
  }
@@ -392,13 +514,13 @@ function parseArgs(argv) {
392
514
  }
393
515
  const flags = rest;
394
516
  const isAllowedForCommand = (flag) => {
395
- if (parsed.command === "init") {
517
+ if (parsed.command === "init" || parsed.command === "sync") {
396
518
  return flag.startsWith("--harnesses=") ||
397
- flag.startsWith("--track=") ||
398
- flag.startsWith("--profile=") ||
519
+ (parsed.command === "init" && flag.startsWith("--track=")) ||
520
+ (parsed.command === "init" && flag.startsWith("--profile=")) ||
399
521
  flag === "--interactive" ||
400
522
  flag === "--no-interactive" ||
401
- flag === "--dry-run";
523
+ (parsed.command === "init" && flag === "--dry-run");
402
524
  }
403
525
  if (parsed.command === "doctor") {
404
526
  return flag === "--reconcile-gates" ||
@@ -410,7 +532,9 @@ function parseArgs(argv) {
410
532
  if (parsed.command === "archive") {
411
533
  return flag.startsWith("--name=") ||
412
534
  flag === "--skip-retro" ||
413
- flag.startsWith("--retro-reason=");
535
+ flag.startsWith("--retro-reason=") ||
536
+ flag.startsWith("--disposition=") ||
537
+ flag.startsWith("--reason=");
414
538
  }
415
539
  return false;
416
540
  };
@@ -473,6 +597,14 @@ function parseArgs(argv) {
473
597
  parsed.archiveSkipRetroReason = flag.replace("--retro-reason=", "").trim();
474
598
  continue;
475
599
  }
600
+ if (flag.startsWith("--disposition=")) {
601
+ parsed.archiveDisposition = parseArchiveDisposition(flag.replace("--disposition=", ""));
602
+ continue;
603
+ }
604
+ if (flag.startsWith("--reason=")) {
605
+ parsed.archiveDispositionReason = flag.replace("--reason=", "").trim();
606
+ continue;
607
+ }
476
608
  }
477
609
  return parsed;
478
610
  }
@@ -532,8 +664,10 @@ async function runCommand(parsed, ctx) {
532
664
  return 0;
533
665
  }
534
666
  if (command === "sync") {
535
- await syncCclaw(ctx.cwd);
536
- info(ctx, "Synchronized harness shims from current .cclaw config");
667
+ const resolved = await resolveSyncInputs(parsed, ctx);
668
+ await syncCclaw(ctx.cwd, { harnesses: resolved.harnesses });
669
+ const harnessNote = resolved.harnesses ? ` (${resolved.harnesses.join(", ")})` : "";
670
+ info(ctx, `Synchronized harness shims from current .cclaw config${harnessNote}`);
537
671
  return 0;
538
672
  }
539
673
  if (command === "doctor") {
@@ -571,12 +705,14 @@ async function runCommand(parsed, ctx) {
571
705
  if (command === "archive") {
572
706
  const archived = await archiveRun(ctx.cwd, parsed.archiveName, {
573
707
  skipRetro: parsed.archiveSkipRetro === true,
574
- skipRetroReason: parsed.archiveSkipRetroReason
708
+ skipRetroReason: parsed.archiveSkipRetroReason,
709
+ disposition: parsed.archiveDisposition,
710
+ dispositionReason: parsed.archiveDispositionReason
575
711
  });
576
712
  const snapshotSummary = archived.snapshottedStateFiles.length > 0
577
713
  ? ` Snapshotted ${archived.snapshottedStateFiles.length} state file(s) under ${archived.archivePath}/state and wrote archive-manifest.json.`
578
714
  : "";
579
- info(ctx, `Archived active artifacts to ${archived.archivePath}. Flow state reset to brainstorm.${snapshotSummary}`);
715
+ info(ctx, `Archived active artifacts to ${archived.archivePath} (${archived.disposition}). Flow state reset to brainstorm.${snapshotSummary}`);
580
716
  const k = archived.knowledge;
581
717
  if (k.overThreshold) {
582
718
  info(ctx, `Knowledge curation recommended: ${k.knowledgePath} now has ${k.activeEntryCount} active entries (soft threshold ${k.softThreshold}). Ask your harness to curate cclaw knowledge and plan a soft-archive of stale/duplicate entries to ${RUNTIME_ROOT}/knowledge.archive.jsonl.`);
@@ -624,4 +760,4 @@ function isDirectExecution() {
624
760
  if (isDirectExecution()) {
625
761
  void main();
626
762
  }
627
- export { parseArgs, parseHarnesses, parseTrack };
763
+ export { parseArgs, parseArchiveDisposition, parseHarnesses, parseTrack };
@@ -11,7 +11,7 @@
11
11
  * ```
12
12
  *
13
13
  * in `$CODEX_HOME/config.toml` (default: `~/.codex/config.toml`).
14
- * cclaw's `init --codex` prompts the user to flip this flag for them;
14
+ * cclaw init/sync can prompt the user to flip this flag for them; `cclaw doctor --explain` reports the concrete repair when it is missing;
15
15
  * this module owns the detection / mutation code so the prompt logic in
16
16
  * `cli.ts` stays small and testable.
17
17
  *
@@ -11,7 +11,7 @@
11
11
  * ```
12
12
  *
13
13
  * in `$CODEX_HOME/config.toml` (default: `~/.codex/config.toml`).
14
- * cclaw's `init --codex` prompts the user to flip this flag for them;
14
+ * cclaw init/sync can prompt the user to flip this flag for them; `cclaw doctor --explain` reports the concrete repair when it is missing;
15
15
  * this module owns the detection / mutation code so the prompt logic in
16
16
  * `cli.ts` stays small and testable.
17
17
  *
package/dist/config.js CHANGED
@@ -245,6 +245,9 @@ export async function readConfig(projectRoot, options = {}) {
245
245
  throw configValidationError(fullPath, `unknown harness id(s): ${formatted}`);
246
246
  }
247
247
  const validatedHarnesses = configuredHarnesses;
248
+ if (hasHarnessesField && validatedHarnesses.length === 0) {
249
+ throw configValidationError(fullPath, `"harnesses" must include at least one harness`);
250
+ }
248
251
  const harnesses = hasHarnessesField
249
252
  ? [...new Set(validatedHarnesses)]
250
253
  : DEFAULT_HARNESSES;
@@ -0,0 +1,2 @@
1
+ export declare function cancelCommandContract(): string;
2
+ export declare function cancelCommandSkillMarkdown(): string;
@@ -0,0 +1,25 @@
1
+ export function cancelCommandContract() {
2
+ return `# /cc-cancel command contract
3
+
4
+ Use this command when the user wants to stop the active run without claiming completion.
5
+
6
+ ## Protocol
7
+
8
+ 1. Ask for a concise cancellation reason if the user has not already provided one.
9
+ 2. Run \`cclaw archive --disposition=cancelled --reason=<reason>\` from the project root. Use \`--disposition=abandoned\` only when the user explicitly frames the run as abandoned rather than cancelled.
10
+ 3. Report the archive path and reset run id. Make clear that the archived run is not a completed ship.
11
+
12
+ Cancelled and abandoned archives are allowed from any stage, but they require a required reason so future readers know why the run ended.
13
+ `;
14
+ }
15
+ export function cancelCommandSkillMarkdown() {
16
+ return `---
17
+ name: flow-cancel
18
+ description: Cancel or abandon the active cclaw run with a required reason. Use when the user types /cc-cancel or asks to cancel, abandon, stop, discard, or reset an unfinished run.
19
+ ---
20
+
21
+ # Cancel cclaw Run
22
+
23
+ Load and follow \`.cclaw/commands/cancel.md\`. This is a non-completion path: require a reason and archive with cancelled or abandoned disposition.
24
+ `;
25
+ }
@@ -0,0 +1,2 @@
1
+ export declare function finishCommandContract(): string;
2
+ export declare function finishCommandSkillMarkdown(): string;
@@ -0,0 +1,26 @@
1
+ export function finishCommandContract() {
2
+ return `# /cc-finish command contract
3
+
4
+ Use this command when the user says the active run is complete and wants to close it out.
5
+
6
+ ## Protocol
7
+
8
+ 1. Read \`.cclaw/state/flow-state.json\` and \`.cclaw/commands/next.md\`.
9
+ 2. Confirm ship closeout is \`ready_to_archive\`. If not, route to \`/cc-next\` until retro and compound closeout are complete or explicitly skipped there.
10
+ 3. Run \`cclaw archive --disposition=completed\` from the project root.
11
+ 4. Report the archive path, reset run id, and any knowledge curation hint printed by the CLI.
12
+
13
+ Completed archives keep strict closeout gates: do not bypass retro or compound review from this command.
14
+ `;
15
+ }
16
+ export function finishCommandSkillMarkdown() {
17
+ return `---
18
+ name: flow-finish
19
+ description: Finish a completed cclaw run by archiving with completed disposition. Use when the user types /cc-finish or asks to finish, close, complete, or archive a successful run.
20
+ ---
21
+
22
+ # Finish cclaw Run
23
+
24
+ Load and follow \`.cclaw/commands/finish.md\`. This is the successful closeout path and must preserve the normal ship closeout gates before archive.
25
+ `;
26
+ }
@@ -35,7 +35,7 @@ function perHarnessRecipeMarkdown() {
35
35
  const examples = recipes
36
36
  .map((recipe) => `**${recipe.harnessId}**:\n\n` + recipe.lifecycleCommands.map((cmd) => ` ${cmd}`).join("\n"))
37
37
  .join("\n\n");
38
- return `\n\n## Per-Harness Lifecycle Recipe\n\n| Harness | Surface | Agent definition path | fulfillmentMode | Lifecycle |\n|---|---|---|---|---|\n${rows}\n\nNeutral placeholder tokens only: \`<agent-name>\`, \`<stage>\`, \`<run-id>\`, \`<span-id>\`, \`<dispatch-id>\`, \`<agent-def-path>\`, \`<iso-ts>\`, \`<artifact-anchor>\`. See \`docs/quality-gates.md\` for stage-by-stage gate mapping.\n\nThe four shipped harnesses (\`claude\`, \`cursor\`, \`opencode\`, \`codex\`) each ship with a canonical primary surface in the table above. The remaining enum values \`generic-task\`, \`role-switch\`, and \`manual\` are documented in the dispatch-surface table below and are available to any harness as fallback paths when the primary surface is unavailable.\n\n${examples}\n\n${dispatchSurfaceTableMarkdown()}\n\n### Legacy ledger upgrade\n\nPre-v3 ledger entries that lack a recorded \`dispatchSurface\` are tagged \`fulfillmentMode: "legacy-inferred"\` on read. Stage-complete blocks completion until those rows are re-recorded with the v3 helper:\n\n node .cclaw/hooks/delegation-record.mjs \\\n --rerecord \\\n --span-id=<span-id> \\\n --dispatch-id=<dispatch-id> \\\n --dispatch-surface=<surface> \\\n --agent-definition-path=<agent-def-path> \\\n --ack-ts=<iso-ts> \\\n --completed-ts=<iso-ts> \\\n --json\n\n\`--dispatch-surface\` must be one of the values listed in the dispatch-surface table above (the enum is generated verbatim from \`src/delegation.ts::DELEGATION_DISPATCH_SURFACES\`). Surfaces must align with the allowed agent-definition-path prefixes shown alongside each surface; \`role-switch\` and \`manual\` accept any path. The deprecated \`task\` surface is rejected.\n\n`;
38
+ return `\n\n## Per-Harness Lifecycle Recipe\n\n| Harness | Surface | Agent definition path | fulfillmentMode | Lifecycle |\n|---|---|---|---|---|\n${rows}\n\nNeutral placeholder tokens only: \`<agent-name>\`, \`<stage>\`, \`<run-id>\`, \`<span-id>\`, \`<dispatch-id>\`, \`<agent-def-path>\`, \`<iso-ts>\`, \`<artifact-anchor>\`. See \`docs/quality-gates.md\` for stage-by-stage gate mapping.\n\nThe four shipped harnesses (\`claude\`, \`cursor\`, \`opencode\`, \`codex\`) each ship with a canonical primary surface in the table above. Repair hints: \`cclaw sync\` safely regenerates shims/plugins/agents; Codex also needs \`[features] codex_hooks = true\`; OpenCode needs \`opencode.json(.c)\` plugin registration; role-switch completions require evidenceRefs. The remaining enum values \`generic-task\`, \`role-switch\`, and \`manual\` are documented in the dispatch-surface table below and are available to any harness as fallback paths when the primary surface is unavailable.\n\n${examples}\n\n${dispatchSurfaceTableMarkdown()}\n\n### Legacy ledger upgrade\n\nPre-v3 ledger entries that lack a recorded \`dispatchSurface\` are tagged \`fulfillmentMode: "legacy-inferred"\` on read. Stage-complete blocks completion until those rows are re-recorded with the v3 helper:\n\n node .cclaw/hooks/delegation-record.mjs \\\n --rerecord \\\n --span-id=<span-id> \\\n --dispatch-id=<dispatch-id> \\\n --dispatch-surface=<surface> \\\n --agent-definition-path=<agent-def-path> \\\n --ack-ts=<iso-ts> \\\n --completed-ts=<iso-ts> \\\n --json\n\n\`--dispatch-surface\` must be one of the values listed in the dispatch-surface table above (the enum is generated verbatim from \`src/delegation.ts::DELEGATION_DISPATCH_SURFACES\`). Surfaces must align with the allowed agent-definition-path prefixes shown alongside each surface; \`role-switch\` and \`manual\` accept any path. The deprecated \`task\` surface is rejected.\n\n`;
39
39
  }
40
40
  export function harnessIntegrationDocMarkdown() {
41
41
  const head = "# Harness Integration Matrix\n\nGenerated from `src/harness-adapters.ts` capabilities and hook event mappings.";
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { RUNTIME_ROOT } from "../constants.js";
5
5
  import { DELEGATION_DISPATCH_SURFACES, DELEGATION_DISPATCH_SURFACE_PATH_PREFIXES } from "../delegation.js";
6
- function resolveCliEntrypointForGeneratedHook() {
6
+ function resolveCliRuntimeForGeneratedHook() {
7
7
  const here = fileURLToPath(import.meta.url);
8
8
  const candidates = [
9
9
  path.resolve(path.dirname(here), "..", "cli.js"),
@@ -13,12 +13,21 @@ function resolveCliEntrypointForGeneratedHook() {
13
13
  // Synchronous probe runs only during cclaw-cli init/sync generation.
14
14
  // The generated hook receives a concrete path and does not need a global bin.
15
15
  if (existsSync(candidate))
16
- return candidate;
16
+ return { entrypoint: candidate, argsPrefix: [] };
17
17
  }
18
- return null;
18
+ // Vitest exercises init/sync directly from src/ without a compiled dist/.
19
+ // Route that dev-only shape through vite-node so hooks still prove a local runtime.
20
+ if (process.env.VITEST === "true") {
21
+ const sourceCli = path.resolve(path.dirname(here), "..", "cli.ts");
22
+ const viteNode = path.resolve(path.dirname(here), "..", "..", "node_modules", "vite-node", "vite-node.mjs");
23
+ if (existsSync(sourceCli) && existsSync(viteNode)) {
24
+ return { entrypoint: viteNode, argsPrefix: ["--script", sourceCli] };
25
+ }
26
+ }
27
+ return { entrypoint: null, argsPrefix: [] };
19
28
  }
20
29
  function internalHelperScript(helperName, internalSubcommand, usage) {
21
- const cliEntrypoint = resolveCliEntrypointForGeneratedHook();
30
+ const cliRuntime = resolveCliRuntimeForGeneratedHook();
22
31
  return `#!/usr/bin/env node
23
32
  import fs from "node:fs/promises";
24
33
  import path from "node:path";
@@ -26,7 +35,8 @@ import process from "node:process";
26
35
  import { spawn } from "node:child_process";
27
36
 
28
37
  const RUNTIME_ROOT = ${JSON.stringify(RUNTIME_ROOT)};
29
- const CCLAW_CLI_ENTRYPOINT = ${JSON.stringify(cliEntrypoint)};
38
+ const CCLAW_CLI_ENTRYPOINT = ${JSON.stringify(cliRuntime.entrypoint)};
39
+ const CCLAW_CLI_ARGS_PREFIX = ${JSON.stringify(cliRuntime.argsPrefix)};
30
40
  const HELPER_NAME = ${JSON.stringify(helperName)};
31
41
  const INTERNAL_SUBCOMMAND = ${JSON.stringify(internalSubcommand)};
32
42
  const USAGE = ${JSON.stringify(usage)};
@@ -77,6 +87,7 @@ async function main() {
77
87
  }
78
88
 
79
89
  const cliEntrypoint = process.env.CCLAW_CLI_JS || CCLAW_CLI_ENTRYPOINT;
90
+ const cliArgsPrefix = process.env.CCLAW_CLI_JS ? [] : CCLAW_CLI_ARGS_PREFIX;
80
91
  if (!cliEntrypoint || cliEntrypoint.trim().length === 0) {
81
92
  process.stderr.write(
82
93
  "[cclaw] " + HELPER_NAME + ": local Node runtime entrypoint is missing. Re-run npx cclaw-cli sync, or set CCLAW_CLI_JS=/absolute/path/to/dist/cli.js for this session.\\n"
@@ -88,6 +99,11 @@ async function main() {
88
99
  try {
89
100
  const stat = await fs.stat(cliEntrypoint);
90
101
  if (!stat.isFile()) throw new Error("not-file");
102
+ for (const argPath of cliArgsPrefix) {
103
+ if (typeof argPath !== "string" || argPath.startsWith("-")) continue;
104
+ const argStat = await fs.stat(argPath);
105
+ if (!argStat.isFile()) throw new Error("arg-not-file");
106
+ }
91
107
  } catch {
92
108
  process.stderr.write(
93
109
  "[cclaw] " + HELPER_NAME + ": local Node runtime entrypoint not found at " + cliEntrypoint + ". Re-run npx cclaw-cli sync, or set CCLAW_CLI_JS=/absolute/path/to/dist/cli.js for this session.\\n"
@@ -96,7 +112,7 @@ async function main() {
96
112
  return;
97
113
  }
98
114
 
99
- const child = spawn(process.execPath, [cliEntrypoint, "internal", INTERNAL_SUBCOMMAND, ...flags], {
115
+ const child = spawn(process.execPath, [cliEntrypoint, ...cliArgsPrefix, "internal", INTERNAL_SUBCOMMAND, ...flags], {
100
116
  cwd: root,
101
117
  env: process.env,
102
118
  stdio: "inherit"
@@ -140,7 +156,7 @@ export function startFlowScript() {
140
156
  return internalHelperScript("start-flow", "start-flow", "Usage: node " + RUNTIME_ROOT + "/hooks/start-flow.mjs --track=<standard|medium|quick> [--class=...] [--prompt=...] [--stack=...] [--reason=...] [--reclassify] [--force-reset]");
141
157
  }
142
158
  export function stageCompleteScript() {
143
- const cliEntrypoint = resolveCliEntrypointForGeneratedHook();
159
+ const cliRuntime = resolveCliRuntimeForGeneratedHook();
144
160
  return `#!/usr/bin/env node
145
161
  import fs from "node:fs/promises";
146
162
  import path from "node:path";
@@ -148,7 +164,8 @@ import process from "node:process";
148
164
  import { spawn } from "node:child_process";
149
165
 
150
166
  const RUNTIME_ROOT = ${JSON.stringify(RUNTIME_ROOT)};
151
- const CCLAW_CLI_ENTRYPOINT = ${JSON.stringify(cliEntrypoint)};
167
+ const CCLAW_CLI_ENTRYPOINT = ${JSON.stringify(cliRuntime.entrypoint)};
168
+ const CCLAW_CLI_ARGS_PREFIX = ${JSON.stringify(cliRuntime.argsPrefix)};
152
169
 
153
170
  async function detectRoot() {
154
171
  const candidates = [
@@ -201,6 +218,7 @@ async function main() {
201
218
  }
202
219
 
203
220
  const cliEntrypoint = process.env.CCLAW_CLI_JS || CCLAW_CLI_ENTRYPOINT;
221
+ const cliArgsPrefix = process.env.CCLAW_CLI_JS ? [] : CCLAW_CLI_ARGS_PREFIX;
204
222
  if (!cliEntrypoint || cliEntrypoint.trim().length === 0) {
205
223
  process.stderr.write(
206
224
  "[cclaw] stage-complete: local Node runtime entrypoint is missing. Re-run npx cclaw-cli sync, or set CCLAW_CLI_JS=/absolute/path/to/dist/cli.js for this session.\\n"
@@ -212,6 +230,11 @@ async function main() {
212
230
  try {
213
231
  const stat = await fs.stat(cliEntrypoint);
214
232
  if (!stat.isFile()) throw new Error("not-file");
233
+ for (const argPath of cliArgsPrefix) {
234
+ if (typeof argPath !== "string" || argPath.startsWith("-")) continue;
235
+ const argStat = await fs.stat(argPath);
236
+ if (!argStat.isFile()) throw new Error("arg-not-file");
237
+ }
215
238
  } catch {
216
239
  process.stderr.write(
217
240
  "[cclaw] stage-complete: local Node runtime entrypoint not found at " + cliEntrypoint + ". Re-run npx cclaw-cli sync, or set CCLAW_CLI_JS=/absolute/path/to/dist/cli.js for this session.\\n"
@@ -222,7 +245,7 @@ async function main() {
222
245
 
223
246
  const child = spawn(
224
247
  process.execPath,
225
- [cliEntrypoint, "internal", "advance-stage", stage, ...flags],
248
+ [cliEntrypoint, ...cliArgsPrefix, "internal", "advance-stage", stage, ...flags],
226
249
  {
227
250
  cwd: root,
228
251
  env: process.env,