cclaw-cli 0.5.15 → 0.5.17
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/README.md +6 -0
- package/dist/artifact-linter.d.ts +13 -0
- package/dist/artifact-linter.js +182 -13
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +76 -12
- package/dist/content/agents.js +5 -5
- package/dist/content/examples.js +71 -62
- package/dist/content/stage-schema.js +3 -3
- package/dist/content/subagents.js +1 -1
- package/dist/content/templates.js +6 -21
- package/dist/delegation.d.ts +6 -0
- package/dist/delegation.js +12 -4
- package/dist/doctor.js +37 -1
- package/dist/gate-evidence.d.ts +14 -0
- package/dist/gate-evidence.js +65 -3
- package/dist/runs.d.ts +30 -1
- package/dist/runs.js +164 -10
- package/package.json +3 -1
package/dist/doctor.js
CHANGED
|
@@ -13,7 +13,7 @@ import { policyChecks } from "./policy.js";
|
|
|
13
13
|
import { readFlowState } from "./runs.js";
|
|
14
14
|
import { checkMandatoryDelegations } from "./delegation.js";
|
|
15
15
|
import { buildTraceMatrix } from "./trace-matrix.js";
|
|
16
|
-
import { reconcileAndWriteCurrentStageGateCatalog, verifyCurrentStageGateEvidence } from "./gate-evidence.js";
|
|
16
|
+
import { reconcileAndWriteCurrentStageGateCatalog, verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence } from "./gate-evidence.js";
|
|
17
17
|
import { stageSkillFolder } from "./content/skills.js";
|
|
18
18
|
import { UTILITY_SKILL_FOLDERS } from "./content/utility-skills.js";
|
|
19
19
|
import { CONTEXT_MODES, DEFAULT_CONTEXT_MODE } from "./content/contexts.js";
|
|
@@ -768,11 +768,37 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
768
768
|
? `warning: waived mandatory delegations for stage "${flowState.currentStage}": ${delegation.waived.join(", ")}`
|
|
769
769
|
: "no waived mandatory delegations for current stage"
|
|
770
770
|
});
|
|
771
|
+
checks.push({
|
|
772
|
+
name: "warning:delegation:stale_runs",
|
|
773
|
+
ok: true,
|
|
774
|
+
details: delegation.staleIgnored.length > 0
|
|
775
|
+
? `warning: ${delegation.staleIgnored.length} delegation entries from other runs were ignored: ${delegation.staleIgnored.join(", ")}`
|
|
776
|
+
: "no stale delegation entries from prior runs"
|
|
777
|
+
});
|
|
771
778
|
const trace = await buildTraceMatrix(projectRoot);
|
|
779
|
+
const artifactsDir = path.join(projectRoot, RUNTIME_ROOT, "artifacts");
|
|
780
|
+
const specExists = await exists(path.join(artifactsDir, "04-spec.md"));
|
|
781
|
+
const planExists = await exists(path.join(artifactsDir, "05-plan.md"));
|
|
782
|
+
const tddExists = await exists(path.join(artifactsDir, "06-tdd.md"));
|
|
772
783
|
const traceHasSignal = trace.entries.length > 0 ||
|
|
773
784
|
trace.orphanedCriteria.length > 0 ||
|
|
774
785
|
trace.orphanedTasks.length > 0 ||
|
|
775
786
|
trace.orphanedTests.length > 0;
|
|
787
|
+
const artifactsPresent = specExists || planExists || tddExists;
|
|
788
|
+
const emptyMatrixWithArtifacts = !traceHasSignal && artifactsPresent;
|
|
789
|
+
checks.push({
|
|
790
|
+
name: "trace:matrix_populated",
|
|
791
|
+
ok: !emptyMatrixWithArtifacts,
|
|
792
|
+
details: emptyMatrixWithArtifacts
|
|
793
|
+
? `trace matrix is empty but artifacts exist (${[
|
|
794
|
+
specExists ? "04-spec.md" : null,
|
|
795
|
+
planExists ? "05-plan.md" : null,
|
|
796
|
+
tddExists ? "06-tdd.md" : null
|
|
797
|
+
].filter(Boolean).join(", ")}). The extractors found no criterion/task/slice IDs — check heading conventions and ID formats.`
|
|
798
|
+
: artifactsPresent
|
|
799
|
+
? `trace matrix parsed ${trace.entries.length} criterion(s) from present artifacts`
|
|
800
|
+
: "no downstream artifacts to trace yet"
|
|
801
|
+
});
|
|
776
802
|
checks.push({
|
|
777
803
|
name: "trace:criteria_coverage",
|
|
778
804
|
ok: !traceHasSignal || trace.orphanedCriteria.length === 0,
|
|
@@ -802,6 +828,16 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
802
828
|
? `stage "${gateEvidence.stage}" gate evidence is consistent (required=${gateEvidence.requiredCount}, passed=${gateEvidence.passedCount}, blocked=${gateEvidence.blockedCount})`
|
|
803
829
|
: gateEvidence.issues.join(" ")
|
|
804
830
|
});
|
|
831
|
+
const completedClosure = verifyCompletedStagesGateClosure(flowState);
|
|
832
|
+
checks.push({
|
|
833
|
+
name: "gates:closure:completed_stages",
|
|
834
|
+
ok: completedClosure.ok,
|
|
835
|
+
details: completedClosure.ok
|
|
836
|
+
? flowState.completedStages.length === 0
|
|
837
|
+
? "no completed stages yet"
|
|
838
|
+
: `all ${flowState.completedStages.length} completed stages have every required gate passed`
|
|
839
|
+
: completedClosure.issues.join(" ")
|
|
840
|
+
});
|
|
805
841
|
// Self-improvement block in stage skills
|
|
806
842
|
for (const stage of COMMAND_FILE_ORDER) {
|
|
807
843
|
const skillPath = path.join(projectRoot, RUNTIME_ROOT, "skills", stageSkillFolder(stage), "SKILL.md");
|
package/dist/gate-evidence.d.ts
CHANGED
|
@@ -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;
|
package/dist/gate-evidence.js
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
|
-
import
|
|
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
|
|
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);
|
package/dist/runs.d.ts
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { type FlowState } from "./flow-state.js";
|
|
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
|
+
}
|
|
2
15
|
export interface CclawRunMeta {
|
|
3
16
|
id: string;
|
|
4
17
|
title: string;
|
|
@@ -10,12 +23,28 @@ export interface ArchiveRunResult {
|
|
|
10
23
|
archivedAt: string;
|
|
11
24
|
featureName: string;
|
|
12
25
|
resetState: FlowState;
|
|
26
|
+
snapshottedStateFiles: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface ArchiveManifest {
|
|
29
|
+
version: 1;
|
|
30
|
+
archiveId: string;
|
|
31
|
+
archivedAt: string;
|
|
32
|
+
featureName: string;
|
|
33
|
+
sourceRunId: string;
|
|
34
|
+
sourceCurrentStage: FlowStage;
|
|
35
|
+
sourceCompletedStages: FlowStage[];
|
|
36
|
+
snapshottedStateFiles: string[];
|
|
13
37
|
}
|
|
14
38
|
interface EnsureRunSystemOptions {
|
|
15
39
|
createIfMissing?: boolean;
|
|
16
40
|
}
|
|
41
|
+
export declare class CorruptFlowStateError extends Error {
|
|
42
|
+
readonly statePath: string;
|
|
43
|
+
readonly quarantinedPath: string;
|
|
44
|
+
constructor(statePath: string, quarantinedPath: string, cause: unknown);
|
|
45
|
+
}
|
|
17
46
|
export declare function readFlowState(projectRoot: string): Promise<FlowState>;
|
|
18
|
-
export declare function writeFlowState(projectRoot: string, state: FlowState): Promise<void>;
|
|
47
|
+
export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
|
|
19
48
|
export declare function ensureRunSystem(projectRoot: string, _options?: EnsureRunSystemOptions): Promise<FlowState>;
|
|
20
49
|
export declare function listRuns(projectRoot: string): Promise<CclawRunMeta[]>;
|
|
21
50
|
export declare function archiveRun(projectRoot: string, featureName?: string): Promise<ArchiveRunResult>;
|
package/dist/runs.js
CHANGED
|
@@ -1,12 +1,45 @@
|
|
|
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 } 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`;
|
|
36
|
+
const STATE_DIR_REL_PATH = `${RUNTIME_ROOT}/state`;
|
|
9
37
|
const FLOW_STAGE_SET = new Set(COMMAND_FILE_ORDER);
|
|
38
|
+
/** State filenames explicitly excluded from the archive snapshot. */
|
|
39
|
+
const STATE_SNAPSHOT_EXCLUDE = new Set([
|
|
40
|
+
".flow-state.lock",
|
|
41
|
+
".delegation.lock"
|
|
42
|
+
]);
|
|
10
43
|
function flowStatePath(projectRoot) {
|
|
11
44
|
return path.join(projectRoot, FLOW_STATE_REL_PATH);
|
|
12
45
|
}
|
|
@@ -19,6 +52,46 @@ function runsRoot(projectRoot) {
|
|
|
19
52
|
function activeArtifactsPath(projectRoot) {
|
|
20
53
|
return path.join(projectRoot, ACTIVE_ARTIFACTS_REL_PATH);
|
|
21
54
|
}
|
|
55
|
+
function stateDirPath(projectRoot) {
|
|
56
|
+
return path.join(projectRoot, STATE_DIR_REL_PATH);
|
|
57
|
+
}
|
|
58
|
+
async function snapshotStateDirectory(projectRoot, destinationRoot) {
|
|
59
|
+
const sourceDir = stateDirPath(projectRoot);
|
|
60
|
+
if (!(await exists(sourceDir))) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
await ensureDir(destinationRoot);
|
|
64
|
+
const copied = [];
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (STATE_SNAPSHOT_EXCLUDE.has(entry.name))
|
|
74
|
+
continue;
|
|
75
|
+
if (entry.name.startsWith(".") && !entry.name.endsWith(".json"))
|
|
76
|
+
continue;
|
|
77
|
+
const from = path.join(sourceDir, entry.name);
|
|
78
|
+
const to = path.join(destinationRoot, entry.name);
|
|
79
|
+
try {
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
await fs.cp(from, to, { recursive: true });
|
|
82
|
+
copied.push(`${entry.name}/`);
|
|
83
|
+
}
|
|
84
|
+
else if (entry.isFile()) {
|
|
85
|
+
await fs.copyFile(from, to);
|
|
86
|
+
copied.push(entry.name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// best-effort snapshot; continue on individual failures
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return copied.sort((a, b) => a.localeCompare(b));
|
|
94
|
+
}
|
|
22
95
|
function isFlowStage(value) {
|
|
23
96
|
return typeof value === "string" && FLOW_STAGE_SET.has(value);
|
|
24
97
|
}
|
|
@@ -144,23 +217,89 @@ async function uniqueArchiveId(projectRoot, baseId) {
|
|
|
144
217
|
}
|
|
145
218
|
return candidate;
|
|
146
219
|
}
|
|
220
|
+
export class CorruptFlowStateError extends Error {
|
|
221
|
+
statePath;
|
|
222
|
+
quarantinedPath;
|
|
223
|
+
constructor(statePath, quarantinedPath, cause) {
|
|
224
|
+
super(`Corrupt flow-state.json detected at ${statePath}. ` +
|
|
225
|
+
`Quarantined to ${quarantinedPath}. ` +
|
|
226
|
+
`Inspect the quarantined file, reconcile by hand, then re-run your command ` +
|
|
227
|
+
`or delete ${statePath} to start over. ` +
|
|
228
|
+
`Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
229
|
+
this.name = "CorruptFlowStateError";
|
|
230
|
+
this.statePath = statePath;
|
|
231
|
+
this.quarantinedPath = quarantinedPath;
|
|
232
|
+
if (cause instanceof Error) {
|
|
233
|
+
this.cause = cause;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function quarantineTimestamp(date = new Date()) {
|
|
238
|
+
return date.toISOString().replace(/[:.]/gu, "-");
|
|
239
|
+
}
|
|
240
|
+
async function quarantineCorruptState(statePath, cause) {
|
|
241
|
+
const quarantinedPath = `${statePath}.corrupt-${quarantineTimestamp()}.json`;
|
|
242
|
+
try {
|
|
243
|
+
await fs.rename(statePath, quarantinedPath);
|
|
244
|
+
}
|
|
245
|
+
catch (renameErr) {
|
|
246
|
+
try {
|
|
247
|
+
const raw = await fs.readFile(statePath, "utf8");
|
|
248
|
+
await fs.writeFile(quarantinedPath, raw, "utf8");
|
|
249
|
+
await fs.unlink(statePath).catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
throw new CorruptFlowStateError(statePath, quarantinedPath, renameErr);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
throw new CorruptFlowStateError(statePath, quarantinedPath, cause);
|
|
256
|
+
}
|
|
147
257
|
export async function readFlowState(projectRoot) {
|
|
148
258
|
const statePath = flowStatePath(projectRoot);
|
|
149
259
|
if (!(await exists(statePath))) {
|
|
150
260
|
return createInitialFlowState();
|
|
151
261
|
}
|
|
262
|
+
let raw;
|
|
152
263
|
try {
|
|
153
|
-
|
|
154
|
-
return coerceFlowState(parsed);
|
|
264
|
+
raw = await fs.readFile(statePath, "utf8");
|
|
155
265
|
}
|
|
156
|
-
catch {
|
|
157
|
-
|
|
266
|
+
catch (readErr) {
|
|
267
|
+
throw new CorruptFlowStateError(statePath, statePath, readErr);
|
|
268
|
+
}
|
|
269
|
+
let parsed;
|
|
270
|
+
try {
|
|
271
|
+
parsed = JSON.parse(raw);
|
|
272
|
+
}
|
|
273
|
+
catch (parseErr) {
|
|
274
|
+
await quarantineCorruptState(statePath, parseErr);
|
|
158
275
|
}
|
|
276
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
277
|
+
await quarantineCorruptState(statePath, new Error("flow-state.json did not deserialize to a JSON object"));
|
|
278
|
+
}
|
|
279
|
+
return coerceFlowState(parsed);
|
|
159
280
|
}
|
|
160
|
-
export async function writeFlowState(projectRoot, state) {
|
|
281
|
+
export async function writeFlowState(projectRoot, state, options = {}) {
|
|
161
282
|
await withDirectoryLock(flowStateLockPath(projectRoot), async () => {
|
|
283
|
+
const statePath = flowStatePath(projectRoot);
|
|
284
|
+
if (!options.allowReset && (await exists(statePath))) {
|
|
285
|
+
try {
|
|
286
|
+
const raw = await fs.readFile(statePath, "utf8");
|
|
287
|
+
const parsed = JSON.parse(raw);
|
|
288
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
289
|
+
const prev = coerceFlowState(parsed);
|
|
290
|
+
validateFlowTransition(prev, state);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
if (err instanceof InvalidStageTransitionError) {
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
// A corrupt prior file is surfaced by readFlowState elsewhere; don't
|
|
298
|
+
// block a legitimate write attempt on parse errors here.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
162
301
|
const safe = coerceFlowState({ ...state });
|
|
163
|
-
await writeFileSafe(
|
|
302
|
+
await writeFileSafe(statePath, `${JSON.stringify(safe, null, 2)}\n`);
|
|
164
303
|
});
|
|
165
304
|
}
|
|
166
305
|
export async function ensureRunSystem(projectRoot, _options = {}) {
|
|
@@ -169,7 +308,7 @@ export async function ensureRunSystem(projectRoot, _options = {}) {
|
|
|
169
308
|
const statePath = flowStatePath(projectRoot);
|
|
170
309
|
const state = await readFlowState(projectRoot);
|
|
171
310
|
if (!(await exists(statePath))) {
|
|
172
|
-
await writeFlowState(projectRoot, state);
|
|
311
|
+
await writeFlowState(projectRoot, state, { allowReset: true });
|
|
173
312
|
}
|
|
174
313
|
return state;
|
|
175
314
|
}
|
|
@@ -214,17 +353,32 @@ export async function archiveRun(projectRoot, featureName) {
|
|
|
214
353
|
const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
|
|
215
354
|
const archivePath = path.join(runsDir, archiveId);
|
|
216
355
|
const archiveArtifactsPath = path.join(archivePath, "artifacts");
|
|
356
|
+
const sourceState = await readFlowState(projectRoot);
|
|
217
357
|
await ensureDir(archivePath);
|
|
218
358
|
await fs.rename(artifactsDir, archiveArtifactsPath);
|
|
219
359
|
await ensureDir(artifactsDir);
|
|
360
|
+
const archiveStatePath = path.join(archivePath, "state");
|
|
361
|
+
const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
|
|
220
362
|
const resetState = createInitialFlowState();
|
|
221
|
-
await writeFlowState(projectRoot, resetState);
|
|
363
|
+
await writeFlowState(projectRoot, resetState, { allowReset: true });
|
|
222
364
|
const archivedAt = new Date().toISOString();
|
|
365
|
+
const manifest = {
|
|
366
|
+
version: 1,
|
|
367
|
+
archiveId,
|
|
368
|
+
archivedAt,
|
|
369
|
+
featureName: feature,
|
|
370
|
+
sourceRunId: sourceState.activeRunId,
|
|
371
|
+
sourceCurrentStage: sourceState.currentStage,
|
|
372
|
+
sourceCompletedStages: sourceState.completedStages,
|
|
373
|
+
snapshottedStateFiles
|
|
374
|
+
};
|
|
375
|
+
await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
223
376
|
return {
|
|
224
377
|
archiveId,
|
|
225
378
|
archivePath,
|
|
226
379
|
archivedAt,
|
|
227
380
|
featureName: feature,
|
|
228
|
-
resetState
|
|
381
|
+
resetState,
|
|
382
|
+
snapshottedStateFiles
|
|
229
383
|
};
|
|
230
384
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cclaw-cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.17",
|
|
4
4
|
"description": "Installer-first flow toolkit for coding agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"build": "npm run clean:dist && tsc -p tsconfig.json",
|
|
21
21
|
"test": "vitest run",
|
|
22
22
|
"test:watch": "vitest",
|
|
23
|
+
"test:coverage": "vitest run --coverage",
|
|
23
24
|
"smoke:runtime": "npm run build && node scripts/smoke-init.mjs",
|
|
24
25
|
"lint:hooks": "npm run build && node scripts/lint-generated-hooks.mjs",
|
|
25
26
|
"build:plugin-manifests": "npm run build && node scripts/build-plugin-manifests.mjs",
|
|
@@ -42,6 +43,7 @@
|
|
|
42
43
|
},
|
|
43
44
|
"devDependencies": {
|
|
44
45
|
"@types/node": "^24.7.2",
|
|
46
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
45
47
|
"typescript": "^5.9.3",
|
|
46
48
|
"vitest": "^3.2.4"
|
|
47
49
|
}
|