@sentry/warden 0.20.0 → 0.22.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 (77) hide show
  1. package/CHANGELOG.md +353 -0
  2. package/README.md +3 -3
  3. package/dist/cli/args.d.ts +7 -5
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +31 -17
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/cli/commands/runs.d.ts +26 -0
  8. package/dist/cli/commands/runs.d.ts.map +1 -0
  9. package/dist/cli/commands/runs.js +726 -0
  10. package/dist/cli/commands/runs.js.map +1 -0
  11. package/dist/cli/log-cleanup.d.ts.map +1 -1
  12. package/dist/cli/log-cleanup.js +4 -0
  13. package/dist/cli/log-cleanup.js.map +1 -1
  14. package/dist/cli/main.d.ts +15 -0
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/main.js +428 -128
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/output/index.d.ts +1 -1
  19. package/dist/cli/output/index.d.ts.map +1 -1
  20. package/dist/cli/output/index.js +1 -1
  21. package/dist/cli/output/index.js.map +1 -1
  22. package/dist/cli/output/ink-runner.d.ts.map +1 -1
  23. package/dist/cli/output/ink-runner.js +50 -14
  24. package/dist/cli/output/ink-runner.js.map +1 -1
  25. package/dist/cli/output/jsonl-schema-gen.d.ts +16 -0
  26. package/dist/cli/output/jsonl-schema-gen.d.ts.map +1 -0
  27. package/dist/cli/output/jsonl-schema-gen.js +65 -0
  28. package/dist/cli/output/jsonl-schema-gen.js.map +1 -0
  29. package/dist/cli/output/jsonl.d.ts +266 -26
  30. package/dist/cli/output/jsonl.d.ts.map +1 -1
  31. package/dist/cli/output/jsonl.js +377 -139
  32. package/dist/cli/output/jsonl.js.map +1 -1
  33. package/dist/cli/output/tasks.d.ts +6 -1
  34. package/dist/cli/output/tasks.d.ts.map +1 -1
  35. package/dist/cli/output/tasks.js +183 -27
  36. package/dist/cli/output/tasks.js.map +1 -1
  37. package/dist/cli/terminal.d.ts.map +1 -1
  38. package/dist/cli/terminal.js +20 -0
  39. package/dist/cli/terminal.js.map +1 -1
  40. package/dist/index.d.ts +2 -2
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +2 -0
  43. package/dist/index.js.map +1 -1
  44. package/dist/sdk/analyze.d.ts.map +1 -1
  45. package/dist/sdk/analyze.js +103 -10
  46. package/dist/sdk/analyze.js.map +1 -1
  47. package/dist/sdk/errors.d.ts +11 -0
  48. package/dist/sdk/errors.d.ts.map +1 -1
  49. package/dist/sdk/errors.js +50 -0
  50. package/dist/sdk/errors.js.map +1 -1
  51. package/dist/sdk/runner.d.ts +1 -1
  52. package/dist/sdk/runner.d.ts.map +1 -1
  53. package/dist/sdk/types.d.ts +28 -1
  54. package/dist/sdk/types.d.ts.map +1 -1
  55. package/dist/types/index.d.ts +130 -6
  56. package/dist/types/index.d.ts.map +1 -1
  57. package/dist/types/index.js +72 -3
  58. package/dist/types/index.js.map +1 -1
  59. package/package.json +2 -1
  60. package/skills/warden/SKILL.md +5 -5
  61. package/skills/warden/SOURCES.md +39 -0
  62. package/skills/warden/references/cli-reference.md +11 -4
  63. package/skills/warden/references/config-schema.md +9 -0
  64. package/skills/warden/references/configuration.md +9 -0
  65. package/skills/warden-sweep/SKILL.md +43 -365
  66. package/skills/warden-sweep/SOURCES.md +43 -0
  67. package/skills/warden-sweep/references/issue-phase.md +24 -0
  68. package/skills/warden-sweep/references/organize-phase.md +31 -0
  69. package/skills/warden-sweep/references/patch-phase.md +142 -0
  70. package/skills/warden-sweep/references/resume-and-artifacts.md +51 -0
  71. package/skills/warden-sweep/references/scan-phase.md +46 -0
  72. package/skills/warden-sweep/references/script-interfaces.md +127 -0
  73. package/skills/warden-sweep/references/verify-phase.md +69 -0
  74. package/dist/cli/commands/logs.d.ts +0 -19
  75. package/dist/cli/commands/logs.d.ts.map +0 -1
  76. package/dist/cli/commands/logs.js +0 -412
  77. package/dist/cli/commands/logs.js.map +0 -1
package/dist/cli/main.js CHANGED
@@ -1,9 +1,11 @@
1
- import { existsSync } from 'node:fs';
1
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import { config as dotenvConfig } from 'dotenv';
4
4
  import { Sentry, flushSentry, setGlobalAttributes, emitRunMetric, getTraceId } from '../sentry.js';
5
5
  import { loadWardenConfig, resolveSkillConfigs } from '../config/loader.js';
6
6
  import { verifyAuth } from '../sdk/runner.js';
