@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.
Files changed (137) hide show
  1. package/dist/artifacts/summary.d.ts +6 -0
  2. package/dist/artifacts/summary.d.ts.map +1 -0
  3. package/dist/artifacts/summary.js +49 -0
  4. package/dist/artifacts/summary.js.map +1 -0
  5. package/dist/commands/activate.d.ts.map +1 -1
  6. package/dist/commands/activate.js +155 -19
  7. package/dist/commands/activate.js.map +1 -1
  8. package/dist/commands/bundle.d.ts.map +1 -1
  9. package/dist/commands/bundle.js +28 -5
  10. package/dist/commands/bundle.js.map +1 -1
  11. package/dist/commands/checkpoint.d.ts +26 -0
  12. package/dist/commands/checkpoint.d.ts.map +1 -0
  13. package/dist/commands/checkpoint.js +382 -0
  14. package/dist/commands/checkpoint.js.map +1 -0
  15. package/dist/commands/cowork-snapshot.d.ts.map +1 -1
  16. package/dist/commands/cowork-snapshot.js +3 -2
  17. package/dist/commands/cowork-snapshot.js.map +1 -1
  18. package/dist/commands/doctor.d.ts +9 -0
  19. package/dist/commands/doctor.d.ts.map +1 -0
  20. package/dist/commands/doctor.js +169 -0
  21. package/dist/commands/doctor.js.map +1 -0
  22. package/dist/commands/init.d.ts.map +1 -1
  23. package/dist/commands/init.js +46 -12
  24. package/dist/commands/init.js.map +1 -1
  25. package/dist/commands/intent.d.ts +37 -0
  26. package/dist/commands/intent.d.ts.map +1 -0
  27. package/dist/commands/intent.js +408 -0
  28. package/dist/commands/intent.js.map +1 -0
  29. package/dist/commands/manifest.d.ts +8 -0
  30. package/dist/commands/manifest.d.ts.map +1 -0
  31. package/dist/commands/manifest.js +144 -0
  32. package/dist/commands/manifest.js.map +1 -0
  33. package/dist/commands/policy.d.ts +8 -0
  34. package/dist/commands/policy.d.ts.map +1 -0
  35. package/dist/commands/policy.js +27 -0
  36. package/dist/commands/policy.js.map +1 -0
  37. package/dist/commands/refine.d.ts.map +1 -1
  38. package/dist/commands/refine.js +16 -0
  39. package/dist/commands/refine.js.map +1 -1
  40. package/dist/commands/review-coverage.d.ts +12 -0
  41. package/dist/commands/review-coverage.d.ts.map +1 -0
  42. package/dist/commands/review-coverage.js +142 -0
  43. package/dist/commands/review-coverage.js.map +1 -0
  44. package/dist/commands/review-gate.d.ts +12 -0
  45. package/dist/commands/review-gate.d.ts.map +1 -0
  46. package/dist/commands/review-gate.js +130 -0
  47. package/dist/commands/review-gate.js.map +1 -0
  48. package/dist/commands/session-cleanup.js +1 -0
  49. package/dist/commands/session-cleanup.js.map +1 -1
  50. package/dist/commands/session-progress.d.ts.map +1 -1
  51. package/dist/commands/session-progress.js +29 -1
  52. package/dist/commands/session-progress.js.map +1 -1
  53. package/dist/commands/session-summary.d.ts.map +1 -1
  54. package/dist/commands/session-summary.js +620 -37
  55. package/dist/commands/session-summary.js.map +1 -1
  56. package/dist/commands/setup-ci.d.ts +9 -0
  57. package/dist/commands/setup-ci.d.ts.map +1 -0
  58. package/dist/commands/setup-ci.js +139 -0
  59. package/dist/commands/setup-ci.js.map +1 -0
  60. package/dist/commands/shared-summary.d.ts +15 -0
  61. package/dist/commands/shared-summary.d.ts.map +1 -0
  62. package/dist/commands/shared-summary.js +194 -0
  63. package/dist/commands/shared-summary.js.map +1 -0
  64. package/dist/commands/sign-manifest.d.ts +8 -0
  65. package/dist/commands/sign-manifest.d.ts.map +1 -0
  66. package/dist/commands/sign-manifest.js +58 -0
  67. package/dist/commands/sign-manifest.js.map +1 -0
  68. package/dist/commands/verify.d.ts +13 -0
  69. package/dist/commands/verify.d.ts.map +1 -0
  70. package/dist/commands/verify.js +288 -0
  71. package/dist/commands/verify.js.map +1 -0
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +153 -2
  74. package/dist/index.js.map +1 -1
  75. package/dist/integrations/github-actions.d.ts +17 -0
  76. package/dist/integrations/github-actions.d.ts.map +1 -0
  77. package/dist/integrations/github-actions.js +64 -0
  78. package/dist/integrations/github-actions.js.map +1 -0
  79. package/dist/manifest/build.d.ts +19 -0
  80. package/dist/manifest/build.d.ts.map +1 -0
  81. package/dist/manifest/build.js +82 -0
  82. package/dist/manifest/build.js.map +1 -0
  83. package/dist/manifest/schema.d.ts +2 -0
  84. package/dist/manifest/schema.d.ts.map +1 -0
  85. package/dist/manifest/schema.js +2 -0
  86. package/dist/manifest/schema.js.map +1 -0
  87. package/dist/manifest/write.d.ts +13 -0
  88. package/dist/manifest/write.d.ts.map +1 -0
  89. package/dist/manifest/write.js +69 -0
  90. package/dist/manifest/write.js.map +1 -0
  91. package/dist/policy/load.d.ts +21 -0
  92. package/dist/policy/load.d.ts.map +1 -0
  93. package/dist/policy/load.js +63 -0
  94. package/dist/policy/load.js.map +1 -0
  95. package/dist/policy/resolve.d.ts +18 -0
  96. package/dist/policy/resolve.d.ts.map +1 -0
  97. package/dist/policy/resolve.js +86 -0
  98. package/dist/policy/resolve.js.map +1 -0
  99. package/dist/policy/schema.d.ts +22 -0
  100. package/dist/policy/schema.d.ts.map +1 -0
  101. package/dist/policy/schema.js +82 -0
  102. package/dist/policy/schema.js.map +1 -0
  103. package/dist/session-paths.d.ts +8 -0
  104. package/dist/session-paths.d.ts.map +1 -1
  105. package/dist/session-paths.js +16 -0
  106. package/dist/session-paths.js.map +1 -1
  107. package/dist/signing/canonicalize.d.ts +17 -0
  108. package/dist/signing/canonicalize.d.ts.map +1 -0
  109. package/dist/signing/canonicalize.js +50 -0
  110. package/dist/signing/canonicalize.js.map +1 -0
  111. package/dist/signing/hmac.d.ts +8 -0
  112. package/dist/signing/hmac.d.ts.map +1 -0
  113. package/dist/signing/hmac.js +16 -0
  114. package/dist/signing/hmac.js.map +1 -0
  115. package/dist/signing/signer.d.ts +16 -0
  116. package/dist/signing/signer.d.ts.map +1 -0
  117. package/dist/signing/signer.js +2 -0
  118. package/dist/signing/signer.js.map +1 -0
  119. package/dist/templates/claude-md.d.ts.map +1 -1
  120. package/dist/templates/claude-md.js +9 -2
  121. package/dist/templates/claude-md.js.map +1 -1
  122. package/dist/templates/config.d.ts.map +1 -1
  123. package/dist/templates/config.js +48 -10
  124. package/dist/templates/config.js.map +1 -1
  125. package/dist/verify/evaluate.d.ts +10 -0
  126. package/dist/verify/evaluate.d.ts.map +1 -0
  127. package/dist/verify/evaluate.js +117 -0
  128. package/dist/verify/evaluate.js.map +1 -0
  129. package/dist/verify/policy-snapshot.d.ts +7 -0
  130. package/dist/verify/policy-snapshot.d.ts.map +1 -0
  131. package/dist/verify/policy-snapshot.js +36 -0
  132. package/dist/verify/policy-snapshot.js.map +1 -0
  133. package/dist/verify/report.d.ts +11 -0
  134. package/dist/verify/report.d.ts.map +1 -0
  135. package/dist/verify/report.js +64 -0
  136. package/dist/verify/report.js.map +1 -0
  137. 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
