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