cclaw-cli 0.46.7 → 0.46.9

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/runs.js CHANGED
@@ -1,710 +1,2 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { COMMAND_FILE_ORDER, RUNTIME_ROOT } from "./constants.js";
4
- import { canTransition, createInitialCloseoutState, createInitialFlowState, isFlowTrack, skippedStagesForTrack, SHIP_SUBSTATES } from "./flow-state.js";
5
- import { ensureFeatureSystem, readActiveFeature, syncActiveFeatureSnapshot } from "./feature-system.js";
6
- import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
7
- export class InvalidStageTransitionError extends Error {
8
- from;
9
- to;
10
- constructor(from, to, message) {
11
- super(message);
12
- this.from = from;
13
- this.to = to;
14
- this.name = "InvalidStageTransitionError";
15
- }
16
- }
17
- function validateFlowTransition(prev, next) {
18
- if (prev.activeRunId !== next.activeRunId) {
19
- // New run — only reset paths may change the runId, but those set allowReset.
20
- throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `cannot change activeRunId from "${prev.activeRunId}" to "${next.activeRunId}" without allowReset.`);
21
- }
22
- for (const completed of prev.completedStages) {
23
- if (!next.completedStages.includes(completed)) {
24
- throw new InvalidStageTransitionError(prev.currentStage, next.currentStage, `completedStages must be monotonic: stage "${completed}" was previously completed but is missing from the new state.`);
25
- }
26
- }
27
- if (prev.currentStage === next.currentStage) {
28
- return;
29
- }
30
- if (!canTransition(prev.currentStage, next.currentStage)) {
31
- 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.`);
32
- }
33
- }
34
- const FLOW_STATE_REL_PATH = `${RUNTIME_ROOT}/state/flow-state.json`;
35
- const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
36
- const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
37
- const STATE_DIR_REL_PATH = `${RUNTIME_ROOT}/state`;
38
- const FLOW_STAGE_SET = new Set(COMMAND_FILE_ORDER);
39
- /** State filenames explicitly excluded from the archive snapshot. */
40
- const STATE_SNAPSHOT_EXCLUDE = new Set([
41
- ".flow-state.lock",
42
- ".delegation.lock"
43
- ]);
44
- function flowStatePath(projectRoot) {
45
- return path.join(projectRoot, FLOW_STATE_REL_PATH);
46
- }
47
- function flowStateLockPath(projectRoot) {
48
- return path.join(projectRoot, RUNTIME_ROOT, "state", ".flow-state.lock");
49
- }
50
- function runsRoot(projectRoot) {
51
- return path.join(projectRoot, RUNS_DIR_REL_PATH);
52
- }
53
- function activeArtifactsPath(projectRoot) {
54
- return path.join(projectRoot, ACTIVE_ARTIFACTS_REL_PATH);
55
- }
56
- function stateDirPath(projectRoot) {
57
- return path.join(projectRoot, STATE_DIR_REL_PATH);
58
- }
59
- async function snapshotStateDirectory(projectRoot, destinationRoot) {
60
- const sourceDir = stateDirPath(projectRoot);
61
- if (!(await exists(sourceDir))) {
62
- return [];
63
- }
64
- await ensureDir(destinationRoot);
65
- const copied = [];
66
- let entries;
67
- try {
68
- entries = await fs.readdir(sourceDir, { withFileTypes: true });
69
- }
70
- catch {
71
- return [];
72
- }
73
- for (const entry of entries) {
74
- if (STATE_SNAPSHOT_EXCLUDE.has(entry.name))
75
- continue;
76
- if (entry.name.startsWith(".") && !entry.name.endsWith(".json"))
77
- continue;
78
- const from = path.join(sourceDir, entry.name);
79
- const to = path.join(destinationRoot, entry.name);
80
- try {
81
- if (entry.isDirectory()) {
82
- await fs.cp(from, to, { recursive: true });
83
- copied.push(`${entry.name}/`);
84
- }
85
- else if (entry.isFile()) {
86
- await fs.copyFile(from, to);
87
- copied.push(entry.name);
88
- }
89
- }
90
- catch {
91
- // best-effort snapshot; continue on individual failures
92
- }
93
- }
94
- return copied.sort((a, b) => a.localeCompare(b));
95
- }
96
- function isFlowStage(value) {
97
- return typeof value === "string" && FLOW_STAGE_SET.has(value);
98
- }
99
- function sanitizeStringArray(value) {
100
- if (!Array.isArray(value)) {
101
- return [];
102
- }
103
- return value.filter((item) => typeof item === "string" && item.trim().length > 0);
104
- }
105
- function sanitizeCompletedStages(value) {
106
- if (!Array.isArray(value)) {
107
- return [];
108
- }
109
- const unique = new Set();
110
- const stages = [];
111
- for (const item of value) {
112
- if (isFlowStage(item) && !unique.has(item)) {
113
- unique.add(item);
114
- stages.push(item);
115
- }
116
- }
117
- return stages;
118
- }
119
- function sanitizeGuardEvidence(value) {
120
- if (!value || typeof value !== "object" || Array.isArray(value)) {
121
- return {};
122
- }
123
- const next = {};
124
- for (const [key, raw] of Object.entries(value)) {
125
- if (typeof raw === "string") {
126
- next[key] = raw;
127
- }
128
- }
129
- return next;
130
- }
131
- function sanitizeStageGateCatalog(value, fallback) {
132
- const uniqueStrings = (items) => [...new Set(items)];
133
- const next = {};
134
- for (const stage of COMMAND_FILE_ORDER) {
135
- const base = fallback[stage];
136
- next[stage] = {
137
- required: [...base.required],
138
- recommended: [...base.recommended],
139
- conditional: [...base.conditional],
140
- triggered: [...base.triggered],
141
- passed: [...base.passed],
142
- blocked: [...base.blocked]
143
- };
144
- }
145
- if (!value || typeof value !== "object" || Array.isArray(value)) {
146
- return next;
147
- }
148
- const rawCatalog = value;
149
- for (const stage of COMMAND_FILE_ORDER) {
150
- const rawStage = rawCatalog[stage];
151
- if (!rawStage || typeof rawStage !== "object" || Array.isArray(rawStage)) {
152
- continue;
153
- }
154
- const typed = rawStage;
155
- const stageState = next[stage];
156
- const allowedGateIds = new Set([
157
- ...stageState.required,
158
- ...stageState.recommended,
159
- ...stageState.conditional
160
- ]);
161
- const conditionalGateIds = new Set(stageState.conditional);
162
- const passed = sanitizeStringArray(typed.passed).filter((gate) => allowedGateIds.has(gate));
163
- const blocked = sanitizeStringArray(typed.blocked).filter((gate) => allowedGateIds.has(gate));
164
- const triggeredFromState = sanitizeStringArray(typed.triggered).filter((gate) => conditionalGateIds.has(gate));
165
- const touchedConditionals = [...passed, ...blocked].filter((gate) => conditionalGateIds.has(gate));
166
- next[stage] = {
167
- required: [...stageState.required],
168
- recommended: [...stageState.recommended],
169
- conditional: [...stageState.conditional],
170
- triggered: uniqueStrings([...triggeredFromState, ...touchedConditionals]),
171
- passed,
172
- blocked
173
- };
174
- }
175
- return next;
176
- }
177
- function coerceTrack(value) {
178
- return isFlowTrack(value) ? value : "standard";
179
- }
180
- function sanitizeSkippedStages(value, track) {
181
- const trackDefault = skippedStagesForTrack(track);
182
- if (!Array.isArray(value)) {
183
- return trackDefault;
184
- }
185
- const seen = new Set();
186
- const out = [];
187
- for (const raw of value) {
188
- if (isFlowStage(raw) && !seen.has(raw)) {
189
- seen.add(raw);
190
- out.push(raw);
191
- }
192
- }
193
- return out.length > 0 ? out : trackDefault;
194
- }
195
- function sanitizeStaleStages(value) {
196
- if (!value || typeof value !== "object" || Array.isArray(value)) {
197
- return {};
198
- }
199
- const out = {};
200
- for (const [stage, raw] of Object.entries(value)) {
201
- if (!isFlowStage(stage))
202
- continue;
203
- if (!raw || typeof raw !== "object" || Array.isArray(raw))
204
- continue;
205
- const typed = raw;
206
- const rewindId = typeof typed.rewindId === "string" ? typed.rewindId : "";
207
- const reason = typeof typed.reason === "string" ? typed.reason : "";
208
- const markedAt = typeof typed.markedAt === "string" ? typed.markedAt : "";
209
- const acknowledgedAt = typeof typed.acknowledgedAt === "string" ? typed.acknowledgedAt : undefined;
210
- if (!rewindId || !reason || !markedAt) {
211
- continue;
212
- }
213
- out[stage] = {
214
- rewindId,
215
- reason,
216
- markedAt,
217
- acknowledgedAt
218
- };
219
- }
220
- return out;
221
- }
222
- function sanitizeRewinds(value) {
223
- if (!Array.isArray(value)) {
224
- return [];
225
- }
226
- const out = [];
227
- for (const raw of value) {
228
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
229
- continue;
230
- }
231
- const typed = raw;
232
- if (typeof typed.id !== "string" ||
233
- !isFlowStage(typed.fromStage) ||
234
- !isFlowStage(typed.toStage) ||
235
- typeof typed.reason !== "string" ||
236
- typeof typed.timestamp !== "string") {
237
- continue;
238
- }
239
- const invalidatedStages = Array.isArray(typed.invalidatedStages)
240
- ? typed.invalidatedStages.filter((stage) => isFlowStage(stage))
241
- : [];
242
- out.push({
243
- id: typed.id,
244
- fromStage: typed.fromStage,
245
- toStage: typed.toStage,
246
- reason: typed.reason,
247
- timestamp: typed.timestamp,
248
- invalidatedStages
249
- });
250
- }
251
- return out;
252
- }
253
- function sanitizeRetroState(value) {
254
- const fallback = {
255
- required: false,
256
- completedAt: undefined,
257
- compoundEntries: 0
258
- };
259
- if (!value || typeof value !== "object" || Array.isArray(value)) {
260
- return fallback;
261
- }
262
- const typed = value;
263
- const required = typeof typed.required === "boolean" ? typed.required : false;
264
- const completedAt = typeof typed.completedAt === "string" ? typed.completedAt : undefined;
265
- const compoundEntriesRaw = typed.compoundEntries;
266
- const compoundEntries = typeof compoundEntriesRaw === "number" && Number.isFinite(compoundEntriesRaw) && compoundEntriesRaw >= 0
267
- ? Math.floor(compoundEntriesRaw)
268
- : 0;
269
- return {
270
- required,
271
- completedAt,
272
- compoundEntries
273
- };
274
- }
275
- function isShipSubstate(value) {
276
- return typeof value === "string" && SHIP_SUBSTATES.includes(value);
277
- }
278
- function sanitizeCloseoutState(value) {
279
- const fallback = createInitialCloseoutState();
280
- if (!value || typeof value !== "object" || Array.isArray(value)) {
281
- return fallback;
282
- }
283
- const typed = value;
284
- const shipSubstate = isShipSubstate(typed.shipSubstate) ? typed.shipSubstate : fallback.shipSubstate;
285
- const retroDraftedAt = typeof typed.retroDraftedAt === "string" ? typed.retroDraftedAt : undefined;
286
- const retroAcceptedAt = typeof typed.retroAcceptedAt === "string" ? typed.retroAcceptedAt : undefined;
287
- const retroSkipped = typeof typed.retroSkipped === "boolean" ? typed.retroSkipped : undefined;
288
- const retroSkipReason = typeof typed.retroSkipReason === "string" ? typed.retroSkipReason : undefined;
289
- const compoundCompletedAt = typeof typed.compoundCompletedAt === "string" ? typed.compoundCompletedAt : undefined;
290
- const compoundSkipped = typeof typed.compoundSkipped === "boolean" ? typed.compoundSkipped : undefined;
291
- const promotedRaw = typed.compoundPromoted;
292
- const compoundPromoted = typeof promotedRaw === "number" && Number.isFinite(promotedRaw) && promotedRaw >= 0
293
- ? Math.floor(promotedRaw)
294
- : 0;
295
- return {
296
- shipSubstate,
297
- retroDraftedAt,
298
- retroAcceptedAt,
299
- retroSkipped,
300
- retroSkipReason,
301
- compoundCompletedAt,
302
- compoundSkipped,
303
- compoundPromoted
304
- };
305
- }
306
- function coerceFlowState(parsed) {
307
- const track = coerceTrack(parsed.track);
308
- const next = createInitialFlowState("active", track);
309
- const activeRunIdRaw = parsed.activeRunId;
310
- const activeRunId = typeof activeRunIdRaw === "string" && activeRunIdRaw.trim().length > 0
311
- ? activeRunIdRaw.trim()
312
- : next.activeRunId;
313
- return {
314
- activeRunId,
315
- currentStage: isFlowStage(parsed.currentStage) ? parsed.currentStage : next.currentStage,
316
- completedStages: sanitizeCompletedStages(parsed.completedStages),
317
- guardEvidence: sanitizeGuardEvidence(parsed.guardEvidence),
318
- stageGateCatalog: sanitizeStageGateCatalog(parsed.stageGateCatalog, next.stageGateCatalog),
319
- track,
320
- skippedStages: sanitizeSkippedStages(parsed.skippedStages, track),
321
- staleStages: sanitizeStaleStages(parsed.staleStages),
322
- rewinds: sanitizeRewinds(parsed.rewinds),
323
- retro: sanitizeRetroState(parsed.retro),
324
- closeout: sanitizeCloseoutState(parsed.closeout)
325
- };
326
- }
327
- function toArchiveDate(date = new Date()) {
328
- const yyyy = date.getFullYear().toString();
329
- const mm = (date.getMonth() + 1).toString().padStart(2, "0");
330
- const dd = date.getDate().toString().padStart(2, "0");
331
- return `${yyyy}-${mm}-${dd}`;
332
- }
333
- function slugifyFeatureName(value) {
334
- const slug = value
335
- .toLowerCase()
336
- .trim()
337
- .replace(/[^a-z0-9]+/gu, "-")
338
- .replace(/^-+/u, "")
339
- .replace(/-+$/u, "");
340
- if (slug.length === 0) {
341
- return "feature";
342
- }
343
- return slug.slice(0, 64);
344
- }
345
- async function inferFeatureNameFromArtifacts(projectRoot) {
346
- const ideaPath = path.join(projectRoot, ACTIVE_ARTIFACTS_REL_PATH, "00-idea.md");
347
- if (!(await exists(ideaPath))) {
348
- return "feature";
349
- }
350
- try {
351
- const raw = await fs.readFile(ideaPath, "utf8");
352
- const firstMeaningful = raw
353
- .split(/\r?\n/gu)
354
- .map((line) => line.trim())
355
- .find((line) => line.length > 0);
356
- if (!firstMeaningful) {
357
- return "feature";
358
- }
359
- return firstMeaningful.replace(/^[-#*\s]+/u, "").trim() || "feature";
360
- }
361
- catch {
362
- return "feature";
363
- }
364
- }
365
- async function uniqueArchiveId(projectRoot, baseId) {
366
- let index = 1;
367
- let candidate = baseId;
368
- while (await exists(path.join(runsRoot(projectRoot), candidate))) {
369
- index += 1;
370
- candidate = `${baseId}-${index}`;
371
- }
372
- return candidate;
373
- }
374
- function retroArtifactPath(projectRoot) {
375
- return path.join(activeArtifactsPath(projectRoot), "09-retro.md");
376
- }
377
- function parseIsoTimestamp(value) {
378
- if (!value || value.trim().length === 0)
379
- return null;
380
- const parsed = Date.parse(value);
381
- return Number.isFinite(parsed) ? parsed : null;
382
- }
383
- function inInclusiveWindow(timestamp, windowStartMs, windowEndMs) {
384
- if (windowStartMs !== null && timestamp < windowStartMs)
385
- return false;
386
- if (windowEndMs !== null && timestamp > windowEndMs)
387
- return false;
388
- return true;
389
- }
390
- async function evaluateRetroGate(projectRoot, state) {
391
- const required = state.completedStages.includes("ship");
392
- const artifactFile = retroArtifactPath(projectRoot);
393
- let hasRetroArtifact = false;
394
- if (await exists(artifactFile)) {
395
- try {
396
- const raw = await fs.readFile(artifactFile, "utf8");
397
- hasRetroArtifact = raw.trim().length > 0;
398
- }
399
- catch {
400
- hasRetroArtifact = false;
401
- }
402
- }
403
- let compoundEntries = state.retro.compoundEntries;
404
- const windowStartMs = parseIsoTimestamp(state.closeout.retroDraftedAt);
405
- const windowEndMs = parseIsoTimestamp(state.closeout.retroAcceptedAt) ?? parseIsoTimestamp(state.retro.completedAt);
406
- const shouldFallbackScan = compoundEntries <= 0 && (windowStartMs !== null || windowEndMs !== null);
407
- const knowledgeFile = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
408
- if (shouldFallbackScan && (await exists(knowledgeFile))) {
409
- try {
410
- const raw = await fs.readFile(knowledgeFile, "utf8");
411
- compoundEntries = 0;
412
- for (const line of raw.split(/\r?\n/)) {
413
- const trimmed = line.trim();
414
- if (!trimmed)
415
- continue;
416
- try {
417
- const parsed = JSON.parse(trimmed);
418
- if (parsed.type !== "compound") {
419
- continue;
420
- }
421
- const created = typeof parsed.created === "string" ? parseIsoTimestamp(parsed.created) : null;
422
- if (created === null || !inInclusiveWindow(created, windowStartMs, windowEndMs)) {
423
- continue;
424
- }
425
- const source = typeof parsed.source === "string"
426
- ? parsed.source.trim().toLowerCase()
427
- : null;
428
- const legacyRetroStage = parsed.stage === "retro";
429
- if (source === "retro" || legacyRetroStage) {
430
- compoundEntries += 1;
431
- }
432
- }
433
- catch {
434
- // ignore malformed lines for retro gate calculation
435
- }
436
- }
437
- }
438
- catch {
439
- compoundEntries = 0;
440
- }
441
- }
442
- const completed = required ? (hasRetroArtifact && compoundEntries > 0) : true;
443
- return {
444
- required,
445
- completed,
446
- compoundEntries,
447
- hasRetroArtifact
448
- };
449
- }
450
- export class CorruptFlowStateError extends Error {
451
- statePath;
452
- quarantinedPath;
453
- constructor(statePath, quarantinedPath, cause) {
454
- super(`Corrupt flow-state.json detected at ${statePath}. ` +
455
- `Quarantined to ${quarantinedPath}. ` +
456
- `Inspect the quarantined file, reconcile by hand, then re-run your command ` +
457
- `or delete ${statePath} to start over. ` +
458
- `Underlying error: ${cause instanceof Error ? cause.message : String(cause)}`);
459
- this.name = "CorruptFlowStateError";
460
- this.statePath = statePath;
461
- this.quarantinedPath = quarantinedPath;
462
- if (cause instanceof Error) {
463
- this.cause = cause;
464
- }
465
- }
466
- }
467
- function quarantineTimestamp(date = new Date()) {
468
- return date.toISOString().replace(/[:.]/gu, "-");
469
- }
470
- async function quarantineCorruptState(statePath, cause) {
471
- const quarantinedPath = `${statePath}.corrupt-${quarantineTimestamp()}.json`;
472
- try {
473
- await fs.rename(statePath, quarantinedPath);
474
- }
475
- catch (renameErr) {
476
- try {
477
- const raw = await fs.readFile(statePath, "utf8");
478
- await fs.writeFile(quarantinedPath, raw, "utf8");
479
- await fs.unlink(statePath).catch(() => undefined);
480
- }
481
- catch {
482
- throw new CorruptFlowStateError(statePath, quarantinedPath, renameErr);
483
- }
484
- }
485
- throw new CorruptFlowStateError(statePath, quarantinedPath, cause);
486
- }
487
- export async function readFlowState(projectRoot) {
488
- await ensureFeatureSystem(projectRoot);
489
- const statePath = flowStatePath(projectRoot);
490
- if (!(await exists(statePath))) {
491
- return createInitialFlowState();
492
- }
493
- let raw;
494
- try {
495
- raw = await fs.readFile(statePath, "utf8");
496
- }
497
- catch (readErr) {
498
- throw new CorruptFlowStateError(statePath, statePath, readErr);
499
- }
500
- let parsed;
501
- try {
502
- parsed = JSON.parse(raw);
503
- }
504
- catch (parseErr) {
505
- await quarantineCorruptState(statePath, parseErr);
506
- }
507
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
508
- await quarantineCorruptState(statePath, new Error("flow-state.json did not deserialize to a JSON object"));
509
- }
510
- return coerceFlowState(parsed);
511
- }
512
- export async function writeFlowState(projectRoot, state, options = {}) {
513
- await ensureFeatureSystem(projectRoot);
514
- await withDirectoryLock(flowStateLockPath(projectRoot), async () => {
515
- const statePath = flowStatePath(projectRoot);
516
- if (!options.allowReset && (await exists(statePath))) {
517
- try {
518
- const raw = await fs.readFile(statePath, "utf8");
519
- const parsed = JSON.parse(raw);
520
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
521
- const prev = coerceFlowState(parsed);
522
- validateFlowTransition(prev, state);
523
- }
524
- }
525
- catch (err) {
526
- if (err instanceof InvalidStageTransitionError) {
527
- throw err;
528
- }
529
- // A corrupt prior file is surfaced by readFlowState elsewhere; don't
530
- // block a legitimate write attempt on parse errors here.
531
- }
532
- }
533
- const safe = coerceFlowState({ ...state });
534
- await writeFileSafe(statePath, `${JSON.stringify(safe, null, 2)}\n`);
535
- });
536
- await syncActiveFeatureSnapshot(projectRoot);
537
- }
538
- export async function ensureRunSystem(projectRoot, _options = {}) {
539
- await ensureFeatureSystem(projectRoot);
540
- await ensureDir(runsRoot(projectRoot));
541
- await ensureDir(activeArtifactsPath(projectRoot));
542
- const statePath = flowStatePath(projectRoot);
543
- const state = await readFlowState(projectRoot);
544
- if (!(await exists(statePath))) {
545
- await writeFlowState(projectRoot, state, { allowReset: true });
546
- }
547
- await syncActiveFeatureSnapshot(projectRoot);
548
- return state;
549
- }
550
- export async function listRuns(projectRoot) {
551
- const root = runsRoot(projectRoot);
552
- if (!(await exists(root))) {
553
- return [];
554
- }
555
- const entries = await fs.readdir(root, { withFileTypes: true });
556
- const runs = [];
557
- for (const entry of entries) {
558
- if (!entry.isDirectory()) {
559
- continue;
560
- }
561
- const runPath = path.join(root, entry.name);
562
- let createdAt = new Date().toISOString();
563
- try {
564
- const stat = await fs.stat(runPath);
565
- createdAt = stat.birthtime?.toISOString?.() ?? stat.mtime.toISOString();
566
- }
567
- catch {
568
- // keep fallback timestamp
569
- }
570
- runs.push({
571
- id: entry.name,
572
- title: entry.name,
573
- createdAt
574
- });
575
- }
576
- return runs.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
577
- }
578
- export async function archiveRun(projectRoot, featureName, options = {}) {
579
- await ensureRunSystem(projectRoot);
580
- const activeFeature = await readActiveFeature(projectRoot);
581
- const artifactsDir = activeArtifactsPath(projectRoot);
582
- const runsDir = runsRoot(projectRoot);
583
- await ensureDir(runsDir);
584
- await ensureDir(artifactsDir);
585
- const feature = (featureName?.trim() && featureName.trim().length > 0)
586
- ? featureName.trim()
587
- : await inferFeatureNameFromArtifacts(projectRoot);
588
- const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
589
- const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
590
- const archivePath = path.join(runsDir, archiveId);
591
- const archiveArtifactsPath = path.join(archivePath, "artifacts");
592
- let sourceState = await readFlowState(projectRoot);
593
- const retroGate = await evaluateRetroGate(projectRoot, sourceState);
594
- const shipCompleted = sourceState.completedStages.includes("ship");
595
- const skipRetro = options.skipRetro === true;
596
- const skipRetroReason = options.skipRetroReason?.trim();
597
- if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
598
- throw new Error("archive --skip-retro requires --retro-reason=<text>.");
599
- }
600
- const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
601
- typeof sourceState.closeout.retroSkipReason === "string" &&
602
- sourceState.closeout.retroSkipReason.trim().length > 0;
603
- const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
604
- if (shipCompleted && !readyForArchive && !skipRetro) {
605
- throw new Error("Archive blocked: closeout is not ready_to_archive. " +
606
- "Resume /cc-next until closeout reaches ready_to_archive, " +
607
- "or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
608
- }
609
- if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
610
- throw new Error("Archive blocked: retro gate is required after ship completion. " +
611
- "Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
612
- }
613
- if (retroGate.completed) {
614
- const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
615
- sourceState = {
616
- ...sourceState,
617
- retro: {
618
- required: retroGate.required,
619
- completedAt,
620
- compoundEntries: retroGate.compoundEntries
621
- }
622
- };
623
- await writeFlowState(projectRoot, sourceState, { allowReset: true });
624
- }
625
- const retroSummary = {
626
- required: retroGate.required,
627
- completed: retroGate.completed,
628
- skipped: skipRetro || retroSkippedInCloseout,
629
- skipReason: skipRetro
630
- ? skipRetroReason
631
- : retroSkippedInCloseout
632
- ? sourceState.closeout.retroSkipReason
633
- : undefined,
634
- compoundEntries: retroGate.compoundEntries
635
- };
636
- await ensureDir(archivePath);
637
- await fs.rename(artifactsDir, archiveArtifactsPath);
638
- await ensureDir(artifactsDir);
639
- const archiveStatePath = path.join(archivePath, "state");
640
- const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
641
- const resetState = createInitialFlowState();
642
- await writeFlowState(projectRoot, resetState, { allowReset: true });
643
- const archivedAt = new Date().toISOString();
644
- const manifest = {
645
- version: 1,
646
- archiveId,
647
- archivedAt,
648
- featureName: feature,
649
- activeFeature,
650
- sourceRunId: sourceState.activeRunId,
651
- sourceCurrentStage: sourceState.currentStage,
652
- sourceCompletedStages: sourceState.completedStages,
653
- snapshottedStateFiles,
654
- retro: retroSummary
655
- };
656
- await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
657
- const knowledgeStats = await readKnowledgeStats(projectRoot);
658
- await syncActiveFeatureSnapshot(projectRoot);
659
- return {
660
- archiveId,
661
- archivePath,
662
- archivedAt,
663
- featureName: feature,
664
- activeFeature,
665
- resetState,
666
- snapshottedStateFiles,
667
- knowledge: knowledgeStats,
668
- retro: retroSummary
669
- };
670
- }
671
- const KNOWLEDGE_SOFT_THRESHOLD = 50;
672
- async function readKnowledgeStats(projectRoot) {
673
- const knowledgePath = path.join(projectRoot, RUNTIME_ROOT, "knowledge.jsonl");
674
- let activeEntryCount = 0;
675
- if (await exists(knowledgePath)) {
676
- const text = await fs.readFile(knowledgePath, "utf8");
677
- activeEntryCount = countActiveKnowledgeEntries(text);
678
- }
679
- return {
680
- activeEntryCount,
681
- softThreshold: KNOWLEDGE_SOFT_THRESHOLD,
682
- overThreshold: activeEntryCount > KNOWLEDGE_SOFT_THRESHOLD,
683
- knowledgePath: `${RUNTIME_ROOT}/knowledge.jsonl`
684
- };
685
- }
686
- /**
687
- * Counts entries in the canonical JSONL knowledge store. An "active" entry is one
688
- * non-empty line that parses as JSON with the required `type` field belonging to the
689
- * allowed set. Malformed lines are ignored (not counted) but do not throw so that a
690
- * hand-edited file cannot break doctor/archive flows.
691
- */
692
- export function countActiveKnowledgeEntries(text) {
693
- const allowed = new Set(["rule", "pattern", "lesson", "compound"]);
694
- let count = 0;
695
- for (const raw of text.split(/\r?\n/)) {
696
- const line = raw.trim();
697
- if (line.length === 0)
698
- continue;
699
- try {
700
- const parsed = JSON.parse(line);
701
- if (typeof parsed.type === "string" && allowed.has(parsed.type)) {
702
- count += 1;
703
- }
704
- }
705
- catch {
706
- // Skip malformed lines silently; curation surfaces them separately.
707
- }
708
- }
709
- return count;
710
- }
1
+ export { CorruptFlowStateError, InvalidStageTransitionError, ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
2
+ export { archiveRun, countActiveKnowledgeEntries, listRuns } from "./run-archive.js";