- // No CodeLedger setup — exit silently (don't pollute agent output)
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
- // ── Print concise recap ─────────────────────────────────────────────────
74
- printRecap(summary);
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 (`.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.
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
- // Both refs must exist and differ to indicate multiple tasks were run
220
- if (!existsSync(sessionRefPath) || !existsSync(activateRefPath))
531
+ if (!existsSync(sessionRefPath))
221
532
  return;
222
533
  const sessionRef = readFileSync(sessionRefPath, 'utf-8').trim();
223
- const activateRef = readFileSync(activateRefPath, 'utf-8').trim();
224
- if (!sessionRef || !activateRef || sessionRef === activateRef)
534
+ if (!sessionRef)
225
535
  return;
226
- // Gather all files changed since session start
227
- const allChanged = new Set();
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 committed = execSync(`git diff --name-only ${sessionRef}..HEAD`, {
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
- if (committed) {
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
- return;
551
+ // non-fatal
242
552
  }
243
- // Include uncommitted work
553
+ // -- Gather all files changed across the entire session -------------------
554
+ const allChanged = new Set();
244
555
  try {
245
- const uncommitted = execSync('git diff --name-only HEAD', {
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 (uncommitted) {
252
- for (const f of uncommitted.split('\n'))
253
- allChanged.add(f);
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 count = execSync(`git rev-list --count ${sessionRef}..HEAD`, {
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
- commitCount = parseInt(count, 10) || 0;
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 Totals (all tasks) --------------------------------');
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