cclaw-cli 0.46.6 → 0.46.8

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.
@@ -0,0 +1,83 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { RUNTIME_ROOT } from "./constants.js";
4
+ import { exists } from "./fs-utils.js";
5
+ function activeArtifactsPath(projectRoot) {
6
+ return path.join(projectRoot, RUNTIME_ROOT, "artifacts");
7
+ }
8
+ function retroArtifactPath(projectRoot) {
9
+ return path.join(activeArtifactsPath(projectRoot), "09-retro.md");
10
+ }
11
+ function parseIsoTimestamp(value) {
12
+ if (!value || value.trim().length === 0)
13
+ return null;
14
+ const parsed = Date.parse(value);
15
+ return Number.isFinite(parsed) ? parsed : null;
16
+ }
17
+ function inInclusiveWindow(timestamp, windowStartMs, windowEndMs) {
18
+ if (windowStartMs !== null && timestamp < windowStartMs)
19
+ return false;
20
+ if (windowEndMs !== null && timestamp > windowEndMs)
21
+ return false;
22
+ return true;
23
+ }
24
+ export async function evaluateRetroGate(projectRoot, state) {
25
+ const required = state.completedStages.includes("ship");
26
+ const artifactFile = retroArtifactPath(projectRoot);
27
+ let hasRetroArtifact = false;
28
+ if (await exists(artifactFile)) {
29
+ try {
30
+ const raw = await fs.readFile(artifactFile, "utf8");
31
+ hasRetroArtifact = raw.trim().length > 0;
32
+ }
33
+ catch {
34
+ hasRetroArtifact = false;
35
+ }
36
+ }
37
+ let compoundEntries = state.retro.compoundEntries;
38
+ const windowStartMs = parseIsoTimestamp(state.closeout.retroDraftedAt);
39
+ const windowEndMs = parseIsoTimestamp(state.closeout.retroAcceptedAt) ?? parseIsoTimestamp(state.retro.completedAt);
40
+ const shouldFallbackScan = compoundEntries <= 0 && (windowStartMs !== null || windowEndMs !== null);
41
+ const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
42
+ if (shouldFallbackScan && (await exists(knowledgeFile))) {
43
+ try {
44
+ const raw = await fs.readFile(knowledgeFile, "utf8");
45
+ compoundEntries = 0;
46
+ for (const line of raw.split(/\r?\n/)) {
47
+ const trimmed = line.trim();
48
+ if (!trimmed)
49
+ continue;
50
+ try {
51
+ const parsed = JSON.parse(trimmed);
52
+ if (parsed.type !== "compound") {
53
+ continue;
54
+ }
55
+ const created = typeof parsed.created === "string" ? parseIsoTimestamp(parsed.created) : null;
56
+ if (created === null || !inInclusiveWindow(created, windowStartMs, windowEndMs)) {
57
+ continue;
58
+ }
59
+ const source = typeof parsed.source === "string"
60
+ ? parsed.source.trim().toLowerCase()
61
+ : null;
62
+ const legacyRetroStage = parsed.stage === "retro";
63
+ if (source === "retro" || legacyRetroStage) {
64
+ compoundEntries += 1;
65
+ }
66
+ }
67
+ catch {
68
+ // ignore malformed lines for retro gate calculation
69
+ }
70
+ }
71
+ }
72
+ catch {
73
+ compoundEntries = 0;
74
+ }
75
+ }
76
+ const completed = required ? (hasRetroArtifact && compoundEntries > 0) : true;
77
+ return {
78
+ required,
79
+ completed,
80
+ compoundEntries,
81
+ hasRetroArtifact
82
+ };
83
+ }
@@ -0,0 +1,55 @@
1
+ import { type FlowState } from "./flow-state.js";
2
+ import type { FlowStage } from "./types.js";
3
+ export interface CclawRunMeta {
4
+ id: string;
5
+ title: string;
6
+ createdAt: string;
7
+ }
8
+ export interface ArchiveRunResult {
9
+ archiveId: string;
10
+ archivePath: string;
11
+ archivedAt: string;
12
+ featureName: string;
13
+ activeFeature: string;
14
+ resetState: FlowState;
15
+ snapshottedStateFiles: string[];
16
+ /** Knowledge curation hint: total active entries + soft threshold (50). */
17
+ knowledge: {
18
+ activeEntryCount: number;
19
+ softThreshold: number;
20
+ overThreshold: boolean;
21
+ knowledgePath: string;
22
+ };
23
+ retro: {
24
+ required: boolean;
25
+ completed: boolean;
26
+ skipped: boolean;
27
+ skipReason?: string;
28
+ compoundEntries: number;
29
+ };
30
+ }
31
+ export interface ArchiveManifest {
32
+ version: 1;
33
+ archiveId: string;
34
+ archivedAt: string;
35
+ featureName: string;
36
+ activeFeature: string;
37
+ sourceRunId: string;
38
+ sourceCurrentStage: FlowStage;
39
+ sourceCompletedStages: FlowStage[];
40
+ snapshottedStateFiles: string[];
41
+ retro: ArchiveRunResult["retro"];
42
+ }
43
+ export interface ArchiveRunOptions {
44
+ skipRetro?: boolean;
45
+ skipRetroReason?: string;
46
+ }
47
+ export declare function listRuns(projectRoot: string): Promise<CclawRunMeta[]>;
48
+ export declare function archiveRun(projectRoot: string, featureName?: string, options?: ArchiveRunOptions): Promise<ArchiveRunResult>;
49
+ /**
50
+ * Counts entries in the canonical JSONL knowledge store. An "active" entry is one
51
+ * non-empty line that parses as JSON with the required `type` field belonging to the
52
+ * allowed set. Malformed lines are ignored (not counted) but do not throw so that a
53
+ * hand-edited file cannot break doctor/archive flows.
54
+ */
55
+ export declare function countActiveKnowledgeEntries(text: string): number;
@@ -0,0 +1,270 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { RUNTIME_ROOT } from "./constants.js";
4
+ import { createInitialFlowState } from "./flow-state.js";
5
+ import { readActiveFeature, syncActiveFeatureSnapshot } from "./feature-system.js";
6
+ import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
7
+ import { evaluateRetroGate } from "./retro-gate.js";
8
+ import { ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
9
+ const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
10
+ const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
11
+ const STATE_DIR_REL_PATH = `${RUNTIME_ROOT}/state`;
12
+ /** State filenames explicitly excluded from the archive snapshot. */
13
+ const STATE_SNAPSHOT_EXCLUDE = new Set([
14
+ ".flow-state.lock",
15
+ ".delegation.lock"
16
+ ]);
17
+ function runsRoot(projectRoot) {
18
+ return path.join(projectRoot, RUNS_DIR_REL_PATH);
19
+ }
20
+ function activeArtifactsPath(projectRoot) {
21
+ return path.join(projectRoot, ACTIVE_ARTIFACTS_REL_PATH);
22
+ }
23
+ function stateDirPath(projectRoot) {
24
+ return path.join(projectRoot, STATE_DIR_REL_PATH);
25
+ }
26
+ async function snapshotStateDirectory(projectRoot, destinationRoot) {
27
+ const sourceDir = stateDirPath(projectRoot);
28
+ if (!(await exists(sourceDir))) {
29
+ return [];
30
+ }
31
+ await ensureDir(destinationRoot);
32
+ const copied = [];
33
+ let entries;
34
+ try {
35
+ entries = await fs.readdir(sourceDir, { withFileTypes: true });
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ for (const entry of entries) {
41
+ if (STATE_SNAPSHOT_EXCLUDE.has(entry.name))
42
+ continue;
43
+ if (entry.name.startsWith(".") && !entry.name.endsWith(".json"))
44
+ continue;
45
+ const from = path.join(sourceDir, entry.name);
46
+ const to = path.join(destinationRoot, entry.name);
47
+ try {
48
+ if (entry.isDirectory()) {
49
+ await fs.cp(from, to, { recursive: true });
50
+ copied.push(`${entry.name}/`);
51
+ }
52
+ else if (entry.isFile()) {
53
+ await fs.copyFile(from, to);
54
+ copied.push(entry.name);
55
+ }
56
+ }
57
+ catch {
58
+ // best-effort snapshot; continue on individual failures
59
+ }
60
+ }
61
+ return copied.sort((a, b) => a.localeCompare(b));
62
+ }
63
+ function toArchiveDate(date = new Date()) {
64
+ const yyyy = date.getFullYear().toString();
65
+ const mm = (date.getMonth() + 1).toString().padStart(2, "0");
66
+ const dd = date.getDate().toString().padStart(2, "0");
67
+ return `${yyyy}-${mm}-${dd}`;
68
+ }
69
+ function slugifyFeatureName(value) {
70
+ const slug = value
71
+ .toLowerCase()
72
+ .trim()
73
+ .replace(/[^a-z0-9]+/gu, "-")
74
+ .replace(/^-+/u, "")
75
+ .replace(/-+$/u, "");
76
+ if (slug.length === 0) {
77
+ return "feature";
78
+ }
79
+ return slug.slice(0, 64);
80
+ }
81
+ async function inferFeatureNameFromArtifacts(projectRoot) {
82
+ const ideaPath = path.join(projectRoot, ACTIVE_ARTIFACTS_REL_PATH, "00-idea.md");
83
+ if (!(await exists(ideaPath))) {
84
+ return "feature";
85
+ }
86
+ try {
87
+ const raw = await fs.readFile(ideaPath, "utf8");
88
+ const firstMeaningful = raw
89
+ .split(/\r?\n/gu)
90
+ .map((line) => line.trim())
91
+ .find((line) => line.length > 0);
92
+ if (!firstMeaningful) {
93
+ return "feature";
94
+ }
95
+ return firstMeaningful.replace(/^[-#*\s]+/u, "").trim() || "feature";
96
+ }
97
+ catch {
98
+ return "feature";
99
+ }
100
+ }
101
+ async function uniqueArchiveId(projectRoot, baseId) {
102
+ let index = 1;
103
+ let candidate = baseId;
104
+ while (await exists(path.join(runsRoot(projectRoot), candidate))) {
105
+ index += 1;
106
+ candidate = `${baseId}-${index}`;
107
+ }
108
+ return candidate;
109
+ }
110
+ export async function listRuns(projectRoot) {
111
+ const root = runsRoot(projectRoot);
112
+ if (!(await exists(root))) {
113
+ return [];
114
+ }
115
+ const entries = await fs.readdir(root, { withFileTypes: true });
116
+ const runs = [];
117
+ for (const entry of entries) {
118
+ if (!entry.isDirectory()) {
119
+ continue;
120
+ }
121
+ const runPath = path.join(root, entry.name);
122
+ let createdAt = new Date().toISOString();
123
+ try {
124
+ const stat = await fs.stat(runPath);
125
+ createdAt = stat.birthtime?.toISOString?.() ?? stat.mtime.toISOString();
126
+ }
127
+ catch {
128
+ // keep fallback timestamp
129
+ }
130
+ runs.push({
131
+ id: entry.name,
132
+ title: entry.name,
133
+ createdAt
134
+ });
135
+ }
136
+ return runs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
137
+ }
138
+ export async function archiveRun(projectRoot, featureName, options = {}) {
139
+ await ensureRunSystem(projectRoot);
140
+ const activeFeature = await readActiveFeature(projectRoot);
141
+ const artifactsDir = activeArtifactsPath(projectRoot);
142
+ const runsDir = runsRoot(projectRoot);
143
+ await ensureDir(runsDir);
144
+ await ensureDir(artifactsDir);
145
+ const feature = (featureName?.trim() && featureName.trim().length > 0)
146
+ ? featureName.trim()
147
+ : await inferFeatureNameFromArtifacts(projectRoot);
148
+ const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
149
+ const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
150
+ const archivePath = path.join(runsDir, archiveId);
151
+ const archiveArtifactsPath = path.join(archivePath, "artifacts");
152
+ let sourceState = await readFlowState(projectRoot);
153
+ const retroGate = await evaluateRetroGate(projectRoot, sourceState);
154
+ const shipCompleted = sourceState.completedStages.includes("ship");
155
+ const skipRetro = options.skipRetro === true;
156
+ const skipRetroReason = options.skipRetroReason?.trim();
157
+ if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
158
+ throw new Error("archive --skip-retro requires --retro-reason=<text>.");
159
+ }
160
+ const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
161
+ typeof sourceState.closeout.retroSkipReason === "string" &&
162
+ sourceState.closeout.retroSkipReason.trim().length > 0;
163
+ const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
164
+ if (shipCompleted && !readyForArchive && !skipRetro) {
165
+ throw new Error("Archive blocked: closeout is not ready_to_archive. " +
166
+ "Resume /cc-next until closeout reaches ready_to_archive, " +
167
+ "or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
168
+ }
169
+ if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
170
+ throw new Error("Archive blocked: retro gate is required after ship completion. " +
171
+ "Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
172
+ }
173
+ if (retroGate.completed) {
174
+ const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
175
+ sourceState = {
176
+ ...sourceState,
177
+ retro: {
178
+ required: retroGate.required,
179
+ completedAt,
180
+ compoundEntries: retroGate.compoundEntries
181
+ }
182
+ };
183
+ await writeFlowState(projectRoot, sourceState, { allowReset: true });
184
+ }
185
+ const retroSummary = {
186
+ required: retroGate.required,
187
+ completed: retroGate.completed,
188
+ skipped: skipRetro || retroSkippedInCloseout,
189
+ skipReason: skipRetro
190
+ ? skipRetroReason
191
+ : retroSkippedInCloseout
192
+ ? sourceState.closeout.retroSkipReason
193
+ : undefined,
194
+ compoundEntries: retroGate.compoundEntries
195
+ };
196
+ await ensureDir(archivePath);
197
+ await fs.rename(artifactsDir, archiveArtifactsPath);
198
+ await ensureDir(artifactsDir);
199
+ const archiveStatePath = path.join(archivePath, "state");
200
+ const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
201
+ const resetState = createInitialFlowState();
202
+ await writeFlowState(projectRoot, resetState, { allowReset: true });
203
+ const archivedAt = new Date().toISOString();
204
+ const manifest = {
205
+ version: 1,
206
+ archiveId,
207
+ archivedAt,
208
+ featureName: feature,
209
+ activeFeature,
210
+ sourceRunId: sourceState.activeRunId,
211
+ sourceCurrentStage: sourceState.currentStage,
212
+ sourceCompletedStages: sourceState.completedStages,
213
+ snapshottedStateFiles,
214
+ retro: retroSummary
215
+ };
216
+ await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
217
+ const knowledgeStats = await readKnowledgeStats(projectRoot);
218
+ await syncActiveFeatureSnapshot(projectRoot);
219
+ return {
220
+ archiveId,
221
+ archivePath,
222
+ archivedAt,
223
+ featureName: feature,
224
+ activeFeature,
225
+ resetState,
226
+ snapshottedStateFiles,
227
+ knowledge: knowledgeStats,
228
+ retro: retroSummary
229
+ };
230
+ }
231
+ const KNOWLEDGE_SOFT_THRESHOLD = 50;
232
+ async function readKnowledgeStats(projectRoot) {
233
+ const knowledgePath = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
234
+ let activeEntryCount = 0;
235
+ if (await exists(knowledgePath)) {
236
+ const text = await fs.readFile(knowledgePath, "utf8");
237
+ activeEntryCount = countActiveKnowledgeEntries(text);
238
+ }
239
+ return {
240
+ activeEntryCount,
241
+ softThreshold: KNOWLEDGE_SOFT_THRESHOLD,
242
+ overThreshold: activeEntryCount > KNOWLEDGE_SOFT_THRESHOLD,
243
+ knowledgePath: `${RUNTIME_ROOT}/knowledge.jsonl`
244
+ };
245
+ }
246
+ /**
247
+ * Counts entries in the canonical JSONL knowledge store. An "active" entry is one
248
+ * non-empty line that parses as JSON with the required `type` field belonging to the
249
+ * allowed set. Malformed lines are ignored (not counted) but do not throw so that a
250
+ * hand-edited file cannot break doctor/archive flows.
251
+ */
252
+ export function countActiveKnowledgeEntries(text) {
253
+ const allowed = new Set(["rule", "pattern", "lesson", "compound"]);
254
+ let count = 0;
255
+ for (const raw of text.split(/\r?\n/)) {
256
+ const line = raw.trim();
257
+ if (line.length === 0)
258
+ continue;
259
+ try {
260
+ const parsed = JSON.parse(line);
261
+ if (typeof parsed.type === "string" && allowed.has(parsed.type)) {
262
+ count += 1;
263
+ }
264
+ }
265
+ catch {
266
+ // Skip malformed lines silently; curation surfaces them separately.
267
+ }
268
+ }
269
+ return count;
270
+ }
@@ -0,0 +1,26 @@
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
+ }
15
+ export declare class CorruptFlowStateError extends Error {
16
+ readonly statePath: string;
17
+ readonly quarantinedPath: string;
18
+ constructor(statePath: string, quarantinedPath: string, cause: unknown);
19
+ }
20
+ export declare function readFlowState(projectRoot: string): Promise<FlowState>;
21
+ export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
22
+ interface EnsureRunSystemOptions {
23
+ createIfMissing?: boolean;
24
+ }
25
+ export declare function ensureRunSystem(projectRoot: string, _options?: EnsureRunSystemOptions): Promise<FlowState>;
26
+ export {};