@fraction12/deepclean 0.1.0-alpha.0 → 0.1.0-alpha.1

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