@fraction12/deepclean 0.1.0-alpha.0 → 0.1.0-alpha.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/cli.js CHANGED
@@ -1,27 +1,47 @@
1
1
  #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
2
3
  import { realpathSync } from "node:fs";
3
- import { readFile } from "node:fs/promises";
4
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
5
  import path from "node:path";
6
+ import { promisify } from "node:util";
5
7
  import { fileURLToPath } from "node:url";
6
8
  import { parseArgs, flagBoolean, flagString } from "./args.js";
7
9
  import { candidatesFromEvidence, rankCandidates, reassignCandidateIds } from "./candidates.js";
8
10
  import { buildClusters, unclusteredCandidateIds } from "./clusters.js";
9
11
  import { discoverSourceFiles } from "./discovery.js";
10
12
  import { runEvidenceAdapters } from "./evidence.js";
13
+ import { mapSemanticFeatures } from "./features.js";
14
+ import { attachStableIdentity } from "./identity.js";
11
15
  import { fail, ok } from "./json.js";
16
+ import { LockContentionError, lockRecoveryCommand, readLockStatuses, recoverStaleLocks, withStateWriteLock, } from "./locks.js";
12
17
  import { buildCandidatePlan, buildClusterPlan } from "./plans.js";
18
+ import { classifyRevalidation } from "./revalidation.js";
13
19
  import { buildHandoff, buildReportRecord, renderMarkdownReport, renderMarkdownReportWithClusters, } from "./reporting.js";
14
- import { ensureState, latestRunId, readLatestCandidates, readLatestClusters, readLatestEvidence, resolveStatePaths, updateLatestCandidates, writeCandidates, writeClusters, writeEvidence, writeHandoff, writePlan, writeReport, writeRun, writeTriage, } from "./state.js";
20
+ import { ensureState, latestRunId, readConfig, readCandidates, readFindings, readLatestCandidates, readLatestClusters, readLatestEvidence, readLatestFeatures, readLatestSynthesisAttempt, readLifecycleEvents, resolveStatePaths, updateLatestCandidates, writeCandidates, writeCandidateObservations, writeCiRun, writeClusters, writeEvidence, writeFeatures, writeFindings, writeFixAttempt, writeHandoff, writeLifecycleEvents, writePlan, writeReport, writeRetentionManifest, writeRevalidation, writeRun, writeSynthesisAttempt, writeTriage, } from "./state.js";
15
21
  import { candidateStatuses, schemaVersion, } from "./types.js";
16
22
  import { timestampId } from "./ids.js";
17
23
  import { synthesizeWithCodex } from "./synthesis.js";
18
24
  import { inferVerificationProfile } from "./verification.js";
