@magnusekdahl/parallix 1.2.0 → 1.3.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 (127) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +737 -838
  3. package/lib/agents/agents.ts +930 -0
  4. package/lib/agents/claude-telemetry.js +149 -154
  5. package/lib/agents/claude-telemetry.ts +233 -0
  6. package/lib/agents/claude.js +121 -131
  7. package/lib/agents/claude.ts +156 -0
  8. package/lib/agents/codex-telemetry.js +176 -164
  9. package/lib/agents/codex-telemetry.ts +205 -0
  10. package/lib/agents/codex.js +168 -201
  11. package/lib/agents/codex.ts +236 -0
  12. package/lib/agents/limit-hit.js +216 -226
  13. package/lib/agents/limit-hit.ts +262 -0
  14. package/lib/agents/mistral-telemetry.js +7 -10
  15. package/lib/agents/mistral-telemetry.ts +44 -0
  16. package/lib/agents/mistral.js +40 -58
  17. package/lib/agents/mistral.ts +84 -0
  18. package/lib/agents/opencode-export.js +126 -127
  19. package/lib/agents/opencode-export.ts +160 -0
  20. package/lib/agents/opencode-telemetry.js +282 -261
  21. package/lib/agents/opencode-telemetry.ts +350 -0
  22. package/lib/agents/opencode.js +263 -305
  23. package/lib/agents/opencode.ts +370 -0
  24. package/lib/agents/stage-telemetry.js +23 -24
  25. package/lib/agents/stage-telemetry.ts +47 -0
  26. package/lib/commands/active.js +502 -579
  27. package/lib/commands/active.ts +665 -0
  28. package/lib/commands/checkpoint.js +98 -74
  29. package/lib/commands/checkpoint.ts +79 -0
  30. package/lib/commands/config.js +61 -31
  31. package/lib/commands/config.ts +47 -0
  32. package/lib/commands/coverage-gate.js +271 -246
  33. package/lib/commands/coverage-gate.ts +362 -0
  34. package/lib/commands/diff.js +140 -119
  35. package/lib/commands/diff.ts +125 -0
  36. package/lib/commands/draft.js +770 -923
  37. package/lib/commands/draft.ts +1021 -0
  38. package/lib/commands/handoff.js +588 -608
  39. package/lib/commands/handoff.ts +694 -0
  40. package/lib/commands/integrate.js +1332 -1463
  41. package/lib/commands/integrate.ts +1688 -0
  42. package/lib/commands/mission-start.js +279 -237
  43. package/lib/commands/mission-start.ts +263 -0
  44. package/lib/commands/rebase.js +606 -582
  45. package/lib/commands/rebase.ts +635 -0
  46. package/lib/commands/repair-handoff.js +181 -179
  47. package/lib/commands/repair-handoff.ts +228 -0
  48. package/lib/commands/resolve-conflict.js +121 -102
  49. package/lib/commands/resolve-conflict.ts +112 -0
  50. package/lib/commands/review.js +3 -4
  51. package/lib/commands/review.ts +14 -0
  52. package/lib/commands/setup-review.js +4 -5
  53. package/lib/commands/setup-review.ts +14 -0
  54. package/lib/commands/setup.js +3 -3
  55. package/lib/commands/setup.ts +2 -0
  56. package/lib/commands/stats-backfill.js +394 -359
  57. package/lib/commands/stats-backfill.ts +418 -0
  58. package/lib/commands/stats.js +1424 -1665
  59. package/lib/commands/stats.ts +2186 -0
  60. package/lib/commands/status.js +219 -200
  61. package/lib/commands/status.ts +219 -0
  62. package/lib/commands/verify.js +3 -1
  63. package/lib/commands/verify.ts +2 -0
  64. package/lib/core/fmt.js +140 -211
  65. package/lib/core/fmt.ts +189 -0
  66. package/lib/core/git.js +121 -102
  67. package/lib/core/git.ts +146 -0
  68. package/lib/core/gitignore.js +59 -106
  69. package/lib/core/gitignore.ts +103 -0
  70. package/lib/core/mission-utils.js +833 -875
  71. package/lib/core/mission-utils.ts +998 -0
  72. package/lib/core/nels.js +112 -127
  73. package/lib/core/nels.ts +189 -0
  74. package/lib/core/persistent-data-migration.js +247 -205
  75. package/lib/core/persistent-data-migration.ts +255 -0
  76. package/lib/core/product-config.js +400 -476
  77. package/lib/core/product-config.ts +518 -0
  78. package/lib/core/runtime-matrix.js +41 -87
  79. package/lib/core/runtime-matrix.ts +84 -0
  80. package/lib/core/spawn-tee.js +152 -192
  81. package/lib/core/spawn-tee.ts +202 -0
  82. package/lib/core/state-map.js +92 -92
  83. package/lib/core/state-map.ts +115 -0
  84. package/lib/core/storage.js +101 -129
  85. package/lib/core/storage.ts +182 -0
  86. package/lib/core/subagent-limit.js +15 -16
  87. package/lib/core/subagent-limit.ts +25 -0
  88. package/lib/core/verification.js +148 -138
  89. package/lib/core/verification.ts +200 -0
  90. package/lib/index.js +142 -59
  91. package/lib/index.ts +134 -0
  92. package/lib/review/rebase.js +142 -145
  93. package/lib/review/rebase.ts +188 -0
  94. package/lib/review/review-adapter.js +99 -87
  95. package/lib/review/review-adapter.ts +198 -0
  96. package/lib/review/review-artifacts.js +486 -629
  97. package/lib/review/review-artifacts.ts +622 -0
  98. package/lib/review/review-commands.js +1220 -1373
  99. package/lib/review/review-commands.ts +1579 -0
  100. package/lib/review/review-events.js +544 -886
  101. package/lib/review/review-events.ts +954 -0
  102. package/lib/review/review-loop.js +945 -1020
  103. package/lib/review/review-loop.ts +1174 -0
  104. package/lib/review/review-polling.js +86 -115
  105. package/lib/review/review-polling.ts +194 -0
  106. package/lib/review/review-prompts.js +154 -189
  107. package/lib/review/review-prompts.ts +182 -0
  108. package/lib/review/review-state.js +246 -225
  109. package/lib/review/review-state.ts +353 -0
  110. package/lib/review/review.js +63 -84
  111. package/lib/review/review.ts +138 -0
  112. package/lib/tools/backlog.js +694 -680
  113. package/lib/tools/backlog.ts +835 -0
  114. package/lib/tools/forgejo.js +1270 -1405
  115. package/lib/tools/forgejo.ts +1793 -0
  116. package/lib/tools/gatekeeper.js +106 -96
  117. package/lib/tools/gatekeeper.ts +124 -0
  118. package/lib/tools/redgreen.js +200 -181
  119. package/lib/tools/redgreen.ts +221 -0
  120. package/lib/tools/sessions.js +58 -52
  121. package/lib/tools/sessions.ts +81 -0
  122. package/lib/tools/setup-review.js +902 -975
  123. package/lib/tools/setup-review.ts +1152 -0
  124. package/package.json +7 -5
  125. package/prompts/draft.md +1 -0
  126. package/prompts/portfolio.md +1 -1
  127. package/px.js +226 -210
