@navels/neal 0.1.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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +527 -0
  3. package/SECURITY.md +91 -0
  4. package/config.yml +104 -0
  5. package/dist/neal/activity-footer.js +177 -0
  6. package/dist/neal/activity-reporting.js +1 -0
  7. package/dist/neal/adjudicator/artifacts.js +58 -0
  8. package/dist/neal/adjudicator/blocked-adjudicator.js +223 -0
  9. package/dist/neal/adjudicator/contracts.js +139 -0
  10. package/dist/neal/adjudicator/execute.js +611 -0
  11. package/dist/neal/adjudicator/final-completion.js +104 -0
  12. package/dist/neal/adjudicator/planning.js +145 -0
  13. package/dist/neal/adjudicator/specs.js +453 -0
  14. package/dist/neal/agents/prompts.js +120 -0
  15. package/dist/neal/agents/rounds.js +706 -0
  16. package/dist/neal/agents/schemas.js +832 -0
  17. package/dist/neal/agents/structured-coder.js +82 -0
  18. package/dist/neal/agents/structured-json.js +528 -0
  19. package/dist/neal/agents.js +4 -0
  20. package/dist/neal/atomic-write.js +18 -0
  21. package/dist/neal/blocked-guidance.js +406 -0
  22. package/dist/neal/cli.js +471 -0
  23. package/dist/neal/commands/check.js +401 -0
  24. package/dist/neal/commands/compat.js +807 -0
  25. package/dist/neal/commands/interactive-activity.js +57 -0
  26. package/dist/neal/commands/new-run.js +79 -0
  27. package/dist/neal/commands/plan-and-execute.js +44 -0
  28. package/dist/neal/commands/recovery-guidance.js +217 -0
  29. package/dist/neal/commands/resume-run.js +395 -0
  30. package/dist/neal/commands/review.js +21 -0
  31. package/dist/neal/commands/runtime.js +557 -0
  32. package/dist/neal/commands/setup.js +596 -0
  33. package/dist/neal/commands/squash.js +113 -0
  34. package/dist/neal/commands/status.js +33 -0
  35. package/dist/neal/commands/writer-exit-codes.js +42 -0
  36. package/dist/neal/commit-message.js +17 -0
  37. package/dist/neal/config.js +432 -0
  38. package/dist/neal/context/artifacts.js +140 -0
  39. package/dist/neal/context/context.js +324 -0
  40. package/dist/neal/context/inline-review-context.js +131 -0
  41. package/dist/neal/context/reviewer-context.js +166 -0
  42. package/dist/neal/context/shared.js +117 -0
  43. package/dist/neal/context/types.js +1 -0
  44. package/dist/neal/diagnostic.js +208 -0
  45. package/dist/neal/execute-finalization.js +5 -0
  46. package/dist/neal/final-completion-review.js +188 -0
  47. package/dist/neal/final-completion.js +229 -0
  48. package/dist/neal/git.js +339 -0
  49. package/dist/neal/index.js +135 -0
  50. package/dist/neal/interactive-controls.js +85 -0
  51. package/dist/neal/logger.js +102 -0
  52. package/dist/neal/manual-gates.js +121 -0
  53. package/dist/neal/orchestrator/artifacts.js +70 -0
  54. package/dist/neal/orchestrator/completion.js +531 -0
  55. package/dist/neal/orchestrator/failures.js +31 -0
  56. package/dist/neal/orchestrator/notifications.js +175 -0
  57. package/dist/neal/orchestrator/phases/coder.js +516 -0
  58. package/dist/neal/orchestrator/phases/planning.js +540 -0
  59. package/dist/neal/orchestrator/phases/recovery.js +798 -0
  60. package/dist/neal/orchestrator/phases/review.js +136 -0
  61. package/dist/neal/orchestrator/phases/shared.js +279 -0
  62. package/dist/neal/orchestrator/run-loop.js +113 -0
  63. package/dist/neal/orchestrator/split-plan.js +235 -0
  64. package/dist/neal/orchestrator/transitions.js +309 -0
  65. package/dist/neal/orchestrator.js +215 -0
  66. package/dist/neal/phase-display.js +27 -0
  67. package/dist/neal/plan-doc.js +154 -0
  68. package/dist/neal/plan-queue.js +1092 -0
  69. package/dist/neal/plan-refinement.js +39 -0
  70. package/dist/neal/plan-validation.js +525 -0
  71. package/dist/neal/progress.js +237 -0
  72. package/dist/neal/prompts/assert-builder.js +13 -0
  73. package/dist/neal/prompts/execute.js +290 -0
  74. package/dist/neal/prompts/guidance.js +70 -0
  75. package/dist/neal/prompts/planning.js +313 -0
  76. package/dist/neal/prompts/review-doctrine.js +142 -0
  77. package/dist/neal/prompts/shared.js +101 -0
  78. package/dist/neal/prompts/specialized.js +212 -0
  79. package/dist/neal/prompts/specs.js +572 -0
  80. package/dist/neal/providers/anthropic-claude.js +1599 -0
  81. package/dist/neal/providers/detection.js +139 -0
  82. package/dist/neal/providers/generic-agentic-tools.js +586 -0
  83. package/dist/neal/providers/generic-agentic.js +1238 -0
  84. package/dist/neal/providers/liveness.js +151 -0
  85. package/dist/neal/providers/openai-codex.js +1014 -0
  86. package/dist/neal/providers/openai-compatible.js +654 -0
  87. package/dist/neal/providers/registry.js +389 -0
  88. package/dist/neal/providers/telemetry.js +208 -0
  89. package/dist/neal/providers/types.js +21 -0
  90. package/dist/neal/recovery-artifacts.js +50 -0
  91. package/dist/neal/resume-decision.js +220 -0
  92. package/dist/neal/resume-planner.js +265 -0
  93. package/dist/neal/retrospective.js +391 -0
  94. package/dist/neal/review-debt.js +18 -0
  95. package/dist/neal/review-findings/artifacts.js +173 -0
  96. package/dist/neal/review-findings/prompts.js +172 -0
  97. package/dist/neal/review-findings/provider.js +330 -0
  98. package/dist/neal/review-findings/run.js +373 -0
  99. package/dist/neal/review-findings/types.js +1 -0
  100. package/dist/neal/review-mode.js +67 -0
  101. package/dist/neal/review.js +137 -0
  102. package/dist/neal/run-lock.js +334 -0
  103. package/dist/neal/run-metrics.js +355 -0
  104. package/dist/neal/run-narrative-types.js +1 -0
  105. package/dist/neal/run-narrative.js +1374 -0
  106. package/dist/neal/run-registry.js +218 -0
  107. package/dist/neal/run-status.js +25 -0
  108. package/dist/neal/scopes.js +451 -0
  109. package/dist/neal/sensitive-text.js +8 -0
  110. package/dist/neal/squash-message.js +379 -0
  111. package/dist/neal/squash.js +591 -0
  112. package/dist/neal/state-invariants.js +496 -0
  113. package/dist/neal/state-views.js +344 -0
  114. package/dist/neal/state.js +887 -0
  115. package/dist/neal/status-footer.js +258 -0
  116. package/dist/neal/status.js +1260 -0
  117. package/dist/neal/storage-paths.js +57 -0
  118. package/dist/neal/support.js +58 -0
  119. package/dist/neal/terminal-narrator.js +435 -0
  120. package/dist/neal/types.js +1 -0
  121. package/dist/neal/verification-events.js +81 -0
  122. package/dist/neal/version.js +37 -0
  123. package/dist/neal/worktree-status.js +137 -0
  124. package/dist/notifier.js +44 -0
  125. package/docs/ADJUDICATOR_INVENTORY.md +310 -0
  126. package/docs/PROMPT_SPECS.md +266 -0
  127. package/docs/README.md +22 -0
  128. package/docs/architecture.md +113 -0
  129. package/docs/assets/neal-execution-flow.png +0 -0
  130. package/docs/automation.md +65 -0
  131. package/docs/comparison.md +105 -0
  132. package/docs/compat.md +269 -0
  133. package/docs/compatible-models.md +135 -0
  134. package/docs/demo.md +55 -0
  135. package/docs/maintenance.md +64 -0
  136. package/docs/plan-format.md +213 -0
  137. package/docs/providers.md +751 -0
  138. package/docs/release.md +147 -0
  139. package/docs/state-machine.md +266 -0
  140. package/docs/storage.md +207 -0
  141. package/docs/troubleshooting.md +152 -0
  142. package/examples/compat/add-edit-verify/PLAN.md +29 -0
  143. package/examples/compat/add-edit-verify/broken.diff +8 -0
  144. package/examples/compat/add-edit-verify/good.diff +8 -0
  145. package/examples/compat/add-edit-verify/package.json +5 -0
  146. package/examples/compat/add-edit-verify/src/add.js +2 -0
  147. package/examples/compat/add-edit-verify/test/add.test.js +9 -0
  148. package/examples/compat/is-even-add-test/PLAN.md +30 -0
  149. package/examples/compat/is-even-add-test/broken.diff +11 -0
  150. package/examples/compat/is-even-add-test/good.diff +11 -0
  151. package/examples/compat/is-even-add-test/package.json +5 -0
  152. package/examples/compat/is-even-add-test/src/is-even.js +3 -0
  153. package/examples/compat/is-even-add-test/test/is-even.test.js +9 -0
  154. package/examples/compat/manifest.json +60 -0
  155. package/examples/compat/plan-greeting/ISSUE.md +25 -0
  156. package/examples/compat/plan-greeting/package.json +5 -0
  157. package/examples/compat/plan-greeting/src/greet.js +2 -0
  158. package/examples/compat/plan-greeting/test/greet.test.js +8 -0
  159. package/examples/compat/reverse-grep-edit/PLAN.md +32 -0
  160. package/examples/compat/reverse-grep-edit/broken.diff +12 -0
  161. package/examples/compat/reverse-grep-edit/good.diff +12 -0
  162. package/examples/compat/reverse-grep-edit/package.json +5 -0
  163. package/examples/compat/reverse-grep-edit/src/strings.js +10 -0
  164. package/examples/compat/reverse-grep-edit/test/strings.test.js +15 -0
  165. package/examples/issue-triage-js/PLAN.md +83 -0
  166. package/examples/issue-triage-js/README.md +76 -0
  167. package/examples/issue-triage-js/package.json +9 -0
  168. package/examples/issue-triage-js/src/issue-triage.js +87 -0
  169. package/examples/issue-triage-js/test/issue-triage.test.js +107 -0
  170. package/package.json +70 -0
