@fraction12/deepclean 0.1.0-alpha.1 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.0-alpha.2 - 2026-05-27
6
+
7
+ - Changed `scan` and CI-style scans to request Codex synthesis by default after local evidence collection, with `--evidence-only` as the deterministic-only escape hatch.
8
+ - Added synthesis attempt ledgers with validation checks, failure records for malformed provider output, and final candidate ID alignment after ranking.
9
+ - Included `.deepclean/synthesis/` in doctor/status/prune retention so synthesis artifacts are visible, validated, and cleaned up with the rest of a run.
10
+ - Refined the public site hero and motion treatment after UAT.
11
+
5
12
  ## 0.1.0-alpha.1 - 2026-05-27
6
13
 
7
14
  - Added semantic feature mapping with `.deepclean/features/` artifacts, `deepclean map`, scan feature counts, and first-pass package script, TS/JS, Python, test-suite, route/component/module, and config feature records.
package/README.md CHANGED
@@ -30,33 +30,34 @@ deepclean next
30
30
  deepclean plan candidate-001
31
31
  ```
32
32
 
33
- To include local Codex synthesis:
33
+ `deepclean scan` collects local evidence first, then runs Codex synthesis by default. Use evidence-only mode when you only want deterministic local analysis:
34
34
 
35
35
  ```bash
36
- deepclean scan --synthesize --json
36
+ deepclean scan --evidence-only --json
37
37
  deepclean report
38
- deepclean plan theme-001
39
38
  ```
40
39
 
41
40
  Global flags work before or after the command:
42
41
 
43
42
  ```bash
44
- deepclean --root ./some-repo scan --synthesize
45
- deepclean scan --root ./some-repo --synthesize
43
+ deepclean --root ./some-repo scan
44
+ deepclean scan --root ./some-repo --evidence-only
46
45
  ```
47
46
 
47
+ Older examples may include `deepclean scan --synthesize`; that flag still works, but it is no longer required. Plain `deepclean scan` is the normal synthesized path. Use `--evidence-only`, `--offline`, or `--local-only` when a run must avoid provider execution.
48
+
48
49
  ## Workflow
49
50
 
50
51
  ```bash
51
52
  deepclean init
52
53
  deepclean map
53
54
  deepclean scan
54
- deepclean scan --synthesize
55
55
  deepclean report
56
56
  deepclean cluster
57
57
  deepclean plan theme-001 --format codex
58
58
  deepclean next
59
59
  deepclean show <candidate-id>
60
+ deepclean explain <candidate-or-finding-id>
60
61
  deepclean triage <candidate-id> --status ignored --note "intentional boundary"
61
62
  deepclean handoff <candidate-id> --format codex
62
63
  ```
@@ -68,6 +69,7 @@ Deepclean writes durable local artifacts under `.deepclean/`:
68
69
  - `runs/` - scan metadata
69
70
  - `features/` - semantic feature/work-unit maps
70
71
  - `evidence/` - raw local evidence records
72
+ - `synthesis/` - provider attempt ledgers, prompt manifests, and candidate validation results
71
73
  - `candidates/` - cleanup candidates
72
74
  - `clusters/` - related cleanup themes
73
75
  - `reports/` - Markdown and JSON reports
@@ -84,12 +86,13 @@ Core commands support `--json` for automation:
84
86
  ```bash
85
87
  deepclean scan --json
86
88
  deepclean map --json
87
- deepclean scan --synthesize --json
89
+ deepclean scan --evidence-only --json
88
90
  deepclean report --json
89
91
  deepclean cluster --json
90
92
  deepclean plan theme-001 --json
91
93
  deepclean next --json
92
94
  deepclean show candidate-001 --json
95
+ deepclean explain candidate-001 --json
93
96
  deepclean handoff candidate-001 --json
94
97
  ```
95
98
 
@@ -104,7 +107,7 @@ Useful global flags:
104
107
 
105
108
  ## Local Evidence
106
109
 
107
- Deepclean runs local evidence first and optional model synthesis second. The built-in evidence layer includes:
110
+ Deepclean runs local evidence first and model synthesis second unless evidence-only or local-only mode is selected. The built-in evidence layer includes:
108
111
 
109
112
  - semantic feature mapping for package scripts, TS/JS modules/routes/components, Python modules, test suites, and config files
110
113
  - file metrics
@@ -122,7 +125,9 @@ For TS/JS projects using NodeNext-style source imports, Deepclean resolves emitt
122
125
 
123
126
  ## Codex Synthesis
124
127
 
125
- `deepclean scan --synthesize` runs the local `codex` CLI in read-only mode over the collected evidence bundle. The model is asked to return strict JSON, and candidates without valid evidence IDs are rejected.
128
+ `deepclean scan` runs the local `codex` CLI in read-only mode over the collected evidence bundle by default. The model is asked to return strict JSON, and candidates are validated before they are persisted: cited evidence IDs must exist, file paths must be anchored by cited evidence, line ranges must be sane, and optional quotes must match source. Rejected drafts stay in the synthesis attempt ledger as diagnostics rather than becoming open findings.
129
+
130
+ Use `deepclean explain <candidate-or-finding-id>` to inspect why a candidate exists, which evidence supports it, which validation checks passed, and what fix-readiness guidance was attached.
126
131
 
127
132
  Synthesis uses a built-in reviewer pack so runs do not depend on arbitrary local agent skills. The current pack looks for architecture boundaries, conceptual duplication, dependency graph risk, testability gaps, domain language drift, agent-sized cleanup slices, and weak findings that should be rejected.
128
133
 
@@ -137,7 +142,7 @@ Reviewer packs can be configured in `.deepclean/config.json`:
137
142
  }
138
143
  ```
139
144
 
140
- Source samples are redacted from the synthesis prompt by default. Use `--allow-source-in-model` only when the target repository and provider configuration make that acceptable.
145
+ Source samples are redacted from the synthesis prompt by default. Use `--allow-source-in-model` only when the target repository and provider configuration make that acceptable. Use `--evidence-only`, `--offline`, or `--local-only` when no provider should run.
141
146
 
142
147
  See [Privacy And Trust](docs/privacy-and-trust.md), [Reviewer References](docs/reviewer-references.md), and [Troubleshooting](docs/troubleshooting.md) before using synthesis on private repos.
143
148
 
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@ import { LockContentionError, lockRecoveryCommand, readLockStatuses, recoverStal
17
17
  import { buildCandidatePlan, buildClusterPlan } from "./plans.js";
18
18
  import { classifyRevalidation } from "./revalidation.js";
19
19
  import { buildHandoff, buildReportRecord, renderMarkdownReport, renderMarkdownReportWithClusters, } from "./reporting.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";
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";
21
21
  import { candidateStatuses, schemaVersion, } from "./types.js";
22
22
  import { timestampId } from "./ids.js";
23
23
  import { synthesizeWithCodex } from "./synthesis.js";
@@ -35,6 +35,7 @@ const commands = [
35
35
  "list",
36
36
  "findings",
37
37
  "show",
38
+ "explain",
38
39
  "history",
39
40
  "revalidate",
40
41
  "unlock",
@@ -60,7 +61,8 @@ Commands:
60
61
  ci Run non-interactive scan and policy gates for CI
61
62
  map Write semantic feature records without producing candidates
62
63
  scan Collect local evidence and generate candidates
63
- --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
64
66
  --allow-source-in-model Include source samples in Codex prompt
65
67
  --offline Skip provider calls and network-style analyzers
66
68
  --local-only Alias for --offline
@@ -87,6 +89,8 @@ Commands:
87
89
  list List findings with shared filters
88
90
  findings Alias for list
89
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
90
94
  history <finding-or-candidate-id>
91
95
  Show lifecycle history for a finding
92
96
  revalidate <finding-id|candidate-id|all>
@@ -177,6 +181,8 @@ export async function main(argv, cwd = process.cwd()) {
177
181
  return await listCommand(context);
178
182
  case "show":
179
183
  return await showCommand(context);
184
+ case "explain":
185
+ return await explainCommand(context);
180
186
  case "history":
181
187
  return await historyCommand(context);
182
188
  case "revalidate":
@@ -696,16 +702,26 @@ async function executeFeatureMap(context) {
696
702
  }
697
703
  async function ciCommand(context) {
698
704
  const requireSynthesis = flagBoolean(context.parsed.flags, "require-synthesis");
699
- if (requireSynthesis && !flagBoolean(context.parsed.flags, "synthesize")) {
705
+ const config = await ensureState(context.paths);
706
+ if (requireSynthesis && synthesisDisabledByPolicy(context, config)) {
700
707
  const diagnostic = {
701
708
  level: "error",
702
709
  code: "ci_synthesis_required",
703
- message: "CI policy requires synthesis; rerun with --synthesize and a configured provider.",
710
+ message: "CI policy requires synthesis; rerun without evidence-only/local-only flags and with a configured provider.",
704
711
  };
705
712
  emit(context.json, fail("ci", "ci_synthesis_required", diagnostic.message, [diagnostic]));
706
713
  return 2;
707
714
  }
708
- const scan = await executeScan(context, { synthesize: flagBoolean(context.parsed.flags, "synthesize") });
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
+ }
709
725
  const policy = ciPolicyFromFlags(context);
710
726
  const gate = evaluateCiPolicy(scan.data.candidates, policy);
711
727
  const createdAt = new Date().toISOString();
@@ -773,14 +789,13 @@ async function executeScan(context, options) {
773
789
  const evidence = markDirtyTreeEvidence(adapterResult.evidence, scope);
774
790
  const completedAt = new Date().toISOString();
775
791
  const localCandidates = candidatesFromEvidence(runId, evidence, completedAt, config.candidateCaps, verificationProfile);
776
- const synthesisRequested = options.synthesize ?? (flagBoolean(context.parsed.flags, "synthesize")
777
- || config.reviewSynthesis.enabled);
792
+ const synthesisRequested = options.synthesize ?? true;
778
793
  const runtime = providerRuntimeControls(context, config);
779
794
  if (synthesisRequested && runtime.offline) {
780
795
  adapterResult.diagnostics.push({
781
796
  level: "info",
782
797
  code: "synthesis_skipped_by_policy",
783
- message: "Provider synthesis was skipped because offline/local-only mode is active.",
798
+ message: "Provider synthesis was skipped because evidence-only/offline/local-only mode is active.",
784
799
  adapter: "codex-synthesis",
785
800
  });
786
801
  }
@@ -814,6 +829,9 @@ async function executeScan(context, options) {
814
829
  const clusters = buildClusters(runId, candidates, evidence, completedAt, config.clusters);
815
830
  await writeFeatures(context.paths, runId, features);
816
831
  await writeEvidence(context.paths, runId, evidence);
832
+ if (synthesisResult.attempt) {
833
+ await writeSynthesisAttempt(context.paths, remapSynthesisAttemptCandidateIds(synthesisResult.attempt, candidates));
834
+ }
817
835
  await writeCandidates(context.paths, runId, candidates);
818
836
  await writeFindings(context.paths, identity.findings);
819
837
  await writeCandidateObservations(context.paths, runId, identity.observations);
@@ -835,6 +853,9 @@ async function executeScan(context, options) {
835
853
  requested: shouldSynthesize,
836
854
  provider: shouldSynthesize ? runtime.provider : undefined,
837
855
  candidateCount: synthesisResult.candidates.length,
856
+ attemptId: synthesisResult.attempt?.id,
857
+ acceptedCandidateCount: synthesisResult.attempt?.acceptedCandidateCount,
858
+ rejectedCandidateCount: synthesisResult.attempt?.rejectedCandidateCount,
838
859
  runtime: providerRuntimeSummary(runtime),
839
860
  },
840
861
  scope,
@@ -851,6 +872,9 @@ async function executeScan(context, options) {
851
872
  synthesis: {
852
873
  requested: shouldSynthesize,
853
874
  candidateCount: synthesisResult.candidates.length,
875
+ attemptId: synthesisResult.attempt?.id,
876
+ acceptedCandidateCount: synthesisResult.attempt?.acceptedCandidateCount,
877
+ rejectedCandidateCount: synthesisResult.attempt?.rejectedCandidateCount,
854
878
  runtime: providerRuntimeSummary(runtime),
855
879
  },
856
880
  candidates,
@@ -860,6 +884,20 @@ async function executeScan(context, options) {
860
884
  };
861
885
  return { runId, diagnostics, data };
862
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
+ })),
899
+ };
900
+ }
863
901
  async function reportCommand(context) {
864
902
  const { candidates, evidence, runId } = await latestState(context.paths);
865
903
  const config = await ensureState(context.paths);
@@ -966,6 +1004,79 @@ async function showCommand(context) {
966
1004
  }
967
1005
  return 0;
968
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
+ }
969
1080
  async function historyCommand(context) {
970
1081
  const id = requireCandidateId(context);
971
1082
  const runId = flagString(context.parsed.flags, "run");
@@ -1456,6 +1567,22 @@ function evidenceForIds(evidence, ids) {
1456
1567
  const wanted = new Set(ids);
1457
1568
  return evidence.filter((item) => wanted.has(item.id));
1458
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
+ }
1459
1586
  function printDiagnostics(diagnostics) {
1460
1587
  for (const diagnostic of diagnostics) {
1461
1588
  console.log(`${diagnostic.level}: ${diagnostic.code}: ${diagnostic.message}`);
@@ -1489,6 +1616,7 @@ async function missingStateDirectories(paths) {
1489
1616
  ["locks", paths.locksDir],
1490
1617
  ["retention", paths.retentionDir],
1491
1618
  ["fixes", paths.fixesDir],
1619
+ ["synthesis", paths.synthesisDir],
1492
1620
  ];
1493
1621
  const missing = [];
1494
1622
  for (const [name, dir] of expected) {
@@ -1805,6 +1933,7 @@ async function buildRetentionManifest(context) {
1805
1933
  [context.paths.candidatesDir, "json"],
1806
1934
  [context.paths.clustersDir, "json"],
1807
1935
  [context.paths.observationsDir, "json"],
1936
+ [context.paths.synthesisDir, "json"],
1808
1937
  ]) {
1809
1938
  const files = await filesWithExtension(dir, extension);
1810
1939
  for (const file of files) {
@@ -2096,6 +2225,7 @@ function providerRuntimeControls(context, config) {
2096
2225
  ?? config.reviewSynthesis.privacyMode;
2097
2226
  const offline = flagBoolean(context.parsed.flags, "offline")
2098
2227
  || flagBoolean(context.parsed.flags, "local-only")
2228
+ || flagBoolean(context.parsed.flags, "evidence-only")
2099
2229
  || config.reviewSynthesis.offline
2100
2230
  || privacyMode === "local-only";
2101
2231
  const excerptBudget = numberFlag(context, "excerpt-budget") ?? config.reviewSynthesis.excerptBudget;
@@ -2125,6 +2255,46 @@ function providerRuntimeControls(context, config) {
2125
2255
  }
2126
2256
  return runtime;
2127
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
+ }
2128
2298
  function providerRuntimeSummary(runtime) {
2129
2299
  return {
2130
2300
  provider: runtime.provider,
@@ -2193,6 +2363,7 @@ async function stateArtifactCounts(paths) {
2193
2363
  ["locks", paths.locksDir],
2194
2364
  ["retention", paths.retentionDir],
2195
2365
  ["fixes", paths.fixesDir],
2366
+ ["synthesis", paths.synthesisDir],
2196
2367
  ];
2197
2368
  const counts = {};
2198
2369
  for (const [name, dir] of dirs) {