7
+ import { mapExtractionErrorCode } from '../sdk/errors.js';
8
+ import { mergeAuxiliaryUsage } from '../sdk/usage.js';
7
9
  import { resolveSkillAsync } from '../skills/loader.js';
8
10
  import { matchTrigger, filterContextByPaths, shouldFail, countFindingsAtOrAbove } from '../triggers/matcher.js';
9
11
  import { filterFindings } from '../types/index.js';
@@ -12,7 +14,7 @@ import { parseCliArgs, showHelp, showVersion, classifyTargets } from './args.js'
12
14
  import { buildLocalEventContext, buildFileEventContext } from './context.js';
13
15
  import { getRepoRoot, getHeadSha, refExists, hasUncommittedChanges } from './git.js';
14
16
  import { renderTerminalReport, filterReports } from './terminal.js';
15
- import { Reporter, detectOutputMode, parseVerbosity, Verbosity, runSkillTasks, runSkillTasksWithInk, pluralize, MODEL_DEFAULT_SENTINEL, writeJsonlContent, renderJsonlString, getRepoLogPath, generateRunId, } from './output/index.js';
17
+ import { Reporter, detectOutputMode, parseVerbosity, Verbosity, runSkillTasks, runSkillTasksWithInk, pluralize, MODEL_DEFAULT_SENTINEL, writeJsonlContent, renderJsonlString, renderJsonlChunkLine, renderJsonlChunkRecords, initJsonlFile, appendJsonlLine, getRepoLogPath, generateRunId, } from './output/index.js';
16
18
  import { cleanupArtifacts } from './log-cleanup.js';
17
19
  import { collectFixableFindings, applyAllFixes, runInteractiveFixFlow, renderFixSummary, } from './fix.js';
18
20
  import { UserAbortError } from './input.js';
@@ -20,7 +22,7 @@ import { runInit } from './commands/init.js';
20
22
  import { runAdd } from './commands/add.js';
21
23
  import { runSetupApp } from './commands/setup-app.js';
22
24
  import { runSync } from './commands/sync.js';
23
- import { runLogs } from './commands/logs.js';
25
+ import { runRuns } from './commands/runs.js';
24
26
  /**
25
27
  * Global abort controller for graceful shutdown on SIGINT.
26
28
  * Used to cancel in-progress SDK queries.
@@ -69,15 +71,18 @@ function resolveConfigPath(options, repoPath) {
69
71
  return options.config ? resolve(cwd, options.config) : resolve(repoPath, 'warden.toml');
70
72
  }
71
73
  /**
72
- * Write a minimal JSONL log (summary-only, 0 findings) for early-exit paths.
73
- * Returns the rendered content and the log file path. The content is always
74
- * available even if the file write fails, so callers can use it for --json
75
- * output without reading back from disk.
74
+ * Emit a minimal JSONL log (summary-only, 0 findings) for early-exit paths.
75
+ *
76
+ * Behavior:
77
+ * - `--json`: writes to stdout (the API contract for piping consumers).
78
+ * - `--output <path>`: writes to that explicit path (CI artifacts).
79
+ * - Default `.warden/logs/`: written only when `error` is set. Real
80
+ * failures (auth, config load, etc.) belong in the on-disk audit trail;
81
+ * pure no-ops (no files, no skills) would just clutter it.
76
82
  */
