@codeledger/cli 0.2.1 → 0.5.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/artifacts/summary.d.ts +6 -0
- package/dist/artifacts/summary.d.ts.map +1 -0
- package/dist/artifacts/summary.js +49 -0
- package/dist/artifacts/summary.js.map +1 -0
- package/dist/commands/activate.d.ts.map +1 -1
- package/dist/commands/activate.js +155 -19
- package/dist/commands/activate.js.map +1 -1
- package/dist/commands/bundle.d.ts.map +1 -1
- package/dist/commands/bundle.js +28 -5
- package/dist/commands/bundle.js.map +1 -1
- package/dist/commands/checkpoint.d.ts +26 -0
- package/dist/commands/checkpoint.d.ts.map +1 -0
- package/dist/commands/checkpoint.js +382 -0
- package/dist/commands/checkpoint.js.map +1 -0
- package/dist/commands/cowork-snapshot.d.ts.map +1 -1
- package/dist/commands/cowork-snapshot.js +3 -2
- package/dist/commands/cowork-snapshot.js.map +1 -1
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +46 -12
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/intent.d.ts +37 -0
- package/dist/commands/intent.d.ts.map +1 -0
- package/dist/commands/intent.js +408 -0
- package/dist/commands/intent.js.map +1 -0
- package/dist/commands/manifest.d.ts +8 -0
- package/dist/commands/manifest.d.ts.map +1 -0
- package/dist/commands/manifest.js +144 -0
- package/dist/commands/manifest.js.map +1 -0
- package/dist/commands/policy.d.ts +8 -0
- package/dist/commands/policy.d.ts.map +1 -0
- package/dist/commands/policy.js +27 -0
- package/dist/commands/policy.js.map +1 -0
- package/dist/commands/refine.d.ts.map +1 -1
- package/dist/commands/refine.js +16 -0
- package/dist/commands/refine.js.map +1 -1
- package/dist/commands/review-coverage.d.ts +12 -0
- package/dist/commands/review-coverage.d.ts.map +1 -0
- package/dist/commands/review-coverage.js +142 -0
- package/dist/commands/review-coverage.js.map +1 -0
- package/dist/commands/review-gate.d.ts +12 -0
- package/dist/commands/review-gate.d.ts.map +1 -0
- package/dist/commands/review-gate.js +130 -0
- package/dist/commands/review-gate.js.map +1 -0
- package/dist/commands/session-cleanup.js +1 -0
- package/dist/commands/session-cleanup.js.map +1 -1
- package/dist/commands/session-progress.d.ts.map +1 -1
- package/dist/commands/session-progress.js +29 -1
- package/dist/commands/session-progress.js.map +1 -1
- package/dist/commands/session-summary.d.ts.map +1 -1
- package/dist/commands/session-summary.js +620 -37
- package/dist/commands/session-summary.js.map +1 -1
- package/dist/commands/setup-ci.d.ts +9 -0
- package/dist/commands/setup-ci.d.ts.map +1 -0
- package/dist/commands/setup-ci.js +139 -0
- package/dist/commands/setup-ci.js.map +1 -0
- package/dist/commands/shared-summary.d.ts +15 -0
- package/dist/commands/shared-summary.d.ts.map +1 -0
- package/dist/commands/shared-summary.js +194 -0
- package/dist/commands/shared-summary.js.map +1 -0
- package/dist/commands/sign-manifest.d.ts +8 -0
- package/dist/commands/sign-manifest.d.ts.map +1 -0
- package/dist/commands/sign-manifest.js +58 -0
- package/dist/commands/sign-manifest.js.map +1 -0
- package/dist/commands/verify.d.ts +13 -0
- package/dist/commands/verify.d.ts.map +1 -0
- package/dist/commands/verify.js +288 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +153 -2
- package/dist/index.js.map +1 -1
- package/dist/integrations/github-actions.d.ts +17 -0
- package/dist/integrations/github-actions.d.ts.map +1 -0
- package/dist/integrations/github-actions.js +64 -0
- package/dist/integrations/github-actions.js.map +1 -0
- package/dist/manifest/build.d.ts +19 -0
- package/dist/manifest/build.d.ts.map +1 -0
- package/dist/manifest/build.js +82 -0
- package/dist/manifest/build.js.map +1 -0
- package/dist/manifest/schema.d.ts +2 -0
- package/dist/manifest/schema.d.ts.map +1 -0
- package/dist/manifest/schema.js +2 -0
- package/dist/manifest/schema.js.map +1 -0
- package/dist/manifest/write.d.ts +13 -0
- package/dist/manifest/write.d.ts.map +1 -0
- package/dist/manifest/write.js +69 -0
- package/dist/manifest/write.js.map +1 -0
- package/dist/policy/load.d.ts +21 -0
- package/dist/policy/load.d.ts.map +1 -0
- package/dist/policy/load.js +63 -0
- package/dist/policy/load.js.map +1 -0
- package/dist/policy/resolve.d.ts +18 -0
- package/dist/policy/resolve.d.ts.map +1 -0
- package/dist/policy/resolve.js +86 -0
- package/dist/policy/resolve.js.map +1 -0
- package/dist/policy/schema.d.ts +22 -0
- package/dist/policy/schema.d.ts.map +1 -0
- package/dist/policy/schema.js +82 -0
- package/dist/policy/schema.js.map +1 -0
- package/dist/session-paths.d.ts +8 -0
- package/dist/session-paths.d.ts.map +1 -1
- package/dist/session-paths.js +16 -0
- package/dist/session-paths.js.map +1 -1
- package/dist/signing/canonicalize.d.ts +17 -0
- package/dist/signing/canonicalize.d.ts.map +1 -0
- package/dist/signing/canonicalize.js +50 -0
- package/dist/signing/canonicalize.js.map +1 -0
- package/dist/signing/hmac.d.ts +8 -0
- package/dist/signing/hmac.d.ts.map +1 -0
- package/dist/signing/hmac.js +16 -0
- package/dist/signing/hmac.js.map +1 -0
- package/dist/signing/signer.d.ts +16 -0
- package/dist/signing/signer.d.ts.map +1 -0
- package/dist/signing/signer.js +2 -0
- package/dist/signing/signer.js.map +1 -0
- package/dist/templates/claude-md.d.ts.map +1 -1
- package/dist/templates/claude-md.js +9 -2
- package/dist/templates/claude-md.js.map +1 -1
- package/dist/templates/config.d.ts.map +1 -1
- package/dist/templates/config.js +48 -10
- package/dist/templates/config.js.map +1 -1
- package/dist/verify/evaluate.d.ts +10 -0
- package/dist/verify/evaluate.d.ts.map +1 -0
- package/dist/verify/evaluate.js +117 -0
- package/dist/verify/evaluate.js.map +1 -0
- package/dist/verify/policy-snapshot.d.ts +7 -0
- package/dist/verify/policy-snapshot.d.ts.map +1 -0
- package/dist/verify/policy-snapshot.js +36 -0
- package/dist/verify/policy-snapshot.js.map +1 -0
- package/dist/verify/report.d.ts +11 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/report.js +64 -0
- package/dist/verify/report.js.map +1 -0
- package/package.json +10 -10
|
@@ -2,7 +2,10 @@ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { resolveSessionId } from '../session-id.js';
|
|
5
|
-
import { sessionActivateRef, sessionStartRef } from '../session-paths.js';
|
|
5
|
+
import { sessionActivateRef, sessionStartRef, sessionTaskHistoryPath, sessionReadLogPath, sessionReviewConfigPath, } from '../session-paths.js';
|
|
6
|
+
import { countCheckpoints } from './checkpoint.js';
|
|
7
|
+
import { evaluateDrift } from './intent.js';
|
|
8
|
+
import { loadReadTracker } from './review-coverage.js';
|
|
6
9
|
/**
|
|
7
10
|
* `codeledger session-summary [--session <id>]`
|
|
8
11
|
*
|
|
@@ -18,7 +21,9 @@ import { sessionActivateRef, sessionStartRef } from '../session-paths.js';
|
|
|
18
21
|
export async function runSessionSummary(cwd, flags) {
|
|
19
22
|
const configPath = join(cwd, '.codeledger', 'config.json');
|
|
20
23
|
if (!existsSync(configPath)) {
|
|
21
|
-
|
|
24
|
+
if (flags['quiet'] !== 'true') {
|
|
25
|
+
console.log('No .codeledger/ found. Run "codeledger init" first.');
|
|
26
|
+
}
|
|
22
27
|
return;
|
|
23
28
|
}
|
|
24
29
|
let config;
|
|
@@ -56,6 +61,43 @@ export async function runSessionSummary(cwd, flags) {
|
|
|
56
61
|
const reductionPct = repoTokens > 0
|
|
57
62
|
? ((repoTokens - bundle.total_tokens) / repoTokens) * 100
|
|
58
63
|
: 0;
|
|
64
|
+
// ── Debt injection detection ─────────────────────────────────────────────
|
|
65
|
+
const debt = analyzeDebt(cwd, sessionId);
|
|
66
|
+
// ── Commit hygiene analysis ─────────────────────────────────────────────
|
|
67
|
+
const commitHygiene = analyzeCommitHygiene(cwd, sessionId, bundleFiles);
|
|
68
|
+
// ── Scope drift detection ─────────────────────────────────────────────
|
|
69
|
+
const scopeDriftFiles = bundle.scope_contract
|
|
70
|
+
? getScopeDriftFiles(filesChanged, bundle.scope_contract)
|
|
71
|
+
: [];
|
|
72
|
+
const scopeDriftCount = scopeDriftFiles.length > 0 ? scopeDriftFiles.length : undefined;
|
|
73
|
+
// ── Conflict zone touches ─────────────────────────────────────────────
|
|
74
|
+
const conflictZoneTouches = bundle.conflict_zones
|
|
75
|
+
? countConflictTouches(filesChanged, bundle.conflict_zones)
|
|
76
|
+
: undefined;
|
|
77
|
+
// ── Bundle invalidation (commit-aware staleness) ──────────────────────
|
|
78
|
+
const bundleInvalidation = analyzeBundleInvalidation(cwd, sessionId, bundleFiles);
|
|
79
|
+
// ── Checkpoint count ──────────────────────────────────────────────────
|
|
80
|
+
const checkpointCount = countCheckpoints(cwd, sessionId);
|
|
81
|
+
// ── Review coverage (Sentinel) ───────────────────────────────────────
|
|
82
|
+
let reviewCoverage;
|
|
83
|
+
const reviewConfigPath = sessionReviewConfigPath(cwd, sessionId);
|
|
84
|
+
if (existsSync(reviewConfigPath)) {
|
|
85
|
+
try {
|
|
86
|
+
const reviewConfig = JSON.parse(readFileSync(reviewConfigPath, 'utf-8'));
|
|
87
|
+
const indexPath = join(cwd, config.workspace.cache_dir, 'repo-index.json');
|
|
88
|
+
if (existsSync(indexPath)) {
|
|
89
|
+
const ridx = JSON.parse(readFileSync(indexPath, 'utf-8'));
|
|
90
|
+
const readLogPath = sessionReadLogPath(cwd, sessionId);
|
|
91
|
+
const tracker = loadReadTracker(readLogPath, ridx);
|
|
92
|
+
const { computeReviewCoverage } = await import('@codeledger/selector');
|
|
93
|
+
const allRequired = [...reviewConfig.must_read_files, ...reviewConfig.should_read_files];
|
|
94
|
+
reviewCoverage = computeReviewCoverage(allRequired, tracker.getAllDepths(), tracker.getReadPaths(), ridx);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Non-fatal: review coverage is optional enrichment
|
|
99
|
+
}
|
|
100
|
+
}
|
|
59
101
|
const summary = {
|
|
60
102
|
bundle_id: bundle.bundle_id,
|
|
61
103
|
task: bundle.task,
|
|
@@ -69,13 +111,24 @@ export async function runSessionSummary(cwd, flags) {
|
|
|
69
111
|
context_reduction_pct: reductionPct,
|
|
70
112
|
generated_at: new Date().toISOString(),
|
|
71
113
|
session_id: sessionId ?? undefined,
|
|
114
|
+
debt: debt ?? undefined,
|
|
115
|
+
commit_hygiene: commitHygiene ?? undefined,
|
|
116
|
+
scope_drift_count: scopeDriftCount,
|
|
117
|
+
conflict_zone_touches: conflictZoneTouches,
|
|
118
|
+
checkpoint_count: checkpointCount > 0 ? checkpointCount : undefined,
|
|
119
|
+
bundle_invalidation: bundleInvalidation ?? undefined,
|
|
120
|
+
review_coverage: reviewCoverage,
|
|
72
121
|
};
|
|
73
|
-
// ──
|
|
74
|
-
|
|
122
|
+
// ── Intent drift evaluation ───────────────────────────────────────────
|
|
123
|
+
const intentDrift = evaluateDrift(cwd, sessionId);
|
|
75
124
|
// ── Session-wide aggregate (only on --final, i.e. Stop hook) ───────────
|
|
125
|
+
// Print the per-task breakdown FIRST when --final is set, so the most
|
|
126
|
+
// useful information appears at the top (Fix 3.1)
|
|
76
127
|
if (flags['final'] === 'true') {
|
|
77
128
|
printSessionAggregate(cwd, sessionId, summary);
|
|
78
129
|
}
|
|
130
|
+
// ── Print concise recap ─────────────────────────────────────────────────
|
|
131
|
+
printRecap(summary, intentDrift, scopeDriftFiles, config.suggestions?.prefill_task === true);
|
|
79
132
|
}
|
|
80
133
|
function loadLatestBundle(cwd, config) {
|
|
81
134
|
const bundleDir = join(cwd, config.workspace.artifacts_dir, 'bundles');
|
|
@@ -145,7 +198,7 @@ function getSessionChangedFiles(cwd, sessionId) {
|
|
|
145
198
|
}
|
|
146
199
|
}
|
|
147
200
|
}
|
|
148
|
-
return [...files];
|
|
201
|
+
return [...files].filter((f) => !f.startsWith('.codeledger/') && !f.startsWith('.claude/'));
|
|
149
202
|
}
|
|
150
203
|
catch {
|
|
151
204
|
return [];
|
|
@@ -164,7 +217,7 @@ function estimateRepoTokens(cwd, config) {
|
|
|
164
217
|
return 0;
|
|
165
218
|
}
|
|
166
219
|
}
|
|
167
|
-
function printRecap(s) {
|
|
220
|
+
function printRecap(s, intentDrift, scopeDriftFiles, prefillTask) {
|
|
168
221
|
const recallPct = Math.round(s.recall * 100);
|
|
169
222
|
const precisionPct = Math.round(s.precision * 100);
|
|
170
223
|
const reductionPct = Math.round(s.context_reduction_pct);
|
|
@@ -183,7 +236,7 @@ function printRecap(s) {
|
|
|
183
236
|
lines.push(` Bundle missed: predicted ${s.files_overlapping.length}/${s.files_changed.length} files you changed (${recallPct}% recall)`);
|
|
184
237
|
lines.push(` Bundle accuracy: ${s.files_overlapping.length}/${s.bundle_files.length} bundled files were relevant (${precisionPct}% precision)`);
|
|
185
238
|
lines.push(` The bundle was not useful for this task.`);
|
|
186
|
-
lines.push(` Tip: Re-run "codeledger activate --task ..." with a more specific task description.`);
|
|
239
|
+
lines.push(` Tip: Re-run "codeledger activate --task ${prefillTask ? `"${s.task}"` : '...'}" with a more specific task description.`);
|
|
187
240
|
}
|
|
188
241
|
else if (s.recall < 0.5) {
|
|
189
242
|
// Bundle was partially useful — show all stats but highlight room to improve
|
|
@@ -198,6 +251,83 @@ function printRecap(s) {
|
|
|
198
251
|
lines.push(` Bundle accuracy: ${s.files_overlapping.length}/${s.bundle_files.length} bundled files were relevant (${precisionPct}% precision)`);
|
|
199
252
|
lines.push(` Context: ~${fmtTokens(s.bundle_tokens)} tokens vs ~${fmtTokens(s.repo_total_tokens)} full repo (${reductionPct}% reduction)`);
|
|
200
253
|
}
|
|
254
|
+
// ── Debt injection section ───────────────────────────────────────────
|
|
255
|
+
if (s.debt && s.debt.total_suppressions > 0) {
|
|
256
|
+
lines.push(` Debt: ${s.debt.total_suppressions} suppression${s.debt.total_suppressions === 1 ? '' : 's'} detected (score: ${s.debt.debt_score.toFixed(2)})`);
|
|
257
|
+
// Show up to 3 debt warnings
|
|
258
|
+
for (const w of s.debt.warnings.slice(0, 3)) {
|
|
259
|
+
lines.push(` - ${w.file}:${w.line}: ${w.message}`);
|
|
260
|
+
}
|
|
261
|
+
if (s.debt.warnings.length > 3) {
|
|
262
|
+
lines.push(` ... and ${s.debt.warnings.length - 3} more`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// ── Commit hygiene section ──────────────────────────────────────────
|
|
266
|
+
if (s.commit_hygiene) {
|
|
267
|
+
const h = s.commit_hygiene;
|
|
268
|
+
const issues = [];
|
|
269
|
+
if (h.empty_commits > 0)
|
|
270
|
+
issues.push(`${h.empty_commits} empty`);
|
|
271
|
+
if (h.no_verify_count > 0)
|
|
272
|
+
issues.push(`${h.no_verify_count} --no-verify`);
|
|
273
|
+
if (h.out_of_scope_files.length > 0)
|
|
274
|
+
issues.push(`${h.out_of_scope_files.length} out-of-scope files`);
|
|
275
|
+
if (issues.length > 0) {
|
|
276
|
+
lines.push(` Commit hygiene: ${h.hygiene_score.toFixed(2)} (${issues.join(', ')})`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// ── Scope drift section ───────────────────────────────────────────
|
|
280
|
+
if (s.scope_drift_count !== undefined && s.scope_drift_count > 0) {
|
|
281
|
+
lines.push(` Scope drift: ${s.scope_drift_count} file${s.scope_drift_count === 1 ? '' : 's'} edited outside scope contract`);
|
|
282
|
+
if (scopeDriftFiles && scopeDriftFiles.length > 0) {
|
|
283
|
+
for (const f of scopeDriftFiles.slice(0, 5)) {
|
|
284
|
+
lines.push(` - ${f}`);
|
|
285
|
+
}
|
|
286
|
+
if (scopeDriftFiles.length > 5) {
|
|
287
|
+
lines.push(` ... and ${scopeDriftFiles.length - 5} more`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// ── Conflict zone touches section ─────────────────────────────────
|
|
292
|
+
if (s.conflict_zone_touches !== undefined && s.conflict_zone_touches > 0) {
|
|
293
|
+
lines.push(` Conflict zones: ${s.conflict_zone_touches} contested file${s.conflict_zone_touches === 1 ? '' : 's'} were modified`);
|
|
294
|
+
}
|
|
295
|
+
// ── Bundle invalidation section ─────────────────────────────────
|
|
296
|
+
if (s.bundle_invalidation && s.bundle_invalidation.addressed_files.length > 0) {
|
|
297
|
+
const inv = s.bundle_invalidation;
|
|
298
|
+
const stalenessPct = Math.round(inv.staleness_ratio * 100);
|
|
299
|
+
lines.push(` Bundle freshness: ${inv.addressed_files.length} of ${inv.addressed_files.length + inv.fresh_files.length} files committed (${stalenessPct}% addressed)`);
|
|
300
|
+
if (inv.staleness_ratio >= 0.75) {
|
|
301
|
+
lines.push(` Tip: Bundle is mostly stale — refresh with "codeledger activate --task ${prefillTask ? `"${s.task}"` : '...'}" for updated context.`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// ── Checkpoint count section ────────────────────────────────────
|
|
305
|
+
if (s.checkpoint_count && s.checkpoint_count > 0) {
|
|
306
|
+
lines.push(` Checkpoints: ${s.checkpoint_count} saved`);
|
|
307
|
+
}
|
|
308
|
+
// ── Intent drift section ───────────────────────────────────────
|
|
309
|
+
if (intentDrift && intentDrift.drift.drift_level !== 'NONE') {
|
|
310
|
+
const d = intentDrift.drift;
|
|
311
|
+
lines.push(` Intent drift: ${d.drift_level} (${d.drift_score.toFixed(3)})`);
|
|
312
|
+
if (d.drift_level === 'MAJOR' || d.drift_level === 'CRITICAL') {
|
|
313
|
+
const topReasons = d.reasons.filter((r) => r.distance > 0).slice(0, 3);
|
|
314
|
+
for (const r of topReasons) {
|
|
315
|
+
lines.push(` - ${r.field}: ${r.distance.toFixed(3)}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// ── Review coverage section (Sentinel) ─────────────────────────
|
|
320
|
+
if (s.review_coverage) {
|
|
321
|
+
const rc = s.review_coverage;
|
|
322
|
+
const pct = (rc.file_coverage * 100).toFixed(1);
|
|
323
|
+
const depthPct = (rc.depth_weighted_coverage * 100).toFixed(1);
|
|
324
|
+
const thresholdTag = rc.file_coverage >= 0.90 ? 'PASS' : 'BELOW 90%';
|
|
325
|
+
lines.push(` Review Coverage: ${rc.files_read.length}/${rc.required_files.length} files read (${pct}%) ${thresholdTag}`);
|
|
326
|
+
lines.push(` Depth Coverage: ${depthPct}%`);
|
|
327
|
+
if (rc.post_hoc_reads.length > 0) {
|
|
328
|
+
lines.push(` Post-hoc reads: ${rc.post_hoc_reads.length} file${rc.post_hoc_reads.length === 1 ? '' : 's'}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
201
331
|
lines.push(` [${s.bundle_id}]`);
|
|
202
332
|
lines.push('-'.repeat(61));
|
|
203
333
|
lines.push('');
|
|
@@ -208,80 +338,533 @@ function fmtTokens(n) {
|
|
|
208
338
|
return `${(n / 1000).toFixed(1)}k`;
|
|
209
339
|
return `${n}`;
|
|
210
340
|
}
|
|
341
|
+
// ── Debt Injection Detection ────────────────────────────────────────────────
|
|
342
|
+
function analyzeDebt(cwd, sessionId) {
|
|
343
|
+
const activateRefPath = sessionActivateRef(cwd, sessionId);
|
|
344
|
+
const sessionRefPath = sessionStartRef(cwd, sessionId);
|
|
345
|
+
const refPath = existsSync(activateRefPath) ? activateRefPath : sessionRefPath;
|
|
346
|
+
if (!existsSync(refPath))
|
|
347
|
+
return null;
|
|
348
|
+
const startRef = readFileSync(refPath, 'utf-8').trim();
|
|
349
|
+
if (!startRef)
|
|
350
|
+
return null;
|
|
351
|
+
try {
|
|
352
|
+
const diff = execSync(`git diff ${startRef}..HEAD`, {
|
|
353
|
+
cwd,
|
|
354
|
+
encoding: 'utf-8',
|
|
355
|
+
timeout: 10000,
|
|
356
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
357
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
358
|
+
});
|
|
359
|
+
if (!diff.trim())
|
|
360
|
+
return null;
|
|
361
|
+
// Dynamic import to avoid bundling selector into CLI at compile time
|
|
362
|
+
const debtFns = loadDebtDetection();
|
|
363
|
+
if (!debtFns.scanDebtPatterns || !debtFns.computeDebtScore)
|
|
364
|
+
return null;
|
|
365
|
+
const warnings = debtFns.scanDebtPatterns(diff);
|
|
366
|
+
const addedLines = diff.split('\n').filter((l) => l.startsWith('+') && !l.startsWith('+++')).length;
|
|
367
|
+
const debtScore = debtFns.computeDebtScore(warnings, addedLines);
|
|
368
|
+
return {
|
|
369
|
+
warnings,
|
|
370
|
+
total_suppressions: warnings.length,
|
|
371
|
+
debt_score: debtScore,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function loadDebtDetection() {
|
|
379
|
+
try {
|
|
380
|
+
// Inline implementation to avoid cross-package import complexity
|
|
381
|
+
const DEBT_PATTERNS = [
|
|
382
|
+
{ pattern: /@Suppress\b/, message: '@Suppress annotation suppresses warnings' },
|
|
383
|
+
{ pattern: /@SuppressWarnings\b/, message: '@SuppressWarnings hides compiler warnings' },
|
|
384
|
+
{ pattern: /eslint-disable(?!-next-line)/, message: 'eslint-disable disables linting for entire block' },
|
|
385
|
+
{ pattern: /eslint-disable-next-line/, message: 'eslint-disable-next-line suppresses linting' },
|
|
386
|
+
{ pattern: /@ts-ignore\b/, message: '@ts-ignore suppresses TypeScript errors' },
|
|
387
|
+
{ pattern: /@ts-expect-error\b/, message: '@ts-expect-error suppresses a TypeScript error' },
|
|
388
|
+
{ pattern: /:\s*any\b/, message: 'Explicit `any` type bypasses type safety' },
|
|
389
|
+
{ pattern: /as\s+any\b/, message: '`as any` assertion bypasses type safety' },
|
|
390
|
+
{ pattern: /\/\/\s*noinspection\b/, message: 'noinspection comment suppresses IDE warnings' },
|
|
391
|
+
{ pattern: /NOSONAR\b/, message: 'NOSONAR suppresses static analysis' },
|
|
392
|
+
{ pattern: /--no-verify\b/, message: '--no-verify skips pre-commit hooks' },
|
|
393
|
+
];
|
|
394
|
+
const scanDebtPatterns = (diffContent) => {
|
|
395
|
+
const warnings = [];
|
|
396
|
+
const lines = diffContent.split('\n');
|
|
397
|
+
let currentFile = '';
|
|
398
|
+
let lineNumber = 0;
|
|
399
|
+
for (const line of lines) {
|
|
400
|
+
const fileMatch = /^\+\+\+ b\/(.+)$/.exec(line);
|
|
401
|
+
if (fileMatch) {
|
|
402
|
+
currentFile = fileMatch[1];
|
|
403
|
+
if (currentFile.startsWith('.codeledger/') || currentFile.startsWith('.claude/')) {
|
|
404
|
+
currentFile = '';
|
|
405
|
+
}
|
|
406
|
+
lineNumber = 0;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const hunkMatch = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(line);
|
|
410
|
+
if (hunkMatch) {
|
|
411
|
+
lineNumber = parseInt(hunkMatch[1], 10);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (!currentFile) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
418
|
+
const content = line.slice(1);
|
|
419
|
+
for (const { pattern, message } of DEBT_PATTERNS) {
|
|
420
|
+
if (pattern.test(content)) {
|
|
421
|
+
warnings.push({ file: currentFile, line: lineNumber, pattern: pattern.source, message });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
lineNumber++;
|
|
425
|
+
}
|
|
426
|
+
else if (!line.startsWith('-')) {
|
|
427
|
+
lineNumber++;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return warnings;
|
|
431
|
+
};
|
|
432
|
+
const computeDebtScore = (warnings, lineCount) => {
|
|
433
|
+
if (lineCount === 0 || warnings.length === 0)
|
|
434
|
+
return 0;
|
|
435
|
+
const ratio = warnings.length / (lineCount / 10);
|
|
436
|
+
return Math.min(1.0, Math.round(ratio * 100) / 100);
|
|
437
|
+
};
|
|
438
|
+
return { scanDebtPatterns, computeDebtScore };
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return { scanDebtPatterns: null, computeDebtScore: null };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
// ── Commit Hygiene Analysis ─────────────────────────────────────────────────
|
|
445
|
+
function analyzeCommitHygiene(cwd, sessionId, bundleFiles) {
|
|
446
|
+
const activateRefPath = sessionActivateRef(cwd, sessionId);
|
|
447
|
+
const sessionRefPath = sessionStartRef(cwd, sessionId);
|
|
448
|
+
const refPath = existsSync(activateRefPath) ? activateRefPath : sessionRefPath;
|
|
449
|
+
if (!existsSync(refPath))
|
|
450
|
+
return null;
|
|
451
|
+
const startRef = readFileSync(refPath, 'utf-8').trim();
|
|
452
|
+
if (!startRef)
|
|
453
|
+
return null;
|
|
454
|
+
try {
|
|
455
|
+
// Get commit log since start ref
|
|
456
|
+
const log = execSync(`git log --format="%H %s" ${startRef}..HEAD`, { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
457
|
+
if (!log)
|
|
458
|
+
return null;
|
|
459
|
+
const commits = log.split('\n').filter(Boolean);
|
|
460
|
+
let emptyCommits = 0;
|
|
461
|
+
let noVerifyCount = 0;
|
|
462
|
+
let forcePushCount = 0;
|
|
463
|
+
for (const commit of commits) {
|
|
464
|
+
const sha = commit.split(' ')[0];
|
|
465
|
+
// Check for empty commits (no file changes)
|
|
466
|
+
try {
|
|
467
|
+
const files = execSync(`git diff-tree --no-commit-id --name-only -r ${sha}`, {
|
|
468
|
+
cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
469
|
+
}).trim();
|
|
470
|
+
if (!files)
|
|
471
|
+
emptyCommits++;
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// non-fatal
|
|
475
|
+
}
|
|
476
|
+
// Check commit message for --no-verify indicators
|
|
477
|
+
const message = commit.slice(sha.length + 1);
|
|
478
|
+
if (message.includes('--no-verify') || message.includes('[skip ci]')) {
|
|
479
|
+
noVerifyCount++;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// Detect out-of-scope files (changed files not in bundle)
|
|
483
|
+
const bundleSet = new Set(bundleFiles);
|
|
484
|
+
const changedFiles = new Set();
|
|
485
|
+
try {
|
|
486
|
+
const diff = execSync(`git diff --name-only ${startRef}..HEAD`, {
|
|
487
|
+
cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
488
|
+
}).trim();
|
|
489
|
+
if (diff) {
|
|
490
|
+
for (const f of diff.split('\n')) {
|
|
491
|
+
if (!f.startsWith('.codeledger/') && !f.startsWith('.claude/'))
|
|
492
|
+
changedFiles.add(f);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
// non-fatal
|
|
498
|
+
}
|
|
499
|
+
const outOfScope = [...changedFiles].filter((f) => !bundleSet.has(f));
|
|
500
|
+
// Compute hygiene score: 1.0 = perfect, deductions for issues
|
|
501
|
+
let hygieneScore = 1.0;
|
|
502
|
+
if (commits.length > 0) {
|
|
503
|
+
hygieneScore -= (emptyCommits / commits.length) * 0.3;
|
|
504
|
+
hygieneScore -= (noVerifyCount / commits.length) * 0.3;
|
|
505
|
+
}
|
|
506
|
+
if (changedFiles.size > 0 && outOfScope.length > 0) {
|
|
507
|
+
hygieneScore -= Math.min(0.3, (outOfScope.length / changedFiles.size) * 0.3);
|
|
508
|
+
}
|
|
509
|
+
hygieneScore = Math.max(0, Math.round(hygieneScore * 100) / 100);
|
|
510
|
+
return {
|
|
511
|
+
total_commits: commits.length,
|
|
512
|
+
empty_commits: emptyCommits,
|
|
513
|
+
no_verify_count: noVerifyCount,
|
|
514
|
+
force_push_count: forcePushCount,
|
|
515
|
+
out_of_scope_files: outOfScope,
|
|
516
|
+
hygiene_score: hygieneScore,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
211
523
|
/**
|
|
212
|
-
* When the session involved multiple tasks
|
|
213
|
-
*
|
|
214
|
-
*
|
|
524
|
+
* When the session involved multiple tasks, print a per-task breakdown
|
|
525
|
+
* showing recall/precision for each activate call, plus session-wide totals.
|
|
526
|
+
*
|
|
527
|
+
* Reads `.task-history.jsonl` to reconstruct what happened in each task.
|
|
215
528
|
*/
|
|
216
529
|
function printSessionAggregate(cwd, sessionId, lastTaskSummary) {
|
|
217
|
-
const activateRefPath = sessionActivateRef(cwd, sessionId);
|
|
218
530
|
const sessionRefPath = sessionStartRef(cwd, sessionId);
|
|
219
|
-
|
|
220
|
-
if (!existsSync(sessionRefPath) || !existsSync(activateRefPath))
|
|
531
|
+
if (!existsSync(sessionRefPath))
|
|
221
532
|
return;
|
|
222
533
|
const sessionRef = readFileSync(sessionRefPath, 'utf-8').trim();
|
|
223
|
-
|
|
224
|
-
if (!sessionRef || !activateRef || sessionRef === activateRef)
|
|
534
|
+
if (!sessionRef)
|
|
225
535
|
return;
|
|
226
|
-
//
|
|
227
|
-
const
|
|
536
|
+
// -- Load task history (JSONL) -------------------------------------------
|
|
537
|
+
const historyPath = sessionTaskHistoryPath(cwd, sessionId);
|
|
538
|
+
const activations = loadTaskHistory(historyPath);
|
|
539
|
+
// -- Count commits since session start -----------------------------------
|
|
540
|
+
let commitCount = 0;
|
|
228
541
|
try {
|
|
229
|
-
const
|
|
542
|
+
const count = execSync(`git rev-list --count ${sessionRef}..HEAD`, {
|
|
230
543
|
cwd,
|
|
231
544
|
encoding: 'utf-8',
|
|
232
545
|
timeout: 5000,
|
|
233
546
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
234
547
|
}).trim();
|
|
235
|
-
|
|
236
|
-
for (const f of committed.split('\n'))
|
|
237
|
-
allChanged.add(f);
|
|
238
|
-
}
|
|
548
|
+
commitCount = parseInt(count, 10) || 0;
|
|
239
549
|
}
|
|
240
550
|
catch {
|
|
241
|
-
|
|
551
|
+
// non-fatal
|
|
242
552
|
}
|
|
243
|
-
//
|
|
553
|
+
// -- Gather all files changed across the entire session -------------------
|
|
554
|
+
const allChanged = new Set();
|
|
244
555
|
try {
|
|
245
|
-
const
|
|
556
|
+
const committed = execSync(`git diff --name-only ${sessionRef}..HEAD`, {
|
|
246
557
|
cwd,
|
|
247
558
|
encoding: 'utf-8',
|
|
248
559
|
timeout: 5000,
|
|
249
560
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
250
561
|
}).trim();
|
|
251
|
-
if (
|
|
252
|
-
for (const f of
|
|
253
|
-
|
|
562
|
+
if (committed) {
|
|
563
|
+
for (const f of committed.split('\n')) {
|
|
564
|
+
if (!f.startsWith('.codeledger/') && !f.startsWith('.claude/'))
|
|
565
|
+
allChanged.add(f);
|
|
566
|
+
}
|
|
254
567
|
}
|
|
255
568
|
}
|
|
256
569
|
catch {
|
|
257
570
|
// non-fatal
|
|
258
571
|
}
|
|
259
|
-
// Only show aggregate if it covers more files than the last task alone
|
|
260
|
-
if (allChanged.size <= lastTaskSummary.files_changed.length)
|
|
261
|
-
return;
|
|
262
|
-
// Count commits since session start
|
|
263
|
-
let commitCount = 0;
|
|
264
572
|
try {
|
|
265
|
-
const
|
|
573
|
+
const uncommitted = execSync('git diff --name-only HEAD', {
|
|
266
574
|
cwd,
|
|
267
575
|
encoding: 'utf-8',
|
|
268
576
|
timeout: 5000,
|
|
269
577
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
270
578
|
}).trim();
|
|
271
|
-
|
|
579
|
+
if (uncommitted) {
|
|
580
|
+
for (const f of uncommitted.split('\n')) {
|
|
581
|
+
if (!f.startsWith('.codeledger/') && !f.startsWith('.claude/'))
|
|
582
|
+
allChanged.add(f);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
272
585
|
}
|
|
273
586
|
catch {
|
|
274
587
|
// non-fatal
|
|
275
588
|
}
|
|
589
|
+
// -- Compute per-task metrics -------------------------------------------
|
|
590
|
+
const taskMetrics = computePerTaskMetrics(cwd, activations);
|
|
276
591
|
const lines = [];
|
|
277
592
|
lines.push('');
|
|
278
|
-
lines.push('-- Session
|
|
279
|
-
lines.push(` ${allChanged.size} files changed across ${commitCount} commit${commitCount === 1 ? '' : 's'} this session`);
|
|
593
|
+
lines.push('-- Session Summary (all tasks) --------------------------------');
|
|
280
594
|
if (sessionId) {
|
|
281
595
|
lines.push(` Session: ${sessionId}`);
|
|
282
596
|
}
|
|
597
|
+
// Per-task breakdown (only if there's history and at least one task had changes)
|
|
598
|
+
if (taskMetrics.length > 0) {
|
|
599
|
+
lines.push('');
|
|
600
|
+
for (let i = 0; i < taskMetrics.length; i++) {
|
|
601
|
+
const m = taskMetrics[i];
|
|
602
|
+
const recallPct = Math.round(m.recall * 100);
|
|
603
|
+
const precisionPct = Math.round(m.precision * 100);
|
|
604
|
+
const icon = m.verdict === 'HIT' ? '\u2705' : m.verdict === 'PARTIAL' ? '\u{1F7E1}' : '\u274C';
|
|
605
|
+
lines.push(` Task ${i + 1}: "${truncate(m.task, 55)}"`);
|
|
606
|
+
if (m.files_changed.length === 0) {
|
|
607
|
+
lines.push(` No files changed — skipped`);
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
lines.push(` Recall: ${m.files_overlapping.length}/${m.files_changed.length} (${recallPct}%) Precision: ${m.files_overlapping.length}/${m.bundle_file_count} (${precisionPct}%) ${icon} ${m.verdict}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
else if (allChanged.size > lastTaskSummary.files_changed.length) {
|
|
615
|
+
// Fallback: no task history but session had multiple tasks worth of changes
|
|
616
|
+
lines.push(` ${allChanged.size} files changed across ${commitCount} commit${commitCount === 1 ? '' : 's'}`);
|
|
617
|
+
}
|
|
618
|
+
// Session totals
|
|
619
|
+
if (taskMetrics.length > 1 || (taskMetrics.length === 0 && allChanged.size > 0)) {
|
|
620
|
+
const totalChanged = taskMetrics.length > 0
|
|
621
|
+
? new Set(taskMetrics.flatMap((m) => m.files_changed)).size
|
|
622
|
+
: allChanged.size;
|
|
623
|
+
const totalOverlap = taskMetrics.length > 0
|
|
624
|
+
? new Set(taskMetrics.flatMap((m) => m.files_overlapping)).size
|
|
625
|
+
: lastTaskSummary.files_overlapping.length;
|
|
626
|
+
const sessionRecall = totalChanged > 0 ? totalOverlap / totalChanged : 0;
|
|
627
|
+
const sessionRecallPct = Math.round(sessionRecall * 100);
|
|
628
|
+
const hits = taskMetrics.filter((m) => m.verdict === 'HIT').length;
|
|
629
|
+
const partials = taskMetrics.filter((m) => m.verdict === 'PARTIAL').length;
|
|
630
|
+
const misses = taskMetrics.filter((m) => m.verdict === 'MISS').length;
|
|
631
|
+
lines.push('');
|
|
632
|
+
lines.push(` Session: ${totalChanged} files changed, ${commitCount} commit${commitCount === 1 ? '' : 's'}, ${sessionRecallPct}% overall recall`);
|
|
633
|
+
if (taskMetrics.length > 0) {
|
|
634
|
+
lines.push(` Tasks: ${hits} hit, ${partials} partial, ${misses} miss (${taskMetrics.length} total)`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
283
637
|
lines.push('-'.repeat(61));
|
|
284
638
|
lines.push('');
|
|
285
639
|
console.log(lines.join('\n'));
|
|
286
640
|
}
|
|
641
|
+
// ── Scope Drift Detection ───────────────────────────────────────────────────
|
|
642
|
+
function getScopeDriftFiles(changedFiles, scopeContract) {
|
|
643
|
+
const scopeSet = new Set([...scopeContract.bundle_files, ...scopeContract.neighbor_files]);
|
|
644
|
+
return changedFiles.filter((f) => !scopeSet.has(f));
|
|
645
|
+
}
|
|
646
|
+
// ── Conflict Zone Touches ───────────────────────────────────────────────────
|
|
647
|
+
function countConflictTouches(changedFiles, conflictZones) {
|
|
648
|
+
const conflictFiles = new Set(conflictZones.map((z) => z.file));
|
|
649
|
+
return changedFiles.filter((f) => conflictFiles.has(f)).length;
|
|
650
|
+
}
|
|
651
|
+
// ── Bundle Invalidation (Commit-Aware) ──────────────────────────────────────
|
|
652
|
+
function analyzeBundleInvalidation(cwd, sessionId, bundleFiles) {
|
|
653
|
+
if (bundleFiles.length === 0)
|
|
654
|
+
return null;
|
|
655
|
+
// Get the activate ref to know when the bundle was generated
|
|
656
|
+
const activateRefPath = sessionActivateRef(cwd, sessionId);
|
|
657
|
+
const sessionRefPath = sessionStartRef(cwd, sessionId);
|
|
658
|
+
const refPath = existsSync(activateRefPath) ? activateRefPath : sessionRefPath;
|
|
659
|
+
if (!existsSync(refPath))
|
|
660
|
+
return null;
|
|
661
|
+
const startRef = readFileSync(refPath, 'utf-8').trim();
|
|
662
|
+
if (!startRef)
|
|
663
|
+
return null;
|
|
664
|
+
try {
|
|
665
|
+
// Get commit log with file names since the bundle was generated
|
|
666
|
+
const log = execSync(`git log --format="%H %aI" --name-only ${startRef}..HEAD`, { cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
667
|
+
if (!log)
|
|
668
|
+
return null;
|
|
669
|
+
// Parse which files were committed
|
|
670
|
+
const committedFiles = new Map();
|
|
671
|
+
const lines = log.split('\n');
|
|
672
|
+
let currentSha = '';
|
|
673
|
+
let currentDate = '';
|
|
674
|
+
for (const line of lines) {
|
|
675
|
+
const trimmed = line.trim();
|
|
676
|
+
if (!trimmed)
|
|
677
|
+
continue;
|
|
678
|
+
const commitMatch = /^([0-9a-f]{40})\s+(.+)$/.exec(trimmed);
|
|
679
|
+
if (commitMatch) {
|
|
680
|
+
currentSha = commitMatch[1];
|
|
681
|
+
currentDate = commitMatch[2];
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
// File path — only record the first (most recent) commit
|
|
685
|
+
if (currentSha && !committedFiles.has(trimmed)) {
|
|
686
|
+
committedFiles.set(trimmed, { commit_sha: currentSha, committed_at: currentDate });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (committedFiles.size === 0)
|
|
690
|
+
return null;
|
|
691
|
+
// Cross-reference with bundle files
|
|
692
|
+
const addressed = [];
|
|
693
|
+
const fresh = [];
|
|
694
|
+
for (const filePath of bundleFiles) {
|
|
695
|
+
const commitInfo = committedFiles.get(filePath);
|
|
696
|
+
if (commitInfo) {
|
|
697
|
+
addressed.push({
|
|
698
|
+
path: filePath,
|
|
699
|
+
addressed_at: commitInfo.committed_at,
|
|
700
|
+
commit_sha: commitInfo.commit_sha,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
else {
|
|
704
|
+
fresh.push(filePath);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (addressed.length === 0)
|
|
708
|
+
return null;
|
|
709
|
+
return {
|
|
710
|
+
addressed_files: addressed,
|
|
711
|
+
fresh_files: fresh,
|
|
712
|
+
staleness_ratio: Math.round((addressed.length / bundleFiles.length) * 100) / 100,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// ── Per-Task History & Metrics ──────────────────────────────────────────────
|
|
720
|
+
/**
|
|
721
|
+
* Load the JSONL task history file. Returns an empty array if missing or
|
|
722
|
+
* malformed (best-effort — task history is non-critical).
|
|
723
|
+
*/
|
|
724
|
+
function loadTaskHistory(historyPath) {
|
|
725
|
+
if (!existsSync(historyPath))
|
|
726
|
+
return [];
|
|
727
|
+
try {
|
|
728
|
+
const content = readFileSync(historyPath, 'utf-8').trim();
|
|
729
|
+
if (!content)
|
|
730
|
+
return [];
|
|
731
|
+
return content
|
|
732
|
+
.split('\n')
|
|
733
|
+
.filter(Boolean)
|
|
734
|
+
.map((line) => {
|
|
735
|
+
try {
|
|
736
|
+
return JSON.parse(line);
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
})
|
|
742
|
+
.filter((r) => r !== null);
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return [];
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* For each activation in the task history, compute the files changed between
|
|
750
|
+
* that activation's ref and the next activation's ref (or HEAD for the last one).
|
|
751
|
+
* Then compute recall/precision against that task's bundle.
|
|
752
|
+
*/
|
|
753
|
+
function computePerTaskMetrics(cwd, activations) {
|
|
754
|
+
if (activations.length === 0)
|
|
755
|
+
return [];
|
|
756
|
+
const metrics = [];
|
|
757
|
+
for (let i = 0; i < activations.length; i++) {
|
|
758
|
+
const activation = activations[i];
|
|
759
|
+
const nextActivation = activations[i + 1];
|
|
760
|
+
// Determine the end ref: next activation's ref or HEAD
|
|
761
|
+
const startRef = activation.activate_ref;
|
|
762
|
+
let endRef = 'HEAD';
|
|
763
|
+
if (nextActivation) {
|
|
764
|
+
endRef = nextActivation.activate_ref;
|
|
765
|
+
}
|
|
766
|
+
// Get files changed in this task's window
|
|
767
|
+
const filesChanged = getFilesChangedBetween(cwd, startRef, endRef);
|
|
768
|
+
// Also include uncommitted changes if this is the last task
|
|
769
|
+
if (!nextActivation) {
|
|
770
|
+
try {
|
|
771
|
+
const uncommitted = execSync('git diff --name-only HEAD', {
|
|
772
|
+
cwd,
|
|
773
|
+
encoding: 'utf-8',
|
|
774
|
+
timeout: 5000,
|
|
775
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
776
|
+
}).trim();
|
|
777
|
+
if (uncommitted) {
|
|
778
|
+
for (const f of uncommitted.split('\n')) {
|
|
779
|
+
if (!f.startsWith('.codeledger/') && !f.startsWith('.claude/'))
|
|
780
|
+
filesChanged.add(f);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
// non-fatal
|
|
786
|
+
}
|
|
787
|
+
try {
|
|
788
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
789
|
+
cwd,
|
|
790
|
+
encoding: 'utf-8',
|
|
791
|
+
timeout: 5000,
|
|
792
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
793
|
+
}).trim();
|
|
794
|
+
if (untracked) {
|
|
795
|
+
for (const f of untracked.split('\n')) {
|
|
796
|
+
if (!f.startsWith('.codeledger/') && !f.startsWith('.claude/'))
|
|
797
|
+
filesChanged.add(f);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
// non-fatal
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const changed = [...filesChanged];
|
|
806
|
+
const bundleSet = new Set(activation.bundle_files);
|
|
807
|
+
const overlapping = changed.filter((f) => bundleSet.has(f));
|
|
808
|
+
const recall = changed.length > 0 ? overlapping.length / changed.length : 0;
|
|
809
|
+
const precision = activation.bundle_files.length > 0
|
|
810
|
+
? overlapping.length / activation.bundle_files.length
|
|
811
|
+
: 0;
|
|
812
|
+
// Verdict based on F1 thresholds
|
|
813
|
+
const f1 = (precision + recall) > 0
|
|
814
|
+
? 2 * (precision * recall) / (precision + recall)
|
|
815
|
+
: 0;
|
|
816
|
+
let verdict;
|
|
817
|
+
if (changed.length === 0) {
|
|
818
|
+
verdict = 'MISS'; // No files changed — can't evaluate
|
|
819
|
+
}
|
|
820
|
+
else if (f1 >= 0.3) {
|
|
821
|
+
verdict = 'HIT';
|
|
822
|
+
}
|
|
823
|
+
else if (recall > 0) {
|
|
824
|
+
verdict = 'PARTIAL';
|
|
825
|
+
}
|
|
826
|
+
else {
|
|
827
|
+
verdict = 'MISS';
|
|
828
|
+
}
|
|
829
|
+
metrics.push({
|
|
830
|
+
task: activation.task,
|
|
831
|
+
bundle_id: activation.bundle_id,
|
|
832
|
+
bundle_file_count: activation.bundle_files.length,
|
|
833
|
+
bundle_tokens: activation.bundle_tokens,
|
|
834
|
+
files_changed: changed,
|
|
835
|
+
files_overlapping: overlapping,
|
|
836
|
+
recall,
|
|
837
|
+
precision,
|
|
838
|
+
verdict,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
return metrics;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Get files changed between two git refs. Returns a mutable set.
|
|
845
|
+
*/
|
|
846
|
+
function getFilesChangedBetween(cwd, startRef, endRef) {
|
|
847
|
+
const files = new Set();
|
|
848
|
+
try {
|
|
849
|
+
const diff = execSync(`git diff --name-only ${startRef}..${endRef}`, {
|
|
850
|
+
cwd,
|
|
851
|
+
encoding: 'utf-8',
|
|
852
|
+
timeout: 5000,
|
|
853
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
854
|
+
}).trim();
|
|
855
|
+
if (diff) {
|
|
856
|
+
for (const f of diff.split('\n')) {
|
|
857
|
+
if (!f.startsWith('.codeledger/') && !f.startsWith('.claude/'))
|
|
858
|
+
files.add(f);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
// non-fatal: ref may no longer be valid
|
|
864
|
+
}
|
|
865
|
+
return files;
|
|
866
|
+
}
|
|
867
|
+
function truncate(s, max) {
|
|
868
|
+
return s.length <= max ? s : s.slice(0, max - 3) + '...';
|
|
869
|
+
}
|
|
287
870
|
//# sourceMappingURL=session-summary.js.map
|