cclaw-cli 0.5.16 → 0.6.0

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.
@@ -1,7 +1,28 @@
1
1
  import { COMMAND_FILE_ORDER } from "./constants.js";
2
2
  import { buildTransitionRules, orderedStageSchemas, stageGateIds } from "./content/stage-schema.js";
3
+ import { FLOW_STAGES, FLOW_TRACKS, TRACK_STAGES } from "./types.js";
3
4
  export const TRANSITION_RULES = buildTransitionRules();
4
- export function createInitialFlowState(activeRunId = "active") {
5
+ export function isFlowTrack(value) {
6
+ return typeof value === "string" && FLOW_TRACKS.includes(value);
7
+ }
8
+ export function trackStages(track) {
9
+ return [...TRACK_STAGES[track]];
10
+ }
11
+ export function skippedStagesForTrack(track) {
12
+ const inTrack = new Set(TRACK_STAGES[track]);
13
+ return FLOW_STAGES.filter((stage) => !inTrack.has(stage));
14
+ }
15
+ export function firstStageForTrack(track) {
16
+ const stages = TRACK_STAGES[track];
17
+ return stages[0] ?? "brainstorm";
18
+ }
19
+ export function createInitialFlowState(activeRunIdOrOptions = "active", maybeTrack) {
20
+ const options = typeof activeRunIdOrOptions === "string"
21
+ ? { activeRunId: activeRunIdOrOptions, track: maybeTrack }
22
+ : activeRunIdOrOptions;
23
+ const activeRunId = options.activeRunId ?? "active";
24
+ const track = options.track ?? "standard";
25
+ const skippedStages = skippedStagesForTrack(track);
5
26
  const stageGateCatalog = {};
6
27
  for (const schema of orderedStageSchemas()) {
7
28
  stageGateCatalog[schema.stage] = {
@@ -12,10 +33,12 @@ export function createInitialFlowState(activeRunId = "active") {
12
33
  }
13
34
  return {
14
35
  activeRunId,
15
- currentStage: "brainstorm",
36
+ currentStage: firstStageForTrack(track),
16
37
  completedStages: [],
17
38
  guardEvidence: {},
18
- stageGateCatalog
39
+ stageGateCatalog,
40
+ track,
41
+ skippedStages
19
42
  };
20
43
  }
21
44
  export function canTransition(from, to) {
@@ -25,17 +48,33 @@ export function getTransitionGuards(from, to) {
25
48
  const match = TRANSITION_RULES.find((rule) => rule.from === from && rule.to === to);
26
49
  return match ? [...match.guards] : [];
27
50
  }
28
- export function nextStage(stage) {
29
- const index = COMMAND_FILE_ORDER.indexOf(stage);
30
- if (index < 0 || index === COMMAND_FILE_ORDER.length - 1) {
51
+ export function nextStage(stage, track = "standard") {
52
+ const ordered = TRACK_STAGES[track];
53
+ const index = ordered.indexOf(stage);
54
+ if (index < 0) {
55
+ const fallback = COMMAND_FILE_ORDER.indexOf(stage);
56
+ if (fallback < 0 || fallback === COMMAND_FILE_ORDER.length - 1) {
57
+ return null;
58
+ }
59
+ return COMMAND_FILE_ORDER[fallback + 1];
60
+ }
61
+ if (index === ordered.length - 1) {
31
62
  return null;
32
63
  }
33
- return COMMAND_FILE_ORDER[index + 1];
64
+ return ordered[index + 1];
34
65
  }
35
- export function previousStage(stage) {
36
- const index = COMMAND_FILE_ORDER.indexOf(stage);
37
- if (index <= 0) {
66
+ export function previousStage(stage, track = "standard") {
67
+ const ordered = TRACK_STAGES[track];
68
+ const index = ordered.indexOf(stage);
69
+ if (index === 0) {
38
70
  return null;
39
71
  }
40
- return COMMAND_FILE_ORDER[index - 1];
72
+ if (index < 0) {
73
+ const fallback = COMMAND_FILE_ORDER.indexOf(stage);
74
+ if (fallback <= 0) {
75
+ return null;
76
+ }
77
+ return COMMAND_FILE_ORDER[fallback - 1];
78
+ }
79
+ return ordered[index - 1];
41
80
  }
@@ -7,8 +7,22 @@ export interface GateEvidenceCheckResult {
7
7
  requiredCount: number;
8
8
  passedCount: number;
9
9
  blockedCount: number;
10
+ /** True only when every required gate for the stage is in `passed` and none are `blocked`. */
11
+ complete: boolean;
12
+ /** Required gate ids that are neither passed nor blocked. */
13
+ missingRequired: string[];
14
+ }
15
+ export interface CompletedStagesClosureResult {
16
+ ok: boolean;
17
+ issues: string[];
18
+ openStages: Array<{
19
+ stage: FlowStage;
20
+ missingRequired: string[];
21
+ blocked: string[];
22
+ }>;
10
23
  }
11
24
  export declare function verifyCurrentStageGateEvidence(projectRoot: string, flowState: FlowState): Promise<GateEvidenceCheckResult>;
25
+ export declare function verifyCompletedStagesGateClosure(flowState: FlowState): CompletedStagesClosureResult;
12
26
  export interface GateReconciliationResult {
13
27
  stage: FlowStage;
14
28
  changed: boolean;
@@ -1,6 +1,29 @@
1
- import { lintArtifact, validateReviewArmy } from "./artifact-linter.js";
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { checkReviewVerdictConsistency, lintArtifact, validateReviewArmy } from "./artifact-linter.js";
4
+ import { RUNTIME_ROOT } from "./constants.js";
2
5
  import { stageSchema } from "./content/stage-schema.js";
6
+ import { exists } from "./fs-utils.js";
3
7
  import { readFlowState, writeFlowState } from "./runs.js";
8
+ async function currentStageArtifactExists(projectRoot, stage) {
9
+ const artifactFile = stageSchema(stage).artifactFile;
10
+ const candidates = [
11
+ path.join(projectRoot, RUNTIME_ROOT, "artifacts", artifactFile),
12
+ path.join(projectRoot, artifactFile)
13
+ ];
14
+ for (const candidate of candidates) {
15
+ if (await exists(candidate))
16
+ return true;
17
+ }
18
+ // Artifact-linter also accepts the file under current working directory fallback; stat once more.
19
+ try {
20
+ await fs.access(path.join(projectRoot, artifactFile));
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
4
27
  function unique(values) {
5
28
  return [...new Set(values)];
6
29
  }
@@ -44,7 +67,8 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
44
67
  issues.push(`blocked gate "${gateId}" is not defined for stage "${stage}".`);
45
68
  }
46
69
  }
47
- const shouldValidateArtifact = catalog.passed.length > 0 || flowState.completedStages.includes(stage);
70
+ const artifactPresent = await currentStageArtifactExists(projectRoot, stage);
71
+ const shouldValidateArtifact = artifactPresent || catalog.passed.length > 0 || flowState.completedStages.includes(stage);
48
72
  if (shouldValidateArtifact) {
49
73
  const lint = await lintArtifact(projectRoot, stage);
50
74
  if (!lint.passed) {
@@ -60,6 +84,21 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
60
84
  if (!reviewArmy.valid) {
61
85
  issues.push(`review-army validation failed: ${reviewArmy.errors.join("; ")}`);
62
86
  }
87
+ const verdictConsistency = await checkReviewVerdictConsistency(projectRoot);
88
+ if (!verdictConsistency.ok) {
89
+ issues.push(`review verdict inconsistency: ${verdictConsistency.errors.join("; ")}`);
90
+ }
91
+ }
92
+ }
93
+ const passedSet = new Set(catalog.passed);
94
+ const missingRequired = required.filter((gateId) => !passedSet.has(gateId));
95
+ const complete = missingRequired.length === 0 && catalog.blocked.length === 0;
96
+ if (flowState.completedStages.includes(stage) && !complete) {
97
+ if (missingRequired.length > 0) {
98
+ issues.push(`stage "${stage}" is marked completed but required gates are not passed: ${missingRequired.join(", ")}.`);
99
+ }
100
+ if (catalog.blocked.length > 0) {
101
+ issues.push(`stage "${stage}" is marked completed but has blocked gates: ${catalog.blocked.join(", ")}.`);
63
102
  }
64
103
  }
65
104
  return {
@@ -68,9 +107,32 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
68
107
  issues,
69
108
  requiredCount: required.length,
70
109
  passedCount: catalog.passed.length,
71
- blockedCount: catalog.blocked.length
110
+ blockedCount: catalog.blocked.length,
111
+ complete,
112
+ missingRequired
72
113
  };
73
114
  }
115
+ export function verifyCompletedStagesGateClosure(flowState) {
116
+ const issues = [];
117
+ const openStages = [];
118
+ for (const stage of flowState.completedStages) {
119
+ const schema = stageSchema(stage);
120
+ const catalog = flowState.stageGateCatalog[stage];
121
+ const required = schema.requiredGates.map((gate) => gate.id);
122
+ const passedSet = new Set(catalog.passed);
123
+ const missingRequired = required.filter((gateId) => !passedSet.has(gateId));
124
+ if (missingRequired.length > 0 || catalog.blocked.length > 0) {
125
+ openStages.push({ stage, missingRequired, blocked: [...catalog.blocked] });
126
+ if (missingRequired.length > 0) {
127
+ issues.push(`completed stage "${stage}" has unpassed required gates: ${missingRequired.join(", ")}.`);
128
+ }
129
+ if (catalog.blocked.length > 0) {
130
+ issues.push(`completed stage "${stage}" still has blocked gates: ${catalog.blocked.join(", ")}.`);
131
+ }
132
+ }
133
+ }
134
+ return { ok: openStages.length === 0, issues, openStages };
135
+ }
74
136
  export function reconcileCurrentStageGateCatalog(flowState) {
75
137
  const stage = flowState.currentStage;
76
138
  const required = stageSchema(stage).requiredGates.map((gate) => gate.id);
@@ -124,6 +124,7 @@ export async function syncHarnessShims(projectRoot, harnesses) {
124
124
  await writeFileSafe(path.join(commandDir, "cc.md"), utilityShimContent(harness, "cc", "flow-start", "start.md"));
125
125
  await writeFileSafe(path.join(commandDir, "cc-next.md"), utilityShimContent(harness, "next", "flow-next-step", "next.md"));
126
126
  await writeFileSafe(path.join(commandDir, "cc-learn.md"), utilityShimContent(harness, "learn", "learnings", "learn.md"));
127
+ await writeFileSafe(path.join(commandDir, "cc-status.md"), utilityShimContent(harness, "status", "flow-status", "status.md"));
127
128
  }
128
129
  await syncAgentFiles(projectRoot);
129
130
  await syncAgentsMd(projectRoot);
package/dist/install.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import type { HarnessId } from "./types.js";
1
+ import type { FlowTrack, HarnessId } from "./types.js";
2
2
  export interface InitOptions {
3
3
  projectRoot: string;
4
4
  harnesses?: HarnessId[];
5
+ track?: FlowTrack;
5
6
  }
6
7
  export declare function initCclaw(options: InitOptions): Promise<void>;
7
8
  export declare function syncCclaw(projectRoot: string): Promise<void>;
package/dist/install.js CHANGED
@@ -9,9 +9,10 @@ import { contextModeFiles, createInitialContextModeState } from "./content/conte
9
9
  import { learnSkillMarkdown, learnCommandContract } from "./content/learnings.js";
10
10
  import { nextCommandContract, nextCommandSkillMarkdown } from "./content/next-command.js";
11
11
  import { startCommandContract, startCommandSkillMarkdown } from "./content/start-command.js";
12
+ import { statusCommandContract, statusCommandSkillMarkdown } from "./content/status-command.js";
12
13
  import { subagentDrivenDevSkill, parallelAgentsSkill } from "./content/subagents.js";
13
14
  import { sessionHooksSkillMarkdown } from "./content/session-hooks.js";
14
- import { sessionStartScript, stopCheckpointScript, opencodePluginJs, claudeHooksJson, cursorHooksJson, codexHooksJson } from "./content/hooks.js";
15
+ import { sessionStartScript, stopCheckpointScript, preCompactScript, opencodePluginJs, claudeHooksJson, cursorHooksJson, codexHooksJson } from "./content/hooks.js";
15
16
  import { contextMonitorScript, promptGuardScript, workflowGuardScript } from "./content/observe.js";
16
17
  import { META_SKILL_NAME, usingCclawSkillMarkdown } from "./content/meta-skill.js";
17
18
  import { ARTIFACT_TEMPLATES, CURSOR_WORKFLOW_RULE_MDC, RULEBOOK_MARKDOWN, buildRulesJson } from "./content/templates.js";
@@ -173,6 +174,7 @@ async function writeSkills(projectRoot) {
173
174
  await writeFileSafe(runtimePath(projectRoot, "skills", "learnings", "SKILL.md"), learnSkillMarkdown());
174
175
  await writeFileSafe(runtimePath(projectRoot, "skills", "flow-next-step", "SKILL.md"), nextCommandSkillMarkdown());
175
176
  await writeFileSafe(runtimePath(projectRoot, "skills", "flow-start", "SKILL.md"), startCommandSkillMarkdown());
177
+ await writeFileSafe(runtimePath(projectRoot, "skills", "flow-status", "SKILL.md"), statusCommandSkillMarkdown());
176
178
  await writeFileSafe(runtimePath(projectRoot, "skills", "subagent-dev", "SKILL.md"), subagentDrivenDevSkill());
177
179
  await writeFileSafe(runtimePath(projectRoot, "skills", "parallel-dispatch", "SKILL.md"), parallelAgentsSkill());
178
180
  await writeFileSafe(runtimePath(projectRoot, "skills", "session", "SKILL.md"), sessionHooksSkillMarkdown());
@@ -186,6 +188,7 @@ async function writeUtilityCommands(projectRoot) {
186
188
  await writeFileSafe(runtimePath(projectRoot, "commands", "learn.md"), learnCommandContract());
187
189
  await writeFileSafe(runtimePath(projectRoot, "commands", "next.md"), nextCommandContract());
188
190
  await writeFileSafe(runtimePath(projectRoot, "commands", "start.md"), startCommandContract());
191
+ await writeFileSafe(runtimePath(projectRoot, "commands", "status.md"), statusCommandContract());
189
192
  }
190
193
  function toObject(value) {
191
194
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -482,6 +485,7 @@ async function writeHooks(projectRoot, config) {
482
485
  await ensureDir(hooksDir);
483
486
  await writeFileSafe(path.join(hooksDir, "session-start.sh"), sessionStartScript());
484
487
  await writeFileSafe(path.join(hooksDir, "stop-checkpoint.sh"), stopCheckpointScript());
488
+ await writeFileSafe(path.join(hooksDir, "pre-compact.sh"), preCompactScript());
485
489
  await writeFileSafe(path.join(hooksDir, "prompt-guard.sh"), promptGuardScript({
486
490
  strictMode: config.promptGuardMode === "strict"
487
491
  }));
@@ -493,6 +497,7 @@ async function writeHooks(projectRoot, config) {
493
497
  for (const script of [
494
498
  "session-start.sh",
495
499
  "stop-checkpoint.sh",
500
+ "pre-compact.sh",
496
501
  "prompt-guard.sh",
497
502
  "workflow-guard.sh",
498
503
  "context-monitor.sh",
@@ -542,6 +547,101 @@ async function ensureKnowledgeStore(projectRoot) {
542
547
  await writeFileSafe(storePath, "# Project Knowledge\n\n");
543
548
  }
544
549
  }
550
+ async function ensureCustomSkillsScaffold(projectRoot) {
551
+ const customDir = runtimePath(projectRoot, "custom-skills");
552
+ await ensureDir(customDir);
553
+ const readmePath = path.join(customDir, "README.md");
554
+ if (!(await exists(readmePath))) {
555
+ await writeFileSafe(readmePath, CUSTOM_SKILLS_README);
556
+ }
557
+ const examplePath = path.join(customDir, "example", "SKILL.md");
558
+ if (!(await exists(examplePath))) {
559
+ await writeFileSafe(examplePath, CUSTOM_SKILLS_EXAMPLE);
560
+ }
561
+ }
562
+ const CUSTOM_SKILLS_README = `# Custom Skills (sync-safe)
563
+
564
+ This directory is **never overwritten** by \`cclaw sync\` or \`cclaw upgrade\`. Use it
565
+ to add project-specific skills that complement the managed skills under
566
+ \`.cclaw/skills/\`.
567
+
568
+ ## When to add a custom skill
569
+
570
+ - A repeatable lens specific to **this project** (e.g. "billing-domain", "kafka-message-contracts").
571
+ - A team convention you want every agent session to load.
572
+ - A domain checklist that does not generalize to other projects.
573
+
574
+ If the skill is general (security, performance, accessibility, etc.) prefer
575
+ contributing it upstream instead — the managed skills receive maintenance.
576
+
577
+ ## File format
578
+
579
+ Each skill lives at \`.cclaw/custom-skills/<folder>/SKILL.md\` with frontmatter:
580
+
581
+ \`\`\`markdown
582
+ ---
583
+ name: <kebab-case-skill-name>
584
+ description: "One sentence describing when this skill applies. Triggers semantic routing."
585
+ ---
586
+
587
+ # <Skill title>
588
+
589
+ ## When to use
590
+ - ...
591
+
592
+ ## HARD-GATE (optional)
593
+ A non-skippable rule, if any. Phrase it as a refusal, not a recommendation.
594
+
595
+ ## Algorithm / checklist
596
+ 1. ...
597
+ 2. ...
598
+
599
+ ## Anti-patterns
600
+ - ...
601
+ \`\`\`
602
+
603
+ ## Routing
604
+
605
+ Custom skills are surfaced via the \`using-cclaw\` meta-skill at session start.
606
+ Mention the skill name in your prompt or let the agent semantic-route to it
607
+ based on the description.
608
+
609
+ ## Removing or replacing
610
+
611
+ Custom skills are user-owned. Delete or edit them at any time — \`cclaw sync\`
612
+ will not touch them.
613
+ `;
614
+ const CUSTOM_SKILLS_EXAMPLE = `---
615
+ name: example-custom-skill
616
+ description: "Replace this with a one-sentence description that triggers when the skill should be used. Delete or rename this folder when you add a real skill."
617
+ ---
618
+
619
+ # Example Custom Skill
620
+
621
+ This is a placeholder. Use it as a starting template, then delete or rename
622
+ the \`example/\` folder.
623
+
624
+ ## When to use
625
+
626
+ - A real, repeatable situation in **this** project that needs a consistent lens.
627
+
628
+ ## HARD-GATE (optional)
629
+
630
+ Drop this section if no hard rule applies. Keep it crisp:
631
+
632
+ > Do not <X> while <Y>.
633
+
634
+ ## Algorithm
635
+
636
+ 1. Step one — observable, file:line evidence required.
637
+ 2. Step two — produce a named artifact, not a vibe.
638
+ 3. Step three — escalate / hand off / record knowledge entry.
639
+
640
+ ## Anti-patterns
641
+
642
+ - Treating this skill as advisory when the situation matches the trigger.
643
+ - Loading this skill when the situation clearly does not match (context bloat).
644
+ `;
545
645
  async function ensureSessionStateFiles(projectRoot) {
546
646
  const stateDir = runtimePath(projectRoot, "state");
547
647
  await ensureDir(stateDir);
@@ -623,12 +723,12 @@ async function syncDisabledHarnessArtifacts(projectRoot, harnesses) {
623
723
  await removeManagedOpenCodePluginConfig(projectRoot, OPENCODE_PLUGIN_REL_PATH);
624
724
  }
625
725
  }
626
- async function writeState(projectRoot, forceReset = false) {
726
+ async function writeState(projectRoot, config, forceReset = false) {
627
727
  const statePath = runtimePath(projectRoot, "state", "flow-state.json");
628
728
  if (!forceReset && (await exists(statePath))) {
629
729
  return;
630
730
  }
631
- const state = createInitialFlowState();
731
+ const state = createInitialFlowState("active", config.defaultTrack ?? "standard");
632
732
  await writeFileSafe(statePath, `${JSON.stringify(state, null, 2)}\n`);
633
733
  }
634
734
  async function writeAdapterManifest(projectRoot, harnesses) {
@@ -740,11 +840,12 @@ async function materializeRuntime(projectRoot, config, forceStateReset) {
740
840
  await writeContextModes(projectRoot);
741
841
  await writeArtifactTemplates(projectRoot);
742
842
  await writeRulebook(projectRoot);
743
- await writeState(projectRoot, forceStateReset);
843
+ await writeState(projectRoot, config, forceStateReset);
744
844
  await ensureRunSystem(projectRoot, { createIfMissing: false });
745
845
  await ensureSessionStateFiles(projectRoot);
746
846
  await writeAdapterManifest(projectRoot, harnesses);
747
847
  await ensureKnowledgeStore(projectRoot);
848
+ await ensureCustomSkillsScaffold(projectRoot);
748
849
  await writeHooks(projectRoot, config);
749
850
  await syncDisabledHarnessArtifacts(projectRoot, harnesses);
750
851
  await syncManagedGitHooks(projectRoot, config);
@@ -753,7 +854,7 @@ async function materializeRuntime(projectRoot, config, forceStateReset) {
753
854
  await ensureGitignore(projectRoot);
754
855
  }
755
856
  export async function initCclaw(options) {
756
- const config = createDefaultConfig(options.harnesses);
857
+ const config = createDefaultConfig(options.harnesses, options.track);
757
858
  await writeConfig(options.projectRoot, config);
758
859
  await materializeRuntime(options.projectRoot, config, true);
759
860
  }
@@ -828,7 +929,7 @@ function stripManagedHookCommands(value) {
828
929
  }
829
930
  function isManagedRuntimeHookCommand(command) {
830
931
  const normalized = command.trim().replace(/\s+/gu, " ");
831
- return /(^|\s)(?:bash\s+)?(?:\.\/)?\.cclaw\/hooks\/(?:session-start|stop-checkpoint|prompt-guard|workflow-guard|context-monitor)\.sh(?:\s|$)/u.test(normalized);
932
+ return /(^|\s)(?:bash\s+)?(?:\.\/)?\.cclaw\/hooks\/(?:session-start|stop-checkpoint|pre-compact|prompt-guard|workflow-guard|context-monitor)\.sh(?:\s|$)/u.test(normalized);
832
933
  }
833
934
  async function removeManagedHookEntries(hookFilePath) {
834
935
  if (!(await exists(hookFilePath)))
package/dist/runs.d.ts CHANGED
@@ -1,5 +1,17 @@
1
1
  import { type FlowState } from "./flow-state.js";
2
2
  import type { FlowStage } from "./types.js";
3
+ export declare class InvalidStageTransitionError extends Error {
4
+ readonly from: FlowStage;
5
+ readonly to: FlowStage;
6
+ constructor(from: FlowStage, to: FlowStage, message: string);
7
+ }
8
+ export interface WriteFlowStateOptions {
9
+ /**
10
+ * When true, skip prior-state validation. Used for run archival, initial
11
+ * bootstrap, or explicit recovery; never set from normal stage handlers.
12
+ */
13
+ allowReset?: boolean;
14
+ }
3
15
  export interface CclawRunMeta {
4
16
  id: string;
5
17
  title: string;
@@ -32,7 +44,7 @@ export declare class CorruptFlowStateError extends Error {
32
44
  constructor(statePath: string, quarantinedPath: string, cause: unknown);
33
45
  }
34
46
  export declare function readFlowState(projectRoot: string): Promise<FlowState>;
35
- export declare function writeFlowState(projectRoot: string, state: FlowState): Promise<void>;
47
+ export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
36
48
  export declare function ensureRunSystem(projectRoot: string, _options?: EnsureRunSystemOptions): Promise<FlowState>;
37
49
  export declare function listRuns(projectRoot: string): Promise<CclawRunMeta[]>;
38
50
  export declare function archiveRun(projectRoot: string, featureName?: string): Promise<ArchiveRunResult>;
package/dist/runs.js CHANGED
@@ -1,8 +1,35 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { COMMAND_FILE_ORDER, RUNTIME_ROOT } from "./constants.js";
4
- import { createInitialFlowState } from "./flow-state.js";
4
+ import { canTransition, createInitialFlowState, isFlowTrack, skippedStagesForTrack } from "./flow-state.js";
5
5
  import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
6
+ export class InvalidStageTransitionError extends Error {
7
+ from;
8
+ to;
9
+ constructor(from, to, message) {
10
+ super(message);
11
+ this.from = from;
12
+ this.to = to;
13
+ this.name = "InvalidStageTransitionError";
14
+ }
15
+ }
16
+ function validateFlowTransition(prev, next) {
17
+ if (prev.activeRunId !== next.activeRunId) {
18
+ // New run — only reset paths may change the runId, but those set allowReset.
19
+ throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change activeRunId from "${prev.activeRunId}" to "${next.activeRunId}" without allowReset.`);
20
+ }
21
+ for (const completed of prev.completedStages) {
22
+ if (!next.completedStages.includes(completed)) {
23
+ throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `completedStages must be monotonic: stage "${completed}" was previously completed but is missing from the new state.`);
24
+ }
25
+ }
26
+ if (prev.currentStage === next.currentStage) {
27
+ return;
28
+ }
29
+ if (!canTransition(prev.currentStage, next.currentStage)) {
30
+ throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `no transition rule allows "${prev.currentStage}" -> "${next.currentStage}". Use /cc-next to advance stages or archive the run to reset.`);
31
+ }
32
+ }
6
33
  const FLOW_STATE_REL_PATH = `${RUNTIME_ROOT}/state/flow-state.json`;
7
34
  const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
8
35
  const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
@@ -129,8 +156,27 @@ function sanitizeStageGateCatalog(value, fallback) {
129
156
  }
130
157
  return next;
131
158
  }
159
+ function coerceTrack(value) {
160
+ return isFlowTrack(value) ? value : "standard";
161
+ }
162
+ function sanitizeSkippedStages(value, track) {
163
+ const trackDefault = skippedStagesForTrack(track);
164
+ if (!Array.isArray(value)) {
165
+ return trackDefault;
166
+ }
167
+ const seen = new Set();
168
+ const out = [];
169
+ for (const raw of value) {
170
+ if (isFlowStage(raw) && !seen.has(raw)) {
171
+ seen.add(raw);
172
+ out.push(raw);
173
+ }
174
+ }
175
+ return out.length > 0 ? out : trackDefault;
176
+ }
132
177
  function coerceFlowState(parsed) {
133
- const next = createInitialFlowState();
178
+ const track = coerceTrack(parsed.track);
179
+ const next = createInitialFlowState("active", track);
134
180
  const activeRunIdRaw = parsed.activeRunId;
135
181
  const activeRunId = typeof activeRunIdRaw === "string" && activeRunIdRaw.trim().length > 0
136
182
  ? activeRunIdRaw.trim()
@@ -140,7 +186,9 @@ function coerceFlowState(parsed) {
140
186
  currentStage: isFlowStage(parsed.currentStage) ? parsed.currentStage : next.currentStage,
141
187
  completedStages: sanitizeCompletedStages(parsed.completedStages),
142
188
  guardEvidence: sanitizeGuardEvidence(parsed.guardEvidence),
143
- stageGateCatalog: sanitizeStageGateCatalog(parsed.stageGateCatalog, next.stageGateCatalog)
189
+ stageGateCatalog: sanitizeStageGateCatalog(parsed.stageGateCatalog, next.stageGateCatalog),
190
+ track,
191
+ skippedStages: sanitizeSkippedStages(parsed.skippedStages, track)
144
192
  };
145
193
  }
146
194
  function toArchiveDate(date = new Date()) {
@@ -251,10 +299,28 @@ export async function readFlowState(projectRoot) {
251
299
  }
252
300
  return coerceFlowState(parsed);
253
301
  }
254
- export async function writeFlowState(projectRoot, state) {
302
+ export async function writeFlowState(projectRoot, state, options = {}) {
255
303
  await withDirectoryLock(flowStateLockPath(projectRoot), async () => {
304
+ const statePath = flowStatePath(projectRoot);
305
+ if (!options.allowReset && (await exists(statePath))) {
306
+ try {
307
+ const raw = await fs.readFile(statePath, "utf8");
308
+ const parsed = JSON.parse(raw);
309
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
310
+ const prev = coerceFlowState(parsed);
311
+ validateFlowTransition(prev, state);
312
+ }
313
+ }
314
+ catch (err) {
315
+ if (err instanceof InvalidStageTransitionError) {
316
+ throw err;
317
+ }
318
+ // A corrupt prior file is surfaced by readFlowState elsewhere; don't
319
+ // block a legitimate write attempt on parse errors here.
320
+ }
321
+ }
256
322
  const safe = coerceFlowState({ ...state });
257
- await writeFileSafe(flowStatePath(projectRoot), `${JSON.stringify(safe, null, 2)}\n`);
323
+ await writeFileSafe(statePath, `${JSON.stringify(safe, null, 2)}\n`);
258
324
  });
259
325
  }
260
326
  export async function ensureRunSystem(projectRoot, _options = {}) {
@@ -263,7 +329,7 @@ export async function ensureRunSystem(projectRoot, _options = {}) {
263
329
  const statePath = flowStatePath(projectRoot);
264
330
  const state = await readFlowState(projectRoot);
265
331
  if (!(await exists(statePath))) {
266
- await writeFlowState(projectRoot, state);
332
+ await writeFlowState(projectRoot, state, { allowReset: true });
267
333
  }
268
334
  return state;
269
335
  }
@@ -315,7 +381,7 @@ export async function archiveRun(projectRoot, featureName) {
315
381
  const archiveStatePath = path.join(archivePath, "state");
316
382
  const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
317
383
  const resetState = createInitialFlowState();
318
- await writeFlowState(projectRoot, resetState);
384
+ await writeFlowState(projectRoot, resetState, { allowReset: true });
319
385
  const archivedAt = new Date().toISOString();
320
386
  const manifest = {
321
387
  version: 1,
package/dist/types.d.ts CHANGED
@@ -1,5 +1,16 @@
1
1
  export declare const FLOW_STAGES: readonly ["brainstorm", "scope", "design", "spec", "plan", "tdd", "review", "ship"];
2
2
  export type FlowStage = (typeof FLOW_STAGES)[number];
3
+ export declare const FLOW_TRACKS: readonly ["quick", "standard"];
4
+ export type FlowTrack = (typeof FLOW_TRACKS)[number];
5
+ /**
6
+ * Ordered stages that make up each flow track.
7
+ *
8
+ * - `standard` runs the full 8-stage pipeline (default — same as before tracks existed).
9
+ * - `quick` skips the upstream product stages (brainstorm/scope/design/plan) for
10
+ * small bug fixes or single-purpose changes where the spec is already known.
11
+ * It still keeps the non-negotiable safety gates: spec → tdd → review → ship.
12
+ */
13
+ export declare const TRACK_STAGES: Record<FlowTrack, readonly FlowStage[]>;
3
14
  export declare const HARNESS_IDS: readonly ["claude", "cursor", "opencode", "codex"];
4
15
  export type HarnessId = (typeof HARNESS_IDS)[number];
5
16
  export interface VibyConfig {
@@ -12,6 +23,8 @@ export interface VibyConfig {
12
23
  promptGuardMode?: "advisory" | "strict";
13
24
  /** When true, cclaw installs managed git pre-commit/pre-push wrappers. */
14
25
  gitHookGuards?: boolean;
26
+ /** Default flow track for new runs (quick = shortened path, standard = full pipeline). */
27
+ defaultTrack?: FlowTrack;
15
28
  }
16
29
  export interface TransitionRule {
17
30
  from: FlowStage;
package/dist/types.js CHANGED
@@ -8,4 +8,17 @@ export const FLOW_STAGES = [
8
8
  "review",
9
9
  "ship"
10
10
  ];
11
+ export const FLOW_TRACKS = ["quick", "standard"];
12
+ /**
13
+ * Ordered stages that make up each flow track.
14
+ *
15
+ * - `standard` runs the full 8-stage pipeline (default — same as before tracks existed).
16
+ * - `quick` skips the upstream product stages (brainstorm/scope/design/plan) for
17
+ * small bug fixes or single-purpose changes where the spec is already known.
18
+ * It still keeps the non-negotiable safety gates: spec → tdd → review → ship.
19
+ */
20
+ export const TRACK_STAGES = {
21
+ standard: FLOW_STAGES,
22
+ quick: ["spec", "tdd", "review", "ship"]
23
+ };
11
24
  export const HARNESS_IDS = ["claude", "cursor", "opencode", "codex"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "0.5.16",
3
+ "version": "0.6.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {