cclaw-cli 0.48.0 → 0.48.2
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/dist/artifact-linter.d.ts +2 -2
- package/dist/artifact-linter.js +4 -10
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +11 -0
- package/dist/content/core-agents.d.ts +53 -1
- package/dist/content/core-agents.js +6 -0
- package/dist/content/observe.js +22 -1
- package/dist/content/opencode-plugin.js +5 -1
- package/dist/content/stage-schema.js +2 -0
- package/dist/content/stages/ship.js +2 -5
- package/dist/content/templates.js +13 -15
- package/dist/content/utility-skills.d.ts +7 -1
- package/dist/content/utility-skills.js +5 -0
- package/dist/delegation.d.ts +15 -1
- package/dist/delegation.js +24 -8
- package/dist/doctor.js +101 -18
- package/dist/feature-system.d.ts +11 -4
- package/dist/feature-system.js +52 -8
- package/dist/flow-state.d.ts +3 -1
- package/dist/flow-state.js +34 -3
- package/dist/fs-utils.d.ts +9 -0
- package/dist/fs-utils.js +46 -3
- package/dist/gate-evidence.d.ts +2 -0
- package/dist/gate-evidence.js +13 -4
- package/dist/gitignore.js +6 -3
- package/dist/harness-adapters.js +11 -1
- package/dist/install.js +41 -5
- package/dist/internal/advance-stage.js +45 -8
- package/dist/knowledge-store.js +2 -2
- package/dist/retro-gate.js +23 -14
- package/dist/run-archive.js +164 -93
- package/dist/run-persistence.d.ts +8 -1
- package/dist/run-persistence.js +13 -5
- package/dist/tdd-cycle.js +6 -1
- package/package.json +1 -1
package/dist/install.js
CHANGED
|
@@ -44,7 +44,8 @@ import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
|
|
|
44
44
|
import { ensureGitignore, removeGitignorePatterns } from "./gitignore.js";
|
|
45
45
|
import { HARNESS_ADAPTERS, harnessShimFileNames, harnessTier, syncHarnessShims, removeCclawFromAgentsMd } from "./harness-adapters.js";
|
|
46
46
|
import { validateHookDocument } from "./hook-schema.js";
|
|
47
|
-
import {
|
|
47
|
+
import { detectHarnesses } from "./init-detect.js";
|
|
48
|
+
import { CorruptFlowStateError, ensureRunSystem, readFlowState } from "./runs.js";
|
|
48
49
|
import { FLOW_STAGES } from "./types.js";
|
|
49
50
|
const OPENCODE_PLUGIN_REL_PATH = ".opencode/plugins/cclaw-plugin.mjs";
|
|
50
51
|
const CURSOR_RULE_REL_PATH = ".cursor/rules/cclaw-workflow.mdc";
|
|
@@ -853,7 +854,24 @@ Drop this section if no hard rule applies. Keep it crisp:
|
|
|
853
854
|
async function ensureSessionStateFiles(projectRoot) {
|
|
854
855
|
const stateDir = runtimePath(projectRoot, "state");
|
|
855
856
|
await ensureDir(stateDir);
|
|
856
|
-
|
|
857
|
+
// If flow-state.json is corrupt, `readFlowState` quarantines the bad
|
|
858
|
+
// file and throws. During install we'd rather continue than abort:
|
|
859
|
+
// the user just asked to set up cclaw, and the corrupt file is already
|
|
860
|
+
// preserved next to the original path. Fall back to a fresh initial
|
|
861
|
+
// state so the rest of install completes and the user can inspect the
|
|
862
|
+
// `.corrupt-<timestamp>.json` quarantine afterwards.
|
|
863
|
+
let flow;
|
|
864
|
+
try {
|
|
865
|
+
flow = await readFlowState(projectRoot);
|
|
866
|
+
}
|
|
867
|
+
catch (err) {
|
|
868
|
+
if (err instanceof CorruptFlowStateError) {
|
|
869
|
+
flow = createInitialFlowState();
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
throw err;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
857
875
|
const activityPath = path.join(stateDir, "stage-activity.jsonl");
|
|
858
876
|
if (!(await exists(activityPath))) {
|
|
859
877
|
await writeFileSafe(activityPath, "");
|
|
@@ -959,7 +977,7 @@ async function writeState(projectRoot, config, forceReset = false) {
|
|
|
959
977
|
if (!forceReset && (await exists(statePath))) {
|
|
960
978
|
return;
|
|
961
979
|
}
|
|
962
|
-
const state = createInitialFlowState(
|
|
980
|
+
const state = createInitialFlowState({ track: config.defaultTrack ?? "standard" });
|
|
963
981
|
await writeFileSafe(statePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
964
982
|
}
|
|
965
983
|
async function writeAdapterManifest(projectRoot, harnesses) {
|
|
@@ -1021,6 +1039,14 @@ async function writeHarnessGapsState(projectRoot, harnesses) {
|
|
|
1021
1039
|
break;
|
|
1022
1040
|
}
|
|
1023
1041
|
for (const event of missingHookEvents) {
|
|
1042
|
+
if (harness === "codex" && event === "precompact_digest") {
|
|
1043
|
+
// Codex CLI has no PreCompact event. Generic "schedule the script
|
|
1044
|
+
// manually" copy doesn't help; instead, point the agent at the
|
|
1045
|
+
// in-thread substitute that already exists in cclaw content
|
|
1046
|
+
// (`/cc-ops retro` reads the same digest the hook would emit).
|
|
1047
|
+
remediation.push("hook event precompact_digest → Codex has no PreCompact event; run `/cc-ops retro` in-thread before compaction instead of relying on a hook");
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1024
1050
|
remediation.push(`hook event ${event} → schedule the corresponding script manually or accept reduced observability`);
|
|
1025
1051
|
}
|
|
1026
1052
|
return {
|
|
@@ -1210,9 +1236,19 @@ export async function initCclaw(options) {
|
|
|
1210
1236
|
}
|
|
1211
1237
|
export async function syncCclaw(projectRoot) {
|
|
1212
1238
|
const configExists = await exists(configPath(projectRoot));
|
|
1213
|
-
|
|
1239
|
+
let config = await readConfig(projectRoot);
|
|
1214
1240
|
if (!configExists) {
|
|
1215
|
-
|
|
1241
|
+
// Prefer detected harness markers over the hardcoded default list.
|
|
1242
|
+
// Without this, a user running `cclaw sync` in a `.claude`-only
|
|
1243
|
+
// project ends up with a config that also enables cursor/opencode/
|
|
1244
|
+
// codex, which then fails doctor checks for missing shim folders.
|
|
1245
|
+
// Fall back to the previous default (config.harnesses) if no markers
|
|
1246
|
+
// are found so brand-new projects still bootstrap cleanly.
|
|
1247
|
+
const detected = await detectHarnesses(projectRoot);
|
|
1248
|
+
const harnesses = detected.length > 0 ? detected : config.harnesses;
|
|
1249
|
+
const defaultConfig = createDefaultConfig(harnesses);
|
|
1250
|
+
await writeConfig(projectRoot, defaultConfig);
|
|
1251
|
+
config = defaultConfig;
|
|
1216
1252
|
}
|
|
1217
1253
|
await materializeRuntime(projectRoot, config, false);
|
|
1218
1254
|
}
|
|
@@ -1,22 +1,51 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { RUNTIME_ROOT } from "../constants.js";
|
|
3
|
+
import { RUNTIME_ROOT, SHIP_FINALIZATION_MODES } from "../constants.js";
|
|
4
4
|
import { stageSchema } from "../content/stage-schema.js";
|
|
5
5
|
import { appendDelegation, checkMandatoryDelegations } from "../delegation.js";
|
|
6
6
|
import { readActiveFeature } from "../feature-system.js";
|
|
7
7
|
import { verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence } from "../gate-evidence.js";
|
|
8
8
|
import { extractMarkdownSectionBody, parseLearningsSection } from "../artifact-linter.js";
|
|
9
|
-
import {
|
|
9
|
+
import { getAvailableTransitions, getTransitionGuards, isFlowTrack } from "../flow-state.js";
|
|
10
10
|
import { appendKnowledge } from "../knowledge-store.js";
|
|
11
11
|
import { readFlowState, writeFlowState } from "../runs.js";
|
|
12
12
|
import { FLOW_STAGES } from "../types.js";
|
|
13
13
|
function unique(values) {
|
|
14
14
|
return [...new Set(values)];
|
|
15
15
|
}
|
|
16
|
+
function resolveSuccessorTransition(stage, track, transitionTargets, satisfiedGuards, selectedTransitionGuards) {
|
|
17
|
+
const natural = transitionTargets[0] ?? null;
|
|
18
|
+
const specialTargets = transitionTargets.filter((target) => target !== natural);
|
|
19
|
+
for (const target of specialTargets) {
|
|
20
|
+
const guards = getTransitionGuards(stage, target, track);
|
|
21
|
+
if (guards.length === 0)
|
|
22
|
+
continue;
|
|
23
|
+
const selectedSpecial = guards.some((guard) => selectedTransitionGuards.has(guard));
|
|
24
|
+
if (!selectedSpecial)
|
|
25
|
+
continue;
|
|
26
|
+
if (guards.every((guard) => satisfiedGuards.has(guard))) {
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (natural) {
|
|
31
|
+
const guards = getTransitionGuards(stage, natural, track);
|
|
32
|
+
if (guards.every((guard) => satisfiedGuards.has(guard))) {
|
|
33
|
+
return natural;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const target of specialTargets) {
|
|
37
|
+
const guards = getTransitionGuards(stage, target, track);
|
|
38
|
+
if (guards.every((guard) => satisfiedGuards.has(guard))) {
|
|
39
|
+
return target;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return natural;
|
|
43
|
+
}
|
|
16
44
|
const TEST_COMMAND_HINT_PATTERN = /\b(?:npm test|pnpm test|yarn test|bun test|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)\b/iu;
|
|
17
45
|
const SHA_WITH_LABEL_PATTERN = /\b(?:sha|commit)(?:\s*[:=]|\s+)\s*[0-9a-f]{7,40}\b/iu;
|
|
18
46
|
const PASS_STATUS_PATTERN = /\b(?:pass|passed|green|ok)\b/iu;
|
|
19
|
-
const SHIP_FINALIZATION_MODE_PATTERN =
|
|
47
|
+
const SHIP_FINALIZATION_MODE_PATTERN = new RegExp(`\\b(?:${SHIP_FINALIZATION_MODES.join("|")})\\b`, "u");
|
|
48
|
+
const SHIP_FINALIZATION_MODE_HINT = SHIP_FINALIZATION_MODES.join(", ");
|
|
20
49
|
// Per-gate validators keyed by `${stage}:${gateId}`. Returning a non-null
|
|
21
50
|
// string surfaces the reason as an `advance-stage` failure so evidence is
|
|
22
51
|
// guaranteed to carry the structural breadcrumbs downstream tooling
|
|
@@ -36,7 +65,7 @@ const GATE_EVIDENCE_VALIDATORS = {
|
|
|
36
65
|
},
|
|
37
66
|
"ship:ship_finalization_executed": (evidence) => {
|
|
38
67
|
if (!SHIP_FINALIZATION_MODE_PATTERN.test(evidence)) {
|
|
39
|
-
return
|
|
68
|
+
return `must name the finalization mode that ran (for example ${SHIP_FINALIZATION_MODE_HINT}).`;
|
|
40
69
|
}
|
|
41
70
|
return null;
|
|
42
71
|
}
|
|
@@ -395,10 +424,16 @@ async function runAdvanceStage(projectRoot, args, io) {
|
|
|
395
424
|
const requiredGateIds = schema.requiredGates
|
|
396
425
|
.filter((gate) => gate.tier === "required")
|
|
397
426
|
.map((gate) => gate.id);
|
|
427
|
+
const transitionTargets = getAvailableTransitions(args.stage, flowState.track).map((rule) => rule.to);
|
|
398
428
|
const allowedGateIds = new Set(schema.requiredGates.map((gate) => gate.id));
|
|
429
|
+
const transitionGuardIds = new Set(transitionTargets
|
|
430
|
+
.flatMap((target) => getTransitionGuards(args.stage, target, flowState.track))
|
|
431
|
+
.filter((guardId) => !allowedGateIds.has(guardId)));
|
|
432
|
+
const selectableGateIds = new Set([...allowedGateIds, ...transitionGuardIds]);
|
|
399
433
|
const selectedGateIds = args.passedGateIds.length > 0
|
|
400
|
-
? args.passedGateIds.filter((gateId) =>
|
|
434
|
+
? args.passedGateIds.filter((gateId) => selectableGateIds.has(gateId))
|
|
401
435
|
: requiredGateIds;
|
|
436
|
+
const selectedTransitionGuards = selectedGateIds.filter((gateId) => transitionGuardIds.has(gateId));
|
|
402
437
|
const missingRequired = requiredGateIds.filter((gateId) => !selectedGateIds.includes(gateId));
|
|
403
438
|
if (missingRequired.length > 0) {
|
|
404
439
|
io.stderr.write(`cclaw internal advance-stage: required gates not selected as passed: ${missingRequired.join(", ")}.\n`);
|
|
@@ -436,7 +471,8 @@ async function runAdvanceStage(projectRoot, args, io) {
|
|
|
436
471
|
...nextPassed.filter((gateId) => conditional.has(gateId)),
|
|
437
472
|
...nextBlocked.filter((gateId) => conditional.has(gateId))
|
|
438
473
|
]);
|
|
439
|
-
const
|
|
474
|
+
const guardEvidenceGateIds = unique([...nextPassed, ...selectedTransitionGuards]);
|
|
475
|
+
const missingGuardEvidence = guardEvidenceGateIds.filter((gateId) => {
|
|
440
476
|
const existing = flowState.guardEvidence[gateId];
|
|
441
477
|
if (typeof existing === "string" && existing.trim().length > 0) {
|
|
442
478
|
return false;
|
|
@@ -464,7 +500,7 @@ async function runAdvanceStage(projectRoot, args, io) {
|
|
|
464
500
|
return 1;
|
|
465
501
|
}
|
|
466
502
|
const nextGuardEvidence = { ...flowState.guardEvidence };
|
|
467
|
-
for (const gateId of
|
|
503
|
+
for (const gateId of guardEvidenceGateIds) {
|
|
468
504
|
const provided = args.evidenceByGate[gateId];
|
|
469
505
|
if (typeof provided === "string" && provided.trim().length > 0) {
|
|
470
506
|
nextGuardEvidence[gateId] = provided.trim();
|
|
@@ -508,7 +544,8 @@ async function runAdvanceStage(projectRoot, args, io) {
|
|
|
508
544
|
io.stderr.write(`cclaw internal advance-stage: learnings harvest failed for "${schema.artifactFile}". ${learningsHarvest.details}\n`);
|
|
509
545
|
return 1;
|
|
510
546
|
}
|
|
511
|
-
const
|
|
547
|
+
const satisfiedGuards = new Set([...nextPassed, ...selectedTransitionGuards]);
|
|
548
|
+
const successor = resolveSuccessorTransition(args.stage, flowState.track, transitionTargets, satisfiedGuards, new Set(selectedTransitionGuards));
|
|
512
549
|
const completedStages = flowState.completedStages.includes(args.stage)
|
|
513
550
|
? [...flowState.completedStages]
|
|
514
551
|
: [...flowState.completedStages, args.stage];
|
package/dist/knowledge-store.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { RUNTIME_ROOT } from "./constants.js";
|
|
4
|
-
import { withDirectoryLock } from "./fs-utils.js";
|
|
4
|
+
import { stripBom, withDirectoryLock } from "./fs-utils.js";
|
|
5
5
|
import { FLOW_STAGES } from "./types.js";
|
|
6
6
|
const KNOWLEDGE_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
|
|
7
7
|
const KNOWLEDGE_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
|
|
@@ -179,7 +179,7 @@ export function materializeKnowledgeEntry(seed, defaults = {}) {
|
|
|
179
179
|
async function readExistingKnowledgeKeys(filePath) {
|
|
180
180
|
const keys = new Set();
|
|
181
181
|
try {
|
|
182
|
-
const raw = await fs.readFile(filePath, "utf8");
|
|
182
|
+
const raw = stripBom(await fs.readFile(filePath, "utf8"));
|
|
183
183
|
const lines = raw.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
184
184
|
for (const line of lines) {
|
|
185
185
|
try {
|
package/dist/retro-gate.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { RUNTIME_ROOT } from "./constants.js";
|
|
4
|
-
import { exists } from "./fs-utils.js";
|
|
4
|
+
import { exists, stripBom } from "./fs-utils.js";
|
|
5
5
|
function activeArtifactsPath(projectRoot) {
|
|
6
6
|
return path.join(projectRoot, RUNTIME_ROOT, "artifacts");
|
|
7
7
|
}
|
|
8
8
|
function retroArtifactPath(projectRoot) {
|
|
9
9
|
return path.join(activeArtifactsPath(projectRoot), "09-retro.md");
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
// Fallback window for compound-entry scanning when `retroDraftedAt` /
|
|
12
|
+
// `retroAcceptedAt` are not set (legacy runs or imports): use the retro
|
|
13
|
+
// artifact's mtime ± 7 days. 24h was too narrow for long-running retros
|
|
14
|
+
// that are edited over several days or runs imported from another
|
|
15
|
+
// machine with slightly different clocks; 7 days is still tight enough
|
|
16
|
+
// that entries from an unrelated future run are excluded.
|
|
17
|
+
const RETRO_ARTIFACT_MTIME_FALLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
18
|
function parseIsoTimestamp(value) {
|
|
13
19
|
if (!value || value.trim().length === 0)
|
|
14
20
|
return null;
|
|
@@ -58,7 +64,7 @@ export async function evaluateRetroGate(projectRoot, state) {
|
|
|
58
64
|
const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
|
|
59
65
|
if (shouldFallbackScan && (await exists(knowledgeFile))) {
|
|
60
66
|
try {
|
|
61
|
-
const raw = await fs.readFile(knowledgeFile, "utf8");
|
|
67
|
+
const raw = stripBom(await fs.readFile(knowledgeFile, "utf8"));
|
|
62
68
|
compoundEntries = 0;
|
|
63
69
|
for (const line of raw.split(/\r?\n/)) {
|
|
64
70
|
const trimmed = line.trim();
|
|
@@ -90,17 +96,20 @@ export async function evaluateRetroGate(projectRoot, state) {
|
|
|
90
96
|
compoundEntries = 0;
|
|
91
97
|
}
|
|
92
98
|
}
|
|
93
|
-
// A retro is considered complete when
|
|
94
|
-
// - at least one compound learning was
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
// A retro is considered complete when any of:
|
|
100
|
+
// - the retro artifact exists AND (at least one compound learning was
|
|
101
|
+
// promoted during the retro window OR compound was explicitly skipped
|
|
102
|
+
// after reviewing the draft), or
|
|
103
|
+
// - the operator explicitly skipped the retro step itself
|
|
104
|
+
// (`retroSkipped === true` with a reason). `retroSkipped` is an
|
|
105
|
+
// operator-level override of the artifact requirement, so it must
|
|
106
|
+
// bypass `hasRetroArtifact` — otherwise a run that legitimately had
|
|
107
|
+
// nothing worth retro-ing dead-locks at closeout waiting for a
|
|
108
|
+
// file that will never exist.
|
|
109
|
+
const retroSkipped = state.closeout.retroSkipped === true;
|
|
110
|
+
const compoundSkipped = state.closeout.compoundSkipped === true;
|
|
111
|
+
const artifactPathComplete = hasRetroArtifact && (compoundEntries > 0 || compoundSkipped);
|
|
112
|
+
const completed = required ? retroSkipped || artifactPathComplete : true;
|
|
104
113
|
return {
|
|
105
114
|
required,
|
|
106
115
|
completed,
|
package/dist/run-archive.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { RUNTIME_ROOT } from "./constants.js";
|
|
4
4
|
import { createInitialFlowState } from "./flow-state.js";
|
|
5
5
|
import { readActiveFeature, syncActiveFeatureSnapshot } from "./feature-system.js";
|
|
6
|
-
import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
|
|
6
|
+
import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
|
|
7
7
|
import { evaluateRetroGate } from "./retro-gate.js";
|
|
8
8
|
import { ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
|
|
9
9
|
const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
|
|
@@ -17,6 +17,12 @@ const STATE_SNAPSHOT_EXCLUDE = new Set([
|
|
|
17
17
|
const DELEGATION_LOG_FILE = "delegation-log.json";
|
|
18
18
|
const TDD_CYCLE_LOG_FILE = "tdd-cycle-log.jsonl";
|
|
19
19
|
const RECONCILIATION_NOTICES_FILE = "reconciliation-notices.json";
|
|
20
|
+
const CRITICAL_STATE_SNAPSHOT_FILES = new Set([
|
|
21
|
+
"flow-state.json",
|
|
22
|
+
DELEGATION_LOG_FILE,
|
|
23
|
+
TDD_CYCLE_LOG_FILE,
|
|
24
|
+
RECONCILIATION_NOTICES_FILE
|
|
25
|
+
]);
|
|
20
26
|
function runsRoot(projectRoot) {
|
|
21
27
|
return path.join(projectRoot, RUNS_DIR_REL_PATH);
|
|
22
28
|
}
|
|
@@ -26,6 +32,9 @@ function activeArtifactsPath(projectRoot) {
|
|
|
26
32
|
function stateDirPath(projectRoot) {
|
|
27
33
|
return path.join(projectRoot, STATE_DIR_REL_PATH);
|
|
28
34
|
}
|
|
35
|
+
function archiveLockPath(projectRoot) {
|
|
36
|
+
return path.join(projectRoot, RUNTIME_ROOT, "state", ".archive.lock");
|
|
37
|
+
}
|
|
29
38
|
async function snapshotStateDirectory(projectRoot, destinationRoot) {
|
|
30
39
|
const sourceDir = stateDirPath(projectRoot);
|
|
31
40
|
if (!(await exists(sourceDir))) {
|
|
@@ -57,8 +66,12 @@ async function snapshotStateDirectory(projectRoot, destinationRoot) {
|
|
|
57
66
|
copied.push(entry.name);
|
|
58
67
|
}
|
|
59
68
|
}
|
|
60
|
-
catch {
|
|
61
|
-
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (CRITICAL_STATE_SNAPSHOT_FILES.has(entry.name)) {
|
|
71
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
72
|
+
throw new Error(`Archive snapshot failed for critical state file "${entry.name}" (${details}).`);
|
|
73
|
+
}
|
|
74
|
+
// Non-critical snapshot files are best-effort and may be skipped.
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
return copied.sort((a, b) => a.localeCompare(b));
|
|
@@ -147,97 +160,155 @@ export async function listRuns(projectRoot) {
|
|
|
147
160
|
}
|
|
148
161
|
export async function archiveRun(projectRoot, featureName, options = {}) {
|
|
149
162
|
await ensureRunSystem(projectRoot);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
163
|
+
return withDirectoryLock(archiveLockPath(projectRoot), async () => {
|
|
164
|
+
const activeFeature = await readActiveFeature(projectRoot);
|
|
165
|
+
const artifactsDir = activeArtifactsPath(projectRoot);
|
|
166
|
+
const runsDir = runsRoot(projectRoot);
|
|
167
|
+
await ensureDir(runsDir);
|
|
168
|
+
await ensureDir(artifactsDir);
|
|
169
|
+
const feature = (featureName?.trim() && featureName.trim().length > 0)
|
|
170
|
+
? featureName.trim()
|
|
171
|
+
: await inferFeatureNameFromArtifacts(projectRoot);
|
|
172
|
+
const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
|
|
173
|
+
const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
|
|
174
|
+
const archivePath = path.join(runsDir, archiveId);
|
|
175
|
+
const archiveArtifactsPath = path.join(archivePath, "artifacts");
|
|
176
|
+
let sourceState = await readFlowState(projectRoot);
|
|
177
|
+
const retroGate = await evaluateRetroGate(projectRoot, sourceState);
|
|
178
|
+
const shipCompleted = sourceState.completedStages.includes("ship");
|
|
179
|
+
const skipRetro = options.skipRetro === true;
|
|
180
|
+
const skipRetroReason = options.skipRetroReason?.trim();
|
|
181
|
+
if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
|
|
182
|
+
throw new Error("archive --skip-retro requires --retro-reason=<text>.");
|
|
183
|
+
}
|
|
184
|
+
const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
|
|
185
|
+
typeof sourceState.closeout.retroSkipReason === "string" &&
|
|
186
|
+
sourceState.closeout.retroSkipReason.trim().length > 0;
|
|
187
|
+
const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
|
|
188
|
+
const inShipCloseout = sourceState.currentStage === "ship";
|
|
189
|
+
if (inShipCloseout && skipRetro) {
|
|
190
|
+
throw new Error("Archive blocked: --skip-retro is not allowed while current stage is ship. " +
|
|
191
|
+
"Complete closeout to ready_to_archive via /cc-next.");
|
|
192
|
+
}
|
|
193
|
+
if (inShipCloseout && !readyForArchive) {
|
|
194
|
+
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
195
|
+
"Resume /cc-next until closeout reaches ready_to_archive.");
|
|
196
|
+
}
|
|
197
|
+
if (shipCompleted && !readyForArchive && !skipRetro) {
|
|
198
|
+
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
199
|
+
"Resume /cc-next until closeout reaches ready_to_archive, " +
|
|
200
|
+
"or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
|
|
201
|
+
}
|
|
202
|
+
if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
|
|
203
|
+
throw new Error("Archive blocked: retro gate is required after ship completion. " +
|
|
204
|
+
"Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
|
|
205
|
+
}
|
|
206
|
+
if (retroGate.completed) {
|
|
207
|
+
const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
|
|
208
|
+
sourceState = {
|
|
209
|
+
...sourceState,
|
|
210
|
+
retro: {
|
|
211
|
+
required: retroGate.required,
|
|
212
|
+
completedAt,
|
|
213
|
+
compoundEntries: retroGate.compoundEntries
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
await writeFlowState(projectRoot, sourceState, { allowReset: true });
|
|
217
|
+
}
|
|
218
|
+
const retroSummary = {
|
|
219
|
+
required: retroGate.required,
|
|
220
|
+
completed: retroGate.completed,
|
|
221
|
+
skipped: skipRetro || retroSkippedInCloseout,
|
|
222
|
+
skipReason: skipRetro
|
|
223
|
+
? skipRetroReason
|
|
224
|
+
: retroSkippedInCloseout
|
|
225
|
+
? sourceState.closeout.retroSkipReason
|
|
226
|
+
: undefined,
|
|
227
|
+
compoundEntries: retroGate.compoundEntries
|
|
192
228
|
};
|
|
193
|
-
await
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
229
|
+
await ensureDir(archivePath);
|
|
230
|
+
// Drop an `.archive-in-progress` sentinel immediately so that a crash
|
|
231
|
+
// between the artifact rename and the final manifest write leaves a
|
|
232
|
+
// recoverable marker (doctor surfaces these; re-running archive on an
|
|
233
|
+
// orphan attempts to complete or roll back). The sentinel is removed
|
|
234
|
+
// only after the manifest lands successfully.
|
|
235
|
+
const sentinelPath = path.join(archivePath, ".archive-in-progress");
|
|
236
|
+
const archivedAt = new Date().toISOString();
|
|
237
|
+
await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
|
|
238
|
+
const stateBeforeReset = sourceState;
|
|
239
|
+
let artifactsMoved = false;
|
|
240
|
+
let stateReset = false;
|
|
241
|
+
try {
|
|
242
|
+
await fs.rename(artifactsDir, archiveArtifactsPath);
|
|
243
|
+
artifactsMoved = true;
|
|
244
|
+
await ensureDir(artifactsDir);
|
|
245
|
+
const archiveStatePath = path.join(archivePath, "state");
|
|
246
|
+
const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
|
|
247
|
+
const resetState = createInitialFlowState();
|
|
248
|
+
await writeFlowState(projectRoot, resetState, { allowReset: true });
|
|
249
|
+
stateReset = true;
|
|
250
|
+
await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
|
|
251
|
+
const manifest = {
|
|
252
|
+
version: 1,
|
|
253
|
+
archiveId,
|
|
254
|
+
archivedAt,
|
|
255
|
+
featureName: feature,
|
|
256
|
+
activeFeature,
|
|
257
|
+
sourceRunId: sourceState.activeRunId,
|
|
258
|
+
sourceCurrentStage: sourceState.currentStage,
|
|
259
|
+
sourceCompletedStages: sourceState.completedStages,
|
|
260
|
+
snapshottedStateFiles,
|
|
261
|
+
retro: retroSummary
|
|
262
|
+
};
|
|
263
|
+
await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
264
|
+
// Manifest landed — sentinel is no longer needed.
|
|
265
|
+
await fs.unlink(sentinelPath).catch(() => undefined);
|
|
266
|
+
const knowledgeStats = await readKnowledgeStats(projectRoot);
|
|
267
|
+
await syncActiveFeatureSnapshot(projectRoot);
|
|
268
|
+
return {
|
|
269
|
+
archiveId,
|
|
270
|
+
archivePath,
|
|
271
|
+
archivedAt,
|
|
272
|
+
featureName: feature,
|
|
273
|
+
activeFeature,
|
|
274
|
+
resetState,
|
|
275
|
+
snapshottedStateFiles,
|
|
276
|
+
knowledge: knowledgeStats,
|
|
277
|
+
retro: retroSummary
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
// Best-effort rollback: if artifacts were moved but the subsequent
|
|
282
|
+
// steps failed, put artifacts back so the user is not left without
|
|
283
|
+
// a working run. The sentinel is intentionally left behind for
|
|
284
|
+
// inspection; doctor surfaces it.
|
|
285
|
+
if (artifactsMoved) {
|
|
286
|
+
try {
|
|
287
|
+
await fs.rm(artifactsDir, { recursive: true, force: true });
|
|
288
|
+
await fs.rename(archiveArtifactsPath, artifactsDir);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Rollback failed — sentinel + orphaned archive dir will be
|
|
292
|
+
// surfaced by doctor and can be reconciled manually.
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (stateReset) {
|
|
296
|
+
try {
|
|
297
|
+
await writeFlowState(projectRoot, stateBeforeReset, { allowReset: true });
|
|
298
|
+
await resetCarryoverStateFiles(projectRoot, stateBeforeReset.activeRunId);
|
|
299
|
+
}
|
|
300
|
+
catch {
|
|
301
|
+
// If rollback of state fails, keep sentinel + archive remnants for
|
|
302
|
+
// manual reconciliation.
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
throw err;
|
|
306
|
+
}
|
|
307
|
+
}, {
|
|
308
|
+
retries: 400,
|
|
309
|
+
retryDelayMs: 25,
|
|
310
|
+
staleAfterMs: 120_000
|
|
311
|
+
});
|
|
241
312
|
}
|
|
242
313
|
const KNOWLEDGE_SOFT_THRESHOLD = 50;
|
|
243
314
|
async function readKnowledgeStats(projectRoot) {
|
|
@@ -12,12 +12,19 @@ export interface WriteFlowStateOptions {
|
|
|
12
12
|
*/
|
|
13
13
|
allowReset?: boolean;
|
|
14
14
|
}
|
|
15
|
+
export interface ReadFlowStateOptions {
|
|
16
|
+
/**
|
|
17
|
+
* When false, skip feature-system auto-repair writes and read flow-state in
|
|
18
|
+
* pure diagnostic mode.
|
|
19
|
+
*/
|
|
20
|
+
repairFeatureSystem?: boolean;
|
|
21
|
+
}
|
|
15
22
|
export declare class CorruptFlowStateError extends Error {
|
|
16
23
|
readonly statePath: string;
|
|
17
24
|
readonly quarantinedPath: string;
|
|
18
25
|
constructor(statePath: string, quarantinedPath: string, cause: unknown);
|
|
19
26
|
}
|
|
20
|
-
export declare function readFlowState(projectRoot: string): Promise<FlowState>;
|
|
27
|
+
export declare function readFlowState(projectRoot: string, options?: ReadFlowStateOptions): Promise<FlowState>;
|
|
21
28
|
export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
|
|
22
29
|
interface EnsureRunSystemOptions {
|
|
23
30
|
createIfMissing?: boolean;
|
package/dist/run-persistence.js
CHANGED
|
@@ -24,6 +24,13 @@ function validateFlowTransition(prev, next) {
|
|
|
24
24
|
// New run — only reset paths may change the runId, but those set allowReset.
|
|
25
25
|
throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change activeRunId from "${prev.activeRunId}" to "${next.activeRunId}" without allowReset.`);
|
|
26
26
|
}
|
|
27
|
+
// Track is immutable within a single run: stage schemas, gate sets, and
|
|
28
|
+
// cross-stage reads all branch on track. Silently flipping the track
|
|
29
|
+
// mid-run would let completed stages satisfy one gate tier and the
|
|
30
|
+
// current stage re-read the catalog under a different tier.
|
|
31
|
+
if (prev.track !== next.track) {
|
|
32
|
+
throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change track from "${prev.track}" to "${next.track}" mid-run (activeRunId="${prev.activeRunId}"). Archive the run and start a new one to switch tracks.`);
|
|
33
|
+
}
|
|
27
34
|
for (const completed of prev.completedStages) {
|
|
28
35
|
if (!next.completedStages.includes(completed)) {
|
|
29
36
|
throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `completedStages must be monotonic: stage "${completed}" was previously completed but is missing from the new state.`);
|
|
@@ -270,7 +277,7 @@ function sanitizeCloseoutState(value) {
|
|
|
270
277
|
}
|
|
271
278
|
function coerceFlowState(parsed) {
|
|
272
279
|
const track = coerceTrack(parsed.track);
|
|
273
|
-
const next = createInitialFlowState(
|
|
280
|
+
const next = createInitialFlowState({ track });
|
|
274
281
|
const activeRunIdRaw = parsed.activeRunId;
|
|
275
282
|
const activeRunId = typeof activeRunIdRaw === "string" && activeRunIdRaw.trim().length > 0
|
|
276
283
|
? activeRunIdRaw.trim()
|
|
@@ -326,8 +333,10 @@ async function quarantineCorruptState(statePath, cause) {
|
|
|
326
333
|
}
|
|
327
334
|
throw new CorruptFlowStateError(statePath, quarantinedPath, cause);
|
|
328
335
|
}
|
|
329
|
-
export async function readFlowState(projectRoot) {
|
|
330
|
-
|
|
336
|
+
export async function readFlowState(projectRoot, options = {}) {
|
|
337
|
+
if (options.repairFeatureSystem !== false) {
|
|
338
|
+
await ensureFeatureSystem(projectRoot);
|
|
339
|
+
}
|
|
331
340
|
const statePath = flowStatePath(projectRoot);
|
|
332
341
|
if (!(await exists(statePath))) {
|
|
333
342
|
return createInitialFlowState();
|
|
@@ -368,8 +377,7 @@ export async function writeFlowState(projectRoot, state, options = {}) {
|
|
|
368
377
|
if (err instanceof InvalidStageTransitionError) {
|
|
369
378
|
throw err;
|
|
370
379
|
}
|
|
371
|
-
|
|
372
|
-
// block a legitimate write attempt on parse errors here.
|
|
380
|
+
throw new Error(`cannot validate flow-state transition because ${FLOW_STATE_REL_PATH} is unreadable or corrupt (${err instanceof Error ? err.message : String(err)}). Run \`cclaw doctor\` and reconcile the state before retrying.`);
|
|
373
381
|
}
|
|
374
382
|
}
|
|
375
383
|
const safe = coerceFlowState({ ...state });
|