77
- function writeEmptyRunLog(repoPath, opts) {
83
+ function emitEmptyRunLog(repoPath, options, error) {
78
84
  const runId = generateRunId();
79
85
  const timestamp = new Date();
80
- const logPath = getRepoLogPath(repoPath, runId, timestamp);
81
86
  let headSha;
82
87
  try {
83
88
  headSha = getHeadSha(repoPath);
@@ -85,43 +90,319 @@ function writeEmptyRunLog(repoPath, opts) {
85
90
  catch {
86
91
  // Not in a git repo or HEAD is unborn
87
92
  }
88
- const content = renderJsonlString([], 0, { runId, traceId: opts?.traceId, timestamp, headSha });
93
+ const content = renderJsonlString([], 0, {
94
+ runId,
95
+ traceId: getTraceId(),
96
+ timestamp,
97
+ headSha,
98
+ error,
99
+ });
100
+ if (error) {
101
+ const logPath = getRepoLogPath(repoPath, runId, timestamp);
102
+ try {
103
+ writeJsonlContent(logPath, content);
104
+ }
105
+ catch (err) {
106
+ console.warn(`Warning: Failed to write run log: ${err instanceof Error ? err.message : String(err)}`);
107
+ }
108
+ }
109
+ if (options.output) {
110
+ try {
111
+ writeJsonlContent(options.output, content);
112
+ }
113
+ catch (err) {
114
+ console.warn(`Warning: Failed to write output file: ${err instanceof Error ? err.message : String(err)}`);
115
+ }
116
+ }
117
+ if (options.json) {
118
+ process.stdout.write(content);
119
+ }
120
+ }
121
+ function initializeRunLog(args) {
122
+ const { repoPath, runId, timestamp, traceId, headSha, model, outputPath, reporter, startTime } = args;
123
+ const baseRun = {
124
+ timestamp: timestamp.toISOString(),
125
+ cwd: process.cwd(),
126
+ runId,
127
+ traceId,
128
+ model,
129
+ headSha,
130
+ };
131
+ const primaryLogPath = getRepoLogPath(repoPath, runId, timestamp);
132
+ let primaryLogWritten = false;
89
133
  try {
90
- writeJsonlContent(logPath, content);
134
+ initJsonlFile(primaryLogPath);
135
+ primaryLogWritten = true;
91
136
  }
92
137
  catch (err) {
93
- console.warn(`Warning: Failed to write run log: ${err instanceof Error ? err.message : String(err)}`);
138
+ reporter.warning(`Failed to write run log: ${err instanceof Error ? err.message : String(err)}`);
94
139
  }
95
- if (opts?.outputPath) {
140
+ let resolvedOutputPath;
141
+ if (outputPath) {
96
142
  try {
97
- writeJsonlContent(opts.outputPath, content);
143
+ initJsonlFile(outputPath);
144
+ resolvedOutputPath = outputPath;
98
145
  }
99
146
  catch (err) {
100
- console.warn(`Warning: Failed to write output file: ${err instanceof Error ? err.message : String(err)}`);
147
+ reporter.warning(`Failed to write output file: ${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+ }
150
+ const paths = [];
151
+ const seenPaths = new Set();
152
+ const addPath = (path) => {
153
+ const key = resolve(process.cwd(), path);
154
+ if (seenPaths.has(key))
155
+ return;
156
+ seenPaths.add(key);
157
+ paths.push(path);
158
+ };
159
+ if (primaryLogWritten)
160
+ addPath(primaryLogPath);
161
+ if (resolvedOutputPath)
162
+ addPath(resolvedOutputPath);
163
+ return { paths, primaryLogPath, primaryLogWritten, outputPath: resolvedOutputPath, startTime, baseRun, chunks: [] };
164
+ }
165
+ function appendChunkToRunLog(log, skillName, chunk) {
166
+ if (log.paths.length === 0)
167
+ return;
168
+ const auxiliaryUsage = chunk.auxiliaryUsage?.reduce((acc, entry) => mergeAuxiliaryUsage(acc, { [entry.agent]: entry.usage }), undefined);
169
+ const error = chunk.failed
170
+ ? {
171
+ code: chunk.failureCode ?? 'unknown',
172
+ message: chunk.failureMessage ?? 'unknown error',
173
+ timestamp: new Date().toISOString(),
174
+ }
175
+ : chunk.extractionFailed
176
+ ? {
177
+ code: mapExtractionErrorCode(chunk.extractionError),
178
+ message: chunk.extractionError ?? 'unknown extraction error',
179
+ timestamp: new Date().toISOString(),
180
+ }
181
+ : undefined;
182
+ const record = {
183
+ schemaVersion: 1,
184
+ run: { ...log.baseRun, durationMs: Date.now() - log.startTime },
185
+ skill: skillName,
186
+ model: chunk.model,
187
+ chunk: {
188
+ file: chunk.filename,
189
+ index: chunk.index,
190
+ total: chunk.total,
191
+ lineRange: chunk.lineRange,
192
+ },
193
+ status: error ? 'error' : 'ok',
194
+ findings: chunk.findings,
195
+ usage: chunk.usage,
196
+ durationMs: chunk.durationMs,
197
+ auxiliaryUsage,
198
+ error,
199
+ };
200
+ let line;
201
+ try {
202
+ line = renderJsonlChunkLine(record);
203
+ }
204
+ catch {
205
+ return;
206
+ }
207
+ log.chunks.push(record);
208
+ for (const p of log.paths) {
209
+ try {
210
+ appendJsonlLine(p, line);
211
+ }
212
+ catch { /* best-effort */ }
213
+ }
214
+ }
215
+ function buildReportChunkRecord(log, report, runDurationMs, index = 1, total = 1, error) {
216
+ const reportError = report.error ?? error;
217
+ return {
218
+ schemaVersion: 1,
219
+ run: { ...log.baseRun, durationMs: runDurationMs },
220
+ skill: report.skill,
221
+ model: report.model,
222
+ chunk: {
223
+ file: report.skippedFiles?.[0]?.filename ?? '',
224
+ index,
225
+ total,
226
+ lineRange: '',
227
+ },
228
+ status: reportError ? 'error' : report.skippedFiles?.length ? 'skipped' : 'ok',
229
+ findings: report.findings,
230
+ usage: report.usage,
231
+ durationMs: report.durationMs ?? runDurationMs,
232
+ auxiliaryUsage: report.auxiliaryUsage,
233
+ error: reportError,
234
+ skippedFiles: report.skippedFiles,
235
+ };
236
+ }
237
+ function buildRunErrorChunkRecord(log, runDurationMs, error) {
238
+ return {
239
+ schemaVersion: 1,
240
+ run: { ...log.baseRun, durationMs: runDurationMs },
241
+ skill: 'run',
242
+ chunk: {
243
+ file: '',
244
+ index: 1,
245
+ total: 1,
246
+ lineRange: '',
247
+ },
248
+ status: 'error',
249
+ findings: [],
250
+ durationMs: runDurationMs,
251
+ error,
252
+ };
253
+ }
254
+ function hasReportRecord(log, report) {
255
+ return log.chunks.some((chunk) => {
256
+ if (chunk.skill !== report.skill)
257
+ return false;
258
+ if (report.error) {
259
+ return chunk.error?.code === report.error.code && chunk.error.message === report.error.message;
260
+ }
261
+ if (report.skippedFiles?.length) {
262
+ return (chunk.skippedFiles?.length ?? 0) > 0;
263
+ }
264
+ return true;
265
+ });
266
+ }
267
+ function shouldStreamReportRecord(log, report) {
268
+ if (hasReportRecord(log, report))
269
+ return false;
270
+ return Boolean(report.error || report.skippedFiles?.length || !log.chunks.some((chunk) => chunk.skill === report.skill));
271
+ }
272
+ function appendReportToRunLog(log, report) {
273
+ if (!shouldStreamReportRecord(log, report))
274
+ return;
275
+ const record = buildReportChunkRecord(log, report, Date.now() - log.startTime);
276
+ let line;
277
+ try {
278
+ line = renderJsonlChunkLine(record);
279
+ }
280
+ catch {
281
+ return;
282
+ }
283
+ log.chunks.push(record);
284
+ for (const p of log.paths) {
285
+ try {
286
+ appendJsonlLine(p, line);
287
+ }
288
+ catch { /* best-effort */ }
289
+ }
290
+ }
291
+ function lineRangeIncludes(lineRange, line) {
292
+ if (!lineRange)
293
+ return false;
294
+ const [startText, endText] = lineRange.split('-');
295
+ const start = Number(startText);
296
+ const end = endText ? Number(endText) : start;
297
+ return Number.isFinite(start) && Number.isFinite(end) && line >= start && line <= end;
298
+ }
299
+ function findChunkForFinding(chunks, skill, finding) {
300
+ const sameSkill = chunks.filter((chunk) => chunk.skill === skill && chunk.chunk.file);
301
+ const location = finding.location;
302
+ if (!location)
303
+ return sameSkill[0];
304
+ return sameSkill.find((chunk) => chunk.chunk.file === location.path && lineRangeIncludes(chunk.chunk.lineRange, location.startLine)) ?? sameSkill.find((chunk) => chunk.chunk.file === location.path) ?? sameSkill[0];
305
+ }
306
+ function buildFinalChunkRecords(log, reports, totalDurationMs, error) {
307
+ const finalRun = { ...log.baseRun, durationMs: totalDurationMs };
308
+ if (log.chunks.length === 0) {
309
+ if (reports.length === 0 && error) {
310
+ return [buildRunErrorChunkRecord(log, totalDurationMs, error)];
311
+ }
312
+ return reports.map((report) => buildReportChunkRecord(log, report, totalDurationMs, undefined, undefined, error));
313
+ }
314
+ const findingsByChunk = new Map();
315
+ for (const report of reports) {
316
+ for (const finding of report.findings) {
317
+ const chunk = findChunkForFinding(log.chunks, report.skill, finding);
318
+ if (!chunk)
319
+ continue;
320
+ const findings = findingsByChunk.get(chunk) ?? [];
321
+ findings.push(finding);
322
+ findingsByChunk.set(chunk, findings);
323
+ }
324
+ }
325
+ const chunkRecords = log.chunks.map((chunk) => ({
326
+ ...chunk,
327
+ run: { ...finalRun },
328
+ findings: findingsByChunk.get(chunk) ?? [],
329
+ }));
330
+ const finalLog = { ...log, chunks: chunkRecords };
331
+ const missingReports = reports.filter((report) => shouldStreamReportRecord(finalLog, report));
332
+ return [
333
+ ...chunkRecords,
334
+ ...missingReports.map((report) => buildReportChunkRecord(log, report, totalDurationMs, undefined, undefined, error)),
335
+ ];
336
+ }
337
+ /**
338
+ * Rewrite the run log with final chunk records. Returns the set of paths that
339
+ * accepted the write, so the caller can decide whether to claim
340
+ * "wrote JSONL output to X" (only true when the final log actually landed).
341
+ */
342
+ function finalizeRunLog(log, reports, totalDurationMs, error) {
343
+ const wrote = new Set();
344
+ if (log.paths.length === 0)
345
+ return wrote;
346
+ let content;
347
+ try {
348
+ const records = buildFinalChunkRecords(log, reports, totalDurationMs, error);
349
+ content = renderJsonlChunkRecords(records);
350
+ }
351
+ catch {
352
+ return wrote;
353
+ }
354
+ for (const p of log.paths) {
355
+ const targetPath = resolve(process.cwd(), p);
356
+ const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`;
357
+ const tempDonePath = `${tempPath}.done`;
358
+ try {
359
+ writeJsonlContent(tempPath, content);
360
+ writeFileSync(tempDonePath, '');
361
+ renameSync(tempPath, targetPath);
362
+ renameSync(tempDonePath, `${targetPath}.done`);
363
+ wrote.add(p);
364
+ }
365
+ catch {
366
+ try {
367
+ unlinkSync(tempPath);
368
+ }
369
+ catch { /* ignore */ }
370
+ try {
371
+ unlinkSync(tempDonePath);
372
+ }
373
+ catch { /* ignore */ }
374
+ // best-effort
101
375
  }
102
376
  }
103
- return { logPath, content };
377
+ return wrote;
104
378
  }
105
379
  /**
106
380
  * Process skill task results into reports and check for failures.
381
+ * Exported for testing; callers inside main.ts use it directly.
107
382
  */
108
- function processTaskResults(results, reportOn, minConfidence) {
383
+ export function processTaskResults(results, reportOn, minConfidence) {
109
384
  const reports = [];
110
385
  let hasFailure = false;
111
386
  const failureReasons = [];
112
387
  for (const result of results) {
113
- if (result.report) {
114
- reports.push(result.report);
115
- // Apply confidence filtering before failOn evaluation so low-confidence findings
116
- // don't cause exit code 1. Per-result minConfidence (from trigger config) takes
117
- // precedence over the global default.
118
- const effectiveConfidence = result.minConfidence ?? minConfidence;
119
- const reportForFail = { ...result.report, findings: filterFindings(result.report.findings, undefined, effectiveConfidence) };
120
- if (result.failOn && shouldFail(reportForFail, result.failOn)) {
121
- hasFailure = true;
122
- const count = countFindingsAtOrAbove(reportForFail, result.failOn);
123
- failureReasons.push(`${result.name}: ${count} ${result.failOn}+ severity ${pluralize(count, 'issue')}`);
124
- }
388
+ if (!result.report)
389
+ continue;
390
+ reports.push(result.report);
391
+ // Skill-level errors always fail the run, independent of failOn thresholds.
392
+ if (result.report.error) {
393
+ hasFailure = true;
394
+ failureReasons.push(`${result.name}: ${result.report.error.code}: ${result.report.error.message}`);
395
+ continue;
396
+ }
397
+ // Apply confidence filtering before failOn evaluation so low-confidence findings
398
+ // don't cause exit code 1. Per-result minConfidence (from trigger config) takes
399
+ // precedence over the global default.
400
+ const effectiveConfidence = result.minConfidence ?? minConfidence;
401
+ const reportForFail = { ...result.report, findings: filterFindings(result.report.findings, undefined, effectiveConfidence) };
402
+ if (result.failOn && shouldFail(reportForFail, result.failOn)) {
403
+ hasFailure = true;
404
+ const count = countFindingsAtOrAbove(reportForFail, result.failOn);
405
+ failureReasons.push(`${result.name}: ${count} ${result.failOn}+ severity ${pluralize(count, 'issue')}`);
125
406
  }
126
407
  }
127
408
  const filteredReports = filterReports(reports, reportOn, minConfidence);
@@ -130,39 +411,17 @@ function processTaskResults(results, reportOn, minConfidence) {
130
411
  /**
131
412
  * Output results and handle fixes. Returns exit code.
132
413
  */
133
- async function outputResultsAndHandleFixes(processed, options, reporter, repoPath, totalDuration, failFastAborted, resolvedModel) {
414
+ async function outputResultsAndHandleFixes(processed, options, reporter, runLog, totalDuration, failFastAborted) {
134
415
  const { reports, filteredReports, hasFailure, failureReasons } = processed;
135
- const traceId = getTraceId();
136
- const runId = generateRunId();
137
- const timestamp = new Date();
138
- // Capture HEAD SHA safely (non-fatal if not in a git repo)
139
- let headSha;
140
- try {
141
- headSha = getHeadSha(repoPath);
142
- }
143
- catch {
144
- // Not in a git repo or HEAD is unborn
145
- }
146
- // Render JSONL content once so repo log and --output have identical timestamps
147
- const jsonlContent = renderJsonlString(reports, totalDuration, { runId, traceId, timestamp, model: resolvedModel, headSha });
148
- // Always write repo-local JSONL log (non-fatal — don't lose analysis output)
149
- const logPath = getRepoLogPath(repoPath, runId, timestamp);
150
- let logWritten = false;
151
- try {
152
- writeJsonlContent(logPath, jsonlContent);
153
- logWritten = true;
154
- }
155
- catch (err) {
156
- reporter.warning(`Failed to write run log: ${err instanceof Error ? err.message : String(err)}`);
157
- }
158
- // Write additional copy to --output path if specified
159
- if (options.output) {
160
- try {
161
- writeJsonlContent(options.output, jsonlContent);
162
- reporter.success(`Wrote JSONL output to ${options.output}`);
416
+ const traceId = runLog.baseRun.traceId;
417
+ const finalizedPaths = finalizeRunLog(runLog, reports, totalDuration);
418
+ // Only claim --output succeeded if the summary actually landed there.
419
+ if (runLog.outputPath) {
420
+ if (finalizedPaths.has(runLog.outputPath)) {
421
+ reporter.success(`Wrote JSONL output to ${runLog.outputPath}`);
163
422
  }
164
- catch (err) {
165
- reporter.warning(`Failed to write output file: ${err instanceof Error ? err.message : String(err)}`);
423
+ else {
424
+ reporter.warning(`Failed to write output file: ${runLog.outputPath}`);
166
425
  }
167
426
  }
168
427
  // Collect fixable findings early so we know whether to suppress diffs in the report
@@ -175,14 +434,31 @@ async function outputResultsAndHandleFixes(processed, options, reporter, repoPat
175
434
  && reporter.verbosity !== Verbosity.Quiet
176
435
  && reporter.mode.isTTY
177
436
  && process.stdin.isTTY;
178
- // Output results
179
437
  reporter.blank();
180
438
  if (options.json) {
181
- // --json: output pre-rendered JSONL (identical to log file contents)
439
+ // Prefer reading the on-disk log (per-skill durationMs is a snapshot).
440
+ // Only read it back if finalize actually landed the summary there;
441
+ // a half-written file should fall through to the in-memory render.
442
+ // The fallback renders the same chunk-record shape in memory.
443
+ let jsonlContent;
444
+ if (finalizedPaths.has(runLog.primaryLogPath)) {
445
+ try {
446
+ jsonlContent = readFileSync(runLog.primaryLogPath, 'utf-8');
447
+ }
448
+ catch { /* fall through */ }
449
+ }
450
+ if (!jsonlContent) {
451
+ try {
452
+ jsonlContent = renderJsonlChunkRecords(buildFinalChunkRecords(runLog, reports, totalDuration));
453
+ }
454
+ catch (err) {
455
+ reporter.error(`Failed to render JSONL output: ${err instanceof Error ? err.message : String(err)}`);
456
+ return 1;
457
+ }
458
+ }
182
459
  process.stdout.write(jsonlContent);
183
460
  }
184
461
  else {
185
- // Suppress fix diffs in report when interactive step-through will show them
186
462
  console.log(renderTerminalReport(filteredReports, reporter.mode, { suppressFixDiffs: willStepThrough, verbosity: reporter.verbosity }));
187
463
  }
188
464
  // Show interrupted / fail-fast banner before summary
@@ -198,8 +474,8 @@ async function outputResultsAndHandleFixes(processed, options, reporter, repoPat
198
474
  reporter.blank();
199
475
  reporter.renderSummary(filteredReports, totalDuration, { traceId });
200
476
  // Show log file path after summary (only if write succeeded)
201
- if (!options.json && logWritten) {
202
- reporter.dim(`Log: ${logPath}`);
477
+ if (!options.json && runLog.primaryLogWritten) {
478
+ reporter.dim(`Log: ${runLog.primaryLogPath}`);
203
479
  }
204
480
  // Handle fixes: --fix (automatic) always runs, interactive step-through in TTY mode
205
481
  if (fixableFindings.length > 0) {
@@ -250,15 +526,13 @@ async function runSkills(context, options, reporter) {
250
526
  verifyAuth({ apiKey });
251
527
  }
252
528
  catch (error) {
253
- reporter.error(error.message);
254
- const effectiveRepo = repoPath ?? cwd;
255
- if (options.json) {
256
- const { content } = writeEmptyRunLog(effectiveRepo, { traceId: getTraceId(), outputPath: options.output });
257
- process.stdout.write(content);
258
- }
259
- else {
260
- writeEmptyRunLog(effectiveRepo, { traceId: getTraceId(), outputPath: options.output });
261
- }
529
+ const message = error.message;
530
+ reporter.error(message);
531
+ emitEmptyRunLog(repoPath ?? cwd, options, {
532
+ code: 'auth_failed',
533
+ message,
534
+ timestamp: new Date().toISOString(),
535
+ });
262
536
  return 1;
263
537
  }
264
538
  // Resolve config path
@@ -310,13 +584,8 @@ async function runSkills(context, options, reporter) {
310
584
  emitRunMetric();
311
585
  // Handle case where no skills to run
312
586
  if (skillsToRun.length === 0) {
313
- const effectiveRepo = repoPath ?? cwd;
314
- if (options.json) {
315
- const { content } = writeEmptyRunLog(effectiveRepo, { traceId: getTraceId(), outputPath: options.output });
316
- process.stdout.write(content);
317
- }
318
- else {
319
- writeEmptyRunLog(effectiveRepo, { traceId: getTraceId(), outputPath: options.output });
587
+ emitEmptyRunLog(repoPath ?? cwd, options);
588
+ if (!options.json) {
320
589
  reporter.warning('No triggers matched for the changed files');
321
590
  reporter.tip('Specify a skill explicitly: warden <target> --skill <name>');
322
591
  }
@@ -347,6 +616,29 @@ async function runSkills(context, options, reporter) {
347
616
  context: filterContextByPaths(context, filters),
348
617
  runnerOptions,
349
618
  }));
619
+ // Open the run's JSONL log before launching skills so `warden runs
620
+ // follow <runId>` works from a second terminal while the run is live.
621
+ const runId = generateRunId();
622
+ const timestamp = new Date();
623
+ const traceId = getTraceId();
624
+ let headSha;
625
+ try {
626
+ headSha = getHeadSha(repoPath ?? cwd);
627
+ }
628
+ catch {
629
+ // Not a git repo or HEAD is unborn — non-fatal
630
+ }
631
+ const runLog = initializeRunLog({
632
+ repoPath: repoPath ?? cwd,
633
+ runId,
634
+ timestamp,
635
+ traceId,
636
+ headSha,
637
+ model: logModel,
638
+ outputPath: options.output,
639
+ reporter,
640
+ startTime,
641
+ });
350
642
  // Run skills with Ink UI (TTY) or simple console output (non-TTY)
351
643
  const concurrency = options.parallel ?? DEFAULT_CONCURRENCY;
352
644
  failFastController = options.failFast ? new AbortController() : undefined;
@@ -355,6 +647,8 @@ async function runSkills(context, options, reporter) {
355
647
  verbosity: reporter.verbosity,
356
648
  concurrency,
357
649
  failFastController,
650
+ onChunkComplete: (skillName, chunk) => appendChunkToRunLog(runLog, skillName, chunk),
651
+ onSkillComplete: (report) => appendReportToRunLog(runLog, report),
358
652
  };
359
653
  const results = reporter.mode.isTTY
360
654
  ? await runSkillTasksWithInk(tasks, taskOptions)
@@ -363,7 +657,7 @@ async function runSkills(context, options, reporter) {
363
657
  const totalDuration = Date.now() - startTime;
364
658
  const effectiveMinConfidence = options.minConfidence ?? config?.defaults?.minConfidence ?? 'medium';
365
659
  const processed = processTaskResults(results, options.reportOn, effectiveMinConfidence);
366
- return outputResultsAndHandleFixes(processed, options, reporter, repoPath ?? cwd, totalDuration, failFastController?.signal.aborted, logModel);
660
+ return outputResultsAndHandleFixes(processed, options, reporter, runLog, totalDuration, failFastController?.signal.aborted);
367
661
  }
368
662
  /**
369
663
  * Run in file mode: analyze specific files.
@@ -382,12 +676,8 @@ async function runFileMode(filePatterns, options, reporter) {
382
676
  return 1;
383
677
  }
384
678
  if (pullRequest.files.length === 0) {
385
- if (options.json) {
386
- const { content } = writeEmptyRunLog(cwd, { traceId: getTraceId(), outputPath: options.output });
387
- process.stdout.write(content);
388
- }
389
- else {
390
- writeEmptyRunLog(cwd, { traceId: getTraceId(), outputPath: options.output });
679
+ emitEmptyRunLog(cwd, options);
680
+ if (!options.json) {
391
681
  reporter.blank();
392
682
  reporter.warning('No files matched the given patterns');
393
683
  }
@@ -456,12 +746,8 @@ async function runGitRefMode(gitRef, options, reporter) {
456
746
  return 1;
457
747
  }
458
748
  if (pullRequest.files.length === 0) {
459
- if (options.json) {
460
- const { content } = writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
461
- process.stdout.write(content);
462
- }
463
- else {
464
- writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
749
+ emitEmptyRunLog(repoPath, options);
750
+ if (!options.json) {
465
751
  reporter.renderEmptyState('No changes found');
466
752
  reporter.blank();
467
753
  }
@@ -509,12 +795,8 @@ async function runConfigMode(options, reporter) {
509
795
  return 1;
510
796
  }
511
797
  if (pullRequest.files.length === 0) {
512
- if (options.json) {
513
- const { content } = writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
514
- process.stdout.write(content);
515
- }
516
- else {
517
- writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
798
+ emitEmptyRunLog(repoPath, options);
799
+ if (!options.json) {
518
800
  if (options.staged) {
519
801
  reporter.renderEmptyState('No staged changes found');
520
802
  }
@@ -549,12 +831,8 @@ async function runConfigMode(options, reporter) {
549
831
  }
550
832
  const triggersToRun = [...seen.values()];
551
833
  if (triggersToRun.length === 0) {
552
- if (options.json) {
553
- const { content } = writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
554
- process.stdout.write(content);
555
- }
556
- else {
557
- writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
834
+ emitEmptyRunLog(repoPath, options);
835
+ if (!options.json) {
558
836
  reporter.blank();
559
837
  if (options.skill) {
560
838
  reporter.warning(`No triggers matched for skill: ${options.skill}`);
@@ -577,14 +855,13 @@ async function runConfigMode(options, reporter) {
577
855
  verifyAuth({ apiKey });
578
856
  }
579
857
  catch (error) {
580
- reporter.error(error.message);
581
- if (options.json) {
582
- const { content } = writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
583
- process.stdout.write(content);
584
- }
585
- else {
586
- writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
587
- }
858
+ const message = error.message;
859
+ reporter.error(message);
860
+ emitEmptyRunLog(repoPath, options, {
861
+ code: 'auth_failed',
862
+ message,
863
+ timestamp: new Date().toISOString(),
864
+ });
588
865
  return 1;
589
866
  }
590
867
  // Build trigger tasks
@@ -608,6 +885,33 @@ async function runConfigMode(options, reporter) {
608
885
  auxiliaryMaxRetries: config.defaults?.auxiliaryMaxRetries,
609
886
  },
610
887
  }));
888
+ // Initialize the run's JSONL log up front so a second terminal can
889
+ // `warden runs follow <runId>` while skills are still running.
890
+ // Skill records are appended on each completion; the trailing summary
891
+ // is appended in `outputResultsAndHandleFixes`.
892
+ // Run-level model is the default (ignoring per-trigger overrides); per-skill models are on each report.
893
+ const defaultModel = config.defaults?.model ?? options.model ?? process.env['WARDEN_MODEL'] ?? MODEL_DEFAULT_SENTINEL;
894
+ const runId = generateRunId();
895
+ const timestamp = new Date();
896
+ const traceId = getTraceId();
897
+ let headSha;
898
+ try {
899
+ headSha = getHeadSha(repoPath);
900
+ }
901
+ catch {
902
+ // Not a git repo or HEAD is unborn — non-fatal
903
+ }
904
+ const runLog = initializeRunLog({
905
+ repoPath,
906
+ runId,
907
+ timestamp,
908
+ traceId,
909
+ headSha,
910
+ model: defaultModel,
911
+ outputPath: options.output,
912
+ reporter,
913
+ startTime,
914
+ });
611
915
  // Run triggers with Ink UI (TTY) or simple console output (non-TTY)
612
916
  const concurrency = options.parallel ?? config.runner?.concurrency ?? DEFAULT_CONCURRENCY;
613
917
  failFastController = options.failFast ? new AbortController() : undefined;
@@ -616,6 +920,8 @@ async function runConfigMode(options, reporter) {
616
920
  verbosity: reporter.verbosity,
617
921
  concurrency,
618
922
  failFastController,
923
+ onChunkComplete: (skillName, chunk) => appendChunkToRunLog(runLog, skillName, chunk),
924
+ onSkillComplete: (report) => appendReportToRunLog(runLog, report),
619
925
  };
620
926
  const results = reporter.mode.isTTY
621
927
  ? await runSkillTasksWithInk(tasks, taskOptions)
@@ -623,9 +929,7 @@ async function runConfigMode(options, reporter) {
623
929
  // Process results and output
624
930
  const totalDuration = Date.now() - startTime;
625
931
  const processed = processTaskResults(results, options.reportOn, effectiveMinConfidence);
626
- // Run-level model is the default (ignoring per-trigger overrides); per-skill models are on each report.
627
- const defaultModel = config.defaults?.model ?? options.model ?? process.env['WARDEN_MODEL'] ?? MODEL_DEFAULT_SENTINEL;
628
- return outputResultsAndHandleFixes(processed, options, reporter, repoPath, totalDuration, failFastController?.signal.aborted, defaultModel);
932
+ return outputResultsAndHandleFixes(processed, options, reporter, runLog, totalDuration, failFastController?.signal.aborted);
629
933
  }
630
934
  /**
631
935
  * Run in direct skill mode: run a specific skill on uncommitted changes.
@@ -660,12 +964,8 @@ async function runDirectSkillMode(options, reporter) {
660
964
  return 1;
661
965
  }
662
966
  if (pullRequest.files.length === 0) {
663
- if (options.json) {
664
- const { content } = writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
665
- process.stdout.write(content);
666
- }
667
- else {
668
- writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output });
967
+ emitEmptyRunLog(repoPath, options);
968
+ if (!options.json) {
669
969
  if (options.staged) {
670
970
  reporter.renderEmptyState('No staged changes found');
671
971
  }
@@ -717,7 +1017,7 @@ async function runCommand(options, reporter) {
717
1017
  return runFileMode(filePatterns, options, reporter);
718
1018
  }
719
1019
  export async function main() {
720
- const { command, options, setupAppOptions, logsOptions } = parseCliArgs();
1020
+ const { command, options, setupAppOptions, runsOptions } = parseCliArgs();
721
1021
  if (command === 'help') {
722
1022
  showHelp();
723
1023
  process.exit(0);
@@ -762,12 +1062,12 @@ export async function main() {
762
1062
  return runSetupApp(setupAppOptions, reporter);
763
1063
  case 'sync':
764
1064
  return runSync(options, reporter);
765
- case 'logs':
766
- if (!logsOptions) {
767
- reporter.error('Missing logs options');
1065
+ case 'runs':
1066
+ if (!runsOptions) {
1067
+ reporter.error('Missing runs options');
768
1068
  process.exit(1);
769
1069
  }
770
- return runLogs(logsOptions, options, reporter);
1070
+ return runRuns(runsOptions, options, reporter);
771
1071
  default:
772
1072
  return runCommand(options, reporter);
773
1073
  }