@delegance/claude-autopilot 7.11.0-pre.1 → 7.11.0-pre.3

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.
@@ -1264,7 +1264,7 @@ switch (subcommand) {
1264
1264
  process.stdout.write(focused ?? buildHelpText());
1265
1265
  process.exit(0);
1266
1266
  }
1267
- const { runRunsList, runRunsShow, runRunsGc, runRunsDelete, runRunsDoctor, } = await import("./runs.js");
1267
+ const { runRunsList, runRunsShow, runRunsGc, runRunsDelete, runRunsDoctor, runRunsCleanup, } = await import("./runs.js");
1268
1268
  let result;
1269
1269
  switch (sub) {
1270
1270
  case 'list': {
@@ -1349,8 +1349,34 @@ switch (subcommand) {
1349
1349
  });
1350
1350
  break;
1351
1351
  }
1352
+ case 'cleanup': {
1353
+ // v7.11.0 PR 2/6 — stale repo-lock recovery. The only operation
1354
+ // currently supported is `--force-unlock`. The verb is scoped
1355
+ // under `runs` (not its own top-level command) because the
1356
+ // intended audience is anyone who already runs `runs list` /
1357
+ // `runs show` etc. — they shouldn't need to learn a new noun.
1358
+ //
1359
+ // SECURITY: `--lock-path` is INTENTIONALLY NOT exposed on the
1360
+ // CLI. The handler accepts an override for tests, but routing a
1361
+ // user-supplied path here would let a typo or malicious script
1362
+ // `forceUnlockRepoLock` an arbitrary `<path>` + `<path>.lock`
1363
+ // pair on disk. The lock path is always derived from the cwd as
1364
+ // `<cwd>/.claude/run-state/repo.lock` in production.
1365
+ // (Codex pass 1 WARNING.)
1366
+ const forceUnlock = boolFlag('force-unlock');
1367
+ const yes = boolFlag('yes');
1368
+ const allowActive = boolFlag('allow-active');
1369
+ result = await runRunsCleanup({
1370
+ cwd,
1371
+ forceUnlock,
1372
+ yes,
1373
+ allowActive,
1374
+ json,
1375
+ });
1376
+ break;
1377
+ }
1352
1378
  default: {
1353
- process.stderr.write(`\x1b[31m[claude-autopilot] runs: unknown sub-verb "${sub}" — valid: list, show, gc, delete, doctor, watch\x1b[0m\n`);
1379
+ process.stderr.write(`\x1b[31m[claude-autopilot] runs: unknown sub-verb "${sub}" — valid: list, show, gc, delete, doctor, watch, cleanup\x1b[0m\n`);
1354
1380
  process.exit(1);
1355
1381
  }
1356
1382
  }
