@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.
Files changed (66) hide show
  1. package/dist/commands/activate.d.ts.map +1 -1
  2. package/dist/commands/activate.js +98 -3
  3. package/dist/commands/activate.js.map +1 -1
  4. package/dist/commands/checkpoint.d.ts +26 -0
  5. package/dist/commands/checkpoint.d.ts.map +1 -0
  6. package/dist/commands/checkpoint.js +382 -0
  7. package/dist/commands/checkpoint.js.map +1 -0
  8. package/dist/commands/compare.d.ts.map +1 -1
  9. package/dist/commands/compare.js +29 -0
  10. package/dist/commands/compare.js.map +1 -1
  11. package/dist/commands/cowork-refresh.d.ts +8 -0
  12. package/dist/commands/cowork-refresh.d.ts.map +1 -0
  13. package/dist/commands/cowork-refresh.js +50 -0
  14. package/dist/commands/cowork-refresh.js.map +1 -0
  15. package/dist/commands/cowork-snapshot.d.ts +8 -0
  16. package/dist/commands/cowork-snapshot.d.ts.map +1 -0
  17. package/dist/commands/cowork-snapshot.js +34 -0
  18. package/dist/commands/cowork-snapshot.js.map +1 -0
  19. package/dist/commands/cowork-start.d.ts +8 -0
  20. package/dist/commands/cowork-start.d.ts.map +1 -0
  21. package/dist/commands/cowork-start.js +71 -0
  22. package/dist/commands/cowork-start.js.map +1 -0
  23. package/dist/commands/cowork-stop.d.ts +7 -0
  24. package/dist/commands/cowork-stop.d.ts.map +1 -0
  25. package/dist/commands/cowork-stop.js +43 -0
  26. package/dist/commands/cowork-stop.js.map +1 -0
  27. package/dist/commands/doctor.d.ts +9 -0
  28. package/dist/commands/doctor.d.ts.map +1 -0
  29. package/dist/commands/doctor.js +169 -0
  30. package/dist/commands/doctor.js.map +1 -0
  31. package/dist/commands/init.d.ts.map +1 -1
  32. package/dist/commands/init.js +36 -0
  33. package/dist/commands/init.js.map +1 -1
  34. package/dist/commands/intent.d.ts +37 -0
  35. package/dist/commands/intent.d.ts.map +1 -0
  36. package/dist/commands/intent.js +393 -0
  37. package/dist/commands/intent.js.map +1 -0
  38. package/dist/commands/refine.d.ts.map +1 -1
  39. package/dist/commands/refine.js +16 -0
  40. package/dist/commands/refine.js.map +1 -1
  41. package/dist/commands/run.d.ts.map +1 -1
  42. package/dist/commands/run.js +21 -0
  43. package/dist/commands/run.js.map +1 -1
  44. package/dist/commands/session-cleanup.js +1 -0
  45. package/dist/commands/session-cleanup.js.map +1 -1
  46. package/dist/commands/session-progress.d.ts.map +1 -1
  47. package/dist/commands/session-progress.js +29 -1
  48. package/dist/commands/session-progress.js.map +1 -1
  49. package/dist/commands/session-summary.d.ts.map +1 -1
  50. package/dist/commands/session-summary.js +585 -36
  51. package/dist/commands/session-summary.js.map +1 -1
  52. package/dist/commands/shared-summary.d.ts +15 -0
  53. package/dist/commands/shared-summary.d.ts.map +1 -0
  54. package/dist/commands/shared-summary.js +194 -0
  55. package/dist/commands/shared-summary.js.map +1 -0
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +115 -2
  58. package/dist/index.js.map +1 -1
  59. package/dist/session-paths.d.ts +2 -0
  60. package/dist/session-paths.d.ts.map +1 -1
  61. package/dist/session-paths.js +4 -0
  62. package/dist/session-paths.js.map +1 -1
  63. package/dist/templates/config.d.ts.map +1 -1
  64. package/dist/templates/config.js +45 -10
  65. package/dist/templates/config.js.map +1 -1
  66. 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
- // No CodeLedger setup — exit silently (don't pollute agent output)
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
- // ── Print concise recap ─────────────────────────────────────────────────
74
- printRecap(summary);
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 (`.session-start-ref` differs from
213
- * `.activate-ref`), print a session-wide aggregate line so the developer sees
214
- * the full picture at session end.
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
- // Both refs must exist and differ to indicate multiple tasks were run
220
- if (!existsSync(sessionRefPath) || !existsSync(activateRefPath))
497
+ if (!existsSync(sessionRefPath))
221
498
  return;
222
499
  const sessionRef = readFileSync(sessionRefPath, 'utf-8').trim();
223
- const activateRef = readFileSync(activateRefPath, 'utf-8').trim();
224
- if (!sessionRef || !activateRef || sessionRef === activateRef)
500
+ if (!sessionRef)
225
501
  return;
226
- // Gather all files changed since session start
227
- const allChanged = new Set();
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 committed = execSync(`git diff --name-only ${sessionRef}..HEAD`, {
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
- if (committed) {
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
- return;
517
+ // non-fatal
242
518
  }
243
- // Include uncommitted work
519
+ // -- Gather all files changed across the entire session -------------------
520
+ const allChanged = new Set();
244
521
  try {
245
- const uncommitted = execSync('git diff --name-only HEAD', {
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 (uncommitted) {
252
- for (const f of uncommitted.split('\n'))
253
- allChanged.add(f);
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 count = execSync(`git rev-list --count ${sessionRef}..HEAD`, {
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
- commitCount = parseInt(count, 10) || 0;
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 Totals (all tasks) --------------------------------');
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