package/lib/core/nels.js CHANGED
@@ -1,3 +1,4 @@
1
+ "use strict";
1
2
  /**
2
3
  * Net Engineering Lines (NEL) computation.
3
4
  *
@@ -14,32 +15,33 @@
14
15
  *
15
16
  * @module lib/core/nels
16
17
  */
17
-
18
- const { spawnSync } = require('child_process');
19
-
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.BUCKET_MEDIUM_MAX = exports.BUCKET_SMALL_MAX = exports.EXCLUSION_PATTERNS = void 0;
20
+ exports.classifyBucket = classifyBucket;
21
+ exports.isExcluded = isExcluded;
22
+ exports.computeNEL = computeNEL;
23
+ exports.computeNELRecord = computeNELRecord;
24
+ const node_child_process_1 = require("node:child_process");
20
25
  // ---------- exclusion globs (ADR 0047) ----------
21
26
  // Each entry is a minimatch-compatible pattern. Patterns are checked in order;
22
27
  // the first match wins.
23
-
24
- const EXCLUSION_PATTERNS = [
25
- 'missions/**', // workflow/process bookkeeping
26
- 'backlog/**', // workflow/process bookkeeping
27
- 'review-*', // workflow/process bookkeeping
28
- 'CP-*', // workflow/process bookkeeping
29
- '**/*.md', // documentation
30
- 'docs/**', // documentation
31
- 'package-lock.json', // generated/vendored
32
- 'coverage/**', // generated/vendored
33
- '*lock*', // lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml, Gemfile.lock, etc.)
34
- '*.lock', // additional lockfile safety net
28
+ exports.EXCLUSION_PATTERNS = [
29
+ 'missions/**', // workflow/process bookkeeping
30
+ 'backlog/**', // workflow/process bookkeeping
31
+ 'review-*', // workflow/process bookkeeping
32
+ 'CP-*', // workflow/process bookkeeping
33
+ '**/*.md', // documentation
34
+ 'docs/**', // documentation
35
+ 'package-lock.json', // generated/vendored
36
+ 'coverage/**', // generated/vendored
37
+ '*lock*', // lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml, Gemfile.lock, etc.)
38
+ '*.lock', // additional lockfile safety net
35
39
  ];
36
-
37
40
  /**
38
41
  * Bucket constants derived from ADR 0047 empirical terciles.
39
42
  */
40
- const BUCKET_SMALL_MAX = 80;
41
- const BUCKET_MEDIUM_MAX = 235;
42
-
43
+ exports.BUCKET_SMALL_MAX = 80;
44
+ exports.BUCKET_MEDIUM_MAX = 235;
43
45
  /**
44
46
  * Classify NEL count into a bucket label.
45
47
  *
@@ -47,15 +49,14 @@ const BUCKET_MEDIUM_MAX = 235;
47
49
  * @returns {{ label: 'Small' | 'Medium' | 'Large', min: number, max: number }}
48
50
  */
49
51
  function classifyBucket(nel) {
50
- if (nel <= BUCKET_SMALL_MAX) {
51
- return { label: 'Small', min: 0, max: BUCKET_SMALL_MAX };
52
- }
53
- if (nel <= BUCKET_MEDIUM_MAX) {
54
- return { label: 'Medium', min: BUCKET_SMALL_MAX + 1, max: BUCKET_MEDIUM_MAX };
55
- }
56
- return { label: 'Large', min: BUCKET_MEDIUM_MAX + 1, max: Infinity };
52
+ if (nel <= exports.BUCKET_SMALL_MAX) {
53
+ return { label: 'Small', min: 0, max: exports.BUCKET_SMALL_MAX };
54
+ }
55
+ if (nel <= exports.BUCKET_MEDIUM_MAX) {
56
+ return { label: 'Medium', min: exports.BUCKET_SMALL_MAX + 1, max: exports.BUCKET_MEDIUM_MAX };
57
+ }
58
+ return { label: 'Large', min: exports.BUCKET_MEDIUM_MAX + 1, max: Infinity };
57
59
  }
58
-
59
60
  /**
60
61
  * Check whether a file path matches any exclusion pattern.
61
62
  *
@@ -63,17 +64,15 @@ function classifyBucket(nel) {
63
64
  * @returns {boolean}
64
65
  */
65
66
  function isExcluded(filePath) {
66
- // Normalize to forward slashes
67
- const normalized = filePath.replace(/\\/g, '/');
68
-
69
- for (const pattern of EXCLUSION_PATTERNS) {
70
- if (patternMatches(normalized, pattern)) {
71
- return true;
67
+ // Normalize to forward slashes
68
+ const normalized = filePath.replace(/\\/g, '/');
69
+ for (const pattern of exports.EXCLUSION_PATTERNS) {
70
+ if (patternMatches(normalized, pattern)) {
71
+ return true;
72
+ }
72
73
  }
73
- }
74
- return false;
74
+ return false;
75
75
  }
76
-
77
76
  /**
78
77
  * Simple glob matcher supporting *, **, and literal segments.
79
78
  * Does not require a full minimatch dependency — keeps this module light.
@@ -83,45 +82,47 @@ function isExcluded(filePath) {
83
82
  * @returns {boolean}
84
83
  */
85
84
  function patternMatches(str, pattern) {
86
- // Convert minimatch-style glob to regex
87
- // Escape regex special chars except * and ?
88
- let regexStr = '';
89
- let i = 0;
90
- const pLen = pattern.length;
91
-
92
- while (i < pLen) {
93
- const ch = pattern[i];
94
- if (ch === '*') {
95
- if (i + 1 < pLen && pattern[i + 1] === '*') {
96
- // ** matches everything including /
97
- if (i + 2 < pLen && pattern[i + 2] === '/') {
98
- regexStr += '(?:.*/)?';
99
- i += 3;
100
- continue;
101
- } else {
102
- regexStr += '.*';
103
- i += 2;
104
- continue;
85
+ // Convert minimatch-style glob to regex
86
+ // Escape regex special chars except * and ?
87
+ let regexStr = '';
88
+ let i = 0;
89
+ const pLen = pattern.length;
90
+ while (i < pLen) {
91
+ const ch = pattern[i];
92
+ if (ch === '*') {
93
+ if (i + 1 < pLen && pattern[i + 1] === '*') {
94
+ // ** matches everything including /
95
+ if (i + 2 < pLen && pattern[i + 2] === '/') {
96
+ regexStr += '(?:.*/)?';
97
+ i += 3;
98
+ continue;
99
+ }
100
+ else {
101
+ regexStr += '.*';
102
+ i += 2;
103
+ continue;
104
+ }
105
+ }
106
+ else {
107
+ regexStr += '[^/]*';
108
+ i += 1;
109
+ continue;
110
+ }
111
+ }
112
+ else if (ch === '?') {
113
+ regexStr += '[^/]';
114
+ }
115
+ else if ('.+^${}()|[]\\'.includes(ch)) {
116
+ regexStr += '\\' + ch;
117
+ }
118
+ else {
119
+ regexStr += ch;
105
120
  }
106
- } else {
107
- regexStr += '[^/]*';
108
- i += 1;
109
- continue;
110
- }
111
- } else if (ch === '?') {
112
- regexStr += '[^/]';
113
- } else if ('.+^${}()|[]\\'.includes(ch)) {
114
- regexStr += '\\' + ch;
115
- } else {
116
- regexStr += ch;
121
+ i++;
117
122
  }
118
- i++;
119
- }
120
-
121
- const re = new RegExp('^' + regexStr + '$');
122
- return re.test(str);
123
+ const re = new RegExp('^' + regexStr + '$');
124
+ return re.test(str);
123
125
  }
124
-
125
126
  /**
126
127
  * Compute Net Engineering Lines (NEL) from a git diff range.
127
128
  *
@@ -133,49 +134,43 @@ function patternMatches(str, pattern) {
133
134
  * @returns {number} Total NEL count (insertions + deletions, whitespace-ignored)
134
135
  */
135
136
  function computeNEL(range, options = {}) {
136
- const cwd = options.cwd || process.cwd();
137
-
138
- const result = spawnSync('git', ['diff', '--numstat', '-w', range], {
139
- cwd,
140
- encoding: 'utf8',
141
- maxBuffer: 50 * 1024 * 1024, // 50 MB
142
- });
143
-
144
- if (result.status !== 0) {
145
- // Git error, empty diff, or uncomputable — return 0
146
- return 0;
147
- }
148
-
149
- const stdout = result.stdout || '';
150
- const lines = stdout.split('\n').filter(line => line.trim().length > 0);
151
-
152
- let total = 0;
153
-
154
- for (const line of lines) {
155
- // numstat format: <insertions>\t<deletions>\t<filename>
156
- // Binary files show: -\t-\t<filename>
157
- const parts = line.split('\t');
158
- if (parts.length < 3) { continue; }
159
-
160
- const adds = parts[0];
161
- const dels = parts[1];
162
- const filePath = parts.slice(2).join('\t');
163
-
164
- // Skip binary files
165
- if (adds === '-' || dels === '-') { continue; }
166
-
167
- // Apply exclusion globs
168
- if (isExcluded(filePath)) { continue; }
169
-
170
- const addsNum = parseInt(adds, 10) || 0;
171
- const delsNum = parseInt(dels, 10) || 0;
172
-
173
- total += addsNum + delsNum;
174
- }
175
-
176
- return total;
137
+ const cwd = options.cwd || process.cwd();
138
+ const result = (0, node_child_process_1.spawnSync)('git', ['diff', '--numstat', '-w', range], {
139
+ cwd,
140
+ encoding: 'utf8',
141
+ maxBuffer: 50 * 1024 * 1024, // 50 MB
142
+ });
143
+ if (result.status !== 0) {
144
+ // Git error, empty diff, or uncomputable — return 0
145
+ return 0;
146
+ }
147
+ const stdout = result.stdout || '';
148
+ const lines = stdout.split('\n').filter(line => line.trim().length > 0);
149
+ let total = 0;
150
+ for (const line of lines) {
151
+ // numstat format: <insertions>\t<deletions>\t<filename>
152
+ // Binary files show: -\t-\t<filename>
153
+ const parts = line.split('\t');
154
+ if (parts.length < 3) {
155
+ continue;
156
+ }
157
+ const adds = parts[0];
158
+ const dels = parts[1];
159
+ const filePath = parts.slice(2).join('\t');
160
+ // Skip binary files
161
+ if (adds === '-' || dels === '-') {
162
+ continue;
163
+ }
164
+ // Apply exclusion globs
165
+ if (isExcluded(filePath)) {
166
+ continue;
167
+ }
168
+ const addsNum = parseInt(adds, 10) || 0;
169
+ const delsNum = parseInt(dels, 10) || 0;
170
+ total += addsNum + delsNum;
171
+ }
172
+ return total;
177
173
  }
178
-
179
174
  /**
180
175
  * Compute NEL and return the full NEL record including bucket classification.
181
176
  *
@@ -183,17 +178,7 @@ function computeNEL(range, options = {}) {
183
178
  * @param {{ cwd?: string }} [options]
184
179
  * @returns {{ nel: number, bucket: { label: string, min: number, max: number } }}
185
180
  */
186
- function computeNELRecord(range, options) {
187
- const nel = computeNEL(range, options);
188
- return { nel, bucket: classifyBucket(nel) };
181
+ function computeNELRecord(range, options = {}) {
182
+ const nel = computeNEL(range, options);
183
+ return { nel, bucket: classifyBucket(nel) };
189
184
  }
190
-
191
- module.exports = {
192
- computeNEL,
193
- computeNELRecord,
194
- classifyBucket,
195
- isExcluded,
196
- EXCLUSION_PATTERNS,
197
- BUCKET_SMALL_MAX,
198
- BUCKET_MEDIUM_MAX,
199
- };
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Net Engineering Lines (NEL) computation.
3
+ *
4
+ * Computes NEL from `git diff --numstat -w` for a given diff range,
5
+ * excluding workflow/process bookkeeping, documentation, and generated files
6
+ * per ADR 0047.
7
+ *
8
+ * Bucket thresholds (empirical terciles from task-1355 data, n=29):
9
+ * - Small: 0–80 NEL (11% rework rate)
10
+ * - Medium: 81–235 NEL (22% rework rate)
11
+ * - Large: 235+ NEL (73% rework rate)
12
+ *
13
+ * This module is purely observational — no enforcement, gates, or blocks.
14
+ *
15
+ * @module lib/core/nels
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+
20
+ // ---------- exclusion globs (ADR 0047) ----------
21
+ // Each entry is a minimatch-compatible pattern. Patterns are checked in order;
22
+ // the first match wins.
23
+
24
+ export const EXCLUSION_PATTERNS = [
25
+ 'missions/**', // workflow/process bookkeeping
26
+ 'backlog/**', // workflow/process bookkeeping
27
+ 'review-*', // workflow/process bookkeeping
28
+ 'CP-*', // workflow/process bookkeeping
29
+ '**/*.md', // documentation
30
+ 'docs/**', // documentation
31
+ 'package-lock.json', // generated/vendored
32
+ 'coverage/**', // generated/vendored
33
+ '*lock*', // lockfiles (package-lock.json, yarn.lock, pnpm-lock.yaml, Gemfile.lock, etc.)
34
+ '*.lock', // additional lockfile safety net
35
+ ];
36
+
37
+ /**
38
+ * Bucket constants derived from ADR 0047 empirical terciles.
39
+ */
40
+ export const BUCKET_SMALL_MAX = 80;
41
+ export const BUCKET_MEDIUM_MAX = 235;
42
+
43
+ /**
44
+ * Classify NEL count into a bucket label.
45
+ *
46
+ * @param {number} nel
47
+ * @returns {{ label: 'Small' | 'Medium' | 'Large', min: number, max: number }}
48
+ */
49
+ export function classifyBucket(nel: number) {
50
+ if (nel <= BUCKET_SMALL_MAX) {
51
+ return { label: 'Small', min: 0, max: BUCKET_SMALL_MAX };
52
+ }
53
+ if (nel <= BUCKET_MEDIUM_MAX) {
54
+ return { label: 'Medium', min: BUCKET_SMALL_MAX + 1, max: BUCKET_MEDIUM_MAX };
55
+ }
56
+ return { label: 'Large', min: BUCKET_MEDIUM_MAX + 1, max: Infinity };
57
+ }
58
+
59
+ /**
60
+ * Check whether a file path matches any exclusion pattern.
61
+ *
62
+ * @param {string} filePath - Relative file path from repo root
63
+ * @returns {boolean}
64
+ */
65
+ export function isExcluded(filePath: string) {
66
+ // Normalize to forward slashes
67
+ const normalized = filePath.replace(/\\/g, '/');
68
+
69
+ for (const pattern of EXCLUSION_PATTERNS) {
70
+ if (patternMatches(normalized, pattern)) {
71
+ return true;
72
+ }
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Simple glob matcher supporting *, **, and literal segments.
79
+ * Does not require a full minimatch dependency — keeps this module light.
80
+ *
81
+ * @param {string} str
82
+ * @param {string} pattern
83
+ * @returns {boolean}
84
+ */
85
+ function patternMatches(str: string, pattern: string) {
86
+ // Convert minimatch-style glob to regex
87
+ // Escape regex special chars except * and ?
88
+ let regexStr = '';
89
+ let i = 0;
90
+ const pLen = pattern.length;
91
+
92
+ while (i < pLen) {
93
+ const ch = pattern[i];
94
+ if (ch === '*') {
95
+ if (i + 1 < pLen && pattern[i + 1] === '*') {
96
+ // ** matches everything including /
97
+ if (i + 2 < pLen && pattern[i + 2] === '/') {
98
+ regexStr += '(?:.*/)?';
99
+ i += 3;
100
+ continue;
101
+ } else {
102
+ regexStr += '.*';
103
+ i += 2;
104
+ continue;
105
+ }
106
+ } else {
107
+ regexStr += '[^/]*';
108
+ i += 1;
109
+ continue;
110
+ }
111
+ } else if (ch === '?') {
112
+ regexStr += '[^/]';
113
+ } else if ('.+^${}()|[]\\'.includes(ch)) {
114
+ regexStr += '\\' + ch;
115
+ } else {
116
+ regexStr += ch;
117
+ }
118
+ i++;
119
+ }
120
+
121
+ const re = new RegExp('^' + regexStr + '$');
122
+ return re.test(str);
123
+ }
124
+
125
+ /**
126
+ * Compute Net Engineering Lines (NEL) from a git diff range.
127
+ *
128
+ * Runs `git diff --numstat -w <range>` and sums insertions+deletions
129
+ * for files not matching exclusion globs from ADR 0047.
130
+ *
131
+ * @param {string} range - Git diff range, e.g. `main..HEAD` or `HEAD~1..HEAD`
132
+ * @param {{ cwd?: string }} [options]
133
+ * @returns {number} Total NEL count (insertions + deletions, whitespace-ignored)
134
+ */
135
+ export function computeNEL(range: string, options: { cwd?: string } = {}) {
136
+ const cwd = options.cwd || process.cwd();
137
+
138
+ const result = spawnSync('git', ['diff', '--numstat', '-w', range], {
139
+ cwd,
140
+ encoding: 'utf8',
141
+ maxBuffer: 50 * 1024 * 1024, // 50 MB
142
+ });
143
+
144
+ if (result.status !== 0) {
145
+ // Git error, empty diff, or uncomputable — return 0
146
+ return 0;
147
+ }
148
+
149
+ const stdout = result.stdout || '';
150
+ const lines = stdout.split('\n').filter(line => line.trim().length > 0);
151
+
152
+ let total = 0;
153
+
154
+ for (const line of lines) {
155
+ // numstat format: <insertions>\t<deletions>\t<filename>
156
+ // Binary files show: -\t-\t<filename>
157
+ const parts = line.split('\t');
158
+ if (parts.length < 3) { continue; }
159
+
160
+ const adds = parts[0];
161
+ const dels = parts[1];
162
+ const filePath = parts.slice(2).join('\t');
163
+
164
+ // Skip binary files
165
+ if (adds === '-' || dels === '-') { continue; }
166
+
167
+ // Apply exclusion globs
168
+ if (isExcluded(filePath)) { continue; }
169
+
170
+ const addsNum = parseInt(adds, 10) || 0;
171
+ const delsNum = parseInt(dels, 10) || 0;
172
+
173
+ total += addsNum + delsNum;
174
+ }
175
+
176
+ return total;
177
+ }
178
+
179
+ /**
180
+ * Compute NEL and return the full NEL record including bucket classification.
181
+ *
182
+ * @param {string} range - Git diff range
183
+ * @param {{ cwd?: string }} [options]
184
+ * @returns {{ nel: number, bucket: { label: string, min: number, max: number } }}
185
+ */
186
+ export function computeNELRecord(range: string, options: { cwd?: string } = {}) {
187
+ const nel = computeNEL(range, options);
188
+ return { nel, bucket: classifyBucket(nel) };
189
+ }