@@ -232,6 +232,43 @@ export function renderEventLine(ev, runningTotal, opts) {
232
232
  case 'replay.override': {
233
233
  return `${ts} ${colorize(verb, 'magenta', opts.ansi)} ${ev.phase} reason=${ev.reason}`;
234
234
  }
235
+ // v7.11.0 concurrent subagent dispatch events. Detailed renderers will
236
+ // land in PR 6 (#193) when the watch UI exposes the per-task pane.
237
+ // For now, render a single-line summary so existing `runs watch`
238
+ // sessions don't silently skip these events.
239
+ case 'task.started': {
240
+ return `${ts} ${colorize(verb, 'cyan', opts.ansi)} ${ev.task_id} branch=${ev.branch}`;
241
+ }
242
+ case 'task.budget_reserved': {
243
+ return `${ts} ${colorize(verb, 'cyan', opts.ansi)} ${ev.task_id} reserved=${fmtUSD(ev.reserved_usd)}`;
244
+ }
245
+ case 'task.budget_increased_reservation': {
246
+ return `${ts} ${colorize(verb, 'cyan', opts.ansi)} ${ev.task_id} ${fmtUSD(ev.prior_reserved_usd)}→${fmtUSD(ev.new_reserved_usd)} ${ev.reason}`;
247
+ }
248
+ case 'task.budget_released': {
249
+ return `${ts} ${colorize(verb, 'cyan', opts.ansi)} ${ev.task_id} actual=${fmtUSD(ev.actual_cost_usd)} delta=${fmtUSD(ev.delta_vs_reservation_usd)}`;
250
+ }
251
+ case 'task.completed': {
252
+ return `${ts} ${colorize(verb, 'green', opts.ansi)} ${ev.task_id} status=${ev.exit_status} commits=${ev.commit_shas.length}`;
253
+ }
254
+ case 'task.failed': {
255
+ return `${ts} ${colorize(verb, 'red', opts.ansi)} ${ev.task_id} type=${ev.error_type} ${ev.error_message}`;
256
+ }
257
+ case 'task.merged': {
258
+ return `${ts} ${colorize(verb, 'green', opts.ansi)} ${ev.task_id}`;
259
+ }
260
+ case 'task.merge_conflict': {
261
+ return `${ts} ${colorize(verb, 'red', opts.ansi)} ${ev.task_id} paths=${ev.conflicting_paths.length}`;
262
+ }
263
+ case 'task.merge_aborted': {
264
+ return `${ts} ${colorize(verb, 'red', opts.ansi)} ${ev.task_id} reason=${ev.reason}`;
265
+ }
266
+ case 'task.timeout': {
267
+ return `${ts} ${colorize(verb, 'yellow', opts.ansi)} ${ev.task_id} ${ev.timeout_ms}ms signal=${ev.killed_signal}`;
268
+ }
269
+ case 'task.budget_halt': {
270
+ return `${ts} ${colorize(verb, 'red', opts.ansi)} ${ev.task_id} remaining=${fmtUSD(ev.budget_remaining_usd)} needed=${fmtUSD(ev.preflight_estimate_usd)}`;
271
+ }
235
272
  default: {
236
273
  // Exhaustiveness guard. New event variants must be added here so a
237
274
  // future RunEvent extension forces a compile error rather than
@@ -118,5 +118,34 @@ export interface RunsDoctorRunReport {
118
118
  * events-corrupt : events.ndjson can't be folded (bigger problem)
119
119
  */
120
120
  export declare function runRunsDoctor(opts: RunRunsDoctorOptions): Promise<RunsCliResult>;
121
+ export interface RunRunsCleanupOptions {
122
+ cwd?: string;
123
+ /** Required — currently the only operation. Future cleanup verbs (e.g.
124
+ * `--gc-worktrees`) would be additional bool flags on the same subcommand. */
125
+ forceUnlock: boolean;
126
+ /** Bypass the interactive `yes` prompt. Used by scripts (not exposed in
127
+ * the README as an example — we want manual operators to type yes). */
128
+ yes?: boolean;
129
+ /** Permit clearing a lock whose holder is still alive (or whose status
130
+ * cannot be determined, e.g. cross-host). By default we refuse to clear
131
+ * non-stale locks because doing so can corrupt git state under a live
132
+ * writer. Codex pass 1 WARNING — see issue #189 PR review. */
133
+ allowActive?: boolean;
134
+ /** Override the repo-lock path. Tests use this. INTENTIONALLY NOT exposed
135
+ * on the CLI (see `src/cli/index.ts`) — a user-supplied path would let a
136
+ * typo destroy arbitrary files on disk. */
137
+ lockPath?: string;
138
+ /** Custom stdin reader for tests. Defaults to a node:readline-bound
139
+ * interface against process.stdin. */
140
+ promptFn?: (question: string) => Promise<string>;
141
+ json?: boolean;
142
+ }
143
+ /**
144
+ * `runs cleanup --force-unlock` — surface the holder, require explicit
145
+ * `yes` confirmation, then unlink the lock metadata + the proper-lockfile
146
+ * `.lock` directory. Idempotent: if nothing is held, returns success with
147
+ * a "nothing to do" message.
148
+ */
149
+ export declare function runRunsCleanup(opts: RunRunsCleanupOptions): Promise<RunsCliResult>;
121
150
  export { statePath };
122
151
  //# sourceMappingURL=runs.d.ts.map
@@ -18,11 +18,13 @@
18
18
  // mode", "Migration path". `--json` envelope shape in Phase 3 is the v1
19
19
  // surface; strict stdout/stderr channel discipline lands in Phase 5.
20
20
  import * as fs from 'node:fs';
21
+ import * as os from 'node:os';
21
22
  import * as path from 'node:path';
22
23
  import * as readline from 'node:readline';
23
24
  import { GuardrailError } from "../core/errors.js";
24
25
  import { foldEvents, readEvents } from "../core/run-state/events.js";
25
26
  import { acquireRunLock } from "../core/run-state/lock.js";
27
+ import { forceUnlockRepoLock, formatLockDiagnostic, isLockStale, peekRepoLock, } from "../core/run-state/repo-lock.js";
26
28
  import { decideReplay } from "../core/run-state/replay-decision.js";
27
29
  import { readStateSnapshot, statePath, writeStateSnapshot } from "../core/run-state/state.js";
28
30
  import { isValidULID } from "../core/run-state/ulid.js";
@@ -897,6 +899,230 @@ function diffStates(a, b) {
897
899
  }
898
900
  return null;
899
901
  }
902
+ /** Default user prompt — single line, expects exactly the string `yes`. */
903
+ function defaultPromptFn(question) {
904
+ return new Promise(resolve => {
905
+ const rl = readline.createInterface({
906
+ input: process.stdin,
907
+ output: process.stdout,
908
+ });
909
+ rl.question(question, answer => {
910
+ rl.close();
911
+ resolve(answer);
912
+ });
913
+ });
914
+ }
915
+ /** Resolve the default repo-lock path relative to a given cwd. */
916
+ function defaultRepoLockPath(cwd) {
917
+ return path.join(cwd, '.claude', 'run-state', 'repo.lock');
918
+ }
919
+ /**
920
+ * Validate that a caller-supplied `lockPath` is safe to use. We accept:
921
+ * - the conventional default `<cwd>/.claude/run-state/repo.lock`
922
+ * - paths under the OS temp dir (for tests using `mkdtemp` scratch dirs)
923
+ *
924
+ * Anything else throws. This protects against an internal caller routing
925
+ * user-controlled input into `forceUnlockRepoLock`, which would otherwise
926
+ * remove arbitrary `<path>` + `<path>.lock` pairs on disk.
927
+ *
928
+ * Codex pass 2 WARNING — the `lockPath` option remained on the exported
929
+ * API surface even after the CLI flag was removed.
930
+ */
931
+ function assertSafeLockPath(lockPath, cwd) {
932
+ const conventional = defaultRepoLockPath(cwd);
933
+ const tmpRoot = os.tmpdir();
934
+ const resolved = path.resolve(lockPath);
935
+ if (resolved === path.resolve(conventional))
936
+ return;
937
+ // Allow temp-dir paths for tests. Use realpath-resolved tmpdir to handle
938
+ // /var → /private/var on macOS.
939
+ let tmpResolved;
940
+ try {
941
+ tmpResolved = fs.realpathSync(tmpRoot);
942
+ }
943
+ catch {
944
+ tmpResolved = path.resolve(tmpRoot);
945
+ }
946
+ const resolvedReal = (() => {
947
+ try {
948
+ return fs.realpathSync(path.dirname(resolved)) + path.sep + path.basename(resolved);
949
+ }
950
+ catch {
951
+ return resolved;
952
+ }
953
+ })();
954
+ if (resolvedReal.startsWith(tmpResolved + path.sep))
955
+ return;
956
+ throw new GuardrailError(`runs cleanup: lockPath override outside the expected location is refused (got "${lockPath}", expected "${conventional}" or a temp-dir path)`, {
957
+ code: 'invalid_config',
958
+ provider: 'runs-cli',
959
+ details: { lockPath, conventional },
960
+ });
961
+ }
962
+ /**
963
+ * `runs cleanup --force-unlock` — surface the holder, require explicit
964
+ * `yes` confirmation, then unlink the lock metadata + the proper-lockfile
965
+ * `.lock` directory. Idempotent: if nothing is held, returns success with
966
+ * a "nothing to do" message.
967
+ */
968
+ export async function runRunsCleanup(opts) {
969
+ const cwd = opts.cwd ?? process.cwd();
970
+ const json = !!opts.json;
971
+ const lockPath = opts.lockPath ?? defaultRepoLockPath(cwd);
972
+ // Validate lockPath if it was supplied (Codex pass 2 WARNING).
973
+ if (opts.lockPath) {
974
+ try {
975
+ assertSafeLockPath(opts.lockPath, cwd);
976
+ }
977
+ catch (err) {
978
+ return maybeEnvelope('runs cleanup', json, {
979
+ exit: 1,
980
+ stdout: [],
981
+ stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
982
+ }, { error: formatErr(err) });
983
+ }
984
+ }
985
+ if (!opts.forceUnlock) {
986
+ const err = new GuardrailError('runs cleanup requires an operation flag — currently only --force-unlock is supported', {
987
+ code: 'invalid_config',
988
+ provider: 'runs-cli',
989
+ details: { lockPath },
990
+ });
991
+ return maybeEnvelope('runs cleanup', json, {
992
+ exit: 1,
993
+ stdout: [],
994
+ stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
995
+ }, { error: formatErr(err) });
996
+ }
997
+ // Read existing metadata. Missing meta + missing lock dir = nothing to
998
+ // clean; we say so and exit 0 (idempotent).
999
+ const meta = peekRepoLock(lockPath);
1000
+ const lockDirExists = fs.existsSync(lockPath + '.lock');
1001
+ if (!meta && !lockDirExists) {
1002
+ return maybeEnvelope('runs cleanup', json, {
1003
+ exit: 0,
1004
+ stdout: [`runs cleanup: no repo lock at ${lockPath} — nothing to do`],
1005
+ stderr: [],
1006
+ }, { lockPath, cleared: false, reason: 'no-lock-present' });
1007
+ }
1008
+ // Print the diagnostic so the user knows exactly what they're clearing.
1009
+ const diagnosticLines = [];
1010
+ if (meta) {
1011
+ diagnosticLines.push(formatLockDiagnostic(meta, lockPath));
1012
+ }
1013
+ else {
1014
+ diagnosticLines.push(`Repo lock at ${lockPath} exists but has no metadata sidecar.`, '(The holder likely crashed between acquiring the lock and writing metadata.)');
1015
+ }
1016
+ // Safety gate: refuse to clear non-stale locks without --allow-active.
1017
+ // A stale lock (dead PID AND >1h old) is safe to clear; anything else
1018
+ // — live holder, unknown holder (no metadata), or cross-host — is
1019
+ // suspicious. Codex pass 1 WARNING flagged that scripted `--yes` could
1020
+ // silently steal an active lock. (#189 PR review)
1021
+ //
1022
+ // Fail-closed semantics (Codex pass 2 WARNING): only proceed when
1023
+ // `isLockStale(meta) === true`. Any other value (false, or — if the
1024
+ // signature evolves — null/undefined for "cannot determine") falls
1025
+ // back to refusing without --allow-active.
1026
+ const isConfirmedStale = meta ? isLockStale(meta) === true : false;
1027
+ if (!isConfirmedStale && !opts.allowActive) {
1028
+ const err = new GuardrailError(meta
1029
+ ? `repo lock at ${lockPath} is not stale (holder appears active) — re-run with --allow-active to override`
1030
+ : `repo lock at ${lockPath} has no metadata sidecar — holder identity is unknown. Re-run with --allow-active to override`, {
1031
+ code: 'invalid_config',
1032
+ provider: 'runs-cli',
1033
+ details: { lockPath, ...(meta ? { metadata: meta } : {}) },
1034
+ });
1035
+ return maybeEnvelope('runs cleanup', json, {
1036
+ exit: 1,
1037
+ stdout: [],
1038
+ stderr: [
1039
+ ...diagnosticLines,
1040
+ '',
1041
+ `[claude-autopilot] runs cleanup: ${formatErr(err)}`,
1042
+ ],
1043
+ }, { error: formatErr(err), lockPath, ...(meta ? { previousHolder: meta } : {}) });
1044
+ }
1045
+ // Confirmation — REQUIRED in text mode, may be bypassed by --yes in
1046
+ // scripted mode. JSON mode without --yes is rejected: we will not
1047
+ // dump a confirmation prompt to JSON consumers.
1048
+ if (!opts.yes) {
1049
+ if (json) {
1050
+ const err = new GuardrailError('runs cleanup --force-unlock requires --yes when used with --json', {
1051
+ code: 'invalid_config',
1052
+ provider: 'runs-cli',
1053
+ details: { lockPath },
1054
+ });
1055
+ return maybeEnvelope('runs cleanup', json, {
1056
+ exit: 1,
1057
+ stdout: [],
1058
+ stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
1059
+ }, { error: formatErr(err), lockPath });
1060
+ }
1061
+ // Print diagnostic to stderr so it appears even under output piping.
1062
+ for (const line of diagnosticLines) {
1063
+ process.stderr.write(`${line}\n`);
1064
+ }
1065
+ process.stderr.write('\n');
1066
+ const prompt = opts.promptFn ?? defaultPromptFn;
1067
+ const answer = (await prompt('Type "yes" to force-unlock: ')).trim();
1068
+ if (answer !== 'yes') {
1069
+ return {
1070
+ exit: 1,
1071
+ stdout: [],
1072
+ stderr: [`runs cleanup: aborted (confirmation not "yes")`],
1073
+ };
1074
+ }
1075
+ }
1076
+ // TOCTOU re-check (Codex pass 2 WARNING): between the safety gate above
1077
+ // and the actual unlock, the holder could have released and a new
1078
+ // process could have acquired the lock. Re-read the metadata and refuse
1079
+ // the unlock if the holder identity changed since the diagnostic was
1080
+ // printed. Skipped when --allow-active was explicitly requested (the
1081
+ // user has accepted the risk; this branch is reserved for force-clear
1082
+ // operations that should not be blocked by a fast-acquiring sibling).
1083
+ if (!opts.allowActive) {
1084
+ const refreshed = peekRepoLock(lockPath);
1085
+ const sameHolder = (refreshed === null && meta === null) ||
1086
+ (refreshed !== null &&
1087
+ meta !== null &&
1088
+ refreshed.pid === meta.pid &&
1089
+ refreshed.hostname === meta.hostname &&
1090
+ refreshed.run_id === meta.run_id &&
1091
+ refreshed.acquired_at_iso === meta.acquired_at_iso);
1092
+ if (!sameHolder) {
1093
+ const err = new GuardrailError('repo lock changed hands during cleanup — refusing to clear the new holder', {
1094
+ code: 'invalid_config',
1095
+ provider: 'runs-cli',
1096
+ details: {
1097
+ lockPath,
1098
+ ...(meta ? { observedHolder: meta } : {}),
1099
+ ...(refreshed ? { currentHolder: refreshed } : {}),
1100
+ },
1101
+ });
1102
+ return maybeEnvelope('runs cleanup', json, {
1103
+ exit: 1,
1104
+ stdout: [],
1105
+ stderr: [`[claude-autopilot] runs cleanup: ${formatErr(err)}`],
1106
+ }, { error: formatErr(err), lockPath });
1107
+ }
1108
+ }
1109
+ const removed = forceUnlockRepoLock(lockPath);
1110
+ const stale = meta ? isLockStale(meta) : null;
1111
+ return maybeEnvelope('runs cleanup', json, {
1112
+ exit: 0,
1113
+ stdout: [
1114
+ ...diagnosticLines,
1115
+ removed
1116
+ ? `runs cleanup: removed repo lock at ${lockPath}`
1117
+ : `runs cleanup: nothing to remove at ${lockPath}`,
1118
+ ],
1119
+ stderr: [],
1120
+ }, {
1121
+ lockPath,
1122
+ cleared: removed,
1123
+ ...(meta ? { previousHolder: meta, wasStale: stale } : {}),
1124
+ });
1125
+ }
900
1126
  // `statePath` is re-exported for convenience to keep CLI imports tidy.
901
1127
  export { statePath };
902
1128
  //# sourceMappingURL=runs.js.map
@@ -406,6 +406,24 @@ function applyEvent(state, ev) {
406
406
  // events.ndjson directly to compute actualSoFar — replay does not
407
407
  // need to track budget decisions for state-correctness purposes.
408
408
  break;
409
+ case 'task.started':
410
+ case 'task.budget_reserved':
411
+ case 'task.budget_increased_reservation':
412
+ case 'task.budget_released':
413
+ case 'task.completed':
414
+ case 'task.failed':
415
+ case 'task.merged':
416
+ case 'task.merge_conflict':
417
+ case 'task.merge_aborted':
418
+ case 'task.timeout':
419
+ case 'task.budget_halt':
420
+ // v7.11.0 concurrent-dispatch task events. State for these lives in
421
+ // the dispatch layer (budget-reservation ledger + scheduler), NOT in
422
+ // the per-phase RunState snapshot — phases remain the coarse-grained
423
+ // unit for the run-state engine. Replay is handled by
424
+ // `budgetReservation.replayFromEvents()` for cost reconstruction and
425
+ // by the scheduler's resume path for task-level state.
426
+ break;
409
427
  case 'phase.start': {
410
428
  state.status = 'running';
411
429
  state.currentPhaseIdx = ev.phaseIdx;
@@ -251,9 +251,135 @@ export interface ReplayOverrideEvent extends RunEventBase {
251
251
  /** Refs the underlying refusal cited (echoed for triage). */
252
252
  refsConsulted: ExternalRef[];
253
253
  }
254
+ /** Subagent dispatch — emitted AFTER `task.budget_reserved` succeeds and the
255
+ * worktree is created. Carries the immutable `base_sha` so resume / merge
256
+ * can verify ancestry against an unforgeable reference. */
257
+ export interface TaskStartedEvent extends RunEventBase {
258
+ event: 'task.started';
259
+ task_id: string;
260
+ worktree_path: string;
261
+ branch: string;
262
+ base_sha: string;
263
+ subagent_id: string;
264
+ /** ISO timestamp the subagent process was spawned. */
265
+ dispatched_at: string;
266
+ preflight_cost_estimate_usd: number;
267
+ }
268
+ /** Budget reservation — emitted atomically with the (replay → check → append
269
+ * → fsync) critical section under the writer's exclusive lock. Two
270
+ * concurrent callers cannot both pass the budget check. */
271
+ export interface TaskBudgetReservedEvent extends RunEventBase {
272
+ event: 'task.budget_reserved';
273
+ task_id: string;
274
+ reserved_usd: number;
275
+ /** `perRunUSD - reserved_total` AFTER this reservation lands. May be 0,
276
+ * never negative (would have failed the check). */
277
+ run_budget_remaining_after_reservation_usd: number;
278
+ }
279
+ /** Mid-execution reservation bump — emitted when telemetry from the subagent
280
+ * shows actual cost is approaching the reservation. Re-checks `perRunUSD`
281
+ * under the writer lock; halts the run if exceeded. */
282
+ export interface TaskBudgetIncreasedReservationEvent extends RunEventBase {
283
+ event: 'task.budget_increased_reservation';
284
+ task_id: string;
285
+ prior_reserved_usd: number;
286
+ new_reserved_usd: number;
287
+ reason: string;
288
+ }
289
+ /** Reservation closed — emitted on task completion OR failure. The
290
+ * `delta_vs_reservation_usd` (positive = under, negative = over) lets
291
+ * cost-analytics consumers spot estimate drift. */
292
+ export interface TaskBudgetReleasedEvent extends RunEventBase {
293
+ event: 'task.budget_released';
294
+ task_id: string;
295
+ actual_cost_usd: number;
296
+ delta_vs_reservation_usd: number;
297
+ }
298
+ /** Successful subagent exit with commits on the task branch. The
299
+ * `task_branch_tip_sha` is the IMMUTABLE authoritative ref for all
300
+ * subsequent merge / resume operations — `task_branch_name` is for
301
+ * diagnostics only (branch can be tampered with). */
302
+ export interface TaskCompletedEvent extends RunEventBase {
303
+ event: 'task.completed';
304
+ task_id: string;
305
+ base_sha: string;
306
+ task_branch_tip_sha: string;
307
+ task_branch_name: string;
308
+ /** Ordered list of commit SHAs `base_sha..tip_sha` (oldest first, from
309
+ * `git rev-list --reverse`). Empty array implies `task.failed` should
310
+ * have been emitted with `error_type: 'no_commits'` instead. */
311
+ commit_shas: string[];
312
+ completed_at: string;
313
+ actual_cost_usd: number;
314
+ exit_status: 'success' | 'failure';
315
+ }
316
+ /** Subagent terminal failure. `error_type` is the resume classifier — see
317
+ * spec "Resume semantics" for the classification table. */
318
+ export interface TaskFailedEvent extends RunEventBase {
319
+ event: 'task.failed';
320
+ task_id: string;
321
+ error_message: string;
322
+ error_type: 'timeout' | 'no_commits' | 'ancestry_violation' | 'budget_exceeded' | 'crash' | 'other';
323
+ failed_at: string;
324
+ actual_cost_usd: number;
325
+ }
326
+ /** Cherry-pick chain landed on the integration worktree. The
327
+ * `feature_branch_sha_after_merge` is recorded so the next merge can
328
+ * verify preconditions (HEAD matches the last `task.merged`). */
329
+ export interface TaskMergedEvent extends RunEventBase {
330
+ event: 'task.merged';
331
+ task_id: string;
332
+ feature_branch_sha_after_merge: string;
333
+ merged_at: string;
334
+ }
335
+ /** Cherry-pick conflict captured BEFORE `cherry-pick --abort` runs.
336
+ * Diagnostics are also persisted to `conflict_report_path`
337
+ * (`.claude/run-state/<run-ulid>/conflicts/<task-id>.md`). */
338
+ export interface TaskMergeConflictEvent extends RunEventBase {
339
+ event: 'task.merge_conflict';
340
+ task_id: string;
341
+ conflicting_paths: string[];
342
+ /** Output of `git ls-files -u` — index stages 1/2/3 for each conflicted
343
+ * path. Free-form lines preserved verbatim. */
344
+ index_stages: string[];
345
+ /** Output of `git status --porcelain`. */
346
+ porcelain: string;
347
+ conflict_report_path: string;
348
+ }
349
+ /** Merge precondition violation — dirty tree, wrong HEAD, in-progress
350
+ * cherry-pick / rebase, or ancestry violation at merge time. Halts the
351
+ * run; user must resolve before resume. */
352
+ export interface TaskMergeAbortedEvent extends RunEventBase {
353
+ event: 'task.merge_aborted';
354
+ task_id: string;
355
+ reason: string;
356
+ precondition_violated: string;
357
+ occurred_at: string;
358
+ }
359
+ /** Subagent exceeded `perSubagentTimeoutMs`. Informational — the resume
360
+ * classifier requires a paired `task.failed` event with
361
+ * `error_type: 'timeout'` for terminal classification. */
362
+ export interface TaskTimeoutEvent extends RunEventBase {
363
+ event: 'task.timeout';
364
+ task_id: string;
365
+ timeout_ms: number;
366
+ /** `SIGTERM` or `SIGKILL` — set to `SIGKILL` when the 30s grace after
367
+ * SIGTERM elapsed without process exit. */
368
+ killed_signal: 'SIGTERM' | 'SIGKILL';
369
+ }
370
+ /** Pre-dispatch budget halt — emitted when `reserve()` finds remaining
371
+ * budget insufficient for the new task's preflight estimate. The task
372
+ * never dispatches; the scheduler halts the run with this event as the
373
+ * terminal record. */
374
+ export interface TaskBudgetHaltEvent extends RunEventBase {
375
+ event: 'task.budget_halt';
376
+ task_id: string;
377
+ budget_remaining_usd: number;
378
+ preflight_estimate_usd: number;
379
+ }
254
380
  /** Discriminated union of every event variant. Add new variants here and
255
381
  * the code that switches over `event` will type-error at compile time. */
256
- export type RunEvent = RunStartEvent | RunCompleteEvent | RunWarningEvent | RunRecoveryEvent | PhaseStartEvent | PhaseSuccessEvent | PhaseFailedEvent | PhaseAbortedEvent | PhaseCostEvent | PhaseExternalRefEvent | PhaseNeedsHumanEvent | LockTakeoverEvent | IndexRebuiltEvent | BudgetCheckEvent | ReplayOverrideEvent;
382
+ export type RunEvent = RunStartEvent | RunCompleteEvent | RunWarningEvent | RunRecoveryEvent | PhaseStartEvent | PhaseSuccessEvent | PhaseFailedEvent | PhaseAbortedEvent | PhaseCostEvent | PhaseExternalRefEvent | PhaseNeedsHumanEvent | LockTakeoverEvent | IndexRebuiltEvent | BudgetCheckEvent | ReplayOverrideEvent | TaskStartedEvent | TaskBudgetReservedEvent | TaskBudgetIncreasedReservationEvent | TaskBudgetReleasedEvent | TaskCompletedEvent | TaskFailedEvent | TaskMergedEvent | TaskMergeConflictEvent | TaskMergeAbortedEvent | TaskTimeoutEvent | TaskBudgetHaltEvent;
257
383
  /** Distributive Omit so the discriminated-union shape is preserved when we
258
384
  * strip the fields the appender fills in. Plain `Omit<RunEvent, ...>`
259
385
  * collapses the union into a single intersection and loses variant-specific
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delegance/claude-autopilot",
3
- "version": "7.11.0-pre.1",
3
+ "version": "7.11.0-pre.3",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "tag": "next"