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/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");
@@ -7,8 +7,22 @@ export interface GateEvidenceCheckResult {
7
7
  requiredCount: number;
8
8
  passedCount: number;
9
9
  blockedCount: number;
10
+ /** True only when every required gate for the stage is in `passed` and none are `blocked`. */
11
+ complete: boolean;
12
+ /** Required gate ids that are neither passed nor blocked. */
13
+ missingRequired: string[];
14
+ }
15
+ export interface CompletedStagesClosureResult {
16
+ ok: boolean;
17
+ issues: string[];
18
+ openStages: Array<{
19
+ stage: FlowStage;
20
+ missingRequired: string[];
21
+ blocked: string[];
22
+ }>;
10
23
  }
11
24
  export declare function verifyCurrentStageGateEvidence(projectRoot: string, flowState: FlowState): Promise<GateEvidenceCheckResult>;
25
+ export declare function verifyCompletedStagesGateClosure(flowState: FlowState): CompletedStagesClosureResult;
12
26
  export interface GateReconciliationResult {
13
27
  stage: FlowStage;
14
28
  changed: boolean;
@@ -1,6 +1,29 @@
1
- import { lintArtifact, validateReviewArmy } from "./artifact-linter.js";
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { checkReviewVerdictConsistency, lintArtifact, validateReviewArmy } from "./artifact-linter.js";
4
+ import { RUNTIME_ROOT } from "./constants.js";
2
5
  import { stageSchema } from "./content/stage-schema.js";
6
+ import { exists } from "./fs-utils.js";
3
7
  import { readFlowState, writeFlowState } from "./runs.js";
8
+ async function currentStageArtifactExists(projectRoot, stage) {
9
+ const artifactFile = stageSchema(stage).artifactFile;
10
+ const candidates = [
11
+ path.join(projectRoot, RUNTIME_ROOT, "artifacts", artifactFile),
12
+ path.join(projectRoot, artifactFile)
13
+ ];
14
+ for (const candidate of candidates) {
15
+ if (await exists(candidate))
16
+ return true;
17
+ }
18
+ // Artifact-linter also accepts the file under current working directory fallback; stat once more.
19
+ try {
20
+ await fs.access(path.join(projectRoot, artifactFile));
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
4
27
  function unique(values) {
5
28
  return [...new Set(values)];
6
29
  }
@@ -44,7 +67,8 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
44
67
  issues.push(`blocked gate "${gateId}" is not defined for stage "${stage}".`);
45
68
  }
46
69
  }
47
- const shouldValidateArtifact = catalog.passed.length > 0 || flowState.completedStages.includes(stage);
70
+ const artifactPresent = await currentStageArtifactExists(projectRoot, stage);
71
+ const shouldValidateArtifact = artifactPresent || catalog.passed.length > 0 || flowState.completedStages.includes(stage);
48
72
  if (shouldValidateArtifact) {
49
73
  const lint = await lintArtifact(projectRoot, stage);
50
74
  if (!lint.passed) {
@@ -60,6 +84,21 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
60
84
  if (!reviewArmy.valid) {
61
85
  issues.push(`review-army validation failed: ${reviewArmy.errors.join("; ")}`);
62
86
  }
87
+ const verdictConsistency = await checkReviewVerdictConsistency(projectRoot);
88
+ if (!verdictConsistency.ok) {
89
+ issues.push(`review verdict inconsistency: ${verdictConsistency.errors.join("; ")}`);
90
+ }
91
+ }
92
+ }
93
+ const passedSet = new Set(catalog.passed);
94
+ const missingRequired = required.filter((gateId) => !passedSet.has(gateId));
95
+ const complete = missingRequired.length === 0 && catalog.blocked.length === 0;
96
+ if (flowState.completedStages.includes(stage) && !complete) {
97
+ if (missingRequired.length > 0) {
98
+ issues.push(`stage "${stage}" is marked completed but required gates are not passed: ${missingRequired.join(", ")}.`);
99
+ }
100
+ if (catalog.blocked.length > 0) {
101
+ issues.push(`stage "${stage}" is marked completed but has blocked gates: ${catalog.blocked.join(", ")}.`);
63
102
  }
64
103
  }
65
104
  return {
@@ -68,9 +107,32 @@ export async function verifyCurrentStageGateEvidence(projectRoot, flowState) {
68
107
  issues,
69
108
  requiredCount: required.length,
70
109
  passedCount: catalog.passed.length,
71
- blockedCount: catalog.blocked.length
110
+ blockedCount: catalog.blocked.length,
111
+ complete,
112
+ missingRequired
72
113
  };
73
114
  }
115
+ export function verifyCompletedStagesGateClosure(flowState) {
116
+ const issues = [];
117
+ const openStages = [];
118
+ for (const stage of flowState.completedStages) {
119
+ const schema = stageSchema(stage);
120
+ const catalog = flowState.stageGateCatalog[stage];
121
+ const required = schema.requiredGates.map((gate) => gate.id);
122
+ const passedSet = new Set(catalog.passed);
123
+ const missingRequired = required.filter((gateId) => !passedSet.has(gateId));
124
+ if (missingRequired.length > 0 || catalog.blocked.length > 0) {
125
+ openStages.push({ stage, missingRequired, blocked: [...catalog.blocked] });
126
+ if (missingRequired.length > 0) {
127
+ issues.push(`completed stage "${stage}" has unpassed required gates: ${missingRequired.join(", ")}.`);
128
+ }
129
+ if (catalog.blocked.length > 0) {
130
+ issues.push(`completed stage "${stage}" still has blocked gates: ${catalog.blocked.join(", ")}.`);
131
+ }
132
+ }
133
+ }
134
+ return { ok: openStages.length === 0, issues, openStages };
135
+ }
74
136
  export function reconcileCurrentStageGateCatalog(flowState) {
75
137
  const stage = flowState.currentStage;
76
138
  const required = stageSchema(stage).requiredGates.map((gate) => gate.id);
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
- const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
154
- return coerceFlowState(parsed);
264
+ raw = await fs.readFile(statePath, "utf8");
155
265
  }
156
- catch {
157
- return createInitialFlowState();
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(flowStatePath(projectRoot), `${JSON.stringify(safe, null, 2)}\n`);
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.15",
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
  }