@@ -0,0 +1,334 @@
1
+ import { hostname } from 'node:os';
2
+ import process from 'node:process';
3
+ import { readFileSync, rmSync } from 'node:fs';
4
+ import { mkdir, open, readFile, rm } from 'node:fs/promises';
5
+ import { dirname, isAbsolute, relative, resolve } from 'node:path';
6
+ import { writeTextAtomic } from './atomic-write.js';
7
+ import { ACTIVE_RUN_LOCK_FILE, getActiveRunLockPath as getStorageActiveRunLockPath } from './storage-paths.js';
8
+ export class ActiveRunLockError extends Error {
9
+ kind;
10
+ lockPath;
11
+ lock;
12
+ constructor(kind, lockPath, lock, message) {
13
+ super(message);
14
+ this.name = 'ActiveRunLockError';
15
+ this.kind = kind;
16
+ this.lockPath = lockPath;
17
+ this.lock = lock;
18
+ }
19
+ }
20
+ const LOCK_VERSION = 1;
21
+ export function getActiveRunLockPath(cwd) {
22
+ return getStorageActiveRunLockPath(cwd);
23
+ }
24
+ export async function inspectActiveRunLock(cwd, requestedRunId) {
25
+ const lockPath = getActiveRunLockPath(resolve(cwd));
26
+ let lock;
27
+ try {
28
+ lock = await readActiveRunLock(lockPath);
29
+ }
30
+ catch (error) {
31
+ if (isNotFoundError(error)) {
32
+ return { kind: 'none' };
33
+ }
34
+ return {
35
+ kind: 'unreadable',
36
+ lockPath,
37
+ reason: error instanceof Error ? error.message : String(error),
38
+ };
39
+ }
40
+ if (lock.hostname !== hostname()) {
41
+ return {
42
+ kind: 'cross_host',
43
+ runId: lock.runId,
44
+ lockPath,
45
+ pid: lock.pid,
46
+ hostname: lock.hostname,
47
+ };
48
+ }
49
+ const live = isProcessAlive(lock.pid);
50
+ if (lock.runId === requestedRunId) {
51
+ return {
52
+ kind: live ? 'live_same_run' : 'stale_same_run',
53
+ runId: lock.runId,
54
+ lockPath,
55
+ pid: lock.pid,
56
+ hostname: lock.hostname,
57
+ };
58
+ }
59
+ return {
60
+ kind: live ? 'live_different_run' : 'stale_different_run',
61
+ runId: lock.runId,
62
+ lockPath,
63
+ pid: lock.pid,
64
+ hostname: lock.hostname,
65
+ };
66
+ }
67
+ export async function clearStaleActiveRunLockForResume(cwd, evidence) {
68
+ if (evidence.kind !== 'stale_same_run' && evidence.kind !== 'stale_different_run') {
69
+ return;
70
+ }
71
+ const lockPath = evidence.lockPath ?? getActiveRunLockPath(resolve(cwd));
72
+ let lock;
73
+ try {
74
+ lock = await readActiveRunLock(lockPath);
75
+ }
76
+ catch (error) {
77
+ if (isNotFoundError(error)) {
78
+ return;
79
+ }
80
+ throw error;
81
+ }
82
+ if (lock.hostname !== hostname() ||
83
+ lock.runId !== evidence.runId ||
84
+ lock.pid !== evidence.pid ||
85
+ isProcessAlive(lock.pid)) {
86
+ return;
87
+ }
88
+ await rm(lockPath, { force: true });
89
+ }
90
+ export async function acquireActiveRunLock(args) {
91
+ const cwd = resolve(args.cwd);
92
+ const lockPath = getActiveRunLockPath(cwd);
93
+ const now = new Date().toISOString();
94
+ const lock = {
95
+ version: LOCK_VERSION,
96
+ runId: args.runId,
97
+ runStatePath: toRepoRelativePath(cwd, args.runStatePath),
98
+ planDoc: args.planDoc,
99
+ topLevelMode: args.topLevelMode,
100
+ pid: process.pid,
101
+ hostname: hostname(),
102
+ cwd,
103
+ startedAt: now,
104
+ updatedAt: now,
105
+ };
106
+ await mkdir(dirname(lockPath), { recursive: true });
107
+ try {
108
+ const file = await open(lockPath, 'wx');
109
+ try {
110
+ await file.writeFile(JSON.stringify(lock, null, 2) + '\n', 'utf8');
111
+ }
112
+ finally {
113
+ await file.close();
114
+ }
115
+ return createOwnedHandle(lockPath, lock);
116
+ }
117
+ catch (error) {
118
+ if (!isFileExistsError(error)) {
119
+ throw error;
120
+ }
121
+ }
122
+ const existing = await readActiveRunLock(lockPath).catch((error) => {
123
+ throw new ActiveRunLockError('unreadable', lockPath, null, [
124
+ '[neal] could not read the active Neal writer lock',
125
+ `[neal] lock: ${lockPath}`,
126
+ `[neal] error: ${error instanceof Error ? error.message : String(error)}`,
127
+ '[neal] inspect the lock file before starting another writer run.',
128
+ ].join('\n'));
129
+ });
130
+ assertCanShareExistingLock(lockPath, existing, args.runId);
131
+ return {
132
+ lockPath,
133
+ lock: existing,
134
+ acquired: false,
135
+ async release() { },
136
+ async refresh() { },
137
+ };
138
+ }
139
+ export async function refreshActiveRunLock(args) {
140
+ const cwd = resolve(args.cwd);
141
+ const lockPath = getActiveRunLockPath(cwd);
142
+ let lock;
143
+ try {
144
+ lock = await readActiveRunLock(lockPath);
145
+ }
146
+ catch (error) {
147
+ if (isNotFoundError(error)) {
148
+ return;
149
+ }
150
+ throw error;
151
+ }
152
+ if (lock.runId !== args.runId || lock.pid !== process.pid || lock.hostname !== hostname()) {
153
+ return;
154
+ }
155
+ const nextLock = {
156
+ ...lock,
157
+ runStatePath: toRepoRelativePath(cwd, args.runStatePath),
158
+ planDoc: args.planDoc,
159
+ topLevelMode: args.topLevelMode,
160
+ updatedAt: new Date().toISOString(),
161
+ };
162
+ // Replace the lock via temp-file rename so a crash mid-refresh can never
163
+ // leave a truncated lock that later commands classify as unreadable.
164
+ await writeTextAtomic(lockPath, JSON.stringify(nextLock, null, 2) + '\n');
165
+ }
166
+ export async function releaseActiveRunLock(cwd, runId) {
167
+ const lockPath = getActiveRunLockPath(resolve(cwd));
168
+ let lock;
169
+ try {
170
+ lock = await readActiveRunLock(lockPath);
171
+ }
172
+ catch (error) {
173
+ if (isNotFoundError(error)) {
174
+ return;
175
+ }
176
+ throw error;
177
+ }
178
+ if (lock.runId !== runId || lock.pid !== process.pid || lock.hostname !== hostname()) {
179
+ return;
180
+ }
181
+ await rm(lockPath, { force: true });
182
+ }
183
+ export function releaseActiveRunLockSync(cwd, runId) {
184
+ const lockPath = getActiveRunLockPath(resolve(cwd));
185
+ let lock;
186
+ try {
187
+ lock = parseActiveRunLock(JSON.parse(readFileSync(lockPath, 'utf8')), lockPath);
188
+ }
189
+ catch (error) {
190
+ if (isNotFoundError(error)) {
191
+ return;
192
+ }
193
+ return;
194
+ }
195
+ if (lock.runId !== runId || lock.pid !== process.pid || lock.hostname !== hostname()) {
196
+ return;
197
+ }
198
+ rmSync(lockPath, { force: true });
199
+ }
200
+ export async function readActiveRunLock(lockPathOrCwd) {
201
+ const lockPath = lockPathOrCwd.endsWith(ACTIVE_RUN_LOCK_FILE) ? lockPathOrCwd : getActiveRunLockPath(lockPathOrCwd);
202
+ const content = await readFile(lockPath, 'utf8');
203
+ return parseActiveRunLock(JSON.parse(content), lockPath);
204
+ }
205
+ function createOwnedHandle(lockPath, lock) {
206
+ return {
207
+ lockPath,
208
+ lock,
209
+ acquired: true,
210
+ release: () => releaseActiveRunLock(lock.cwd, lock.runId),
211
+ refresh: () => refreshActiveRunLock({
212
+ cwd: lock.cwd,
213
+ runId: lock.runId,
214
+ runStatePath: lock.runStatePath,
215
+ planDoc: lock.planDoc,
216
+ topLevelMode: lock.topLevelMode,
217
+ }),
218
+ };
219
+ }
220
+ function assertCanShareExistingLock(lockPath, lock, requestedRunId) {
221
+ if (lock.hostname !== hostname()) {
222
+ throw new ActiveRunLockError('cross_host', lockPath, lock, formatCrossHostLockMessage(lockPath, lock));
223
+ }
224
+ if (!isProcessAlive(lock.pid)) {
225
+ throw new ActiveRunLockError('stale_same_host', lockPath, lock, formatStaleLockMessage(lockPath, lock));
226
+ }
227
+ if (lock.runId !== requestedRunId) {
228
+ throw new ActiveRunLockError('active_different_run', lockPath, lock, formatActiveLockMessage(lockPath, lock));
229
+ }
230
+ // Sharing an existing lock is only legitimate for the same process
231
+ // re-entering its own run. A live same-run lock under another pid means a
232
+ // concurrent writer already owns this run's state and worktree.
233
+ if (lock.pid !== process.pid) {
234
+ throw new ActiveRunLockError('active_same_run', lockPath, lock, formatActiveSameRunLockMessage(lockPath, lock));
235
+ }
236
+ }
237
+ function formatActiveLockMessage(lockPath, lock) {
238
+ return [
239
+ '[neal] another Neal writer run is active in this checkout',
240
+ `[neal] active run: ${lock.runId}`,
241
+ `[neal] plan: ${lock.planDoc}`,
242
+ `[neal] pid: ${lock.pid}`,
243
+ `[neal] lock: ${lockPath}`,
244
+ `[neal] use \`neal resume --run ${lock.runId}\` to continue it, or wait for the active run to finish.`,
245
+ ].join('\n');
246
+ }
247
+ function formatActiveSameRunLockMessage(lockPath, lock) {
248
+ return [
249
+ '[neal] another Neal process is already resuming this run',
250
+ `[neal] active run: ${lock.runId}`,
251
+ `[neal] plan: ${lock.planDoc}`,
252
+ `[neal] owning pid: ${lock.pid}`,
253
+ `[neal] lock: ${lockPath}`,
254
+ `[neal] wait for that process to finish, or inspect it with \`neal status --run ${lock.runId}\`.`,
255
+ ].join('\n');
256
+ }
257
+ function formatStaleLockMessage(lockPath, lock) {
258
+ return [
259
+ '[neal] stale Neal writer lock found in this checkout',
260
+ `[neal] active run: ${lock.runId}`,
261
+ `[neal] plan: ${lock.planDoc}`,
262
+ `[neal] pid: ${lock.pid}`,
263
+ `[neal] lock: ${lockPath}`,
264
+ '[neal] no process with that PID is running on this host.',
265
+ `[neal] inspect the run, then remove ${lockPath} if no Neal writer process is active.`,
266
+ `[neal] exact resume command: neal resume --run ${lock.runId}`,
267
+ ].join('\n');
268
+ }
269
+ function formatCrossHostLockMessage(lockPath, lock) {
270
+ return [
271
+ '[neal] Neal writer lock belongs to another host',
272
+ `[neal] active run: ${lock.runId}`,
273
+ `[neal] plan: ${lock.planDoc}`,
274
+ `[neal] pid: ${lock.pid}`,
275
+ `[neal] host: ${lock.hostname}`,
276
+ `[neal] lock: ${lockPath}`,
277
+ '[neal] inspect the other host before removing this lock or starting another writer run.',
278
+ `[neal] exact resume command on that checkout: neal resume --run ${lock.runId}`,
279
+ ].join('\n');
280
+ }
281
+ function parseActiveRunLock(value, lockPath) {
282
+ if (!value || typeof value !== 'object') {
283
+ throw new Error(`Invalid active Neal writer lock at ${lockPath}: expected object`);
284
+ }
285
+ const lock = value;
286
+ if (lock.version !== LOCK_VERSION ||
287
+ typeof lock.runId !== 'string' ||
288
+ typeof lock.runStatePath !== 'string' ||
289
+ typeof lock.planDoc !== 'string' ||
290
+ (lock.topLevelMode !== 'plan' && lock.topLevelMode !== 'execute') ||
291
+ typeof lock.pid !== 'number' ||
292
+ typeof lock.hostname !== 'string' ||
293
+ typeof lock.cwd !== 'string' ||
294
+ typeof lock.startedAt !== 'string' ||
295
+ typeof lock.updatedAt !== 'string') {
296
+ throw new Error(`Invalid active Neal writer lock at ${lockPath}: malformed lock`);
297
+ }
298
+ return {
299
+ version: LOCK_VERSION,
300
+ runId: lock.runId,
301
+ runStatePath: lock.runStatePath,
302
+ planDoc: lock.planDoc,
303
+ topLevelMode: lock.topLevelMode,
304
+ pid: lock.pid,
305
+ hostname: lock.hostname,
306
+ cwd: lock.cwd,
307
+ startedAt: lock.startedAt,
308
+ updatedAt: lock.updatedAt,
309
+ };
310
+ }
311
+ function isProcessAlive(pid) {
312
+ if (!Number.isInteger(pid) || pid <= 0) {
313
+ return false;
314
+ }
315
+ try {
316
+ process.kill(pid, 0);
317
+ return true;
318
+ }
319
+ catch (error) {
320
+ const code = error && typeof error === 'object' ? error.code : null;
321
+ return code === 'EPERM';
322
+ }
323
+ }
324
+ function toRepoRelativePath(cwd, path) {
325
+ const absolutePath = isAbsolute(path) ? path : resolve(cwd, path);
326
+ const relativePath = relative(cwd, absolutePath);
327
+ return relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath) ? relativePath : absolutePath;
328
+ }
329
+ function isFileExistsError(error) {
330
+ return Boolean(error && typeof error === 'object' && error.code === 'EEXIST');
331
+ }
332
+ function isNotFoundError(error) {
333
+ return Boolean(error && typeof error === 'object' && error.code === 'ENOENT');
334
+ }
@@ -0,0 +1,355 @@
1
+ import { formatMaybePublicPhase } from './phase-display.js';
2
+ const EMPTY_USAGE = {
3
+ inputTokens: 0,
4
+ cachedInputTokens: 0,
5
+ cacheCreationInputTokens: 0,
6
+ cacheReadInputTokens: 0,
7
+ outputTokens: 0,
8
+ reasoningOutputTokens: 0,
9
+ totalTokens: 0,
10
+ };
11
+ function validIso(value) {
12
+ if (typeof value !== 'string') {
13
+ return null;
14
+ }
15
+ const time = Date.parse(value);
16
+ return Number.isFinite(time) ? new Date(time).toISOString() : null;
17
+ }
18
+ function timeMs(value) {
19
+ if (!value) {
20
+ return null;
21
+ }
22
+ const parsed = Date.parse(value);
23
+ return Number.isFinite(parsed) ? parsed : null;
24
+ }
25
+ function stringValue(value) {
26
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
27
+ }
28
+ function numberValue(value) {
29
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
30
+ }
31
+ function isRecord(value) {
32
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+ function addUsage(target, value) {
35
+ if (!isRecord(value)) {
36
+ return;
37
+ }
38
+ target.inputTokens += numberValue(value.input_tokens) + numberValue(value.inputTokens);
39
+ target.cachedInputTokens += numberValue(value.cached_input_tokens) + numberValue(value.cachedInputTokens);
40
+ target.cacheCreationInputTokens +=
41
+ numberValue(value.cache_creation_input_tokens) + numberValue(value.cacheCreationInputTokens);
42
+ target.cacheReadInputTokens += numberValue(value.cache_read_input_tokens) + numberValue(value.cacheReadInputTokens);
43
+ target.outputTokens += numberValue(value.output_tokens) + numberValue(value.outputTokens);
44
+ target.reasoningOutputTokens +=
45
+ numberValue(value.reasoning_output_tokens) + numberValue(value.reasoningOutputTokens);
46
+ target.totalTokens += numberValue(value.total_tokens) + numberValue(value.totalTokens);
47
+ }
48
+ function hasUsage(usage) {
49
+ return Object.values(usage).some((value) => value > 0);
50
+ }
51
+ function cloneEmptyUsage() {
52
+ return { ...EMPTY_USAGE };
53
+ }
54
+ function usageSortValue(summary) {
55
+ const usage = summary.usage;
56
+ return (usage.inputTokens +
57
+ usage.cachedInputTokens +
58
+ usage.cacheCreationInputTokens +
59
+ usage.cacheReadInputTokens +
60
+ usage.outputTokens +
61
+ usage.reasoningOutputTokens +
62
+ usage.totalTokens);
63
+ }
64
+ function providerKey(data) {
65
+ const provider = stringValue(data.provider) ?? 'unknown';
66
+ const role = stringValue(data.role) ?? 'unknown';
67
+ const label = stringValue(data.label);
68
+ return {
69
+ provider,
70
+ role,
71
+ label,
72
+ key: `${provider}\0${role}\0${label ?? ''}`,
73
+ };
74
+ }
75
+ function commandIsNonZero(event) {
76
+ const exitCode = event.data?.exitCode;
77
+ if (typeof exitCode === 'number') {
78
+ return exitCode !== 0;
79
+ }
80
+ return event.data?.status === 'failed';
81
+ }
82
+ function commandIsSuccess(event) {
83
+ return event.data?.exitCode === 0;
84
+ }
85
+ function commandKey(event) {
86
+ const command = stringValue(event.data?.command);
87
+ if (!command) {
88
+ return null;
89
+ }
90
+ const cwd = stringValue(event.data?.cwd) ?? '';
91
+ return `${cwd}\0${command}`;
92
+ }
93
+ function findResolvedNonZeroCommandIndexes(items) {
94
+ const successfulCommands = new Set();
95
+ const resolvedIndexes = new Set();
96
+ for (let index = items.length - 1; index >= 0; index -= 1) {
97
+ const item = items[index];
98
+ if (!item || item.event.type !== 'provider.command_completed') {
99
+ continue;
100
+ }
101
+ const key = commandKey(item.event);
102
+ if (!key) {
103
+ continue;
104
+ }
105
+ if (commandIsSuccess(item.event)) {
106
+ successfulCommands.add(key);
107
+ continue;
108
+ }
109
+ if (commandIsNonZero(item.event) && successfulCommands.has(key)) {
110
+ resolvedIndexes.add(item.index);
111
+ }
112
+ }
113
+ return resolvedIndexes;
114
+ }
115
+ function createPhase(event, index) {
116
+ const phase = stringValue(event.data?.phase);
117
+ if (!phase) {
118
+ return null;
119
+ }
120
+ return {
121
+ phase,
122
+ startedAt: validIso(event.ts),
123
+ completedAt: null,
124
+ durationMs: null,
125
+ providerTurns: 0,
126
+ commandCount: 0,
127
+ nonZeroCommandCount: 0,
128
+ resolvedNonZeroCommandCount: 0,
129
+ unresolvedNonZeroCommandCount: 0,
130
+ toolEventCount: 0,
131
+ fileChangeEventCount: 0,
132
+ startIndex: index,
133
+ endIndex: null,
134
+ };
135
+ }
136
+ function completePhase(phase, event, index) {
137
+ phase.completedAt = validIso(event.ts);
138
+ phase.endIndex = index;
139
+ const start = timeMs(phase.startedAt);
140
+ const end = timeMs(phase.completedAt);
141
+ phase.durationMs = start !== null && end !== null && end >= start ? end - start : null;
142
+ }
143
+ function findOpenPhase(phases, phaseName) {
144
+ for (let index = phases.length - 1; index >= 0; index -= 1) {
145
+ const phase = phases[index];
146
+ if (phase?.phase === phaseName && phase.endIndex === null) {
147
+ return phase;
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ function phaseForEvent(phases, eventIndex) {
153
+ for (let index = phases.length - 1; index >= 0; index -= 1) {
154
+ const phase = phases[index];
155
+ if (!phase || phase.startIndex > eventIndex) {
156
+ continue;
157
+ }
158
+ if (phase.endIndex === null || phase.endIndex >= eventIndex) {
159
+ return phase;
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ export function summarizeRunMetrics(events) {
165
+ const normalizedEvents = events
166
+ .map((event, index) => ({ event, index, ts: validIso(event.ts) }))
167
+ .filter((item) => item.ts !== null);
168
+ const hasDedicatedUsageEvents = normalizedEvents.some((item) => item.event.type === 'provider.usage_reported');
169
+ const firstTimestamp = normalizedEvents[0]?.ts ?? null;
170
+ const lastTimestamp = normalizedEvents.at(-1)?.ts ?? null;
171
+ const firstTime = timeMs(firstTimestamp);
172
+ const lastTime = timeMs(lastTimestamp);
173
+ const phases = [];
174
+ const providers = new Map();
175
+ let providerTurns = 0;
176
+ let commandCount = 0;
177
+ let nonZeroCommandCount = 0;
178
+ let resolvedNonZeroCommandCount = 0;
179
+ let unresolvedNonZeroCommandCount = 0;
180
+ let toolEventCount = 0;
181
+ let fileChangeEventCount = 0;
182
+ const resolvedNonZeroCommandIndexes = findResolvedNonZeroCommandIndexes(normalizedEvents);
183
+ for (const { event, index } of normalizedEvents) {
184
+ if (event.type === 'phase.start') {
185
+ const phase = createPhase(event, index);
186
+ if (phase) {
187
+ phases.push(phase);
188
+ }
189
+ }
190
+ else if (event.type === 'phase.complete') {
191
+ const phaseName = stringValue(event.data?.phase);
192
+ const phase = phaseName ? findOpenPhase(phases, phaseName) : null;
193
+ if (phase) {
194
+ completePhase(phase, event, index);
195
+ }
196
+ }
197
+ }
198
+ for (const { event, index } of normalizedEvents) {
199
+ const phase = phaseForEvent(phases, index);
200
+ if (event.type === 'provider.turn_completed') {
201
+ providerTurns += 1;
202
+ if (phase) {
203
+ phase.providerTurns += 1;
204
+ }
205
+ const data = event.data ?? {};
206
+ const identity = providerKey(data);
207
+ const existing = providers.get(identity.key) ?? {
208
+ provider: identity.provider,
209
+ role: identity.role,
210
+ label: identity.label,
211
+ turns: 0,
212
+ usage: cloneEmptyUsage(),
213
+ };
214
+ existing.turns += 1;
215
+ if (!hasDedicatedUsageEvents) {
216
+ addUsage(existing.usage, data.usage);
217
+ }
218
+ providers.set(identity.key, existing);
219
+ }
220
+ else if (event.type === 'provider.usage_reported') {
221
+ const data = event.data ?? {};
222
+ const identity = providerKey(data);
223
+ const existing = providers.get(identity.key) ?? {
224
+ provider: identity.provider,
225
+ role: identity.role,
226
+ label: identity.label,
227
+ turns: 0,
228
+ usage: cloneEmptyUsage(),
229
+ };
230
+ addUsage(existing.usage, data.usage);
231
+ providers.set(identity.key, existing);
232
+ }
233
+ else if (event.type === 'provider.command_completed') {
234
+ commandCount += 1;
235
+ if (commandIsNonZero(event)) {
236
+ nonZeroCommandCount += 1;
237
+ if (resolvedNonZeroCommandIndexes.has(index)) {
238
+ resolvedNonZeroCommandCount += 1;
239
+ }
240
+ else {
241
+ unresolvedNonZeroCommandCount += 1;
242
+ }
243
+ }
244
+ if (phase) {
245
+ phase.commandCount += 1;
246
+ if (commandIsNonZero(event)) {
247
+ phase.nonZeroCommandCount += 1;
248
+ if (resolvedNonZeroCommandIndexes.has(index)) {
249
+ phase.resolvedNonZeroCommandCount += 1;
250
+ }
251
+ else {
252
+ phase.unresolvedNonZeroCommandCount += 1;
253
+ }
254
+ }
255
+ }
256
+ }
257
+ else if (event.type === 'provider.tool_started' || event.type === 'provider.tool_progress') {
258
+ toolEventCount += 1;
259
+ if (phase) {
260
+ phase.toolEventCount += 1;
261
+ }
262
+ }
263
+ else if (event.type === 'provider.file_changed') {
264
+ fileChangeEventCount += 1;
265
+ if (phase) {
266
+ phase.fileChangeEventCount += 1;
267
+ }
268
+ }
269
+ }
270
+ for (const phase of phases) {
271
+ if (phase.durationMs !== null || phase.endIndex !== null) {
272
+ continue;
273
+ }
274
+ const start = timeMs(phase.startedAt);
275
+ const end = timeMs(lastTimestamp);
276
+ phase.durationMs = start !== null && end !== null && end >= start ? end - start : null;
277
+ }
278
+ const publicPhases = phases.map(({ startIndex: _startIndex, endIndex: _endIndex, ...phase }) => phase);
279
+ return {
280
+ observedStartedAt: firstTimestamp,
281
+ observedCompletedAt: lastTimestamp,
282
+ observedDurationMs: firstTime !== null && lastTime !== null && lastTime >= firstTime ? lastTime - firstTime : null,
283
+ providerTurns,
284
+ commandCount,
285
+ nonZeroCommandCount,
286
+ resolvedNonZeroCommandCount,
287
+ unresolvedNonZeroCommandCount,
288
+ toolEventCount,
289
+ fileChangeEventCount,
290
+ phases: publicPhases,
291
+ providers: [...providers.values()].sort((left, right) => {
292
+ const turns = right.turns - left.turns;
293
+ if (turns !== 0) {
294
+ return turns;
295
+ }
296
+ return usageSortValue(right) - usageSortValue(left);
297
+ }),
298
+ };
299
+ }
300
+ function formatDuration(value) {
301
+ if (value === null) {
302
+ return 'unknown';
303
+ }
304
+ const totalSeconds = Math.max(0, Math.round(value / 1_000));
305
+ const hours = Math.floor(totalSeconds / 3_600);
306
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
307
+ const seconds = totalSeconds % 60;
308
+ if (hours > 0) {
309
+ return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(seconds).padStart(2, '0')}s`;
310
+ }
311
+ return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
312
+ }
313
+ function formatNumber(value) {
314
+ return value === 0 ? '0' : value.toLocaleString('en-US');
315
+ }
316
+ function formatPhaseName(phase) {
317
+ return formatMaybePublicPhase(phase) ?? phase;
318
+ }
319
+ function formatPhaseDuration(phase) {
320
+ const formatted = formatDuration(phase.durationMs);
321
+ return phase.completedAt || phase.durationMs === null ? formatted : `>= ${formatted}`;
322
+ }
323
+ function formatProviderName(summary) {
324
+ const label = summary.label ? `:${summary.label}` : '';
325
+ return `${summary.provider} / ${summary.role}${label}`;
326
+ }
327
+ function usageCell(value) {
328
+ return value > 0 ? formatNumber(value) : '-';
329
+ }
330
+ export function renderRunMetricsMarkdown(metrics) {
331
+ const commandSummary = metrics.resolvedNonZeroCommandCount > 0
332
+ ? `- Commands: ${metrics.commandCount} total, ${metrics.unresolvedNonZeroCommandCount} unresolved non-zero or failed, ${metrics.resolvedNonZeroCommandCount} resolved by later passing rerun`
333
+ : `- Commands: ${metrics.commandCount} total, ${metrics.nonZeroCommandCount} non-zero or failed`;
334
+ const lines = [
335
+ `- Observed duration: ${formatDuration(metrics.observedDurationMs)}`,
336
+ `- Provider turns: ${metrics.providerTurns}`,
337
+ commandSummary,
338
+ `- Tool events: ${metrics.toolEventCount}`,
339
+ `- File change events: ${metrics.fileChangeEventCount}`,
340
+ ];
341
+ if (metrics.phases.length > 0) {
342
+ lines.push('', '### Phase Timing', '', '| Phase | Duration | Provider turns | Commands | Non-zero commands | Tool events | File changes |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', ...metrics.phases.map((phase) => `| ${formatPhaseName(phase.phase)} | ${formatPhaseDuration(phase)} | ${phase.providerTurns} | ${phase.commandCount} | ${phase.nonZeroCommandCount} | ${phase.toolEventCount} | ${phase.fileChangeEventCount} |`));
343
+ }
344
+ const providersWithUsage = metrics.providers.filter((provider) => provider.turns > 0 || hasUsage(provider.usage));
345
+ if (providersWithUsage.length > 0) {
346
+ lines.push('', '### Provider Usage', '', '| Provider / role | Turns | Input | Cached input | Cache created | Cache read | Output | Reasoning output | Total |', '| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', ...providersWithUsage.map((provider) => {
347
+ const usage = provider.usage;
348
+ return `| ${formatProviderName(provider)} | ${provider.turns} | ${usageCell(usage.inputTokens)} | ${usageCell(usage.cachedInputTokens)} | ${usageCell(usage.cacheCreationInputTokens)} | ${usageCell(usage.cacheReadInputTokens)} | ${usageCell(usage.outputTokens)} | ${usageCell(usage.reasoningOutputTokens)} | ${usageCell(usage.totalTokens)} |`;
349
+ }));
350
+ }
351
+ else {
352
+ lines.push('', '### Provider Usage', '', '- No provider usage events recorded.');
353
+ }
354
+ return lines.join('\n');
355
+ }
@@ -0,0 +1 @@
1
+ export {};