25
+ const execFileAsync = promisify(execFile);
19
26
  const commands = [
20
27
  "init",
28
+ "doctor",
29
+ "status",
30
+ "ci",
31
+ "map",
21
32
  "scan",
22
33
  "report",
23
34
  "next",
35
+ "list",
36
+ "findings",
24
37
  "show",
38
+ "explain",
39
+ "history",
40
+ "revalidate",
41
+ "unlock",
42
+ "prune",
43
+ "scrub",
44
+ "fix",
25
45
  "cluster",
26
46
  "plan",
27
47
  "triage",
@@ -29,25 +49,71 @@ const commands = [
29
49
  "export",
30
50
  ];
31
51
  function printHelp() {
32
- console.log(`deepclean: agent-first cleanup reports for working-but-sloppy codebases
52
+ console.log(`deepclean: local structure reports and agent-ready plans
33
53
 
34
54
  Usage:
35
55
  deepclean <command> [args] [flags]
36
56
 
37
57
  Commands:
38
58
  init Create or validate .deepclean state
59
+ doctor Check environment, config, state, git, provider, and privacy readiness
60
+ status Summarize current project-local Deepclean state
61
+ ci Run non-interactive scan and policy gates for CI
62
+ map Write semantic feature records without producing candidates
39
63
  scan Collect local evidence and generate candidates
40
- --synthesize Run local Codex synthesis over evidence
64
+ --synthesize Run local Codex synthesis over evidence (default)
65
+ --evidence-only Skip synthesis and produce local evidence candidates only
41
66
  --allow-source-in-model Include source samples in Codex prompt
67
+ --offline Skip provider calls and network-style analyzers
68
+ --local-only Alias for --offline
69
+ --provider <provider> Provider adapter, currently codex
42
70
  --model <model> Override Codex model for synthesis
71
+ --effort <effort> Record provider reasoning effort
72
+ --timeout <seconds> Provider timeout in seconds
73
+ --retries <n> Provider retry attempts
74
+ --rpm <n> Provider request-per-minute budget
75
+ --concurrency <n> Provider concurrency budget
76
+ --token-budget <n> Provider token budget metadata
77
+ --excerpt-budget <n> Source excerpt budget; 0 keeps prompts metadata-only
78
+ --privacy-mode <mode> local-only, metadata, or source-ok
79
+ --since <ref> Scan files changed since a git ref
80
+ --merge-base <ref> Use merge-base with ref for changed-file scope
81
+ --include-dirty Include uncommitted and untracked files in scope
82
+ --paths <a,b> Restrict scan to paths or path prefixes
83
+ --categories <a,b> Restrict emitted candidates to categories
84
+ --reviewers <a,b> Record reviewer-surface scope for synthesis/metadata
85
+ --only-existing Keep only findings previously known to Deepclean
86
+ --new-only Keep only newly discovered findings
43
87
  report Write and print a ranked report
44
88
  next Show the highest-priority open candidate
89
+ list List findings with shared filters
90
+ findings Alias for list
45
91
  show <candidate-or-theme> Show one candidate or cleanup theme with evidence
92
+ explain <candidate-or-finding>
93
+ Explain evidence, validation, and fix-readiness for a finding
94
+ history <finding-or-candidate-id>
95
+ Show lifecycle history for a finding
96
+ revalidate <finding-id|candidate-id|all>
97
+ Freshly recheck whether findings still hold
98
+ unlock --stale Remove stale project-local writer locks
99
+ prune Remove stale Deepclean artifacts with retention safety
100
+ --dry-run Persist a manifest without deleting files
101
+ --keep-runs <n> Keep latest n runs, defaults to 5
102
+ --keep-days <n> Also keep runs newer than n days
103
+ scrub Emit source-safe generated-state export
104
+ fix <finding-or-candidate> Preview or apply a guarded local patch
105
+ --patch <file> Patch file to preview/apply
106
+ --dry-run Persist preview without changing source
107
+ --apply Apply the patch locally
108
+ --allow-source-mutation Required with --apply
109
+ --allow-dirty Allow dirty files inside target scope
110
+ --verification-command <c> Override verification command
46
111
  cluster [theme-id] Group related candidates into cleanup themes
47
112
  plan <candidate-or-theme> Generate an agent-ready cleanup plan
48
113
  triage <candidate-id> Update candidate status with --status and --note
49
114
  handoff <candidate-id> Generate an agent-ready handoff packet
50
115
  export <candidate-id> Alias for handoff
116
+ export --source-safe Alias for scrub
51
117
 
52
118
  Global flags:
53
119
  --json Emit JSON envelope
@@ -58,6 +124,8 @@ Global flags:
58
124
  --config <path> Config file, defaults to .deepclean/config.json
59
125
  --quiet Suppress human success output
60
126
  --debug Include stack traces for unexpected errors
127
+ --wait-lock Wait for an active writer lock instead of failing immediately
128
+ --lock-timeout-ms <ms> Maximum time to wait for --wait-lock
61
129
  -h, --help Show help
62
130
  --version Show version`);
63
131
  }
@@ -94,28 +162,64 @@ export async function main(argv, cwd = process.cwd()) {
94
162
  switch (command) {
95
163
  case "init":
96
164
  return await initCommand(context);
165
+ case "doctor":
166
+ return await doctorCommand(context);
167
+ case "status":
168
+ return await statusCommand(context);
169
+ case "ci":
170
+ return await withWriteLock(context, () => ciCommand(context));
171
+ case "map":
172
+ return await withWriteLock(context, () => mapCommand(context));
97
173
  case "scan":
98
- return await scanCommand(context);
174
+ return await withWriteLock(context, () => scanCommand(context));
99
175
  case "report":
100
- return await reportCommand(context);
176
+ return await withWriteLock(context, () => reportCommand(context));
101
177
  case "next":
102
178
  return await nextCommand(context);
179
+ case "list":
180
+ case "findings":
181
+ return await listCommand(context);
103
182
  case "show":
104
183
  return await showCommand(context);
184
+ case "explain":
185
+ return await explainCommand(context);
186
+ case "history":
187
+ return await historyCommand(context);
188
+ case "revalidate":
189
+ return await withWriteLock(context, () => revalidateCommand(context));
190
+ case "unlock":
191
+ return await unlockCommand(context);
192
+ case "prune":
193
+ return await withWriteLock(context, () => pruneCommand(context));
194
+ case "scrub":
195
+ return await scrubCommand(context);
196
+ case "fix":
197
+ return await withWriteLock(context, () => fixCommand(context));
105
198
  case "cluster":
106
- return await clusterCommand(context);
199
+ return await withWriteLock(context, () => clusterCommand(context));
107
200
  case "plan":
108
- return await planCommand(context);
201
+ return await withWriteLock(context, () => planCommand(context));
109
202
  case "triage":
110
- return await triageCommand(context);
203
+ return await withWriteLock(context, () => triageCommand(context));
111
204
  case "handoff":
205
+ return await withWriteLock(context, () => handoffCommand(context));
112
206
  case "export":
113
- return await handoffCommand(context);
207
+ if (flagBoolean(context.parsed.flags, "source-safe")) {
208
+ return await scrubCommand(context);
209
+ }
210
+ return await withWriteLock(context, () => handoffCommand(context));
114
211
  }
115
212
  emit(json, fail(command, "unknown_command", `Unknown command: ${command}`));
116
213
  return 2;
117
214
  }
118
215
  catch (error) {
216
+ if (error instanceof LockContentionError) {
217
+ emit(json, fail(command, "lock_contention", error.message, [error.diagnostic]));
218
+ if (!json && !quiet) {
219
+ console.error(error.message);
220
+ }
221
+ return 4;
222
+ }
119
223
  const message = error instanceof Error ? error.message : String(error);
120
224
  emit(json, fail(command, "command_failed", debug && error instanceof Error && error.stack ? error.stack : message));
121
225
  return 1;
@@ -135,12 +239,546 @@ async function initCommand(context) {
135
239
  }
136
240
  return 0;
137
241
  }
242
+ async function doctorCommand(context) {
243
+ const diagnostics = [];
244
+ const initialized = await pathExists(context.paths.stateDir);
245
+ const missingDirs = initialized ? await missingStateDirectories(context.paths) : [];
246
+ const locks = initialized ? await readLockStatuses(context.paths, {
247
+ staleAfterMs: staleLockMsFromFlags(context),
248
+ }) : [];
249
+ const staleLocks = locks.filter((lock) => lock.stale);
250
+ const configResult = await readConfigForDoctor(context.paths);
251
+ diagnostics.push(...configResult.diagnostics);
252
+ if (missingDirs.length > 0) {
253
+ diagnostics.push({
254
+ level: "warning",
255
+ code: "state_dirs_missing",
256
+ message: `Missing state directories: ${missingDirs.join(", ")}`,
257
+ });
258
+ }
259
+ if (staleLocks.length > 0) {
260
+ diagnostics.push({
261
+ level: "warning",
262
+ code: "stale_locks",
263
+ message: `Found ${staleLocks.length} stale writer lock${staleLocks.length === 1 ? "" : "s"}. Run \`${lockRecoveryCommand(context.paths)}\` before the next write command.`,
264
+ });
265
+ }
266
+ const git = await gitDoctor(context.paths.root);
267
+ if (!git.available) {
268
+ diagnostics.push({
269
+ level: "warning",
270
+ code: "git_unavailable",
271
+ message: git.error ?? "Git is unavailable for this repository.",
272
+ });
273
+ }
274
+ const provider = configResult.config
275
+ ? await providerDoctor(context.paths.root, configResult.config.reviewSynthesis.command)
276
+ : { command: undefined, available: false, error: "Config is unavailable." };
277
+ if (configResult.config && !provider.available) {
278
+ diagnostics.push({
279
+ level: "warning",
280
+ code: "provider_unavailable",
281
+ message: provider.error ?? `Provider command is unavailable: ${provider.command}`,
282
+ });
283
+ }
284
+ const data = {
285
+ root: context.paths.root,
286
+ stateDir: context.paths.stateDir,
287
+ initialized,
288
+ packageVersion: await packageVersion(),
289
+ config: {
290
+ path: context.paths.configPath,
291
+ valid: configResult.valid,
292
+ error: configResult.error,
293
+ },
294
+ state: {
295
+ valid: initialized && missingDirs.length === 0,
296
+ missingDirs,
297
+ },
298
+ locks: {
299
+ active: locks.filter((lock) => !lock.stale).length,
300
+ stale: staleLocks.length,
301
+ records: locks.map((lock) => ({
302
+ id: lock.record?.id,
303
+ owner: lock.record?.owner,
304
+ pid: lock.record?.pid,
305
+ command: lock.record?.command,
306
+ statePath: lock.record?.statePath,
307
+ createdAt: lock.record?.createdAt,
308
+ stale: lock.stale,
309
+ reason: lock.reason,
310
+ recoveryCommand: lock.recoveryCommand,
311
+ })),
312
+ },
313
+ git,
314
+ provider,
315
+ privacy: configResult.config?.privacy,
316
+ supportedSurfaces: await supportedSurfaces(context.paths.root),
317
+ };
318
+ emit(context.json, ok("doctor", data, diagnostics));
319
+ if (!context.json && !context.quiet) {
320
+ console.log(`Deepclean ${data.packageVersion}`);
321
+ console.log(`root: ${data.root}`);
322
+ console.log(`state: ${data.state.valid ? "ok" : initialized ? "incomplete" : "not initialized"}`);
323
+ console.log(`config: ${data.config.valid ? "ok" : "invalid"}`);
324
+ console.log(`git: ${git.available ? git.dirty ? "dirty" : "clean" : "unavailable"}`);
325
+ console.log(`provider: ${provider.available ? "ok" : "unavailable"}`);
326
+ console.log(`locks: ${data.locks.active} active / ${data.locks.stale} stale`);
327
+ printDiagnostics(diagnostics);
328
+ }
329
+ return 0;
330
+ }
331
+ async function statusCommand(context) {
332
+ const diagnostics = [];
333
+ const initialized = await pathExists(context.paths.stateDir);
334
+ const latest = initialized ? await latestRunId(context.paths) : undefined;
335
+ const candidates = latest ? await readLatestCandidates(context.paths) : [];
336
+ const clusters = latest ? await readLatestClusters(context.paths) : [];
337
+ const evidence = latest ? await readLatestEvidence(context.paths) : [];
338
+ const features = initialized ? await readLatestFeatures(context.paths) : [];
339
+ const git = await gitDoctor(context.paths.root);
340
+ if (!git.available) {
341
+ diagnostics.push({
342
+ level: "warning",
343
+ code: "git_unavailable",
344
+ message: git.error ?? "Git is unavailable for this repository.",
345
+ });
346
+ }
347
+ const artifactCounts = await stateArtifactCounts(context.paths);
348
+ const locks = initialized ? await readLockStatuses(context.paths, {
349
+ staleAfterMs: staleLockMsFromFlags(context),
350
+ }) : [];
351
+ const statusCounts = countBy(candidates, (candidate) => candidate.status);
352
+ const data = {
353
+ root: context.paths.root,
354
+ stateDir: context.paths.stateDir,
355
+ initialized,
356
+ latestRunId: latest,
357
+ git: {
358
+ branch: git.branch,
359
+ dirty: git.dirty,
360
+ available: git.available,
361
+ },
362
+ queue: {
363
+ total: candidates.length,
364
+ open: candidates.filter((candidate) => candidate.status === "open").length,
365
+ byStatus: statusCounts,
366
+ themes: clusters.length,
367
+ evidence: evidence.length,
368
+ features: features.length,
369
+ },
370
+ locks: {
371
+ active: locks.filter((lock) => !lock.stale).length,
372
+ stale: locks.filter((lock) => lock.stale).length,
373
+ records: locks.map((lock) => ({
374
+ id: lock.record?.id,
375
+ owner: lock.record?.owner,
376
+ pid: lock.record?.pid,
377
+ command: lock.record?.command,
378
+ statePath: lock.record?.statePath,
379
+ createdAt: lock.record?.createdAt,
380
+ stale: lock.stale,
381
+ reason: lock.reason,
382
+ recoveryCommand: lock.recoveryCommand,
383
+ })),
384
+ },
385
+ pendingRevalidation: candidates.filter((candidate) => candidate.lifecycleState === "stale").length,
386
+ artifacts: artifactCounts,
387
+ };
388
+ emit(context.json, ok("status", data, diagnostics));
389
+ if (!context.json && !context.quiet) {
390
+ console.log(`root: ${data.root}`);
391
+ console.log(`state: ${initialized ? "initialized" : "not initialized"}`);
392
+ console.log(`latest run: ${latest ?? "none"}`);
393
+ console.log(`queue: ${data.queue.open} open / ${data.queue.total} total`);
394
+ console.log(`git: ${git.available ? git.dirty ? "dirty" : "clean" : "unavailable"}`);
395
+ console.log(`locks: ${data.locks.active} active / ${data.locks.stale} stale`);
396
+ printDiagnostics(diagnostics);
397
+ }
398
+ return 0;
399
+ }
400
+ async function unlockCommand(context) {
401
+ if (!flagBoolean(context.parsed.flags, "stale")) {
402
+ emit(context.json, fail("unlock", "stale_required", "Only stale lock recovery is supported. Rerun with --stale."));
403
+ return 2;
404
+ }
405
+ const result = await recoverStaleLocks(context.paths, {
406
+ staleAfterMs: staleLockMsFromFlags(context),
407
+ });
408
+ emit(context.json, ok("unlock", {
409
+ removed: result.removed.map(lockStatusPayload),
410
+ active: result.active.map(lockStatusPayload),
411
+ recoveryCommand: lockRecoveryCommand(context.paths),
412
+ }));
413
+ if (!context.json && !context.quiet) {
414
+ console.log(`Removed ${result.removed.length} stale lock${result.removed.length === 1 ? "" : "s"}.`);
415
+ if (result.active.length > 0) {
416
+ console.log(`${result.active.length} active lock${result.active.length === 1 ? "" : "s"} left in place.`);
417
+ }
418
+ }
419
+ return result.active.length > 0 ? 4 : 0;
420
+ }
421
+ async function pruneCommand(context) {
422
+ await ensureState(context.paths);
423
+ const manifest = await buildRetentionManifest(context);
424
+ if (!manifest.dryRun) {
425
+ for (const relativePath of manifest.deletePaths) {
426
+ await rm(path.resolve(context.paths.root, relativePath), { force: true });
427
+ }
428
+ }
429
+ const manifestPath = await writeRetentionManifest(context.paths, manifest);
430
+ emit(context.json, ok("prune", {
431
+ dryRun: manifest.dryRun,
432
+ manifest,
433
+ manifestPath,
434
+ deleteCount: manifest.deletePaths.length,
435
+ retainedCount: manifest.retainedPaths.length,
436
+ blockedCount: manifest.blockedPaths.length,
437
+ }));
438
+ if (!context.json && !context.quiet) {
439
+ console.log(`${manifest.dryRun ? "Would delete" : "Deleted"} ${manifest.deletePaths.length} artifact${manifest.deletePaths.length === 1 ? "" : "s"}.`);
440
+ console.log(`Retention manifest written to ${path.relative(context.paths.root, manifestPath)}`);
441
+ }
442
+ return 0;
443
+ }
444
+ async function scrubCommand(context) {
445
+ const [candidates, clusters, evidence, features] = await Promise.all([
446
+ readLatestCandidates(context.paths),
447
+ readLatestClusters(context.paths),
448
+ readLatestEvidence(context.paths),
449
+ readLatestFeatures(context.paths),
450
+ ]);
451
+ const latest = await latestRunId(context.paths);
452
+ const output = {
453
+ schemaVersion,
454
+ sourceSafe: true,
455
+ generatedAt: new Date().toISOString(),
456
+ project: path.basename(context.paths.root),
457
+ latestRunId: latest,
458
+ counts: {
459
+ candidates: candidates.length,
460
+ clusters: clusters.length,
461
+ evidence: evidence.length,
462
+ features: features.length,
463
+ },
464
+ candidates: rankCandidates(candidates).map((candidate) => ({
465
+ id: candidate.id,
466
+ findingId: candidate.findingId,
467
+ title: candidate.title,
468
+ category: candidate.category,
469
+ status: candidate.status,
470
+ lifecycleState: candidate.lifecycleState,
471
+ priority: candidate.priority,
472
+ confidence: candidate.confidence,
473
+ impact: candidate.impact,
474
+ effort: candidate.effort,
475
+ risk: candidate.risk,
476
+ baselineStatus: candidate.baselineStatus,
477
+ evidenceIds: candidate.evidenceIds,
478
+ files: candidate.files.map((file) => sourceSafeFile(context.paths.root, file)),
479
+ verification: candidate.verification,
480
+ })),
481
+ clusters: clusters.map((cluster) => ({
482
+ id: cluster.id,
483
+ title: cluster.title,
484
+ category: cluster.category,
485
+ priority: cluster.priority,
486
+ confidence: cluster.confidence,
487
+ candidateIds: cluster.candidateIds,
488
+ evidenceIds: cluster.evidenceIds,
489
+ files: cluster.files.map((file) => sourceSafeFile(context.paths.root, file)),
490
+ actionability: cluster.actionability,
491
+ warnings: cluster.warnings,
492
+ })),
493
+ evidence: evidence.map((record) => ({
494
+ id: record.id,
495
+ kind: record.kind,
496
+ title: record.title,
497
+ files: record.files.map((file) => sourceSafeFile(context.paths.root, file)),
498
+ })),
499
+ features: features.map((feature) => ({
500
+ featureId: feature.featureId,
501
+ title: feature.title,
502
+ kind: feature.kind,
503
+ confidence: feature.confidence,
504
+ ownedFiles: feature.ownedFiles.map((file) => sourceSafeFile(context.paths.root, file)),
505
+ testFiles: feature.testFiles.map((file) => sourceSafeFile(context.paths.root, file)),
506
+ verification: feature.verification,
507
+ tags: feature.tags,
508
+ })),
509
+ privacyNotes: [
510
+ "Source-safe export omits source excerpts, model prompts, provider payloads, absolute state paths, and generated handoff/plan prose.",
511
+ "Repository-relative paths are retained so findings remain actionable.",
512
+ ],
513
+ };
514
+ const outputPath = flagString(context.parsed.flags, "output");
515
+ if (outputPath) {
516
+ const resolved = path.resolve(context.paths.root, outputPath);
517
+ await mkdir(path.dirname(resolved), { recursive: true });
518
+ await writeFile(resolved, `${JSON.stringify(output, null, 2)}\n`, "utf8");
519
+ }
520
+ emit(context.json, ok("scrub", {
521
+ export: output,
522
+ ...(outputPath ? { outputPath: path.resolve(context.paths.root, outputPath) } : {}),
523
+ }));
524
+ if (!context.json && !context.quiet) {
525
+ console.log(`Source-safe export: ${output.counts.candidates} candidates, ${output.counts.features} features, ${output.counts.evidence} evidence references`);
526
+ if (outputPath) {
527
+ console.log(`Export written to ${outputPath}`);
528
+ }
529
+ }
530
+ return 0;
531
+ }
532
+ async function fixCommand(context) {
533
+ const target = requireCandidateId(context);
534
+ if (target.startsWith("theme-")) {
535
+ emit(context.json, fail("fix", "fix_target_too_broad", "Fix execution requires one stable finding or candidate, not a broad theme."));
536
+ return 2;
537
+ }
538
+ const patch = flagString(context.parsed.flags, "patch");
539
+ if (!patch) {
540
+ emit(context.json, fail("fix", "patch_required", "--patch is required so Deepclean can preview/apply an explicit local patch."));
541
+ return 2;
542
+ }
543
+ const dryRun = flagBoolean(context.parsed.flags, "dry-run") || !flagBoolean(context.parsed.flags, "apply");
544
+ const config = await ensureState(context.paths);
545
+ if (!dryRun && (!config.fixExecution.enabled || !flagBoolean(context.parsed.flags, "allow-source-mutation"))) {
546
+ emit(context.json, fail("fix", "fix_opt_in_required", "Applying fixes requires fixExecution.enabled=true and --allow-source-mutation."));
547
+ return 2;
548
+ }
549
+ const resolved = await resolveFixTarget(context.paths, target);
550
+ if (!resolved) {
551
+ emit(context.json, fail("fix", "finding_not_found", `Finding or candidate not found: ${target}`));
552
+ return 1;
553
+ }
554
+ const blocked = fixReadinessBlocker(resolved.candidate);
555
+ if (blocked) {
556
+ emit(context.json, fail("fix", blocked.code, blocked.message));
557
+ return 2;
558
+ }
559
+ const plan = await latestPlanForTarget(context.paths, resolved.candidate.id);
560
+ if (!plan) {
561
+ emit(context.json, fail("fix", "fix_plan_required", "Generate a current plan before fixing this finding."));
562
+ return 2;
563
+ }
564
+ const revalidation = await latestRevalidationForFinding(context.paths, resolved.findingId);
565
+ if (!revalidation || !["unchanged", "changed"].includes(revalidation.outcome)) {
566
+ emit(context.json, fail("fix", "fix_revalidation_required", "Run revalidation before applying or previewing this fix."));
567
+ return 2;
568
+ }
569
+ const patchPath = path.resolve(context.paths.root, patch);
570
+ const patchContent = await readFile(patchPath, "utf8");
571
+ const changedFiles = changedFilesFromPatch(patchContent);
572
+ if (changedFiles.length === 0) {
573
+ emit(context.json, fail("fix", "patch_empty", "Patch preview did not contain changed files."));
574
+ return 2;
575
+ }
576
+ const dirty = await dirtyFiles(context.paths.root);
577
+ const targetPaths = new Set(resolved.candidate.files.map((file) => file.path));
578
+ const statePrefix = `${relativeStatePath(context.paths, context.paths.stateDir).replace(/\/$/, "")}/`;
579
+ const patchRelativePath = relativeStatePath(context.paths, patchPath);
580
+ const dirtyOutsideTarget = dirty.filter((file) => (!targetPaths.has(file)
581
+ && file !== patchRelativePath
582
+ && !file.startsWith(statePrefix)));
583
+ if (!dryRun && dirtyOutsideTarget.length > 0 && !flagBoolean(context.parsed.flags, "allow-dirty")) {
584
+ emit(context.json, fail("fix", "dirty_tree", `Dirty files outside target scope: ${dirtyOutsideTarget.join(", ")}`));
585
+ return 2;
586
+ }
587
+ const attemptId = timestampId("fix");
588
+ const patchPreviewPath = path.join(context.paths.fixesDir, `${attemptId}.patch`);
589
+ await mkdir(context.paths.fixesDir, { recursive: true });
590
+ await writeFile(patchPreviewPath, patchContent, "utf8");
591
+ const verificationCommands = verificationCommandsForFix(context, config, resolved.candidate);
592
+ let status = dryRun ? "previewed" : "unverified";
593
+ let verificationResults = [];
594
+ const diagnostics = [];
595
+ if (!dryRun) {
596
+ const apply = await execFileAsync("git", ["apply", patchPath], { cwd: context.paths.root, timeout: 30_000 })
597
+ .then(() => ({ ok: true, error: undefined }))
598
+ .catch((error) => ({ ok: false, error: error instanceof Error ? error.message : String(error) }));
599
+ if (!apply.ok) {
600
+ diagnostics.push({ level: "error", code: "patch_apply_failed", message: apply.error ?? "Patch failed to apply." });
601
+ status = "failed";
602
+ }
603
+ else if (verificationCommands.length > 0) {
604
+ verificationResults = await runFixVerification(context.paths, attemptId, verificationCommands);
605
+ status = verificationResults.every((result) => result.passed) ? "passed" : "failed";
606
+ }
607
+ }
608
+ const now = new Date().toISOString();
609
+ const attempt = {
610
+ schemaVersion,
611
+ recordType: "fix_attempt",
612
+ id: attemptId,
613
+ findingId: resolved.findingId,
614
+ planId: plan.id,
615
+ status,
616
+ dryRun,
617
+ changedFiles,
618
+ patchPreviewPath,
619
+ verificationCommands,
620
+ verificationResults,
621
+ diagnostics,
622
+ createdAt: now,
623
+ updatedAt: now,
624
+ };
625
+ const attemptPath = await writeFixAttempt(context.paths, attempt);
626
+ await writeLifecycleEvents(context.paths, [
627
+ {
628
+ schemaVersion,
629
+ recordType: "lifecycle_event",
630
+ id: timestampId("event"),
631
+ targetType: "fix_attempt",
632
+ targetId: attempt.id,
633
+ findingId: resolved.findingId,
634
+ kind: "fix-attempted",
635
+ command: "fix",
636
+ createdAt: now,
637
+ data: { dryRun, status, changedFiles },
638
+ },
639
+ ...(status === "passed" || status === "failed"
640
+ ? [{
641
+ schemaVersion,
642
+ recordType: "lifecycle_event",
643
+ id: timestampId("event"),
644
+ targetType: "fix_attempt",
645
+ targetId: attempt.id,
646
+ findingId: resolved.findingId,
647
+ kind: status === "passed" ? "verification-passed" : "verification-failed",
648
+ command: "fix",
649
+ createdAt: now,
650
+ data: { verificationResults },
651
+ }]
652
+ : []),
653
+ ]);
654
+ emit(context.json, ok("fix", {
655
+ attempt,
656
+ attemptPath,
657
+ patchPreviewPath,
658
+ changedFiles,
659
+ externalSideEffects: [],
660
+ next: status === "passed" ? "Review local changes and open a PR manually if desired." : "Inspect the fix attempt artifact before continuing.",
661
+ }, diagnostics));
662
+ if (!context.json && !context.quiet) {
663
+ console.log(`${dryRun ? "Previewed" : "Applied"} fix ${attempt.id}: ${status}`);
664
+ }
665
+ return status === "failed" ? 3 : 0;
666
+ }
667
+ async function mapCommand(context) {
668
+ const result = await executeFeatureMap(context);
669
+ emit(context.json, ok("map", result));
670
+ if (!context.json && !context.quiet) {
671
+ console.log(`Mapped ${result.featureCount} semantic feature${result.featureCount === 1 ? "" : "s"}.`);
672
+ console.log(`Features written to ${path.relative(context.paths.root, result.path)}`);
673
+ }
674
+ return 0;
675
+ }
676
+ async function executeFeatureMap(context) {
677
+ const createdAt = new Date().toISOString();
678
+ const mapId = timestampId("map");
679
+ const config = await ensureState(context.paths);
680
+ const verificationProfile = await inferVerificationProfile(context.paths.root);
681
+ const discoveredFiles = await discoverSourceFiles(context.paths.root, config.exclude);
682
+ const scope = await resolveScanScope(context, discoveredFiles);
683
+ const files = discoveredFiles.filter((file) => fileInScope(file, scope));
684
+ const features = await mapSemanticFeatures({
685
+ root: context.paths.root,
686
+ runId: mapId,
687
+ createdAt,
688
+ files,
689
+ verificationProfile,
690
+ excludes: config.exclude,
691
+ });
692
+ const featurePath = await writeFeatures(context.paths, mapId, features);
693
+ return {
694
+ mapId,
695
+ root: context.paths.root,
696
+ sourceFileCount: files.length,
697
+ featureCount: features.length,
698
+ features,
699
+ scope,
700
+ path: featurePath,
701
+ };
702
+ }
703
+ async function ciCommand(context) {
704
+ const requireSynthesis = flagBoolean(context.parsed.flags, "require-synthesis");
705
+ const config = await ensureState(context.paths);
706
+ if (requireSynthesis && synthesisDisabledByPolicy(context, config)) {
707
+ const diagnostic = {
708
+ level: "error",
709
+ code: "ci_synthesis_required",
710
+ message: "CI policy requires synthesis; rerun without evidence-only/local-only flags and with a configured provider.",
711
+ };
712
+ emit(context.json, fail("ci", "ci_synthesis_required", diagnostic.message, [diagnostic]));
713
+ return 2;
714
+ }
715
+ const scan = await executeScan(context, {});
716
+ const synthesisFailure = requireSynthesis ? requiredSynthesisFailure(scan) : undefined;
717
+ if (synthesisFailure) {
718
+ const diagnostics = [
719
+ synthesisFailure,
720
+ ...scan.diagnostics.filter((diagnostic) => !sameSynthesisFailure(diagnostic, synthesisFailure)),
721
+ ];
722
+ emit(context.json, fail("ci", "ci_synthesis_failed", synthesisFailure.message, diagnostics));
723
+ return 2;
724
+ }
725
+ const policy = ciPolicyFromFlags(context);
726
+ const gate = evaluateCiPolicy(scan.data.candidates, policy);
727
+ const createdAt = new Date().toISOString();
728
+ const artifactPaths = await writeCiArtifacts(context, scan.data, gate);
729
+ const ciRun = {
730
+ schemaVersion,
731
+ recordType: "ci_run",
732
+ id: timestampId("ci"),
733
+ runId: scan.runId,
734
+ baselineRef: scan.data.scope.since ?? scan.data.scope.mergeBase,
735
+ status: gate.blockingFindingIds.length > 0 ? "policy-failed" : "passed",
736
+ policy,
737
+ blockingFindingIds: gate.blockingFindingIds,
738
+ artifactPaths,
739
+ diagnostics: scan.diagnostics,
740
+ createdAt,
741
+ };
742
+ await writeCiRun(context.paths, ciRun);
743
+ emit(context.json, ok("ci", {
744
+ ciRun,
745
+ policy,
746
+ result: gate,
747
+ scan: scan.data,
748
+ }, scan.diagnostics));
749
+ if (!context.json && !context.quiet) {
750
+ console.log(`CI ${ciRun.status}: ${gate.blockingFindingIds.length} blocking finding${gate.blockingFindingIds.length === 1 ? "" : "s"}`);
751
+ }
752
+ return gate.blockingFindingIds.length > 0 ? 3 : 0;
753
+ }
138
754
  async function scanCommand(context) {
755
+ const result = await executeScan(context, {});
756
+ emit(context.json, ok("scan", result.data, result.diagnostics));
757
+ if (!context.json && !context.quiet) {
758
+ const synthesisText = result.data.synthesis.requested
759
+ ? `, ${result.data.synthesis.candidateCount} synthesized`
760
+ : "";
761
+ console.log(`Scan complete: ${result.data.evidenceCount} evidence records, ${result.data.candidateCount} candidates, ${result.data.clusterCount} clusters${synthesisText}`);
762
+ printCandidateSummary(result.data.candidates);
763
+ }
764
+ return 0;
765
+ }
766
+ async function executeScan(context, options) {
139
767
  const startedAt = new Date().toISOString();
140
768
  const runId = timestampId("run");
141
769
  const config = await ensureState(context.paths);
142
770
  const verificationProfile = await inferVerificationProfile(context.paths.root);
143
- const files = await discoverSourceFiles(context.paths.root, config.exclude);
771
+ const discoveredFiles = await discoverSourceFiles(context.paths.root, config.exclude);
772
+ const scope = await resolveScanScope(context, discoveredFiles);
773
+ const files = discoveredFiles.filter((file) => fileInScope(file, scope));
774
+ const features = await mapSemanticFeatures({
775
+ root: context.paths.root,
776
+ runId,
777
+ createdAt: startedAt,
778
+ files,
779
+ verificationProfile,
780
+ excludes: config.exclude,
781
+ });
144
782
  const adapterResult = await runEvidenceAdapters(config.enabledAdapters, {
145
783
  root: context.paths.root,
146
784
  runId,
@@ -148,32 +786,56 @@ async function scanCommand(context) {
148
786
  files,
149
787
  config,
150
788
  });
789
+ const evidence = markDirtyTreeEvidence(adapterResult.evidence, scope);
151
790
  const completedAt = new Date().toISOString();
152
- const localCandidates = candidatesFromEvidence(runId, adapterResult.evidence, completedAt, config.candidateCaps, verificationProfile);
153
- const synthesisRequested = flagBoolean(context.parsed.flags, "synthesize")
154
- || config.reviewSynthesis.enabled;
155
- const synthesisResult = synthesisRequested
791
+ const localCandidates = candidatesFromEvidence(runId, evidence, completedAt, config.candidateCaps, verificationProfile);
792
+ const synthesisRequested = options.synthesize ?? true;
793
+ const runtime = providerRuntimeControls(context, config);
794
+ if (synthesisRequested && runtime.offline) {
795
+ adapterResult.diagnostics.push({
796
+ level: "info",
797
+ code: "synthesis_skipped_by_policy",
798
+ message: "Provider synthesis was skipped because evidence-only/offline/local-only mode is active.",
799
+ adapter: "codex-synthesis",
800
+ });
801
+ }
802
+ const shouldSynthesize = synthesisRequested && !runtime.offline;
803
+ const synthesisResult = shouldSynthesize
156
804
  ? await synthesizeWithCodex({
157
805
  root: context.paths.root,
158
806
  runId,
159
807
  createdAt: completedAt,
160
- evidence: adapterResult.evidence,
808
+ evidence,
161
809
  config,
162
810
  existingCandidates: localCandidates,
163
- includeSource: flagBoolean(context.parsed.flags, "allow-source-in-model")
164
- || config.privacy.allowSourceInModel,
165
- model: flagString(context.parsed.flags, "model"),
811
+ includeSource: runtime.allowSourceInModel,
812
+ runtime,
166
813
  verificationProfile,
167
814
  })
168
815
  : { candidates: [], diagnostics: [] };
169
816
  const diagnostics = [...adapterResult.diagnostics, ...synthesisResult.diagnostics];
170
- const candidates = reassignCandidateIds(rankCandidates([
817
+ const rankedCandidates = reassignCandidateIds(rankCandidates([
171
818
  ...localCandidates,
172
819
  ...synthesisResult.candidates,
173
820
  ]));
174
- const clusters = buildClusters(runId, candidates, adapterResult.evidence, completedAt, config.clusters);
175
- await writeEvidence(context.paths, runId, adapterResult.evidence);
821
+ const identity = attachStableIdentity({
822
+ runId,
823
+ candidates: rankedCandidates,
824
+ evidence,
825
+ existingFindings: await readFindings(context.paths),
826
+ observedAt: completedAt,
827
+ });
828
+ const candidates = filterCandidatesByScanScope(identity.candidates, scope);
829
+ const clusters = buildClusters(runId, candidates, evidence, completedAt, config.clusters);
830
+ await writeFeatures(context.paths, runId, features);
831
+ await writeEvidence(context.paths, runId, evidence);
832
+ if (synthesisResult.attempt) {
833
+ await writeSynthesisAttempt(context.paths, remapSynthesisAttemptCandidateIds(synthesisResult.attempt, candidates));
834
+ }
176
835
  await writeCandidates(context.paths, runId, candidates);
836
+ await writeFindings(context.paths, identity.findings);
837
+ await writeCandidateObservations(context.paths, runId, identity.observations);
838
+ await writeLifecycleEvents(context.paths, identity.lifecycleEvents);
177
839
  await writeClusters(context.paths, runId, clusters);
178
840
  await writeRun(context.paths, {
179
841
  schemaVersion,
@@ -183,44 +845,66 @@ async function scanCommand(context) {
183
845
  root: context.paths.root,
184
846
  startedAt,
185
847
  completedAt,
186
- evidenceCount: adapterResult.evidence.length,
848
+ featureCount: features.length,
849
+ evidenceCount: evidence.length,
187
850
  candidateCount: candidates.length,
188
851
  clusterCount: clusters.length,
189
852
  synthesis: {
190
- requested: synthesisRequested,
191
- provider: synthesisRequested ? "codex" : undefined,
853
+ requested: shouldSynthesize,
854
+ provider: shouldSynthesize ? runtime.provider : undefined,
192
855
  candidateCount: synthesisResult.candidates.length,
856
+ attemptId: synthesisResult.attempt?.id,
857
+ acceptedCandidateCount: synthesisResult.attempt?.acceptedCandidateCount,
858
+ rejectedCandidateCount: synthesisResult.attempt?.rejectedCandidateCount,
859
+ runtime: providerRuntimeSummary(runtime),
193
860
  },
861
+ scope,
194
862
  diagnostics,
195
863
  });
196
864
  const data = {
197
865
  runId,
198
866
  root: context.paths.root,
199
867
  sourceFileCount: files.length,
200
- evidenceCount: adapterResult.evidence.length,
868
+ featureCount: features.length,
869
+ evidenceCount: evidence.length,
201
870
  candidateCount: candidates.length,
202
871
  clusterCount: clusters.length,
203
872
  synthesis: {
204
- requested: synthesisRequested,
873
+ requested: shouldSynthesize,
205
874
  candidateCount: synthesisResult.candidates.length,
875
+ attemptId: synthesisResult.attempt?.id,
876
+ acceptedCandidateCount: synthesisResult.attempt?.acceptedCandidateCount,
877
+ rejectedCandidateCount: synthesisResult.attempt?.rejectedCandidateCount,
878
+ runtime: providerRuntimeSummary(runtime),
206
879
  },
207
880
  candidates,
208
881
  clusters,
882
+ features,
883
+ scope,
884
+ };
885
+ return { runId, diagnostics, data };
886
+ }
887
+ function remapSynthesisAttemptCandidateIds(attempt, candidates) {
888
+ const candidateIdByValidationId = new Map(candidates
889
+ .filter((candidate) => candidate.provenance.source === "model-synthesis")
890
+ .flatMap((candidate) => candidate.provenance.validationId
891
+ ? [[candidate.provenance.validationId, candidate.id]]
892
+ : []));
893
+ return {
894
+ ...attempt,
895
+ validations: attempt.validations.map((validation) => ({
896
+ ...validation,
897
+ candidateId: candidateIdByValidationId.get(validation.id),
898
+ })),
209
899
  };
210
- emit(context.json, ok("scan", data, diagnostics));
211
- if (!context.json && !context.quiet) {
212
- const synthesisText = synthesisRequested
213
- ? `, ${synthesisResult.candidates.length} synthesized`
214
- : "";
215
- console.log(`Scan complete: ${adapterResult.evidence.length} evidence records, ${candidates.length} candidates, ${clusters.length} clusters${synthesisText}`);
216
- printCandidateSummary(candidates);
217
- }
218
- return 0;
219
900
  }
220
901
  async function reportCommand(context) {
221
902
  const { candidates, evidence, runId } = await latestState(context.paths);
222
903
  const config = await ensureState(context.paths);
223
- const ranked = rankCandidates(candidates);
904
+ const latestClusters = await readLatestClusters(context.paths);
905
+ const filter = queryFilterFromFlags(context);
906
+ const filtered = filterCandidatesForQuery(candidates, latestClusters, filter);
907
+ const ranked = rankCandidates(filtered);
224
908
  const clusters = buildClusters(runId, ranked, evidence, new Date().toISOString(), config.clusters);
225
909
  await writeClusters(context.paths, runId, clusters);
226
910
  const report = buildReportRecord(runId, ranked, clusters);
@@ -236,6 +920,7 @@ async function reportCommand(context) {
236
920
  jsonPath: paths.jsonPath,
237
921
  candidates: ranked,
238
922
  clusters,
923
+ filters: filter,
239
924
  evidenceCount: evidence.length,
240
925
  }));
241
926
  if (!context.json && !context.quiet) {
@@ -246,8 +931,13 @@ async function reportCommand(context) {
246
931
  return 0;
247
932
  }
248
933
  async function nextCommand(context) {
249
- const candidates = rankCandidates(await readLatestCandidates(context.paths));
250
- const candidate = candidates.find((item) => item.status === "open");
934
+ const [candidates, clusters] = await Promise.all([
935
+ readLatestCandidates(context.paths),
936
+ readLatestClusters(context.paths),
937
+ ]);
938
+ const filtered = filterCandidatesForQuery(candidates, clusters, queryFilterFromFlags(context));
939
+ const ranked = rankCandidates(filtered);
940
+ const candidate = ranked.find((item) => item.status === "open");
251
941
  emit(context.json, ok("next", { candidate: candidate ?? null }));
252
942
  if (!context.json && !context.quiet) {
253
943
  if (!candidate) {
@@ -259,6 +949,28 @@ async function nextCommand(context) {
259
949
  }
260
950
  return 0;
261
951
  }
952
+ async function listCommand(context) {
953
+ const [candidates, clusters] = await Promise.all([
954
+ readLatestCandidates(context.paths),
955
+ readLatestClusters(context.paths),
956
+ ]);
957
+ const filter = queryFilterFromFlags(context);
958
+ const filtered = rankCandidates(filterCandidatesForQuery(candidates, clusters, filter));
959
+ const format = flagString(context.parsed.flags, "format");
960
+ const queue = format === "codex" ? filtered.map(candidateQueueItem) : undefined;
961
+ emit(context.json, ok("list", {
962
+ filters: filter,
963
+ count: filtered.length,
964
+ candidates: filtered,
965
+ ...(queue ? { queue } : {}),
966
+ }));
967
+ if (!context.json && !context.quiet) {
968
+ for (const candidate of filtered) {
969
+ console.log(`${candidate.findingId ?? candidate.id} ${candidate.priority} ${candidate.title}`);
970
+ }
971
+ }
972
+ return 0;
973
+ }
262
974
  async function showCommand(context) {
263
975
  const id = requireCandidateId(context);
264
976
  const { candidates, evidence, clusters, runId } = await latestState(context.paths);
@@ -292,6 +1004,180 @@ async function showCommand(context) {
292
1004
  }
293
1005
  return 0;
294
1006
  }
1007
+ async function explainCommand(context) {
1008
+ const id = requireCandidateId(context);
1009
+ const { candidates, evidence } = await latestState(context.paths);
1010
+ const attempt = await readLatestSynthesisAttempt(context.paths);
1011
+ const candidate = candidates.find((item) => item.id === id || item.findingId === id);
1012
+ if (!candidate) {
1013
+ emit(context.json, fail("explain", "candidate_not_found", `Candidate or finding not found: ${id}`));
1014
+ return 1;
1015
+ }
1016
+ const supportingEvidence = evidenceForIds(evidence, candidate.evidenceIds);
1017
+ const validation = validationForCandidate(candidate, attempt);
1018
+ const diagnostics = validation?.diagnostics ?? [];
1019
+ const explanation = {
1020
+ candidate,
1021
+ evidence: supportingEvidence,
1022
+ synthesisAttempt: attempt ? {
1023
+ id: attempt.id,
1024
+ runId: attempt.runId,
1025
+ provider: attempt.provider,
1026
+ model: attempt.model,
1027
+ promptVersion: attempt.promptVersion,
1028
+ promptBytes: attempt.promptBytes,
1029
+ rawCandidateCount: attempt.rawCandidateCount,
1030
+ acceptedCandidateCount: attempt.acceptedCandidateCount,
1031
+ rejectedCandidateCount: attempt.rejectedCandidateCount,
1032
+ evidenceManifest: attempt.evidenceManifest,
1033
+ } : undefined,
1034
+ validation,
1035
+ fixReadiness: candidate.fixReadiness,
1036
+ verification: candidate.verification,
1037
+ diagnostics,
1038
+ };
1039
+ emit(context.json, ok("explain", explanation, diagnostics));
1040
+ if (!context.json && !context.quiet) {
1041
+ printCandidate(candidate);
1042
+ console.log("");
1043
+ console.log("Why this exists:");
1044
+ console.log(` ${candidate.whyItMatters}`);
1045
+ console.log("");
1046
+ console.log("Evidence:");
1047
+ for (const record of supportingEvidence) {
1048
+ console.log(` ${record.id} ${record.kind}: ${record.summary}`);
1049
+ for (const file of record.files) {
1050
+ console.log(` ${formatFileRef(file)}`);
1051
+ }
1052
+ }
1053
+ if (validation) {
1054
+ console.log("");
1055
+ console.log(`Validation: ${validation.status} (${validation.id})`);
1056
+ if (validation.diagnostics.length === 0) {
1057
+ console.log(" All cited evidence IDs, file paths, line ranges, and quotes passed validation.");
1058
+ }
1059
+ else {
1060
+ for (const diagnostic of validation.diagnostics) {
1061
+ console.log(` ${diagnostic.code}: ${diagnostic.message}`);
1062
+ }
1063
+ }
1064
+ }
1065
+ if (candidate.fixReadiness) {
1066
+ console.log("");
1067
+ console.log("Fix readiness:");
1068
+ console.log(` scope: ${candidate.fixReadiness.minimumFixScope}`);
1069
+ console.log(` regression: ${candidate.fixReadiness.suggestedRegressionTest}`);
1070
+ console.log(` test gap: ${candidate.fixReadiness.whyCurrentTestsMissIt}`);
1071
+ for (const reason of candidate.fixReadiness.confidenceDowngradeReasons) {
1072
+ console.log(` confidence note: ${reason}`);
1073
+ }
1074
+ }
1075
+ console.log("");
1076
+ console.log(`Verification: ${candidate.verification.join("; ") || "n/a"}`);
1077
+ }
1078
+ return 0;
1079
+ }
1080
+ async function historyCommand(context) {
1081
+ const id = requireCandidateId(context);
1082
+ const runId = flagString(context.parsed.flags, "run");
1083
+ const [findings, events] = await Promise.all([
1084
+ readFindings(context.paths),
1085
+ readLifecycleEvents(context.paths),
1086
+ ]);
1087
+ const candidate = id.startsWith("candidate-")
1088
+ ? await candidateForHistoryLookup(context.paths, id, runId)
1089
+ : undefined;
1090
+ const findingId = candidate?.findingId ?? id;
1091
+ const finding = findings.find((item) => item.id === findingId);
1092
+ if (!finding) {
1093
+ emit(context.json, fail("history", "finding_not_found", `Finding not found: ${id}`));
1094
+ return 1;
1095
+ }
1096
+ const history = events.filter((event) => event.findingId === finding.id || event.targetId === finding.id);
1097
+ emit(context.json, ok("history", { finding, candidate, events: history }));
1098
+ if (!context.json && !context.quiet) {
1099
+ console.log(`${finding.id}: ${finding.title}`);
1100
+ for (const event of history) {
1101
+ console.log(`${event.createdAt} ${event.kind}${event.toState ? ` -> ${event.toState}` : ""}`);
1102
+ }
1103
+ }
1104
+ return 0;
1105
+ }
1106
+ async function revalidateCommand(context) {
1107
+ const target = requireCandidateId(context);
1108
+ const beforeFindings = await readFindings(context.paths);
1109
+ const targetFindings = await resolveRevalidationTargets(context.paths, target, beforeFindings);
1110
+ if (targetFindings.length === 0 && target !== "all") {
1111
+ emit(context.json, fail("revalidate", "finding_not_found", `Finding not found: ${target}`));
1112
+ return 1;
1113
+ }
1114
+ const scan = await executeScan(context, { synthesize: false });
1115
+ const now = new Date().toISOString();
1116
+ const records = [];
1117
+ for (const finding of target === "all" ? beforeFindings : targetFindings) {
1118
+ records.push(await classifyRevalidation({
1119
+ root: context.paths.root,
1120
+ finding,
1121
+ currentCandidates: scan.data.candidates,
1122
+ runId: scan.runId,
1123
+ createdAt: now,
1124
+ }));
1125
+ }
1126
+ if (target === "all" && beforeFindings.length === 0) {
1127
+ records.push(await classifyRevalidation({
1128
+ root: context.paths.root,
1129
+ finding: undefined,
1130
+ currentCandidates: scan.data.candidates,
1131
+ runId: scan.runId,
1132
+ createdAt: now,
1133
+ }));
1134
+ }
1135
+ for (const record of records) {
1136
+ await writeRevalidation(context.paths, record);
1137
+ }
1138
+ const updatedFindings = beforeFindings.map((finding) => {
1139
+ const record = records.find((item) => item.targetId === finding.id);
1140
+ if (!record) {
1141
+ return finding;
1142
+ }
1143
+ const state = revalidationOutcomeToLifecycleState(record.outcome);
1144
+ return {
1145
+ ...finding,
1146
+ lifecycleState: state,
1147
+ status: revalidationOutcomeToStatus(record.outcome, finding.status),
1148
+ updatedAt: record.createdAt,
1149
+ };
1150
+ });
1151
+ await writeFindings(context.paths, updatedFindings);
1152
+ await writeLifecycleEvents(context.paths, records.flatMap((record) => (record.targetId
1153
+ ? [{
1154
+ schemaVersion,
1155
+ recordType: "lifecycle_event",
1156
+ id: timestampId("event"),
1157
+ targetType: "finding",
1158
+ targetId: record.targetId,
1159
+ findingId: record.targetId,
1160
+ runId: record.runId,
1161
+ kind: "revalidated",
1162
+ toState: record.outcome,
1163
+ command: "revalidate",
1164
+ createdAt: record.createdAt,
1165
+ data: { revalidationId: record.id, outcome: record.outcome },
1166
+ }]
1167
+ : [])));
1168
+ emit(context.json, ok("revalidate", {
1169
+ target,
1170
+ runId: scan.runId,
1171
+ revalidations: records,
1172
+ candidates: scan.data.candidates,
1173
+ }, scan.diagnostics));
1174
+ if (!context.json && !context.quiet) {
1175
+ for (const record of records) {
1176
+ console.log(`${record.targetId ?? "all"}: ${record.outcome}`);
1177
+ }
1178
+ }
1179
+ return 0;
1180
+ }
295
1181
  async function clusterCommand(context) {
296
1182
  const id = context.parsed.positional[0];
297
1183
  const { candidates, evidence, runId } = await latestState(context.paths);
@@ -417,6 +1303,24 @@ async function triageCommand(context) {
417
1303
  createdAt: now,
418
1304
  };
419
1305
  await writeTriage(context.paths, triage);
1306
+ if (updated.findingId) {
1307
+ await writeLifecycleEvents(context.paths, [{
1308
+ schemaVersion,
1309
+ recordType: "lifecycle_event",
1310
+ id: timestampId("event"),
1311
+ targetType: "finding",
1312
+ targetId: updated.findingId,
1313
+ findingId: updated.findingId,
1314
+ runId: updated.runId,
1315
+ kind: "triaged",
1316
+ fromState: existing.status,
1317
+ toState: updated.status,
1318
+ note: note ?? "",
1319
+ command: "triage",
1320
+ createdAt: now,
1321
+ data: { candidateId: id, triageId: triage.id },
1322
+ }]);
1323
+ }
420
1324
  emit(context.json, ok("triage", { candidate: updated, triage }));
421
1325
  if (!context.json && !context.quiet) {
422
1326
  console.log(`${id}: ${existing.status} -> ${updated.status}`);
@@ -435,8 +1339,12 @@ async function handoffCommand(context) {
435
1339
  const supportingEvidence = evidenceForIds(evidence, candidate.evidenceIds);
436
1340
  const handoff = buildHandoff(candidate, supportingEvidence, format);
437
1341
  const handoffPath = await writeHandoff(context.paths, handoff);
438
- emit(context.json, ok("handoff", { handoff, path: handoffPath }));
1342
+ const warnings = handoffFreshnessWarnings(candidate);
1343
+ emit(context.json, ok("handoff", { handoff, path: handoffPath, warnings }));
439
1344
  if (!context.json && !context.quiet) {
1345
+ for (const warning of warnings) {
1346
+ console.log(`warning: ${warning}`);
1347
+ }
440
1348
  console.log(handoff.content);
441
1349
  console.log("");
442
1350
  console.log(`Handoff written to ${path.relative(context.paths.root, handoffPath)}`);
@@ -455,6 +1363,149 @@ async function latestState(paths) {
455
1363
  ]);
456
1364
  return { runId, candidates, clusters, evidence };
457
1365
  }
1366
+ async function resolveFixTarget(paths, target) {
1367
+ const candidates = await readLatestCandidates(paths);
1368
+ const candidate = candidates.find((item) => item.id === target || item.findingId === target);
1369
+ if (!candidate) {
1370
+ return undefined;
1371
+ }
1372
+ return { findingId: candidate.findingId ?? candidate.id, candidate };
1373
+ }
1374
+ function fixReadinessBlocker(candidate) {
1375
+ if (candidate.confidence === "low") {
1376
+ return { code: "fix_low_confidence", message: "Low-confidence findings must be confirmed before fix execution." };
1377
+ }
1378
+ const lifecycleState = candidate.lifecycleState ?? "open";
1379
+ if (["stale", "fixed", "superseded", "inconclusive", "suppressed"].includes(lifecycleState)) {
1380
+ return { code: "fix_not_current", message: `Finding lifecycle state is ${lifecycleState}; revalidate or choose another finding.` };
1381
+ }
1382
+ if (candidate.risk === "design-needed") {
1383
+ return { code: "fix_ambiguous", message: "Design-needed findings are too ambiguous for guarded fix execution." };
1384
+ }
1385
+ return undefined;
1386
+ }
1387
+ async function latestPlanForTarget(paths, candidateId) {
1388
+ const files = await filesWithExtension(paths.plansDir, "json");
1389
+ const plans = [];
1390
+ for (const file of files) {
1391
+ try {
1392
+ const parsed = JSON.parse(await readFile(file, "utf8"));
1393
+ if (typeof parsed.id === "string" && typeof parsed.targetId === "string") {
1394
+ plans.push({
1395
+ id: parsed.id,
1396
+ targetId: parsed.targetId,
1397
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
1398
+ });
1399
+ }
1400
+ }
1401
+ catch {
1402
+ // Ignore malformed historical plan records here; schema validation handles them elsewhere.
1403
+ }
1404
+ }
1405
+ return plans
1406
+ .filter((plan) => plan.targetId === candidateId)
1407
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
1408
+ .at(-1);
1409
+ }
1410
+ async function latestRevalidationForFinding(paths, findingId) {
1411
+ const files = await filesWithExtension(paths.revalidationsDir, "json");
1412
+ const records = [];
1413
+ for (const file of files) {
1414
+ try {
1415
+ const parsed = JSON.parse(await readFile(file, "utf8"));
1416
+ if (parsed.targetId === findingId && typeof parsed.outcome === "string") {
1417
+ records.push({
1418
+ targetId: findingId,
1419
+ outcome: parsed.outcome,
1420
+ createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : "",
1421
+ });
1422
+ }
1423
+ }
1424
+ catch {
1425
+ // Ignore malformed historical revalidation records.
1426
+ }
1427
+ }
1428
+ return records.sort((a, b) => a.createdAt.localeCompare(b.createdAt)).at(-1);
1429
+ }
1430
+ function changedFilesFromPatch(patchContent) {
1431
+ const files = new Set();
1432
+ for (const line of patchContent.split(/\r?\n/)) {
1433
+ const match = line.match(/^\+\+\+ b\/(.+)$/);
1434
+ if (match?.[1] && match[1] !== "/dev/null") {
1435
+ files.add(match[1]);
1436
+ }
1437
+ }
1438
+ return [...files].sort();
1439
+ }
1440
+ async function dirtyFiles(root) {
1441
+ try {
1442
+ const { stdout } = await execFileAsync("git", ["status", "--short"], { cwd: root, timeout: 5000 });
1443
+ return stdout.split(/\r?\n/)
1444
+ .map((line) => line.slice(3).trim())
1445
+ .filter(Boolean)
1446
+ .map((line) => line.split(" -> ").at(-1) ?? line)
1447
+ .sort();
1448
+ }
1449
+ catch {
1450
+ return [];
1451
+ }
1452
+ }
1453
+ function verificationCommandsForFix(context, config, candidate) {
1454
+ const override = flagString(context.parsed.flags, "verification-command");
1455
+ if (override) {
1456
+ return [override];
1457
+ }
1458
+ if (config.fixExecution.verificationCommands.length > 0) {
1459
+ return config.fixExecution.verificationCommands;
1460
+ }
1461
+ return candidate.verification;
1462
+ }
1463
+ async function runFixVerification(paths, attemptId, commandsToRun) {
1464
+ const results = [];
1465
+ for (const [index, command] of commandsToRun.entries()) {
1466
+ const outputPath = path.join(paths.fixesDir, `${attemptId}-verification-${String(index + 1).padStart(2, "0")}.txt`);
1467
+ const result = await execFileAsync("sh", ["-lc", command], { cwd: paths.root, timeout: 120_000 })
1468
+ .then((output) => ({ exitCode: 0, output: `${output.stdout}${output.stderr}` }))
1469
+ .catch((error) => ({
1470
+ exitCode: typeof error === "object" && error && "code" in error && typeof error.code === "number" ? error.code : 1,
1471
+ output: error instanceof Error ? error.message : String(error),
1472
+ }));
1473
+ await writeFile(outputPath, result.output, "utf8");
1474
+ results.push({
1475
+ command,
1476
+ exitCode: result.exitCode,
1477
+ passed: result.exitCode === 0,
1478
+ outputPath,
1479
+ });
1480
+ }
1481
+ return results;
1482
+ }
1483
+ async function candidateForHistoryLookup(paths, id, runId) {
1484
+ if (runId) {
1485
+ return (await readCandidates(paths, runId)).find((candidate) => candidate.id === id);
1486
+ }
1487
+ return (await readLatestCandidates(paths)).find((candidate) => candidate.id === id);
1488
+ }
1489
+ async function resolveRevalidationTargets(paths, target, findings) {
1490
+ if (target === "all") {
1491
+ return findings;
1492
+ }
1493
+ if (target.startsWith("candidate-")) {
1494
+ const candidate = await candidateForHistoryLookup(paths, target, undefined);
1495
+ return candidate?.findingId
1496
+ ? findings.filter((finding) => finding.id === candidate.findingId)
1497
+ : [];
1498
+ }
1499
+ return findings.filter((finding) => finding.id === target);
1500
+ }
1501
+ async function withWriteLock(context, fn) {
1502
+ return withStateWriteLock(context.paths, {
1503
+ command: context.parsed.command ?? "unknown",
1504
+ wait: flagBoolean(context.parsed.flags, "wait-lock"),
1505
+ timeoutMs: numberFlag(context, "lock-timeout-ms") ?? 0,
1506
+ staleAfterMs: staleLockMsFromFlags(context),
1507
+ }, fn);
1508
+ }
458
1509
  function requireCandidateId(context) {
459
1510
  const id = context.parsed.positional[0];
460
1511
  if (!id) {
@@ -462,6 +1513,19 @@ function requireCandidateId(context) {
462
1513
  }
463
1514
  return id;
464
1515
  }
1516
+ function lockStatusPayload(lock) {
1517
+ return {
1518
+ id: lock.record?.id,
1519
+ owner: lock.record?.owner,
1520
+ pid: lock.record?.pid,
1521
+ command: lock.record?.command,
1522
+ statePath: lock.record?.statePath,
1523
+ createdAt: lock.record?.createdAt,
1524
+ stale: lock.stale,
1525
+ reason: lock.reason,
1526
+ recoveryCommand: lock.recoveryCommand,
1527
+ };
1528
+ }
465
1529
  function emit(json, value) {
466
1530
  if (json) {
467
1531
  console.log(JSON.stringify(value, null, 2));
@@ -503,6 +1567,856 @@ function evidenceForIds(evidence, ids) {
503
1567
  const wanted = new Set(ids);
504
1568
  return evidence.filter((item) => wanted.has(item.id));
505
1569
  }
1570
+ function validationForCandidate(candidate, attempt) {
1571
+ const validationId = candidate.provenance.validationId;
1572
+ if (!attempt || !validationId) {
1573
+ return undefined;
1574
+ }
1575
+ return attempt.validations.find((validation) => validation.id === validationId);
1576
+ }
1577
+ function formatFileRef(file) {
1578
+ if (file.startLine !== undefined && file.endLine !== undefined) {
1579
+ return `${file.path}:${file.startLine}-${file.endLine}`;
1580
+ }
1581
+ if (file.startLine !== undefined) {
1582
+ return `${file.path}:${file.startLine}`;
1583
+ }
1584
+ return file.path;
1585
+ }
1586
+ function printDiagnostics(diagnostics) {
1587
+ for (const diagnostic of diagnostics) {
1588
+ console.log(`${diagnostic.level}: ${diagnostic.code}: ${diagnostic.message}`);
1589
+ }
1590
+ }
1591
+ async function pathExists(filePath) {
1592
+ try {
1593
+ await stat(filePath);
1594
+ return true;
1595
+ }
1596
+ catch {
1597
+ return false;
1598
+ }
1599
+ }
1600
+ async function missingStateDirectories(paths) {
1601
+ const expected = [
1602
+ ["runs", paths.runsDir],
1603
+ ["findings", paths.findingsDir],
1604
+ ["observations", paths.observationsDir],
1605
+ ["features", paths.featuresDir],
1606
+ ["evidence", paths.evidenceDir],
1607
+ ["candidates", paths.candidatesDir],
1608
+ ["clusters", paths.clustersDir],
1609
+ ["reports", paths.reportsDir],
1610
+ ["triage", paths.triageDir],
1611
+ ["handoffs", paths.handoffsDir],
1612
+ ["plans", paths.plansDir],
1613
+ ["lifecycle", paths.lifecycleDir],
1614
+ ["revalidations", paths.revalidationsDir],
1615
+ ["ci", paths.ciDir],
1616
+ ["locks", paths.locksDir],
1617
+ ["retention", paths.retentionDir],
1618
+ ["fixes", paths.fixesDir],
1619
+ ["synthesis", paths.synthesisDir],
1620
+ ];
1621
+ const missing = [];
1622
+ for (const [name, dir] of expected) {
1623
+ if (!(await pathExists(dir))) {
1624
+ missing.push(name);
1625
+ }
1626
+ }
1627
+ return missing;
1628
+ }
1629
+ async function readConfigForDoctor(paths) {
1630
+ if (!(await pathExists(paths.configPath))) {
1631
+ return {
1632
+ valid: false,
1633
+ error: "Config file is missing.",
1634
+ diagnostics: [{
1635
+ level: "info",
1636
+ code: "config_missing",
1637
+ message: "Run `deepclean init` to create project-local configuration.",
1638
+ }],
1639
+ };
1640
+ }
1641
+ try {
1642
+ return {
1643
+ valid: true,
1644
+ config: await readConfig(paths),
1645
+ diagnostics: [],
1646
+ };
1647
+ }
1648
+ catch (error) {
1649
+ return {
1650
+ valid: false,
1651
+ error: error instanceof Error ? error.message : String(error),
1652
+ diagnostics: [{
1653
+ level: "error",
1654
+ code: "config_invalid",
1655
+ message: error instanceof Error ? error.message : String(error),
1656
+ }],
1657
+ };
1658
+ }
1659
+ }
1660
+ async function gitDoctor(root) {
1661
+ try {
1662
+ const { stdout: statusOutput } = await execFileAsync("git", ["status", "--short"], { cwd: root, timeout: 5000 });
1663
+ const branchOutput = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: root, timeout: 5000 })
1664
+ .then((result) => result.stdout.trim())
1665
+ .catch(() => undefined);
1666
+ const result = {
1667
+ available: true,
1668
+ dirty: statusOutput.trim().length > 0,
1669
+ };
1670
+ if (branchOutput) {
1671
+ result.branch = branchOutput;
1672
+ }
1673
+ return result;
1674
+ }
1675
+ catch (error) {
1676
+ return {
1677
+ available: false,
1678
+ dirty: false,
1679
+ error: error instanceof Error ? error.message : String(error),
1680
+ };
1681
+ }
1682
+ }
1683
+ async function providerDoctor(root, command) {
1684
+ try {
1685
+ await execFileAsync(command, ["--version"], { cwd: root, timeout: 5000 });
1686
+ return { command, available: true };
1687
+ }
1688
+ catch (error) {
1689
+ return {
1690
+ command,
1691
+ available: false,
1692
+ error: error instanceof Error ? error.message : String(error),
1693
+ };
1694
+ }
1695
+ }
1696
+ async function resolveScanScope(context, files) {
1697
+ const since = flagString(context.parsed.flags, "since");
1698
+ const mergeBaseRef = flagString(context.parsed.flags, "merge-base");
1699
+ const includeDirty = flagBoolean(context.parsed.flags, "include-dirty");
1700
+ const paths = csvFlag(context, "paths");
1701
+ const categories = csvFlag(context, "categories");
1702
+ const reviewers = csvFlag(context, "reviewers");
1703
+ const dirtyPaths = includeDirty ? await gitChangedPaths(context.paths.root, ["diff", "--name-only"]) : [];
1704
+ const untrackedPaths = includeDirty ? await gitChangedPaths(context.paths.root, ["ls-files", "--others", "--exclude-standard"]) : [];
1705
+ const committedChangedPaths = mergeBaseRef
1706
+ ? await gitMergeBaseChangedPaths(context.paths.root, mergeBaseRef)
1707
+ : since
1708
+ ? await gitChangedPaths(context.paths.root, ["diff", "--name-only", `${since}...HEAD`])
1709
+ : [];
1710
+ const changedPaths = uniqueNormalized([
1711
+ ...committedChangedPaths,
1712
+ ...dirtyPaths,
1713
+ ...untrackedPaths,
1714
+ ]).filter((filePath) => files.some((file) => file.path === filePath));
1715
+ return {
1716
+ incremental: Boolean(since || mergeBaseRef || includeDirty || paths.length > 0),
1717
+ ...(since ? { since } : {}),
1718
+ ...(mergeBaseRef ? { mergeBase: mergeBaseRef } : {}),
1719
+ includeDirty,
1720
+ paths,
1721
+ changedPaths,
1722
+ categories,
1723
+ reviewers,
1724
+ onlyExisting: flagBoolean(context.parsed.flags, "only-existing"),
1725
+ newOnly: flagBoolean(context.parsed.flags, "new-only"),
1726
+ dirtyPaths: uniqueNormalized([...dirtyPaths, ...untrackedPaths]),
1727
+ };
1728
+ }
1729
+ function fileInScope(file, scope) {
1730
+ const pathMatched = scope.paths.length === 0
1731
+ || scope.paths.some((prefix) => file.path === prefix || file.path.startsWith(`${prefix.replace(/\/$/, "")}/`));
1732
+ if (!pathMatched) {
1733
+ return false;
1734
+ }
1735
+ if (scope.changedPaths.length === 0) {
1736
+ return true;
1737
+ }
1738
+ return scope.changedPaths.includes(file.path);
1739
+ }
1740
+ function filterCandidatesByScanScope(candidates, scope) {
1741
+ return candidates.filter((candidate) => {
1742
+ if (scope.categories.length > 0 && !scope.categories.includes(candidate.category)) {
1743
+ return false;
1744
+ }
1745
+ if (scope.onlyExisting && candidate.baselineStatus !== "existing") {
1746
+ return false;
1747
+ }
1748
+ if (scope.newOnly && candidate.baselineStatus !== "new") {
1749
+ return false;
1750
+ }
1751
+ return true;
1752
+ });
1753
+ }
1754
+ function markDirtyTreeEvidence(evidence, scope) {
1755
+ if (scope.dirtyPaths.length === 0) {
1756
+ return evidence;
1757
+ }
1758
+ const dirty = new Set(scope.dirtyPaths);
1759
+ return evidence.map((record) => {
1760
+ const dirtyTree = record.files.some((file) => dirty.has(file.path));
1761
+ return dirtyTree
1762
+ ? {
1763
+ ...record,
1764
+ data: {
1765
+ ...record.data,
1766
+ dirtyTree: true,
1767
+ freshness: "dirty",
1768
+ },
1769
+ }
1770
+ : record;
1771
+ });
1772
+ }
1773
+ function queryFilterFromFlags(context) {
1774
+ const filter = {};
1775
+ const entries = [
1776
+ ["status", "status"],
1777
+ ["priority", "priority"],
1778
+ ["category", "category"],
1779
+ ["risk", "risk"],
1780
+ ["source", "source"],
1781
+ ["theme", "theme"],
1782
+ ["path", "path"],
1783
+ ["lifecycleState", "lifecycle-state"],
1784
+ ["revalidationState", "revalidation-state"],
1785
+ ["baselineStatus", "baseline-status"],
1786
+ ];
1787
+ for (const [property, flag] of entries) {
1788
+ const value = flagString(context.parsed.flags, flag);
1789
+ if (value) {
1790
+ filter[property] = value;
1791
+ }
1792
+ }
1793
+ return filter;
1794
+ }
1795
+ function filterCandidatesForQuery(candidates, clusters, filter) {
1796
+ const themeCandidateIds = filter.theme
1797
+ ? new Set(clusters.find((cluster) => cluster.id === filter.theme)?.candidateIds ?? [])
1798
+ : undefined;
1799
+ return candidates.filter((candidate) => {
1800
+ if (filter.status && candidate.status !== filter.status) {
1801
+ return false;
1802
+ }
1803
+ if (filter.priority && candidate.priority !== filter.priority.toUpperCase()) {
1804
+ return false;
1805
+ }
1806
+ if (filter.category && candidate.category !== filter.category) {
1807
+ return false;
1808
+ }
1809
+ if (filter.risk && candidate.risk !== filter.risk) {
1810
+ return false;
1811
+ }
1812
+ if (filter.source && candidate.provenance.source !== filter.source) {
1813
+ return false;
1814
+ }
1815
+ if (themeCandidateIds && !themeCandidateIds.has(candidate.id)) {
1816
+ return false;
1817
+ }
1818
+ if (filter.path && !candidate.files.some((file) => file.path === filter.path || file.path.startsWith(`${filter.path}/`))) {
1819
+ return false;
1820
+ }
1821
+ if (filter.lifecycleState && (candidate.lifecycleState ?? "open") !== filter.lifecycleState) {
1822
+ return false;
1823
+ }
1824
+ if (filter.revalidationState && (candidate.lifecycleState ?? "open") !== filter.revalidationState) {
1825
+ return false;
1826
+ }
1827
+ if (filter.baselineStatus && (candidate.baselineStatus ?? "unknown") !== filter.baselineStatus) {
1828
+ return false;
1829
+ }
1830
+ return true;
1831
+ });
1832
+ }
1833
+ function candidateQueueItem(candidate) {
1834
+ return {
1835
+ findingId: candidate.findingId ?? candidate.id,
1836
+ candidateId: candidate.id,
1837
+ title: candidate.title,
1838
+ priority: candidate.priority,
1839
+ category: candidate.category,
1840
+ risk: candidate.risk,
1841
+ status: candidate.status,
1842
+ lifecycleState: candidate.lifecycleState ?? "open",
1843
+ baselineStatus: candidate.baselineStatus ?? "unknown",
1844
+ problem: candidate.whyItMatters,
1845
+ evidenceIds: candidate.evidenceIds,
1846
+ files: candidate.files,
1847
+ constraints: [
1848
+ "Keep changes scoped to this finding.",
1849
+ "Preserve behavior unless verification proves current behavior is wrong.",
1850
+ ],
1851
+ verification: candidate.verification,
1852
+ };
1853
+ }
1854
+ function handoffFreshnessWarnings(candidate) {
1855
+ const warnings = [];
1856
+ const lifecycleState = candidate.lifecycleState ?? "open";
1857
+ if (["stale", "fixed", "superseded", "inconclusive"].includes(lifecycleState)) {
1858
+ warnings.push(`Finding lifecycle state is ${lifecycleState}; revalidate before assigning implementation work.`);
1859
+ }
1860
+ if (candidate.confidence === "low") {
1861
+ warnings.push("Finding confidence is low; confirm evidence before implementation.");
1862
+ }
1863
+ return warnings;
1864
+ }
1865
+ function ciPolicyFromFlags(context) {
1866
+ const policy = {};
1867
+ for (const key of [
1868
+ "max-p0",
1869
+ "max-p1",
1870
+ "max-p2",
1871
+ "max-p3",
1872
+ "max-new-p0",
1873
+ "max-new-p1",
1874
+ "max-new-p2",
1875
+ "max-new-p3",
1876
+ "max-stale",
1877
+ ]) {
1878
+ const value = flagString(context.parsed.flags, key);
1879
+ if (value !== undefined && value !== "") {
1880
+ policy[key] = Number(value);
1881
+ }
1882
+ }
1883
+ const minConfidence = flagString(context.parsed.flags, "min-confidence");
1884
+ if (minConfidence) {
1885
+ policy["min-confidence"] = minConfidence;
1886
+ }
1887
+ const failCategory = csvFlag(context, "fail-category");
1888
+ if (failCategory.length > 0) {
1889
+ policy["fail-category"] = failCategory;
1890
+ }
1891
+ return policy;
1892
+ }
1893
+ async function buildRetentionManifest(context) {
1894
+ const keepRuns = numberFlag(context, "keep-runs") ?? 5;
1895
+ const keepDays = numberFlag(context, "keep-days");
1896
+ const dryRun = flagBoolean(context.parsed.flags, "dry-run");
1897
+ const now = new Date();
1898
+ const runRecords = await readRunRetentionRecords(context.paths);
1899
+ const sortedRuns = [...runRecords].sort((a, b) => a.id.localeCompare(b.id));
1900
+ const retainedRunIds = new Set();
1901
+ for (const run of sortedRuns.slice(Math.max(0, sortedRuns.length - keepRuns))) {
1902
+ retainedRunIds.add(run.id);
1903
+ }
1904
+ const latest = sortedRuns.at(-1);
1905
+ if (latest) {
1906
+ retainedRunIds.add(latest.id);
1907
+ }
1908
+ if (keepDays !== undefined) {
1909
+ const cutoff = now.getTime() - keepDays * 24 * 60 * 60 * 1000;
1910
+ for (const run of sortedRuns) {
1911
+ if (Date.parse(run.createdAt) >= cutoff) {
1912
+ retainedRunIds.add(run.id);
1913
+ }
1914
+ }
1915
+ }
1916
+ const retainedPaths = new Set();
1917
+ const deletePaths = new Set();
1918
+ const blockedPaths = [{
1919
+ path: relativeStatePath(context.paths, context.paths.configPath),
1920
+ reason: "config is never pruned",
1921
+ }];
1922
+ const activeLocks = (await readLockStatuses(context.paths)).filter((lock) => !lock.stale);
1923
+ for (const lock of activeLocks) {
1924
+ blockedPaths.push({
1925
+ path: relativeStatePath(context.paths, lock.filePath),
1926
+ reason: "active locks are never pruned",
1927
+ });
1928
+ }
1929
+ for (const [dir, extension] of [
1930
+ [context.paths.runsDir, "json"],
1931
+ [context.paths.featuresDir, "json"],
1932
+ [context.paths.evidenceDir, "json"],
1933
+ [context.paths.candidatesDir, "json"],
1934
+ [context.paths.clustersDir, "json"],
1935
+ [context.paths.observationsDir, "json"],
1936
+ [context.paths.synthesisDir, "json"],
1937
+ ]) {
1938
+ const files = await filesWithExtension(dir, extension);
1939
+ for (const file of files) {
1940
+ const runId = path.basename(file, `.${extension}`);
1941
+ const relativePath = relativeStatePath(context.paths, file);
1942
+ if (retainedRunIds.has(runId)) {
1943
+ retainedPaths.add(relativePath);
1944
+ }
1945
+ else {
1946
+ deletePaths.add(relativePath);
1947
+ }
1948
+ }
1949
+ }
1950
+ const retainedCandidateIds = await candidateIdsForRuns(context.paths, retainedRunIds);
1951
+ await classifyRunLinkedArtifacts(context.paths, context.paths.reportsDir, retainedRunIds, retainedPaths, deletePaths, ["json", "md"]);
1952
+ await classifyRunLinkedArtifacts(context.paths, context.paths.plansDir, retainedRunIds, retainedPaths, deletePaths, ["json"]);
1953
+ await classifyHandoffArtifacts(context.paths, retainedCandidateIds, retainedPaths, deletePaths);
1954
+ return {
1955
+ schemaVersion,
1956
+ recordType: "retention_manifest",
1957
+ id: timestampId("retention"),
1958
+ dryRun,
1959
+ keepRuns,
1960
+ ...(keepDays !== undefined ? { keepDays } : {}),
1961
+ deletePaths: [...deletePaths].sort(),
1962
+ retainedPaths: [...retainedPaths].sort(),
1963
+ blockedPaths,
1964
+ privacyNotes: [
1965
+ "Prune never deletes .deepclean/config.json.",
1966
+ "Active locks and latest retained run artifacts are preserved.",
1967
+ "Source-safe sharing should use deepclean scrub or export --source-safe before attaching generated state outside the local machine.",
1968
+ ],
1969
+ createdAt: now.toISOString(),
1970
+ };
1971
+ }
1972
+ async function readRunRetentionRecords(paths) {
1973
+ const files = await filesWithExtension(paths.runsDir, "json");
1974
+ const records = [];
1975
+ for (const file of files) {
1976
+ try {
1977
+ const raw = JSON.parse(await readFile(file, "utf8"));
1978
+ const id = typeof raw.id === "string" ? raw.id : path.basename(file, ".json");
1979
+ const createdAt = typeof raw.completedAt === "string"
1980
+ ? raw.completedAt
1981
+ : typeof raw.startedAt === "string"
1982
+ ? raw.startedAt
1983
+ : "1970-01-01T00:00:00.000Z";
1984
+ records.push({ id, createdAt });
1985
+ }
1986
+ catch {
1987
+ records.push({ id: path.basename(file, ".json"), createdAt: "1970-01-01T00:00:00.000Z" });
1988
+ }
1989
+ }
1990
+ return records;
1991
+ }
1992
+ async function candidateIdsForRuns(paths, retainedRunIds) {
1993
+ const ids = new Set();
1994
+ for (const runId of retainedRunIds) {
1995
+ for (const candidate of await readCandidates(paths, runId).catch(() => [])) {
1996
+ ids.add(candidate.id);
1997
+ }
1998
+ }
1999
+ return ids;
2000
+ }
2001
+ async function classifyRunLinkedArtifacts(paths, dir, retainedRunIds, retainedPaths, deletePaths, extensions) {
2002
+ const jsonFiles = await filesWithExtension(dir, "json");
2003
+ for (const jsonFile of jsonFiles) {
2004
+ let runId;
2005
+ try {
2006
+ const raw = JSON.parse(await readFile(jsonFile, "utf8"));
2007
+ runId = typeof raw.runId === "string" ? raw.runId : undefined;
2008
+ }
2009
+ catch {
2010
+ runId = undefined;
2011
+ }
2012
+ const id = path.basename(jsonFile, ".json");
2013
+ for (const extension of extensions) {
2014
+ const artifact = path.join(dir, `${id}.${extension}`);
2015
+ if (!(await pathExists(artifact))) {
2016
+ continue;
2017
+ }
2018
+ const relativePath = relativeStatePath(paths, artifact);
2019
+ if (runId && retainedRunIds.has(runId)) {
2020
+ retainedPaths.add(relativePath);
2021
+ }
2022
+ else {
2023
+ deletePaths.add(relativePath);
2024
+ }
2025
+ }
2026
+ }
2027
+ }
2028
+ async function classifyHandoffArtifacts(paths, retainedCandidateIds, retainedPaths, deletePaths) {
2029
+ const jsonFiles = await filesWithExtension(paths.handoffsDir, "json");
2030
+ for (const file of jsonFiles) {
2031
+ let candidateId;
2032
+ try {
2033
+ const raw = JSON.parse(await readFile(file, "utf8"));
2034
+ candidateId = typeof raw.candidateId === "string" ? raw.candidateId : undefined;
2035
+ }
2036
+ catch {
2037
+ candidateId = undefined;
2038
+ }
2039
+ const relativePath = relativeStatePath(paths, file);
2040
+ if (candidateId && retainedCandidateIds.has(candidateId)) {
2041
+ retainedPaths.add(relativePath);
2042
+ }
2043
+ else {
2044
+ deletePaths.add(relativePath);
2045
+ }
2046
+ }
2047
+ }
2048
+ async function filesWithExtension(dir, extension) {
2049
+ try {
2050
+ return (await readdir(dir))
2051
+ .filter((file) => file.endsWith(`.${extension}`))
2052
+ .map((file) => path.join(dir, file))
2053
+ .sort();
2054
+ }
2055
+ catch {
2056
+ return [];
2057
+ }
2058
+ }
2059
+ function relativeStatePath(paths, filePath) {
2060
+ return path.relative(paths.root, filePath).split(path.sep).join("/");
2061
+ }
2062
+ function sourceSafeFile(root, file) {
2063
+ const normalized = path.isAbsolute(file.path)
2064
+ ? path.relative(root, file.path)
2065
+ : file.path;
2066
+ return {
2067
+ path: normalized.split(path.sep).join("/"),
2068
+ ...(file.startLine ? { startLine: file.startLine } : {}),
2069
+ ...(file.endLine ? { endLine: file.endLine } : {}),
2070
+ };
2071
+ }
2072
+ function evaluateCiPolicy(candidates, policy) {
2073
+ const blockers = new Map();
2074
+ const byPriority = countBy(candidates, (candidate) => candidate.priority.toLowerCase());
2075
+ for (const priority of ["p0", "p1", "p2", "p3"]) {
2076
+ const max = numberPolicy(policy, `max-${priority}`);
2077
+ if (max !== undefined && (byPriority[priority] ?? 0) > max) {
2078
+ for (const candidate of candidates.filter((item) => item.priority.toLowerCase() === priority).slice(max)) {
2079
+ blockers.set(candidate.findingId ?? candidate.id, `max-${priority}`);
2080
+ }
2081
+ }
2082
+ const maxNew = numberPolicy(policy, `max-new-${priority}`);
2083
+ if (maxNew !== undefined) {
2084
+ const newCandidates = candidates.filter((item) => (item.priority.toLowerCase() === priority
2085
+ && item.baselineStatus === "new"));
2086
+ if (newCandidates.length > maxNew) {
2087
+ for (const candidate of newCandidates.slice(maxNew)) {
2088
+ blockers.set(candidate.findingId ?? candidate.id, `max-new-${priority}`);
2089
+ }
2090
+ }
2091
+ }
2092
+ }
2093
+ const maxStale = numberPolicy(policy, "max-stale");
2094
+ if (maxStale !== undefined) {
2095
+ const stale = candidates.filter((candidate) => candidate.lifecycleState === "stale" || candidate.status === "stale");
2096
+ if (stale.length > maxStale) {
2097
+ for (const candidate of stale.slice(maxStale)) {
2098
+ blockers.set(candidate.findingId ?? candidate.id, "max-stale");
2099
+ }
2100
+ }
2101
+ }
2102
+ const categories = Array.isArray(policy["fail-category"]) ? policy["fail-category"] : [];
2103
+ for (const candidate of candidates) {
2104
+ if (categories.includes(candidate.category)) {
2105
+ blockers.set(candidate.findingId ?? candidate.id, `fail-category:${candidate.category}`);
2106
+ }
2107
+ }
2108
+ const minConfidence = typeof policy["min-confidence"] === "string" ? policy["min-confidence"] : undefined;
2109
+ if (minConfidence) {
2110
+ const order = ["low", "medium", "high"];
2111
+ const minimum = order.indexOf(minConfidence);
2112
+ if (minimum >= 0) {
2113
+ for (const candidate of candidates) {
2114
+ if (order.indexOf(candidate.confidence) < minimum) {
2115
+ blockers.set(candidate.findingId ?? candidate.id, `min-confidence:${minConfidence}`);
2116
+ }
2117
+ }
2118
+ }
2119
+ }
2120
+ return {
2121
+ blockingFindingIds: [...blockers.keys()].sort(),
2122
+ reasons: [...blockers.entries()].map(([findingId, reason]) => ({ findingId, reason })),
2123
+ };
2124
+ }
2125
+ async function writeCiArtifacts(context, scan, gate) {
2126
+ const artifactPaths = {};
2127
+ const output = flagString(context.parsed.flags, "output");
2128
+ if (output) {
2129
+ const markdownPath = path.resolve(context.paths.root, output);
2130
+ await mkdir(path.dirname(markdownPath), { recursive: true });
2131
+ await writeFile(markdownPath, renderCiMarkdown(scan, gate), "utf8");
2132
+ artifactPaths.markdown = markdownPath;
2133
+ }
2134
+ const sarif = flagString(context.parsed.flags, "sarif");
2135
+ if (sarif) {
2136
+ const sarifPath = path.resolve(context.paths.root, sarif);
2137
+ await mkdir(path.dirname(sarifPath), { recursive: true });
2138
+ await writeFile(sarifPath, JSON.stringify(renderCiSarif(scan.candidates), null, 2) + "\n", "utf8");
2139
+ artifactPaths.sarif = sarifPath;
2140
+ }
2141
+ return artifactPaths;
2142
+ }
2143
+ function renderCiMarkdown(scan, gate) {
2144
+ return [
2145
+ "# Deepclean CI",
2146
+ "",
2147
+ `Run: ${scan.runId}`,
2148
+ `Candidates: ${scan.candidateCount}`,
2149
+ `Blocking: ${gate.blockingFindingIds.length}`,
2150
+ "",
2151
+ "## Blocking Findings",
2152
+ "",
2153
+ ...(gate.reasons.length > 0
2154
+ ? gate.reasons.map((reason) => `- ${reason.findingId}: ${reason.reason}`)
2155
+ : ["None"]),
2156
+ "",
2157
+ ].join("\n");
2158
+ }
2159
+ function renderCiSarif(candidates) {
2160
+ return {
2161
+ version: "2.1.0",
2162
+ runs: [{
2163
+ tool: { driver: { name: "Deepclean" } },
2164
+ results: candidates.map((candidate) => ({
2165
+ ruleId: `deepclean/${candidate.category}`,
2166
+ level: candidate.priority === "P0" || candidate.priority === "P1" ? "warning" : "note",
2167
+ message: { text: `${candidate.id}: ${candidate.title}` },
2168
+ locations: candidate.files.slice(0, 1).map((file) => ({
2169
+ physicalLocation: {
2170
+ artifactLocation: { uri: file.path },
2171
+ region: { startLine: file.startLine ?? 1, endLine: file.endLine ?? file.startLine ?? 1 },
2172
+ },
2173
+ })),
2174
+ properties: {
2175
+ findingId: candidate.findingId,
2176
+ priority: candidate.priority,
2177
+ confidence: candidate.confidence,
2178
+ baselineStatus: candidate.baselineStatus,
2179
+ },
2180
+ })),
2181
+ }],
2182
+ };
2183
+ }
2184
+ function numberPolicy(policy, key) {
2185
+ const value = policy[key];
2186
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
2187
+ }
2188
+ async function gitMergeBaseChangedPaths(root, ref) {
2189
+ try {
2190
+ const { stdout } = await execFileAsync("git", ["merge-base", ref, "HEAD"], { cwd: root, timeout: 5000 });
2191
+ const mergeBase = stdout.trim();
2192
+ return mergeBase ? gitChangedPaths(root, ["diff", "--name-only", `${mergeBase}...HEAD`]) : [];
2193
+ }
2194
+ catch {
2195
+ return [];
2196
+ }
2197
+ }
2198
+ async function gitChangedPaths(root, args) {
2199
+ try {
2200
+ const { stdout } = await execFileAsync("git", args, { cwd: root, timeout: 5000 });
2201
+ return stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
2202
+ }
2203
+ catch {
2204
+ return [];
2205
+ }
2206
+ }
2207
+ function csvFlag(context, key) {
2208
+ const value = flagString(context.parsed.flags, key);
2209
+ if (!value) {
2210
+ return [];
2211
+ }
2212
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
2213
+ }
2214
+ function staleLockMsFromFlags(context) {
2215
+ return numberFlag(context, "stale-lock-ms");
2216
+ }
2217
+ function providerRuntimeControls(context, config) {
2218
+ const provider = flagString(context.parsed.flags, "provider");
2219
+ if (provider && provider !== "codex") {
2220
+ throw new Error(`Unsupported provider: ${provider}. Only codex is currently supported.`);
2221
+ }
2222
+ const timeoutSeconds = numberFlag(context, "timeout");
2223
+ const timeoutMs = numberFlag(context, "timeout-ms") ?? (timeoutSeconds !== undefined ? timeoutSeconds * 1000 : config.reviewSynthesis.timeoutMs);
2224
+ const privacyMode = privacyModeFromFlag(flagString(context.parsed.flags, "privacy-mode"))
2225
+ ?? config.reviewSynthesis.privacyMode;
2226
+ const offline = flagBoolean(context.parsed.flags, "offline")
2227
+ || flagBoolean(context.parsed.flags, "local-only")
2228
+ || flagBoolean(context.parsed.flags, "evidence-only")
2229
+ || config.reviewSynthesis.offline
2230
+ || privacyMode === "local-only";
2231
+ const excerptBudget = numberFlag(context, "excerpt-budget") ?? config.reviewSynthesis.excerptBudget;
2232
+ const allowSourceInModel = !offline && (flagBoolean(context.parsed.flags, "allow-source-in-model")
2233
+ || config.privacy.allowSourceInModel
2234
+ || privacyMode === "source-ok") && excerptBudget > 0;
2235
+ const runtime = {
2236
+ provider: "codex",
2237
+ command: config.reviewSynthesis.command,
2238
+ timeoutMs,
2239
+ retries: numberFlag(context, "retries") ?? config.reviewSynthesis.retries,
2240
+ rpm: numberFlag(context, "rpm") ?? config.reviewSynthesis.rpm,
2241
+ concurrency: numberFlag(context, "concurrency") ?? config.reviewSynthesis.concurrency,
2242
+ tokenBudget: numberFlag(context, "token-budget") ?? config.reviewSynthesis.tokenBudget,
2243
+ excerptBudget,
2244
+ offline,
2245
+ privacyMode,
2246
+ allowSourceInModel,
2247
+ };
2248
+ const model = flagString(context.parsed.flags, "model") ?? config.reviewSynthesis.model;
2249
+ if (model) {
2250
+ runtime.model = model;
2251
+ }
2252
+ const effort = flagString(context.parsed.flags, "effort") ?? config.reviewSynthesis.effort;
2253
+ if (effort) {
2254
+ runtime.effort = effort;
2255
+ }
2256
+ return runtime;
2257
+ }
2258
+ function synthesisDisabledByPolicy(context, config) {
2259
+ const privacyMode = privacyModeFromFlag(flagString(context.parsed.flags, "privacy-mode"))
2260
+ ?? config.reviewSynthesis.privacyMode;
2261
+ return flagBoolean(context.parsed.flags, "offline")
2262
+ || flagBoolean(context.parsed.flags, "local-only")
2263
+ || flagBoolean(context.parsed.flags, "evidence-only")
2264
+ || config.reviewSynthesis.offline
2265
+ || privacyMode === "local-only";
2266
+ }
2267
+ const requiredSynthesisFailureCodes = new Set([
2268
+ "codex_provider_unavailable",
2269
+ "codex_synthesis_timeout",
2270
+ "codex_synthesis_failed",
2271
+ "codex_synthesis_error",
2272
+ ]);
2273
+ function requiredSynthesisFailure(scan) {
2274
+ if (!scan.data.synthesis.requested) {
2275
+ return {
2276
+ level: "error",
2277
+ code: "ci_synthesis_required",
2278
+ message: "CI policy requires synthesis, but the scan did not run provider synthesis.",
2279
+ adapter: "codex-synthesis",
2280
+ };
2281
+ }
2282
+ const diagnostic = scan.diagnostics.find((item) => (item.adapter === "codex-synthesis"
2283
+ && requiredSynthesisFailureCodes.has(item.code)));
2284
+ if (!diagnostic) {
2285
+ return undefined;
2286
+ }
2287
+ return {
2288
+ ...diagnostic,
2289
+ level: "error",
2290
+ message: `CI policy requires synthesis, but provider synthesis failed: ${diagnostic.message}`,
2291
+ };
2292
+ }
2293
+ function sameSynthesisFailure(diagnostic, failure) {
2294
+ return diagnostic.adapter === failure.adapter
2295
+ && diagnostic.code === failure.code
2296
+ && requiredSynthesisFailureCodes.has(diagnostic.code);
2297
+ }
2298
+ function providerRuntimeSummary(runtime) {
2299
+ return {
2300
+ provider: runtime.provider,
2301
+ model: runtime.model,
2302
+ effort: runtime.effort,
2303
+ timeoutMs: runtime.timeoutMs,
2304
+ retries: runtime.retries,
2305
+ rpm: runtime.rpm,
2306
+ concurrency: runtime.concurrency,
2307
+ tokenBudget: runtime.tokenBudget,
2308
+ excerptBudget: runtime.excerptBudget,
2309
+ offline: runtime.offline,
2310
+ privacyMode: runtime.privacyMode,
2311
+ allowSourceInModel: runtime.allowSourceInModel,
2312
+ };
2313
+ }
2314
+ function privacyModeFromFlag(value) {
2315
+ if (value === "local-only" || value === "metadata" || value === "source-ok") {
2316
+ return value;
2317
+ }
2318
+ return undefined;
2319
+ }
2320
+ function numberFlag(context, key) {
2321
+ const value = flagString(context.parsed.flags, key);
2322
+ if (value === undefined || value === "") {
2323
+ return undefined;
2324
+ }
2325
+ const parsed = Number(value);
2326
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
2327
+ }
2328
+ function uniqueNormalized(values) {
2329
+ return [...new Set(values.map((value) => value.split(path.sep).join("/")))].sort();
2330
+ }
2331
+ async function supportedSurfaces(root) {
2332
+ const checks = [
2333
+ ["node", "package.json"],
2334
+ ["typescript", "tsconfig.json"],
2335
+ ["python", "pyproject.toml"],
2336
+ ["python", "requirements.txt"],
2337
+ ["make", "Makefile"],
2338
+ ];
2339
+ const found = new Set();
2340
+ for (const [surface, file] of checks) {
2341
+ if (await pathExists(path.join(root, file))) {
2342
+ found.add(surface);
2343
+ }
2344
+ }
2345
+ return [...found].sort();
2346
+ }
2347
+ async function stateArtifactCounts(paths) {
2348
+ const dirs = [
2349
+ ["runs", paths.runsDir],
2350
+ ["findings", paths.findingsDir],
2351
+ ["observations", paths.observationsDir],
2352
+ ["features", paths.featuresDir],
2353
+ ["evidence", paths.evidenceDir],
2354
+ ["candidates", paths.candidatesDir],
2355
+ ["clusters", paths.clustersDir],
2356
+ ["reports", paths.reportsDir],
2357
+ ["triage", paths.triageDir],
2358
+ ["handoffs", paths.handoffsDir],
2359
+ ["plans", paths.plansDir],
2360
+ ["lifecycle", paths.lifecycleDir],
2361
+ ["revalidations", paths.revalidationsDir],
2362
+ ["ci", paths.ciDir],
2363
+ ["locks", paths.locksDir],
2364
+ ["retention", paths.retentionDir],
2365
+ ["fixes", paths.fixesDir],
2366
+ ["synthesis", paths.synthesisDir],
2367
+ ];
2368
+ const counts = {};
2369
+ for (const [name, dir] of dirs) {
2370
+ counts[name] = await countJsonFiles(dir);
2371
+ }
2372
+ return counts;
2373
+ }
2374
+ async function countJsonFiles(dir) {
2375
+ try {
2376
+ const files = await readdir(dir);
2377
+ return files.filter((file) => file.endsWith(".json")).length;
2378
+ }
2379
+ catch {
2380
+ return 0;
2381
+ }
2382
+ }
2383
+ function countBy(items, keyFor) {
2384
+ const counts = {};
2385
+ for (const item of items) {
2386
+ const key = keyFor(item);
2387
+ counts[key] = (counts[key] ?? 0) + 1;
2388
+ }
2389
+ return counts;
2390
+ }
2391
+ function revalidationOutcomeToLifecycleState(outcome) {
2392
+ switch (outcome) {
2393
+ case "fixed":
2394
+ return "fixed";
2395
+ case "stale":
2396
+ return "stale";
2397
+ case "superseded":
2398
+ return "superseded";
2399
+ case "inconclusive":
2400
+ return "inconclusive";
2401
+ case "changed":
2402
+ case "unchanged":
2403
+ return "open";
2404
+ }
2405
+ }
2406
+ function revalidationOutcomeToStatus(outcome, fallback) {
2407
+ switch (outcome) {
2408
+ case "fixed":
2409
+ return "fixed";
2410
+ case "stale":
2411
+ return "stale";
2412
+ case "superseded":
2413
+ return "superseded";
2414
+ case "inconclusive":
2415
+ case "changed":
2416
+ case "unchanged":
2417
+ return fallback === "fixed" || fallback === "stale" || fallback === "superseded" ? "open" : fallback;
2418
+ }
2419
+ }
506
2420
  async function packageVersion() {
507
2421
  try {
508
2422
  const packagePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");