@autopilot-harness/cli 0.2.12 → 0.2.13

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.
@@ -42,6 +42,7 @@ var en_default = {
42
42
  recover_ambient: "Recover: the previous turn ended with an error. Continue your current work; Autopilot RUN is not active.",
43
43
  review_complete: "Review complete. All {total} confirm rounds passed; the review chain has ended. If the working tree still has uncommitted changes from this session, local commit only per the safe checklist (never stage .env/secrets/.autopilot runtime; no push unless the user asks); if clean, briefly confirm only.",
44
44
  stuck: "Stuck: no progress for several stops. Change strategy or send Autopilot RESUME after fixing.",
45
+ stuck_soft: "Stuck: no progress for several stops (missing completion evidence). Change strategy, write matching .autopilot/verify-last.json, then end the turn. Session stays armed \u2014 Autopilot RESUME is not required unless the session was paused.",
45
46
  verify_fix: "Verify failed ({reason}). Fix verify commands and rewrite verify-last.json; do not advance.",
46
47
  need_evidence: `Need evidence: no-code item {currentId}{currentTitleSuffix} cannot advance without matching soft completion evidence. Write .autopilot/verify-last.json with itemId "{currentId}" and ok: true (only after this item's work is done). Then end the turn so the stop hook can advance/done. Do not ask the user to continue; do not invent Advance/Done.`,
47
48
  track_pick: "Select a plan by number or slug."
@@ -118,6 +119,7 @@ var zh_CN_default = {
118
119
  recover_ambient: "\u6062\u590D\uFF1A\u4E0A\u4E00\u56DE\u5408\u51FA\u9519\u3002\u7EE7\u7EED\u5F53\u524D\u4EFB\u52A1\uFF08\u672A\u5728\u6267\u884C checklist\uFF09\u3002",
119
120
  review_complete: "\u81EA\u5BA1\u5B8C\u6210\u3002\u8FDE\u7EED {total} \u8F6E\u786E\u8BA4\u5DF2\u901A\u8FC7\uFF0C\u81EA\u5BA1\u94FE\u5DF2\u7ED3\u675F\u3002\u82E5\u5DE5\u4F5C\u533A\u4ECD\u6709\u672C\u4F1A\u8BDD\u672A\u63D0\u4EA4\u6539\u52A8\uFF0C\u6309\u5B89\u5168\u6E05\u5355\u672C\u5730 commit\uFF08\u52FF stage .env/\u5BC6\u94A5/.autopilot \u8FD0\u884C\u65F6\uFF1B\u52FF push\uFF0C\u9664\u975E\u7528\u6237\u660E\u786E\u8981\u6C42\uFF09\uFF1B\u5DF2\u5E72\u51C0\u5219\u53EA\u7B80\u77ED\u786E\u8BA4\u5373\u53EF\u3002",
120
121
  stuck: "\u5361\u4F4F\uFF1A\u8FDE\u7EED\u591A\u8F6E\u65E0\u8FDB\u5C55\u3002\u8BF7\u6362\u7B56\u7565\uFF0C\u6216\u4FEE\u597D\u540E\u53D1\u9001 Autopilot RESUME\u3002",
122
+ stuck_soft: "\u5361\u4F4F\uFF1A\u8FDE\u7EED\u591A\u8F6E\u65E0\u8FDB\u5C55\uFF08\u7F3A\u5B8C\u6210\u8BC1\u636E\uFF09\u3002\u8BF7\u6362\u7B56\u7565\u5E76\u5199\u5165\u5339\u914D\u7684 .autopilot/verify-last.json \u540E\u7ED3\u675F\u56DE\u5408\u3002\u4F1A\u8BDD\u4ECD\u5728\u8FD0\u884C\uFF0C\u65E0\u9700 RESUME\uFF1B\u4EC5\u5728\u5DF2 pause \u65F6\u624D\u53D1\u9001 Autopilot RESUME\u3002",
121
123
  verify_fix: "\u6821\u9A8C\u5931\u8D25\uFF08{reason}\uFF09\u3002\u8BF7\u4FEE\u590D verify \u547D\u4EE4\u5E76\u91CD\u5199 verify-last.json\uFF1B\u4E0D\u8981\u63A8\u8FDB\u3002",
122
124
  need_evidence: '\u9700\u8981\u5B8C\u6210\u8BC1\u636E\uFF1A\u65E0\u4EE3\u7801\u6539\u52A8\u9879 {currentId}{currentTitleSuffix} \u7F3A\u5C11\u5339\u914D\u7684 soft \u5B8C\u6210\u8BC1\u636E\uFF0C\u65E0\u6CD5\u63A8\u8FDB\u3002\u8BF7\u5199\u5165 .autopilot/verify-last.json\uFF08itemId \u4E3A "{currentId}"\uFF0Cok: true\uFF1B\u987B\u5728\u8BE5\u9879\u5DE5\u4F5C\u5B8C\u6210\u540E\uFF09\u3002\u7136\u540E\u7ED3\u675F\u672C\u56DE\u5408\uFF0C\u7531 stop hook \u63A8\u8FDB/\u5B8C\u6210\u3002\u4E0D\u8981\u8BA9\u7528\u6237\u8BF4\u300C\u7EE7\u7EED\u300D\uFF1B\u4E0D\u8981\u81EA\u884C\u53D1\u660E\u63A8\u8FDB/\u5B8C\u6210\u6307\u4EE4\u3002',
123
125
  track_pick: "\u8BF7\u7528\u6570\u5B57\u6216 slug \u9009\u62E9\u8981\u6267\u884C\u7684 plan\u3002"
@@ -2398,6 +2400,380 @@ function pendingRedeliverAllowed(lastRedeliverAt) {
2398
2400
  return Date.now() - t >= PENDING_REDELIVER_COOLDOWN_MS;
2399
2401
  }
2400
2402
 
2403
+ // ../core/src/code-edit-detector.ts
2404
+ import { spawnSync } from "node:child_process";
2405
+ import path7 from "node:path";
2406
+
2407
+ // ../core/src/autopilot-ignore.ts
2408
+ import fs7 from "node:fs";
2409
+ import path6 from "node:path";
2410
+ var DEFAULT_AUTOPILOT_IGNORE_TEXT = `# Autopilot \u2014 paths that do NOT trigger self-review (gitignore syntax).
2411
+ #
2412
+ # What this file is:
2413
+ # - Controls whether an afterFileEdit counts as "product code" (opens fix/confirm).
2414
+ # - Does NOT change \`git diff\` / \`git status\` output (that is \`.gitignore\`).
2415
+ # - Review followups ask the agent to skip these paths when reading diffs (soft).
2416
+ #
2417
+ # Semantics:
2418
+ # - Same glob rules as gitignore; last matching pattern wins.
2419
+ # - Use \`!\` to force-include an exception (e.g. \`!docs/feed/**/*.yml\`).
2420
+ # - Markdown (*.md / *.mdx) is NOT ignored by default \u2014 design docs can be reviewed.
2421
+ # - \`docs/**\` is NOT ignored by default.
2422
+ # - Also skip untracked paths ignored by \`.gitignore\` (tracked files still count).
2423
+ #
2424
+ # Later (not implemented): hard-filtered review-diff / path ledger \u2014 see
2425
+ # docs/autopilot/workflows/autopilot-executing.md (B2 strong).
2426
+
2427
+ # Runtime / editor (prefer also listing these in .gitignore)
2428
+ .autopilot/**
2429
+ .cursor/**
2430
+ .claude/**
2431
+
2432
+ # Planning artifacts
2433
+ plans/**
2434
+
2435
+ # Common build / vendor trees
2436
+ node_modules/**
2437
+ dist/**
2438
+ build/**
2439
+ out/**
2440
+ target/**
2441
+ .target/**
2442
+ coverage/**
2443
+ .venv/**
2444
+ venv/**
2445
+ __pycache__/**
2446
+
2447
+ # Lockfiles / package manager noise
2448
+ package-lock.json
2449
+ pnpm-lock.yaml
2450
+ yarn.lock
2451
+ bun.lock
2452
+ bun.lockb
2453
+ Cargo.lock
2454
+ poetry.lock
2455
+ composer.lock
2456
+
2457
+ # Media / binary (do not trigger self-review)
2458
+ *.png
2459
+ *.jpg
2460
+ *.jpeg
2461
+ *.gif
2462
+ *.webp
2463
+ *.ico
2464
+ *.svg
2465
+ *.bmp
2466
+ *.mp3
2467
+ *.mp4
2468
+ *.wav
2469
+ *.webm
2470
+ *.mov
2471
+ *.woff
2472
+ *.woff2
2473
+ *.ttf
2474
+ *.otf
2475
+ *.eot
2476
+ *.pdf
2477
+ *.zip
2478
+ *.gz
2479
+ *.tgz
2480
+ *.7z
2481
+ *.rar
2482
+ *.jar
2483
+ *.class
2484
+ *.o
2485
+ *.a
2486
+ *.so
2487
+ *.dylib
2488
+ *.dll
2489
+ *.exe
2490
+ *.wasm
2491
+
2492
+ # Prose / data noise
2493
+ *.txt
2494
+ *.html
2495
+ *.htm
2496
+ *.csv
2497
+ *.tsv
2498
+ *.log
2499
+ *.map
2500
+ *.min.js
2501
+ *.min.css
2502
+ `;
2503
+ var MAX_AUTOPILOT_IGNORE_BYTES = 1e6;
2504
+ var MAX_AUTOPILOT_IGNORE_LINE_CHARS = 4096;
2505
+ var MAX_AUTOPILOT_IGNORE_PATTERNS = 1e4;
2506
+ var ignoreCache = /* @__PURE__ */ new Map();
2507
+ function escapeRegexChar(ch) {
2508
+ return ch.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
2509
+ }
2510
+ function globBodyToRegex(glob) {
2511
+ let out = "";
2512
+ for (let i = 0; i < glob.length; ) {
2513
+ const ch = glob[i];
2514
+ if (ch === "*" && glob[i + 1] === "*") {
2515
+ if (glob[i + 2] === "/") {
2516
+ out += "(?:.*/)?";
2517
+ i += 3;
2518
+ continue;
2519
+ }
2520
+ out += ".*";
2521
+ i += 2;
2522
+ continue;
2523
+ }
2524
+ if (ch === "*") {
2525
+ out += "[^/]*";
2526
+ i += 1;
2527
+ continue;
2528
+ }
2529
+ if (ch === "?") {
2530
+ out += "[^/]";
2531
+ i += 1;
2532
+ continue;
2533
+ }
2534
+ out += escapeRegexChar(ch);
2535
+ i += 1;
2536
+ }
2537
+ return out;
2538
+ }
2539
+ function compilePattern(raw) {
2540
+ let line = raw.trim();
2541
+ if (!line || line.startsWith("#")) return null;
2542
+ if (line.length > MAX_AUTOPILOT_IGNORE_LINE_CHARS) return null;
2543
+ let negated = false;
2544
+ if (line.startsWith("!")) {
2545
+ negated = true;
2546
+ line = line.slice(1).trim();
2547
+ if (!line || line.startsWith("#")) return null;
2548
+ }
2549
+ const dirOnly = line.endsWith("/");
2550
+ if (dirOnly) line = line.slice(0, -1);
2551
+ if (!line) return null;
2552
+ if (line.length > MAX_AUTOPILOT_IGNORE_LINE_CHARS) return null;
2553
+ let anchored = false;
2554
+ if (line.startsWith("/")) {
2555
+ anchored = true;
2556
+ line = line.slice(1);
2557
+ }
2558
+ const body = globBodyToRegex(line);
2559
+ let regexSource;
2560
+ if (anchored) {
2561
+ regexSource = `^${body}`;
2562
+ if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
2563
+ regexSource += "(?:/.*)?";
2564
+ }
2565
+ regexSource += "$";
2566
+ } else if (line.includes("/")) {
2567
+ regexSource = `(?:^|.*/)${body}`;
2568
+ if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
2569
+ regexSource += "(?:/.*)?";
2570
+ }
2571
+ regexSource += "$";
2572
+ } else {
2573
+ regexSource = `(?:^|.*/)?${body}$`;
2574
+ }
2575
+ return { negated, regex: new RegExp(regexSource) };
2576
+ }
2577
+ function parseAutopilotIgnore(text) {
2578
+ const patterns = [];
2579
+ for (const rawLine of text.split(/\r?\n/)) {
2580
+ const compiled = compilePattern(rawLine);
2581
+ if (!compiled) continue;
2582
+ patterns.push(compiled);
2583
+ if (patterns.length >= MAX_AUTOPILOT_IGNORE_PATTERNS) break;
2584
+ }
2585
+ return patterns;
2586
+ }
2587
+ var DEFAULT_AUTOPILOT_IGNORE_PATTERNS = parseAutopilotIgnore(
2588
+ DEFAULT_AUTOPILOT_IGNORE_TEXT
2589
+ );
2590
+ function normalizeRelativePath(filePath) {
2591
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
2592
+ }
2593
+ function isAutopilotIgnoredPath(relativePath, patterns) {
2594
+ const norm = normalizeRelativePath(relativePath);
2595
+ let ignored = false;
2596
+ for (const pat of patterns) {
2597
+ if (pat.regex.test(norm)) {
2598
+ ignored = !pat.negated;
2599
+ }
2600
+ }
2601
+ return ignored;
2602
+ }
2603
+ function realpathForCompare(absPath) {
2604
+ try {
2605
+ return fs7.realpathSync(absPath);
2606
+ } catch {
2607
+ }
2608
+ let dir = path6.dirname(absPath);
2609
+ const base = path6.basename(absPath);
2610
+ const missing = [];
2611
+ for (; ; ) {
2612
+ try {
2613
+ const realDir = fs7.realpathSync(dir);
2614
+ return path6.join(realDir, ...missing, base);
2615
+ } catch {
2616
+ const parent = path6.dirname(dir);
2617
+ if (parent === dir) return absPath;
2618
+ missing.unshift(path6.basename(dir));
2619
+ dir = parent;
2620
+ }
2621
+ }
2622
+ }
2623
+ function toProjectRelativePath(filePath, projectRoot) {
2624
+ const posix = filePath.replace(/\\/g, "/");
2625
+ if (!projectRoot?.trim()) {
2626
+ return normalizeRelativePath(posix);
2627
+ }
2628
+ const root = realpathForCompare(path6.resolve(projectRoot));
2629
+ const abs = realpathForCompare(
2630
+ path6.isAbsolute(posix) ? path6.resolve(posix) : path6.resolve(root, posix)
2631
+ );
2632
+ const rel = path6.relative(root, abs);
2633
+ if (rel.startsWith("..") || path6.isAbsolute(rel)) {
2634
+ return "";
2635
+ }
2636
+ return normalizeRelativePath(rel.replace(/\\/g, "/"));
2637
+ }
2638
+ function loadAutopilotIgnorePatterns(projectRoot) {
2639
+ const root = path6.resolve(projectRoot);
2640
+ const filePath = path6.join(root, ".autopilotignore");
2641
+ let st;
2642
+ try {
2643
+ st = fs7.lstatSync(filePath);
2644
+ } catch {
2645
+ return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
2646
+ }
2647
+ if (st.isSymbolicLink() || !st.isFile()) {
2648
+ return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
2649
+ }
2650
+ if (st.size > MAX_AUTOPILOT_IGNORE_BYTES) {
2651
+ return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
2652
+ }
2653
+ const cached = ignoreCache.get(root);
2654
+ if (cached && cached.mtimeMs === st.mtimeMs) {
2655
+ return cached.patterns;
2656
+ }
2657
+ let text;
2658
+ try {
2659
+ text = fs7.readFileSync(filePath, "utf8");
2660
+ } catch {
2661
+ return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
2662
+ }
2663
+ if (text.length > MAX_AUTOPILOT_IGNORE_BYTES) {
2664
+ return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
2665
+ }
2666
+ const patterns = parseAutopilotIgnore(text);
2667
+ ignoreCache.set(root, { mtimeMs: st.mtimeMs, patterns });
2668
+ return patterns;
2669
+ }
2670
+
2671
+ // ../core/src/code-edit-detector.ts
2672
+ function isUntrackedGitIgnored(projectRoot, relativePath) {
2673
+ if (!relativePath || relativePath.includes("\0")) return false;
2674
+ try {
2675
+ const r = spawnSync(
2676
+ "git",
2677
+ ["check-ignore", "-q", "--", relativePath],
2678
+ {
2679
+ cwd: projectRoot,
2680
+ encoding: "utf8",
2681
+ timeout: 5e3,
2682
+ windowsHide: true,
2683
+ shell: false
2684
+ }
2685
+ );
2686
+ return r.status === 0;
2687
+ } catch {
2688
+ return false;
2689
+ }
2690
+ }
2691
+ function isProductCodeEdit(filePath, opts) {
2692
+ const relative = toProjectRelativePath(filePath, opts?.projectRoot);
2693
+ if (!relative) return false;
2694
+ const patterns = opts?.projectRoot?.trim() ? loadAutopilotIgnorePatterns(opts.projectRoot) : DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
2695
+ if (isAutopilotIgnoredPath(relative, patterns)) return false;
2696
+ const root = opts?.projectRoot?.trim();
2697
+ if (root && isUntrackedGitIgnored(root, relative)) return false;
2698
+ return true;
2699
+ }
2700
+ var MAX_DIRTY_PATHS_COLLECT = 2e3;
2701
+ var MAX_DIRTY_PATHS_CLASSIFY = 200;
2702
+ function gitDirtyRelativePaths(projectRoot) {
2703
+ if (!projectRoot || projectRoot.includes("\0")) return [];
2704
+ const out = [];
2705
+ const seen = /* @__PURE__ */ new Set();
2706
+ const pushZ = (stdout) => {
2707
+ for (const raw of stdout.split("\0")) {
2708
+ const p = raw.replace(/\r$/, "");
2709
+ if (!p) continue;
2710
+ if (seen.has(p)) continue;
2711
+ seen.add(p);
2712
+ out.push(p);
2713
+ if (out.length >= MAX_DIRTY_PATHS_COLLECT) return;
2714
+ }
2715
+ };
2716
+ try {
2717
+ const tracked = spawnSync(
2718
+ "git",
2719
+ ["diff", "--name-only", "-z", "HEAD", "--"],
2720
+ {
2721
+ cwd: projectRoot,
2722
+ encoding: "utf8",
2723
+ timeout: 5e3,
2724
+ windowsHide: true,
2725
+ shell: false
2726
+ }
2727
+ );
2728
+ if (tracked.error || tracked.status !== 0) {
2729
+ return [];
2730
+ }
2731
+ if (tracked.stdout) pushZ(tracked.stdout);
2732
+ if (out.length >= MAX_DIRTY_PATHS_COLLECT) return out;
2733
+ const untracked = spawnSync(
2734
+ "git",
2735
+ ["ls-files", "-o", "--exclude-standard", "-z", "--"],
2736
+ {
2737
+ cwd: projectRoot,
2738
+ encoding: "utf8",
2739
+ timeout: 5e3,
2740
+ windowsHide: true,
2741
+ shell: false
2742
+ }
2743
+ );
2744
+ if (!untracked.error && untracked.status === 0 && untracked.stdout) {
2745
+ pushZ(untracked.stdout);
2746
+ }
2747
+ } catch {
2748
+ return [];
2749
+ }
2750
+ return out;
2751
+ }
2752
+ function hasDirtyProductCode(projectRoot) {
2753
+ const root = normalizeProjectRoot(projectRoot);
2754
+ if (!root) return false;
2755
+ const relatives = gitDirtyRelativePaths(root);
2756
+ const patterns = loadAutopilotIgnorePatterns(root);
2757
+ let classified = 0;
2758
+ for (const rel of relatives) {
2759
+ if (!rel || rel.includes("\0") || path7.isAbsolute(rel) || rel.split(/[/\\]/).includes("..")) {
2760
+ continue;
2761
+ }
2762
+ const abs = path7.join(root, rel);
2763
+ const relative = toProjectRelativePath(abs, root);
2764
+ if (!relative) continue;
2765
+ if (isAutopilotIgnoredPath(relative, patterns)) continue;
2766
+ classified += 1;
2767
+ if (classified > MAX_DIRTY_PATHS_CLASSIFY) {
2768
+ return false;
2769
+ }
2770
+ if (isProductCodeEdit(abs, { projectRoot: root })) {
2771
+ return true;
2772
+ }
2773
+ }
2774
+ return false;
2775
+ }
2776
+
2401
2777
  // ../core/src/review-engine.ts
2402
2778
  function defaultRender(kind, vars) {
2403
2779
  switch (kind) {
@@ -2421,6 +2797,8 @@ function defaultRender(kind, vars) {
2421
2797
  return `Review complete. All ${vars.total ?? 5} confirm rounds passed; the review chain has ended. If the working tree still has uncommitted changes from this session, local commit only per the safe checklist (never stage .env/secrets/.autopilot runtime; no push unless the user asks); if clean, briefly confirm only.`;
2422
2798
  case "stuck":
2423
2799
  return `Stuck: no progress for several stops. Change strategy or send Autopilot RESUME after fixing.`;
2800
+ case "stuck_soft":
2801
+ return `Stuck: no progress for several stops (missing completion evidence). Change strategy, write matching .autopilot/verify-last.json, then end the turn. Session stays armed \u2014 Autopilot RESUME is not required unless the session was paused.`;
2424
2802
  case "verify_fix":
2425
2803
  return `Verify failed (${vars.reason ?? "unknown"}). Fix verify commands and rewrite verify-last.json; do not advance.`;
2426
2804
  case "need_evidence":
@@ -2527,6 +2905,31 @@ var ReviewEngine = class {
2527
2905
  return fromPending;
2528
2906
  });
2529
2907
  }
2908
+ /**
2909
+ * Stop-time arm when Shell (or other non-afterFileEdit) dirtied product paths.
2910
+ * - `clean`: no product dirty → caller may soft E0
2911
+ * - `armed`: code_edited set → caller must prefer E2 (never soft)
2912
+ * - `dirty_unarmed`: dirty seen but arm failed → caller must not soft
2913
+ * (otherwise unreviewed product could soft-advance)
2914
+ */
2915
+ maybeArmCodeEditedFromDirtyTree(session) {
2916
+ const root = this.trustedProjectRoot();
2917
+ if (!root) return "clean";
2918
+ let dirty = false;
2919
+ try {
2920
+ dirty = hasDirtyProductCode(root);
2921
+ } catch {
2922
+ return "dirty_unarmed";
2923
+ }
2924
+ if (!dirty) return "clean";
2925
+ try {
2926
+ this.onCodeEdited(session.conversation_id);
2927
+ } catch {
2928
+ return "dirty_unarmed";
2929
+ }
2930
+ const live = this.store.getReviewChain(session.conversation_id);
2931
+ return live?.code_edited === 1 ? "armed" : "dirty_unarmed";
2932
+ }
2530
2933
  handleStop(input) {
2531
2934
  if (!this.store.isConversationIdOk(input.conversationId)) {
2532
2935
  return null;
@@ -2688,10 +3091,48 @@ var ReviewEngine = class {
2688
3091
  return null;
2689
3092
  }
2690
3093
  }
2691
- const inChain = chainNow.chain_pending === 1 || chainNow.fix_round > 0 && isChecklistExecuting(session);
2692
- if (chainNow.confirm_left === null && chainNow.item_confirm_complete === 0 && inChain) {
2693
- return this.e3ArmConfirm(session, chainNow);
2694
- }
3094
+ const inChain = chainNow.chain_pending === 1 || chainNow.fix_round > 0 && isChecklistExecuting(session);
3095
+ if (chainNow.confirm_left === null && chainNow.item_confirm_complete === 0 && inChain) {
3096
+ return this.e3ArmConfirm(session, chainNow);
3097
+ }
3098
+ if (chainNow.code_edited === 0 && isChecklistExecuting(session)) {
3099
+ const dirtyArm = this.maybeArmCodeEditedFromDirtyTree(session);
3100
+ if (dirtyArm === "dirty_unarmed") {
3101
+ return null;
3102
+ }
3103
+ if (dirtyArm === "armed") {
3104
+ const armed = this.store.getReviewChain(session.conversation_id) ?? chainNow;
3105
+ if (armed.code_edited === 1) {
3106
+ const tip = armed.pending_followup?.trim() ?? "";
3107
+ if (isRecoverOrStuckFollowupMessage(tip)) {
3108
+ const again = this.tryRedeliverPending(
3109
+ session.conversation_id,
3110
+ armed,
3111
+ events,
3112
+ transcriptPath
3113
+ );
3114
+ if (again) return again;
3115
+ return null;
3116
+ }
3117
+ const fix = this.e2Fix(session, armed);
3118
+ if (fix) return fix;
3119
+ const afterE2 = this.store.getReviewChain(session.conversation_id) ?? armed;
3120
+ const afterTip = afterE2.pending_followup?.trim() ?? "";
3121
+ if (isRecoverOrStuckFollowupMessage(afterTip)) {
3122
+ const again = this.tryRedeliverPending(
3123
+ session.conversation_id,
3124
+ afterE2,
3125
+ events,
3126
+ transcriptPath
3127
+ );
3128
+ if (again) return again;
3129
+ return null;
3130
+ }
3131
+ return null;
3132
+ }
3133
+ return null;
3134
+ }
3135
+ }
2695
3136
  return this.e0NoCodeContinue(session, chainNow);
2696
3137
  } catch (err) {
2697
3138
  const msg = err instanceof Error ? err.message : String(err);
@@ -3207,10 +3648,10 @@ var ReviewEngine = class {
3207
3648
  * salvage again — the prior tip does not cover the new failure.
3208
3649
  */
3209
3650
  classifyCompletedOrphan(transcriptPath) {
3210
- const path10 = transcriptPath?.trim();
3211
- if (!path10) return "none";
3651
+ const path11 = transcriptPath?.trim();
3652
+ if (!path11) return "none";
3212
3653
  try {
3213
- const events = readTranscriptTail(path10);
3654
+ const events = readTranscriptTail(path11);
3214
3655
  const errIdx = latestUnresolvedTurnEndedErrorIndex(events);
3215
3656
  if (errIdx < 0) return "none";
3216
3657
  for (let i = events.length - 1; i > errIdx; i--) {
@@ -3924,28 +4365,16 @@ var ReviewEngine = class {
3924
4365
  }
3925
4366
  const nextIdle = sess.idle_stop_count + 1;
3926
4367
  const nowStuck = nextIdle >= this.config.maxIdleStops;
3927
- if (nowStuck) {
3928
- this.store.upsertSession({
3929
- conversation_id: cid2,
3930
- project_root: sess.project_root,
3931
- code_root: sess.code_root,
3932
- idle_stop_count: nextIdle,
3933
- paused: 1,
3934
- paused_reason: "stuck",
3935
- armed: 0
3936
- });
3937
- } else {
3938
- this.store.upsertSession({
3939
- conversation_id: cid2,
3940
- project_root: sess.project_root,
3941
- code_root: sess.code_root,
3942
- idle_stop_count: nextIdle
3943
- });
3944
- }
4368
+ this.store.upsertSession({
4369
+ conversation_id: cid2,
4370
+ project_root: sess.project_root,
4371
+ code_root: sess.code_root,
4372
+ idle_stop_count: nextIdle
4373
+ });
3945
4374
  const kind = nowStuck ? "stuck" : "need_evidence";
3946
4375
  const title = (lockedItem.title ?? "").trim();
3947
4376
  const message = this.render(
3948
- kind,
4377
+ nowStuck ? "stuck_soft" : "need_evidence",
3949
4378
  nowStuck ? {} : {
3950
4379
  currentId: lockedItem.id,
3951
4380
  currentTitle: title,
@@ -4304,8 +4733,8 @@ var ReviewEngine = class {
4304
4733
  let unchecked = checklist.unchecked;
4305
4734
  let next = checklist.next;
4306
4735
  let targets = null;
4307
- const path10 = lockedSession.checklist_path?.trim() ?? "";
4308
- const onChecklistPath = isChecklistExecuting(lockedSession) && path10.length > 0;
4736
+ const path11 = lockedSession.checklist_path?.trim() ?? "";
4737
+ const onChecklistPath = isChecklistExecuting(lockedSession) && path11.length > 0;
4309
4738
  if (onChecklistPath) {
4310
4739
  const refreshed = this.parseSessionChecklist(lockedSession);
4311
4740
  if (!refreshed?.checklist) {
@@ -4437,28 +4866,7 @@ var ReviewEngine = class {
4437
4866
  } catch {
4438
4867
  }
4439
4868
  }
4440
- incrementIdle(session) {
4441
- const next = session.idle_stop_count + 1;
4442
- if (next >= this.config.maxIdleStops) {
4443
- this.store.upsertSession({
4444
- conversation_id: session.conversation_id,
4445
- project_root: session.project_root,
4446
- code_root: session.code_root,
4447
- idle_stop_count: next,
4448
- paused: 1,
4449
- paused_reason: "stuck",
4450
- armed: 0
4451
- });
4452
- } else {
4453
- this.store.upsertSession({
4454
- conversation_id: session.conversation_id,
4455
- project_root: session.project_root,
4456
- code_root: session.code_root,
4457
- idle_stop_count: next
4458
- });
4459
- }
4460
- }
4461
- /** After a no-progress stop that didn't inject, check stuck threshold. */
4869
+ /** After a hard-stuck pause that didn't inject, surface stuck tip. */
4462
4870
  checkStuck(session) {
4463
4871
  const fresh = this.store.getSession(session.conversation_id);
4464
4872
  if (!fresh) return null;
@@ -4663,8 +5071,8 @@ function applyResumeReview(store, conversationId) {
4663
5071
  }
4664
5072
 
4665
5073
  // ../core/src/project-config.ts
4666
- import fs7 from "node:fs";
4667
- import path6 from "node:path";
5074
+ import fs8 from "node:fs";
5075
+ import path8 from "node:path";
4668
5076
  var MAX_CONFIG_BYTES = 1e6;
4669
5077
  var DEFAULT_PROJECT_REVIEW_CONFIG = {
4670
5078
  confirmRounds: 5,
@@ -4824,28 +5232,28 @@ function loadProjectReviewConfig(projectRoot) {
4824
5232
  if (!root) {
4825
5233
  return cloneDefaultProjectReviewConfig();
4826
5234
  }
4827
- const configPath = path6.join(root, ".autopilot", "config.yml");
5235
+ const configPath = path8.join(root, ".autopilot", "config.yml");
4828
5236
  try {
4829
- const nofollow = typeof fs7.constants.O_NOFOLLOW === "number" ? fs7.constants.O_NOFOLLOW : 0;
5237
+ const nofollow = typeof fs8.constants.O_NOFOLLOW === "number" ? fs8.constants.O_NOFOLLOW : 0;
4830
5238
  if (nofollow === 0) {
4831
- if (!fs7.existsSync(configPath)) return cloneDefaultProjectReviewConfig();
4832
- if (fs7.lstatSync(configPath).isSymbolicLink()) {
5239
+ if (!fs8.existsSync(configPath)) return cloneDefaultProjectReviewConfig();
5240
+ if (fs8.lstatSync(configPath).isSymbolicLink()) {
4833
5241
  return cloneDefaultProjectReviewConfig();
4834
5242
  }
4835
5243
  }
4836
5244
  let fd;
4837
5245
  try {
4838
- fd = fs7.openSync(configPath, fs7.constants.O_RDONLY | nofollow);
5246
+ fd = fs8.openSync(configPath, fs8.constants.O_RDONLY | nofollow);
4839
5247
  } catch {
4840
5248
  return cloneDefaultProjectReviewConfig();
4841
5249
  }
4842
5250
  let raw;
4843
5251
  try {
4844
- const st = fs7.fstatSync(fd);
5252
+ const st = fs8.fstatSync(fd);
4845
5253
  if (!st.isFile() || st.size > MAX_CONFIG_BYTES) {
4846
5254
  return cloneDefaultProjectReviewConfig();
4847
5255
  }
4848
- const lst = fs7.lstatSync(configPath);
5256
+ const lst = fs8.lstatSync(configPath);
4849
5257
  if (lst.isSymbolicLink() || !lst.isFile()) {
4850
5258
  return cloneDefaultProjectReviewConfig();
4851
5259
  }
@@ -4856,10 +5264,10 @@ function loadProjectReviewConfig(projectRoot) {
4856
5264
  return cloneDefaultProjectReviewConfig();
4857
5265
  }
4858
5266
  const buf = Buffer.alloc(st.size);
4859
- const n = fs7.readSync(fd, buf, 0, st.size, 0);
5267
+ const n = fs8.readSync(fd, buf, 0, st.size, 0);
4860
5268
  raw = buf.subarray(0, n).toString("utf8");
4861
5269
  } finally {
4862
- fs7.closeSync(fd);
5270
+ fs8.closeSync(fd);
4863
5271
  }
4864
5272
  if (Buffer.byteLength(raw, "utf8") > MAX_CONFIG_BYTES) {
4865
5273
  return cloneDefaultProjectReviewConfig();
@@ -4959,6 +5367,12 @@ function createRenderFollowup(bundle) {
4959
5367
  );
4960
5368
  case "stuck":
4961
5369
  return renderTemplate(f.stuck ?? "", vars);
5370
+ case "stuck_soft":
5371
+ return renderTemplate(
5372
+ // Never fall back to hard `stuck` (RESUME-required) — that defeats C2.
5373
+ f.stuck_soft ?? "Stuck: no progress for several stops (missing completion evidence). Change strategy, write matching .autopilot/verify-last.json, then end the turn. Session stays armed \u2014 Autopilot RESUME is not required unless the session was paused.",
5374
+ vars
5375
+ );
4962
5376
  case "verify_fix":
4963
5377
  return renderTemplate(
4964
5378
  f.verify_fix ?? "Verify failed ({reason}). Fix verify commands and rewrite verify-last.json; do not advance.",
@@ -5011,1000 +5425,703 @@ function createConfiguredReviewEngine(store, projectRoot, localeBundle, preloade
5011
5425
  }
5012
5426
 
5013
5427
  // ../core/src/phase-actions.ts
5014
- import fs9 from "node:fs";
5015
- import path8 from "node:path";
5428
+ import fs10 from "node:fs";
5429
+ import path10 from "node:path";
5016
5430
 
5017
5431
  // ../core/src/list-tracks.ts
5018
- import fs8 from "node:fs";
5019
- import path7 from "node:path";
5432
+ import fs9 from "node:fs";
5433
+ import path9 from "node:path";
5020
5434
  function isRunnableTrack(t) {
5021
5435
  if (t.paused) return false;
5022
5436
  const unchecked = t.checklistTotal - t.checklistDone;
5023
5437
  if (unchecked <= 0) return false;
5024
5438
  return t.phase === "planning" || t.phase === "executing" || t.phase === "idle" || t.phase === "done";
5025
- }
5026
- function readPlansDir(root, plansDir = "plans") {
5027
- if (typeof plansDir !== "string" || !root || root.includes("\0") || plansDir.includes("\0")) {
5028
- return [];
5029
- }
5030
- const dir = path7.join(root, plansDir);
5031
- try {
5032
- const lst = fs8.lstatSync(dir);
5033
- if (lst.isSymbolicLink() || !lst.isDirectory()) return [];
5034
- if (!isRealpathInsideProject(root, dir)) return [];
5035
- } catch {
5036
- return [];
5037
- }
5038
- const names = fs8.readdirSync(dir, { withFileTypes: true }).filter(
5039
- (d) => d.isDirectory() && !d.isSymbolicLink() && isSafeTrackSlug(d.name)
5040
- ).map((d) => d.name);
5041
- try {
5042
- const lst = fs8.lstatSync(dir);
5043
- if (lst.isSymbolicLink() || !lst.isDirectory()) return [];
5044
- if (!isRealpathInsideProject(root, dir)) return [];
5045
- } catch {
5046
- return [];
5047
- }
5048
- return names;
5049
- }
5050
- function titleFromPlan(planPath, slug, projectRoot) {
5051
- if (!planPath || planPath.includes("\0")) return slug;
5052
- const root = normalizeProjectRoot(projectRoot);
5053
- if (!root) return slug;
5054
- const maxBytes = 65536;
5055
- try {
5056
- const nofollow = typeof fs8.constants.O_NOFOLLOW === "number" ? fs8.constants.O_NOFOLLOW : 0;
5057
- if (nofollow === 0) {
5058
- const lst = fs8.lstatSync(planPath);
5059
- if (lst.isSymbolicLink() || !lst.isFile()) return slug;
5060
- }
5061
- const fd = fs8.openSync(planPath, fs8.constants.O_RDONLY | nofollow);
5062
- try {
5063
- const st = fs8.fstatSync(fd);
5064
- if (!st.isFile() || st.size <= 0) return slug;
5065
- const lst = fs8.lstatSync(planPath);
5066
- if (lst.isSymbolicLink() || !lst.isFile()) return slug;
5067
- if (lst.ino !== st.ino || lst.dev !== st.dev) return slug;
5068
- if (!isRealpathInsideProject(root, planPath)) return slug;
5069
- const len = Math.min(st.size, maxBytes);
5070
- const buf = Buffer.alloc(len);
5071
- const n = fs8.readSync(fd, buf, 0, len, 0);
5072
- const first = buf.subarray(0, n).toString("utf8").split(/\r?\n/)[0] ?? "";
5073
- const m = first.match(/^#\s+(.+)/);
5074
- return m?.[1]?.trim() ?? slug;
5075
- } finally {
5076
- fs8.closeSync(fd);
5077
- }
5078
- } catch {
5079
- return slug;
5080
- }
5081
- }
5082
- function listTracks(root, store, filter = "all", plansDir = "plans") {
5083
- const normalized = normalizeProjectRoot(root);
5084
- if (!normalized) return [];
5085
- root = normalized;
5086
- const safePlans = normalizeInProjectPlansDir(root, plansDir);
5087
- if (!safePlans) return [];
5088
- plansDir = safePlans;
5089
- const slugs = readPlansDir(root, plansDir);
5090
- const tracks = [];
5091
- for (const slug of slugs) {
5092
- const trackDir = path7.join(root, plansDir, slug);
5093
- try {
5094
- const lst = fs8.lstatSync(trackDir);
5095
- if (lst.isSymbolicLink() || !lst.isDirectory()) continue;
5096
- if (!isRealpathInsideProject(root, trackDir)) continue;
5097
- } catch {
5098
- continue;
5099
- }
5100
- const planPath = path7.join(trackDir, "plan.md");
5101
- const checklistPath = path7.join(trackDir, "checklist.md");
5102
- let checklistTotal = 0;
5103
- let checklistDone = 0;
5104
- const checklistInProject = isRealpathInsideProject(root, checklistPath);
5105
- const planInProject = isRealpathInsideProject(root, planPath);
5106
- if (checklistInProject) {
5107
- try {
5108
- const cl = parseChecklist(checklistPath, { projectRoot: root });
5109
- checklistTotal = cl.items.length;
5110
- checklistDone = cl.items.filter((i) => i.checked).length;
5111
- } catch {
5112
- }
5113
- } else if (!planInProject) {
5114
- continue;
5115
- }
5116
- let phase = "idle";
5117
- let paused = false;
5118
- let pausedReason;
5119
- let updatedAt = (/* @__PURE__ */ new Date(0)).toISOString();
5120
- if (store) {
5121
- const sessions = store.db.prepare(
5122
- `SELECT * FROM sessions WHERE track_id = ? ORDER BY last_active_at DESC LIMIT 1`
5123
- ).all(slug);
5124
- const latest = sessions[0];
5125
- if (latest) {
5126
- phase = latest.phase;
5127
- paused = latest.paused === 1;
5128
- if (latest.paused_reason === "stuck" || latest.paused_reason === "repeated_errors" || latest.paused_reason === "human_gate") {
5129
- pausedReason = latest.paused_reason;
5130
- }
5131
- updatedAt = latest.last_active_at;
5132
- } else {
5133
- if (checklistTotal > 0 && checklistDone === checklistTotal) {
5134
- phase = "done";
5135
- } else if (checklistTotal - checklistDone > 0 && planInProject) {
5136
- phase = "idle";
5137
- }
5138
- }
5139
- } else {
5140
- if (checklistTotal > 0 && checklistDone === checklistTotal) {
5141
- phase = "done";
5142
- } else if (checklistTotal - checklistDone > 0) {
5143
- phase = "idle";
5144
- }
5145
- }
5146
- tracks.push({
5147
- slug,
5148
- title: planInProject ? titleFromPlan(planPath, slug, root) : slug,
5149
- phase,
5150
- paused,
5151
- pausedReason,
5152
- checklistTotal,
5153
- checklistDone,
5154
- planPath,
5155
- updatedAt
5156
- });
5157
- }
5158
- if (filter === "all") return tracks;
5159
- if (filter === "planning") {
5160
- return tracks.filter((t) => t.phase === "planning");
5161
- }
5162
- return tracks.filter(isRunnableTrack);
5163
- }
5164
- function canEnterExecuting(options) {
5165
- const { slug, checklistPath, paused, projectRoot } = options;
5166
- if (!slug || slug === "_pending") {
5167
- return { ok: false, reason: "no track slug" };
5168
- }
5169
- const root = normalizeProjectRoot(projectRoot);
5170
- if (!root) {
5171
- return { ok: false, reason: "invalid project root" };
5172
- }
5173
- if (!checklistPath || !fs8.existsSync(checklistPath)) {
5174
- return { ok: false, reason: "checklist missing" };
5175
- }
5176
- if (!isRealpathInsideProject(root, checklistPath)) {
5177
- return { ok: false, reason: "checklist outside project" };
5178
- }
5179
- let unchecked = 0;
5180
- try {
5181
- unchecked = countUnchecked(
5182
- parseChecklist(checklistPath, { projectRoot: root })
5183
- );
5184
- } catch {
5185
- return { ok: false, reason: "checklist unreadable" };
5186
- }
5187
- if (unchecked < 1) {
5188
- return { ok: false, reason: "no unchecked items" };
5189
- }
5190
- if (paused) {
5191
- return { ok: false, reason: "session paused" };
5192
- }
5193
- return { ok: true };
5194
- }
5195
-
5196
- // ../core/src/phase-actions.ts
5197
- function displayUntrusted(raw, max = 64) {
5198
- const text = typeof raw === "string" ? raw : String(raw ?? "");
5199
- return sanitizeSessionDisplayText(text).slice(0, max) || "?";
5200
- }
5201
- function isChannelANeedPick(fail) {
5202
- return fail.needPick === true && fail.busy !== true;
5203
- }
5204
- function formatOneExecutorBusyMessage(trackId, conversationId) {
5205
- const occTrack = displayUntrusted(trackId || "(unknown)");
5206
- const rawCid = typeof conversationId === "string" ? conversationId : String(conversationId ?? "");
5207
- const occSession = displayUntrusted(shortConversationId(rawCid) || "?") || "?";
5208
- return `Another session is already executing (track: ${occTrack}, session: ${occSession}). Send Autopilot OFF there or wait, then retry. If the host only shows opaque blocked, run: npx @autopilot-harness/cli status or npx @autopilot-harness/cli doctor`;
5209
- }
5210
- function logBusyOccupier(message) {
5211
- try {
5212
- process.stderr.write(`[autopilot] ${message}
5213
- `);
5214
- } catch {
5215
- }
5216
- }
5217
- function nowIso2() {
5218
- return (/* @__PURE__ */ new Date()).toISOString();
5219
- }
5220
- function checklistPathFor(projectRoot, slug, plansDir) {
5221
- const root = normalizeProjectRoot(projectRoot);
5222
- if (!root) return path8.join(plansDir, slug, "checklist.md");
5223
- return path8.join(root, plansDir, slug, "checklist.md");
5224
- }
5225
- function sameChecklistBinding(stored, rebuilt, projectRoot) {
5226
- if (stored === rebuilt) return true;
5227
- if (!stored || !rebuilt || stored.includes("\0") || rebuilt.includes("\0")) {
5228
- return false;
5439
+ }
5440
+ function readPlansDir(root, plansDir = "plans") {
5441
+ if (typeof plansDir !== "string" || !root || root.includes("\0") || plansDir.includes("\0")) {
5442
+ return [];
5229
5443
  }
5230
- const root = normalizeProjectRoot(projectRoot);
5231
- if (!root) return false;
5444
+ const dir = path9.join(root, plansDir);
5232
5445
  try {
5233
- const absStored = path8.isAbsolute(stored) ? path8.resolve(stored) : path8.resolve(root, stored);
5234
- const absRebuilt = path8.isAbsolute(rebuilt) ? path8.resolve(rebuilt) : path8.resolve(root, rebuilt);
5235
- return absStored === absRebuilt;
5446
+ const lst = fs9.lstatSync(dir);
5447
+ if (lst.isSymbolicLink() || !lst.isDirectory()) return [];
5448
+ if (!isRealpathInsideProject(root, dir)) return [];
5236
5449
  } catch {
5237
- return false;
5238
- }
5239
- }
5240
- function trustedChecklistPath(projectRoot, plansDir, slug, storedPath, boundTrackId) {
5241
- const rebuilt = checklistPathFor(projectRoot, slug, plansDir);
5242
- if (storedPath && boundTrackId === slug) {
5243
- if (isChecklistPathAllowed(projectRoot, storedPath)) {
5244
- return storedPath;
5245
- }
5450
+ return [];
5246
5451
  }
5247
- return rebuilt;
5248
- }
5249
- function isChecklistPathAllowed(projectRoot, checklistPath) {
5250
- if (!checklistPath || checklistPath.includes("\0")) return false;
5251
- const root = normalizeProjectRoot(projectRoot);
5252
- if (!root) return false;
5452
+ const names = fs9.readdirSync(dir, { withFileTypes: true }).filter(
5453
+ (d) => d.isDirectory() && !d.isSymbolicLink() && isSafeTrackSlug(d.name)
5454
+ ).map((d) => d.name);
5253
5455
  try {
5254
- fs9.lstatSync(
5255
- path8.isAbsolute(checklistPath) ? checklistPath : path8.resolve(root, checklistPath)
5256
- );
5257
- return isRealpathInsideProject(root, checklistPath);
5456
+ const lst = fs9.lstatSync(dir);
5457
+ if (lst.isSymbolicLink() || !lst.isDirectory()) return [];
5458
+ if (!isRealpathInsideProject(root, dir)) return [];
5258
5459
  } catch {
5259
- return isLexicallyInsideProject(root, checklistPath);
5260
- }
5261
- }
5262
- function ensureSession(store, conversationId, projectRoot, platform) {
5263
- const existing = store.getSession(conversationId);
5264
- if (existing) {
5265
- const want = resolveSessionPlatform(platform, existing.platform);
5266
- if (want !== existing.platform) {
5267
- return store.upsertSession({
5268
- conversation_id: conversationId,
5269
- project_root: existing.project_root,
5270
- code_root: existing.code_root,
5271
- platform: want
5272
- });
5273
- }
5274
- return existing;
5460
+ return [];
5275
5461
  }
5276
- return store.upsertSession({
5277
- conversation_id: conversationId,
5278
- project_root: projectRoot,
5279
- code_root: projectRoot,
5280
- platform: normalizeSessionPlatform(platform),
5281
- phase: "idle",
5282
- armed: 0,
5283
- paused: 0,
5284
- track_id: "_pending",
5285
- checklist_path: ""
5286
- });
5462
+ return names;
5287
5463
  }
5288
- function upsertTrack(store, slug, checklistPath, plansDir, projectRoot) {
5289
- const ts = nowIso2();
5464
+ function titleFromPlan(planPath, slug, projectRoot) {
5465
+ if (!planPath || planPath.includes("\0")) return slug;
5290
5466
  const root = normalizeProjectRoot(projectRoot);
5291
- if (!root) return;
5292
- const planPath = path8.join(root, plansDir, slug, "plan.md");
5293
- const briefPath = path8.join(root, plansDir, slug, "brief.md");
5294
- store.db.prepare(
5295
- `INSERT INTO tracks (track_id, slug, checklist_path, plan_path, brief_path, updated_at)
5296
- VALUES (?, ?, ?, ?, ?, ?)
5297
- ON CONFLICT(track_id) DO UPDATE SET
5298
- slug = excluded.slug,
5299
- checklist_path = excluded.checklist_path,
5300
- plan_path = excluded.plan_path,
5301
- brief_path = excluded.brief_path,
5302
- updated_at = excluded.updated_at`
5303
- ).run(
5304
- slug,
5305
- slug,
5306
- checklistPath,
5307
- fs9.existsSync(planPath) ? planPath : null,
5308
- fs9.existsSync(briefPath) ? briefPath : null,
5309
- ts
5310
- );
5311
- }
5312
- function candidatePayload(tracks) {
5313
- return JSON.stringify(
5314
- tracks.map((t) => ({
5315
- slug: t.slug,
5316
- title: sanitizeSessionDisplayText(t.title || t.slug) || t.slug,
5317
- phase: t.phase,
5318
- progress: `${t.checklistDone}/${t.checklistTotal}`
5319
- }))
5320
- );
5321
- }
5322
- function resolveRunSlug(store, session, projectRoot, plansDir, requestedSlug) {
5323
- const runnable = listTracks(projectRoot, store, "runnable", plansDir).filter(
5324
- (t) => isSafeTrackSlug(t.slug)
5325
- );
5326
- if (requestedSlug) {
5327
- if (!isSafeTrackSlug(requestedSlug)) {
5328
- return {
5329
- kind: "none",
5330
- userMessage: `Invalid track slug "${displayUntrusted(requestedSlug)}".`
5331
- };
5332
- }
5333
- const hit = runnable.find((t) => t.slug === requestedSlug);
5334
- if (!hit) {
5335
- const all = listTracks(projectRoot, store, "all", plansDir);
5336
- if (!all.some((t) => t.slug === requestedSlug)) {
5337
- return {
5338
- kind: "none",
5339
- userMessage: `Track "${displayUntrusted(requestedSlug)}" not found or has no unchecked checklist items.`
5340
- };
5341
- }
5342
- return {
5343
- kind: "none",
5344
- userMessage: `Track "${displayUntrusted(requestedSlug)}" is not runnable (paused or no unchecked items).`
5345
- };
5346
- }
5347
- return { kind: "slug", slug: requestedSlug };
5348
- }
5349
- const midPick = session.pending_action === "run" || session.pending_action === "replan";
5350
- if (!midPick && isBoundRunTrackId(session.track_id)) {
5351
- const bound = runnable.find((t) => t.slug === session.track_id);
5352
- if (bound) {
5353
- return { kind: "slug", slug: bound.slug };
5467
+ if (!root) return slug;
5468
+ const maxBytes = 65536;
5469
+ try {
5470
+ const nofollow = typeof fs9.constants.O_NOFOLLOW === "number" ? fs9.constants.O_NOFOLLOW : 0;
5471
+ if (nofollow === 0) {
5472
+ const lst = fs9.lstatSync(planPath);
5473
+ if (lst.isSymbolicLink() || !lst.isFile()) return slug;
5354
5474
  }
5355
- }
5356
- if (runnable.length === 0) {
5357
- return {
5358
- kind: "none",
5359
- userMessage: "No runnable plan. Use /autopilot-on to plan, then finalize a checklist with unchecked items."
5360
- };
5361
- }
5362
- if (runnable.length === 1) {
5363
- if (session.pending_action === "replan") {
5364
- return { kind: "pick", candidates: runnable };
5475
+ const fd = fs9.openSync(planPath, fs9.constants.O_RDONLY | nofollow);
5476
+ try {
5477
+ const st = fs9.fstatSync(fd);
5478
+ if (!st.isFile() || st.size <= 0) return slug;
5479
+ const lst = fs9.lstatSync(planPath);
5480
+ if (lst.isSymbolicLink() || !lst.isFile()) return slug;
5481
+ if (lst.ino !== st.ino || lst.dev !== st.dev) return slug;
5482
+ if (!isRealpathInsideProject(root, planPath)) return slug;
5483
+ const len = Math.min(st.size, maxBytes);
5484
+ const buf = Buffer.alloc(len);
5485
+ const n = fs9.readSync(fd, buf, 0, len, 0);
5486
+ const first = buf.subarray(0, n).toString("utf8").split(/\r?\n/)[0] ?? "";
5487
+ const m = first.match(/^#\s+(.+)/);
5488
+ return m?.[1]?.trim() ?? slug;
5489
+ } finally {
5490
+ fs9.closeSync(fd);
5365
5491
  }
5366
- return { kind: "slug", slug: runnable[0].slug };
5492
+ } catch {
5493
+ return slug;
5367
5494
  }
5368
- return { kind: "pick", candidates: runnable };
5369
- }
5370
- function requireProjectRoot(store, projectRoot) {
5371
- return normalizeProjectRoot(store.projectRoot) ?? normalizeProjectRoot(projectRoot);
5372
5495
  }
5373
- function requirePlansDir(projectRoot, plansDir) {
5374
- return normalizeInProjectPlansDir(projectRoot, plansDir);
5375
- }
5376
- function applyRun(store, conversationId, projectRoot, opts) {
5377
- const root = requireProjectRoot(store, projectRoot);
5378
- if (!root) {
5379
- return { ok: false, userMessage: "Invalid project root." };
5380
- }
5381
- projectRoot = root;
5382
- const plansDir = requirePlansDir(projectRoot, opts?.config?.plansDir);
5383
- if (!plansDir) {
5384
- return { ok: false, userMessage: "Invalid plans directory." };
5385
- }
5386
- const concurrencyMode = opts?.config?.concurrencyMode ?? "one_executor";
5387
- const session = ensureSession(
5388
- store,
5389
- conversationId,
5390
- projectRoot,
5391
- opts?.platform
5392
- );
5393
- const resolved = resolveRunSlug(
5394
- store,
5395
- session,
5396
- projectRoot,
5397
- plansDir,
5398
- opts?.slug
5399
- );
5400
- if (resolved.kind === "none") {
5401
- return { ok: false, userMessage: resolved.userMessage };
5402
- }
5403
- if (resolved.kind === "pick") {
5404
- const clearingStaleBind = isBoundRunTrackId(session.track_id);
5405
- const trackForPick = clearingStaleBind ? "_pending" : session.track_id;
5406
- store.upsertSession({
5407
- conversation_id: conversationId,
5408
- project_root: session.project_root,
5409
- code_root: session.code_root,
5410
- track_id: trackForPick,
5411
- // Drop checklist binding with the stale track so planning state stays consistent.
5412
- ...clearingStaleBind ? { checklist_path: "" } : {},
5413
- pending_action: "run",
5414
- track_candidates_json: candidatePayload(resolved.candidates),
5415
- armed: 0
5416
- // phase unchanged — do not write executing
5417
- });
5418
- const lines = resolved.candidates.map((t, i) => {
5419
- const title = sanitizeSessionDisplayText(t.title || t.slug) || t.slug;
5420
- const left = t.checklistTotal - t.checklistDone;
5421
- return ` ${i + 1}. ${t.slug} \u2014 ${title} (${left}/${t.checklistTotal} left)`;
5422
- }).join("\n");
5423
- return {
5424
- ok: false,
5425
- needPick: true,
5426
- candidates: resolved.candidates,
5427
- userMessage: `Select a plan to execute:
5428
-
5429
- ${lines}
5430
-
5431
- Reply with a number or /autopilot-run <slug>.`
5432
- };
5433
- }
5434
- const slug = resolved.slug;
5435
- if (!isSafeTrackSlug(slug)) {
5436
- return {
5437
- ok: false,
5438
- userMessage: `Invalid track slug "${displayUntrusted(slug)}".`
5439
- };
5440
- }
5441
- const checklistPath = checklistPathFor(projectRoot, slug, plansDir);
5442
- const gate = canEnterExecuting({
5443
- slug,
5444
- checklistPath,
5445
- paused: session.paused === 1,
5446
- projectRoot
5447
- });
5448
- if (!gate.ok) {
5449
- return {
5450
- ok: false,
5451
- userMessage: `Cannot start executing: ${gate.reason}.`
5452
- };
5453
- }
5454
- try {
5455
- const result = store.exclusiveWrite(() => {
5456
- if (concurrencyMode === "one_executor") {
5457
- const other = store.findExecutingSession(conversationId);
5458
- if (other) {
5459
- const userMessage = formatOneExecutorBusyMessage(
5460
- other.track_id,
5461
- other.conversation_id
5462
- );
5463
- return {
5464
- commit: false,
5465
- value: {
5466
- ok: false,
5467
- busy: true,
5468
- userMessage
5469
- }
5470
- };
5471
- }
5496
+ function listTracks(root, store, filter = "all", plansDir = "plans") {
5497
+ const normalized = normalizeProjectRoot(root);
5498
+ if (!normalized) return [];
5499
+ root = normalized;
5500
+ const safePlans = normalizeInProjectPlansDir(root, plansDir);
5501
+ if (!safePlans) return [];
5502
+ plansDir = safePlans;
5503
+ const slugs = readPlansDir(root, plansDir);
5504
+ const tracks = [];
5505
+ for (const slug of slugs) {
5506
+ const trackDir = path9.join(root, plansDir, slug);
5507
+ try {
5508
+ const lst = fs9.lstatSync(trackDir);
5509
+ if (lst.isSymbolicLink() || !lst.isDirectory()) continue;
5510
+ if (!isRealpathInsideProject(root, trackDir)) continue;
5511
+ } catch {
5512
+ continue;
5513
+ }
5514
+ const planPath = path9.join(trackDir, "plan.md");
5515
+ const checklistPath = path9.join(trackDir, "checklist.md");
5516
+ let checklistTotal = 0;
5517
+ let checklistDone = 0;
5518
+ const checklistInProject = isRealpathInsideProject(root, checklistPath);
5519
+ const planInProject = isRealpathInsideProject(root, planPath);
5520
+ if (checklistInProject) {
5521
+ try {
5522
+ const cl = parseChecklist(checklistPath, { projectRoot: root });
5523
+ checklistTotal = cl.items.length;
5524
+ checklistDone = cl.items.filter((i) => i.checked).length;
5525
+ } catch {
5472
5526
  }
5473
- const fresh = store.getSession(conversationId);
5474
- const alreadyExecutingSameTrack = fresh?.phase === "executing" && fresh.armed === 1 && fresh.paused === 0 && fresh.track_id === slug && sameChecklistBinding(fresh.checklist_path, checklistPath, projectRoot);
5475
- upsertTrack(store, slug, checklistPath, plansDir, projectRoot);
5476
- const updated = store.upsertSession({
5477
- conversation_id: conversationId,
5478
- project_root: projectRoot,
5479
- code_root: projectRoot,
5480
- track_id: slug,
5481
- checklist_path: checklistPath,
5482
- phase: "executing",
5483
- armed: 1,
5484
- paused: 0,
5485
- paused_reason: null,
5486
- pending_action: null,
5487
- track_candidates_json: null,
5488
- error_count: 0,
5489
- idle_stop_count: 0
5490
- });
5491
- if (!alreadyExecutingSameTrack) {
5492
- let reviewingItemId = null;
5493
- try {
5494
- const cl = parseChecklist(checklistPath, { projectRoot });
5495
- reviewingItemId = firstUnchecked(cl)?.id ?? null;
5496
- } catch {
5527
+ } else if (!planInProject) {
5528
+ continue;
5529
+ }
5530
+ let phase = "idle";
5531
+ let paused = false;
5532
+ let pausedReason;
5533
+ let updatedAt = (/* @__PURE__ */ new Date(0)).toISOString();
5534
+ if (store) {
5535
+ const sessions = store.db.prepare(
5536
+ `SELECT * FROM sessions WHERE track_id = ? ORDER BY last_active_at DESC LIMIT 1`
5537
+ ).all(slug);
5538
+ const latest = sessions[0];
5539
+ if (latest) {
5540
+ phase = latest.phase;
5541
+ paused = latest.paused === 1;
5542
+ if (latest.paused_reason === "stuck" || latest.paused_reason === "repeated_errors" || latest.paused_reason === "human_gate") {
5543
+ pausedReason = latest.paused_reason;
5497
5544
  }
5498
- store.updateReviewChain(conversationId, {
5499
- fix_round: 0,
5500
- confirm_left: null,
5501
- chain_pending: 0,
5502
- code_edited: 0,
5503
- item_confirm_complete: 0,
5504
- reviewing_item_id: reviewingItemId,
5505
- pending_followup: null,
5506
- pending_followup_at: null,
5507
- pending_redeliver_at: null
5508
- });
5545
+ updatedAt = latest.last_active_at;
5509
5546
  } else {
5510
- store.ensureReviewChain(conversationId);
5547
+ if (checklistTotal > 0 && checklistDone === checklistTotal) {
5548
+ phase = "done";
5549
+ } else if (checklistTotal - checklistDone > 0 && planInProject) {
5550
+ phase = "idle";
5551
+ }
5552
+ }
5553
+ } else {
5554
+ if (checklistTotal > 0 && checklistDone === checklistTotal) {
5555
+ phase = "done";
5556
+ } else if (checklistTotal - checklistDone > 0) {
5557
+ phase = "idle";
5511
5558
  }
5512
- return { commit: true, value: { ok: true, session: updated } };
5513
- });
5514
- if (!result.ok && result.busy === true) {
5515
- logBusyOccupier(result.userMessage);
5516
- }
5517
- return result;
5518
- } catch (err) {
5519
- const msg = err instanceof Error ? err.message : String(err);
5520
- if (/busy|locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(msg)) {
5521
- const userMessage = "State database is busy; retry Autopilot RUN in a moment.";
5522
- logBusyOccupier(userMessage);
5523
- return {
5524
- ok: false,
5525
- busy: true,
5526
- userMessage
5527
- };
5528
5559
  }
5529
- throw err;
5560
+ tracks.push({
5561
+ slug,
5562
+ title: planInProject ? titleFromPlan(planPath, slug, root) : slug,
5563
+ phase,
5564
+ paused,
5565
+ pausedReason,
5566
+ checklistTotal,
5567
+ checklistDone,
5568
+ planPath,
5569
+ updatedAt
5570
+ });
5571
+ }
5572
+ if (filter === "all") return tracks;
5573
+ if (filter === "planning") {
5574
+ return tracks.filter((t) => t.phase === "planning");
5530
5575
  }
5576
+ return tracks.filter(isRunnableTrack);
5531
5577
  }
5532
- function applyReplan(store, conversationId, projectRoot, opts) {
5533
- const root = requireProjectRoot(store, projectRoot);
5534
- if (!root) {
5535
- return { ok: false, userMessage: "Invalid project root." };
5578
+ function canEnterExecuting(options) {
5579
+ const { slug, checklistPath, paused, projectRoot } = options;
5580
+ if (!slug || slug === "_pending") {
5581
+ return { ok: false, reason: "no track slug" };
5536
5582
  }
5537
- projectRoot = root;
5538
- const plansDir = requirePlansDir(projectRoot, opts?.config?.plansDir);
5539
- if (!plansDir) {
5540
- return { ok: false, userMessage: "Invalid plans directory." };
5583
+ const root = normalizeProjectRoot(projectRoot);
5584
+ if (!root) {
5585
+ return { ok: false, reason: "invalid project root" };
5541
5586
  }
5542
- const session = ensureSession(
5543
- store,
5544
- conversationId,
5545
- projectRoot,
5546
- opts?.platform
5547
- );
5548
- let slug = opts?.slug;
5549
- if (!slug) {
5550
- const tid = session.track_id;
5551
- const midPick = session.pending_action === "run" || session.pending_action === "replan";
5552
- slug = !midPick && isBoundRunTrackId(tid) ? tid : void 0;
5587
+ if (!checklistPath || !fs9.existsSync(checklistPath)) {
5588
+ return { ok: false, reason: "checklist missing" };
5553
5589
  }
5554
- if (slug && !isSafeTrackSlug(slug)) {
5555
- return {
5556
- ok: false,
5557
- userMessage: `Invalid track slug "${displayUntrusted(slug)}".`
5558
- };
5590
+ if (!isRealpathInsideProject(root, checklistPath)) {
5591
+ return { ok: false, reason: "checklist outside project" };
5559
5592
  }
5560
- if (!slug) {
5561
- const all = listTracks(projectRoot, store, "all", plansDir).filter(
5562
- (t) => isSafeTrackSlug(t.slug)
5593
+ let unchecked = 0;
5594
+ try {
5595
+ unchecked = countUnchecked(
5596
+ parseChecklist(checklistPath, { projectRoot: root })
5563
5597
  );
5564
- if (all.length === 1 && session.pending_action !== "run") {
5565
- slug = all[0].slug;
5566
- } else if (all.length > 1 || all.length === 1 && session.pending_action === "run") {
5567
- const clearingStaleBind = isBoundRunTrackId(session.track_id);
5568
- store.upsertSession({
5569
- conversation_id: conversationId,
5570
- project_root: session.project_root,
5571
- code_root: session.code_root,
5572
- track_id: clearingStaleBind ? "_pending" : session.track_id,
5573
- ...clearingStaleBind ? { checklist_path: "" } : {},
5574
- pending_action: "replan",
5575
- track_candidates_json: candidatePayload(all),
5576
- armed: 0
5577
- });
5578
- const lines = all.map((t, i) => {
5579
- const title = sanitizeSessionDisplayText(t.title || t.slug) || t.slug;
5580
- return ` ${i + 1}. ${t.slug} \u2014 ${title}`;
5581
- }).join("\n");
5582
- return {
5583
- ok: false,
5584
- needPick: true,
5585
- candidates: all,
5586
- userMessage: `Select a plan to replan:
5587
-
5588
- ${lines}
5589
-
5590
- Reply with a number or /autopilot-replan <slug>.`
5591
- };
5592
- } else {
5593
- return {
5594
- ok: false,
5595
- userMessage: "No plan to replan. Use /autopilot-on first."
5596
- };
5597
- }
5598
+ } catch {
5599
+ return { ok: false, reason: "checklist unreadable" };
5598
5600
  }
5599
- const checklistPath = trustedChecklistPath(
5600
- projectRoot,
5601
- plansDir,
5602
- slug,
5603
- session.checklist_path,
5604
- session.track_id
5605
- );
5606
- const updated = store.upsertSession({
5607
- conversation_id: conversationId,
5608
- project_root: projectRoot,
5609
- code_root: projectRoot,
5610
- track_id: slug,
5611
- checklist_path: checklistPath,
5612
- phase: "planning",
5613
- armed: 0,
5614
- paused: 0,
5615
- paused_reason: null,
5616
- pending_action: null,
5617
- track_candidates_json: null
5618
- });
5619
- store.updateReviewChain(conversationId, {
5620
- fix_round: 0,
5621
- confirm_left: null,
5622
- chain_pending: 0,
5623
- code_edited: 0,
5624
- item_confirm_complete: 0,
5625
- reviewing_item_id: null,
5626
- pending_followup: null,
5627
- pending_followup_at: null,
5628
- pending_redeliver_at: null
5629
- });
5630
- return { ok: true, session: updated };
5601
+ if (unchecked < 1) {
5602
+ return { ok: false, reason: "no unchecked items" };
5603
+ }
5604
+ if (paused) {
5605
+ return { ok: false, reason: "session paused" };
5606
+ }
5607
+ return { ok: true };
5631
5608
  }
5632
- function applyTrackPick(store, conversationId, projectRoot, pick, opts) {
5633
- const root = requireProjectRoot(store, projectRoot);
5634
- if (!root) {
5635
- return { ok: false, userMessage: "Invalid project root." };
5609
+
5610
+ // ../core/src/phase-actions.ts
5611
+ function displayUntrusted(raw, max = 64) {
5612
+ const text = typeof raw === "string" ? raw : String(raw ?? "");
5613
+ return sanitizeSessionDisplayText(text).slice(0, max) || "?";
5614
+ }
5615
+ function isChannelANeedPick(fail) {
5616
+ return fail.needPick === true && fail.busy !== true;
5617
+ }
5618
+ function formatOneExecutorBusyMessage(trackId, conversationId) {
5619
+ const occTrack = displayUntrusted(trackId || "(unknown)");
5620
+ const rawCid = typeof conversationId === "string" ? conversationId : String(conversationId ?? "");
5621
+ const occSession = displayUntrusted(shortConversationId(rawCid) || "?") || "?";
5622
+ return `Another session is already executing (track: ${occTrack}, session: ${occSession}). Send Autopilot OFF there or wait, then retry. If the host only shows opaque blocked, run: npx @autopilot-harness/cli status or npx @autopilot-harness/cli doctor`;
5623
+ }
5624
+ function logBusyOccupier(message) {
5625
+ try {
5626
+ process.stderr.write(`[autopilot] ${message}
5627
+ `);
5628
+ } catch {
5636
5629
  }
5637
- projectRoot = root;
5638
- const session = store.getSession(conversationId);
5639
- if (!session?.pending_action) {
5640
- return {
5641
- ok: false,
5642
- userMessage: "No pending track selection."
5643
- };
5630
+ }
5631
+ function nowIso2() {
5632
+ return (/* @__PURE__ */ new Date()).toISOString();
5633
+ }
5634
+ function checklistPathFor(projectRoot, slug, plansDir) {
5635
+ const root = normalizeProjectRoot(projectRoot);
5636
+ if (!root) return path10.join(plansDir, slug, "checklist.md");
5637
+ return path10.join(root, plansDir, slug, "checklist.md");
5638
+ }
5639
+ function sameChecklistBinding(stored, rebuilt, projectRoot) {
5640
+ if (stored === rebuilt) return true;
5641
+ if (!stored || !rebuilt || stored.includes("\0") || rebuilt.includes("\0")) {
5642
+ return false;
5644
5643
  }
5645
- const pending = session.pending_action;
5646
- if (pending !== "run" && pending !== "replan") {
5647
- return {
5648
- ok: false,
5649
- userMessage: `Unknown pending action "${displayUntrusted(pending)}".`
5650
- };
5644
+ const root = normalizeProjectRoot(projectRoot);
5645
+ if (!root) return false;
5646
+ try {
5647
+ const absStored = path10.isAbsolute(stored) ? path10.resolve(stored) : path10.resolve(root, stored);
5648
+ const absRebuilt = path10.isAbsolute(rebuilt) ? path10.resolve(rebuilt) : path10.resolve(root, rebuilt);
5649
+ return absStored === absRebuilt;
5650
+ } catch {
5651
+ return false;
5651
5652
  }
5652
- if (!session.track_candidates_json) {
5653
- return { ok: false, userMessage: "Invalid track candidates JSON." };
5653
+ }
5654
+ function trustedChecklistPath(projectRoot, plansDir, slug, storedPath, boundTrackId) {
5655
+ const rebuilt = checklistPathFor(projectRoot, slug, plansDir);
5656
+ if (storedPath && boundTrackId === slug) {
5657
+ if (isChecklistPathAllowed(projectRoot, storedPath)) {
5658
+ return storedPath;
5659
+ }
5654
5660
  }
5655
- let candidates = [];
5661
+ return rebuilt;
5662
+ }
5663
+ function isChecklistPathAllowed(projectRoot, checklistPath) {
5664
+ if (!checklistPath || checklistPath.includes("\0")) return false;
5665
+ const root = normalizeProjectRoot(projectRoot);
5666
+ if (!root) return false;
5656
5667
  try {
5657
- const parsed = JSON.parse(session.track_candidates_json);
5658
- if (!Array.isArray(parsed) || parsed.length === 0) {
5659
- return { ok: false, userMessage: "Invalid track candidates JSON." };
5660
- }
5661
- const hasValidSlug = parsed.some(
5662
- (c) => !!c && typeof c === "object" && !Array.isArray(c) && typeof c.slug === "string" && isSafeTrackSlug(c.slug)
5668
+ fs10.lstatSync(
5669
+ path10.isAbsolute(checklistPath) ? checklistPath : path10.resolve(root, checklistPath)
5663
5670
  );
5664
- if (!hasValidSlug) {
5665
- return { ok: false, userMessage: "Invalid track candidates JSON." };
5666
- }
5667
- candidates = parsed;
5671
+ return isRealpathInsideProject(root, checklistPath);
5668
5672
  } catch {
5669
- return { ok: false, userMessage: "Invalid track candidates JSON." };
5673
+ return isLexicallyInsideProject(root, checklistPath);
5670
5674
  }
5671
- let slug;
5672
- if (/^\d+$/.test(pick)) {
5673
- const idx = Number.parseInt(pick, 10) - 1;
5674
- const entry = candidates[idx];
5675
- slug = entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.slug === "string" ? entry.slug : void 0;
5676
- if (!slug || !isSafeTrackSlug(slug)) {
5677
- return {
5678
- ok: false,
5679
- userMessage: `Invalid selection "${displayUntrusted(pick)}". Choose 1\u2013${candidates.length}.`
5680
- };
5681
- }
5682
- } else {
5683
- if (!isSafeTrackSlug(pick)) {
5684
- return {
5685
- ok: false,
5686
- userMessage: `Invalid track slug "${displayUntrusted(pick)}".`
5687
- };
5688
- }
5689
- slug = pick;
5690
- if (!candidates.some(
5691
- (c) => !!c && typeof c === "object" && !Array.isArray(c) && c.slug === slug
5692
- )) {
5693
- return {
5694
- ok: false,
5695
- userMessage: `Unknown slug "${displayUntrusted(pick)}".`
5696
- };
5675
+ }
5676
+ function ensureSession(store, conversationId, projectRoot, platform) {
5677
+ const existing = store.getSession(conversationId);
5678
+ if (existing) {
5679
+ const want = resolveSessionPlatform(platform, existing.platform);
5680
+ if (want !== existing.platform) {
5681
+ return store.upsertSession({
5682
+ conversation_id: conversationId,
5683
+ project_root: existing.project_root,
5684
+ code_root: existing.code_root,
5685
+ platform: want
5686
+ });
5697
5687
  }
5688
+ return existing;
5698
5689
  }
5699
- if (pending === "replan") {
5700
- return applyReplan(store, conversationId, projectRoot, {
5701
- slug,
5702
- config: opts?.config,
5703
- platform: opts?.platform
5704
- });
5705
- }
5706
- return applyRun(store, conversationId, projectRoot, {
5707
- slug,
5708
- config: opts?.config,
5709
- platform: opts?.platform
5690
+ return store.upsertSession({
5691
+ conversation_id: conversationId,
5692
+ project_root: projectRoot,
5693
+ code_root: projectRoot,
5694
+ platform: normalizeSessionPlatform(platform),
5695
+ phase: "idle",
5696
+ armed: 0,
5697
+ paused: 0,
5698
+ track_id: "_pending",
5699
+ checklist_path: ""
5710
5700
  });
5711
5701
  }
5712
-
5713
- // ../core/src/code-edit-detector.ts
5714
- import { spawnSync } from "node:child_process";
5715
-
5716
- // ../core/src/autopilot-ignore.ts
5717
- import fs10 from "node:fs";
5718
- import path9 from "node:path";
5719
- var DEFAULT_AUTOPILOT_IGNORE_TEXT = `# Autopilot \u2014 paths that do NOT trigger self-review (gitignore syntax).
5720
- #
5721
- # What this file is:
5722
- # - Controls whether an afterFileEdit counts as "product code" (opens fix/confirm).
5723
- # - Does NOT change \`git diff\` / \`git status\` output (that is \`.gitignore\`).
5724
- # - Review followups ask the agent to skip these paths when reading diffs (soft).
5725
- #
5726
- # Semantics:
5727
- # - Same glob rules as gitignore; last matching pattern wins.
5728
- # - Use \`!\` to force-include an exception (e.g. \`!docs/feed/**/*.yml\`).
5729
- # - Markdown (*.md / *.mdx) is NOT ignored by default \u2014 design docs can be reviewed.
5730
- # - \`docs/**\` is NOT ignored by default.
5731
- # - Also skip untracked paths ignored by \`.gitignore\` (tracked files still count).
5732
- #
5733
- # Later (not implemented): hard-filtered review-diff / path ledger \u2014 see
5734
- # docs/autopilot/workflows/autopilot-executing.md (B2 strong).
5735
-
5736
- # Runtime / editor (prefer also listing these in .gitignore)
5737
- .autopilot/**
5738
- .cursor/**
5739
- .claude/**
5740
-
5741
- # Planning artifacts
5742
- plans/**
5743
-
5744
- # Common build / vendor trees
5745
- node_modules/**
5746
- dist/**
5747
- build/**
5748
- out/**
5749
- target/**
5750
- .target/**
5751
- coverage/**
5752
- .venv/**
5753
- venv/**
5754
- __pycache__/**
5755
-
5756
- # Lockfiles / package manager noise
5757
- package-lock.json
5758
- pnpm-lock.yaml
5759
- yarn.lock
5760
- bun.lock
5761
- bun.lockb
5762
- Cargo.lock
5763
- poetry.lock
5764
- composer.lock
5765
-
5766
- # Media / binary (do not trigger self-review)
5767
- *.png
5768
- *.jpg
5769
- *.jpeg
5770
- *.gif
5771
- *.webp
5772
- *.ico
5773
- *.svg
5774
- *.bmp
5775
- *.mp3
5776
- *.mp4
5777
- *.wav
5778
- *.webm
5779
- *.mov
5780
- *.woff
5781
- *.woff2
5782
- *.ttf
5783
- *.otf
5784
- *.eot
5785
- *.pdf
5786
- *.zip
5787
- *.gz
5788
- *.tgz
5789
- *.7z
5790
- *.rar
5791
- *.jar
5792
- *.class
5793
- *.o
5794
- *.a
5795
- *.so
5796
- *.dylib
5797
- *.dll
5798
- *.exe
5799
- *.wasm
5800
-
5801
- # Prose / data noise
5802
- *.txt
5803
- *.html
5804
- *.htm
5805
- *.csv
5806
- *.tsv
5807
- *.log
5808
- *.map
5809
- *.min.js
5810
- *.min.css
5811
- `;
5812
- var MAX_AUTOPILOT_IGNORE_BYTES = 1e6;
5813
- var MAX_AUTOPILOT_IGNORE_LINE_CHARS = 4096;
5814
- var MAX_AUTOPILOT_IGNORE_PATTERNS = 1e4;
5815
- var ignoreCache = /* @__PURE__ */ new Map();
5816
- function escapeRegexChar(ch) {
5817
- return ch.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
5702
+ function upsertTrack(store, slug, checklistPath, plansDir, projectRoot) {
5703
+ const ts = nowIso2();
5704
+ const root = normalizeProjectRoot(projectRoot);
5705
+ if (!root) return;
5706
+ const planPath = path10.join(root, plansDir, slug, "plan.md");
5707
+ const briefPath = path10.join(root, plansDir, slug, "brief.md");
5708
+ store.db.prepare(
5709
+ `INSERT INTO tracks (track_id, slug, checklist_path, plan_path, brief_path, updated_at)
5710
+ VALUES (?, ?, ?, ?, ?, ?)
5711
+ ON CONFLICT(track_id) DO UPDATE SET
5712
+ slug = excluded.slug,
5713
+ checklist_path = excluded.checklist_path,
5714
+ plan_path = excluded.plan_path,
5715
+ brief_path = excluded.brief_path,
5716
+ updated_at = excluded.updated_at`
5717
+ ).run(
5718
+ slug,
5719
+ slug,
5720
+ checklistPath,
5721
+ fs10.existsSync(planPath) ? planPath : null,
5722
+ fs10.existsSync(briefPath) ? briefPath : null,
5723
+ ts
5724
+ );
5725
+ }
5726
+ function candidatePayload(tracks) {
5727
+ return JSON.stringify(
5728
+ tracks.map((t) => ({
5729
+ slug: t.slug,
5730
+ title: sanitizeSessionDisplayText(t.title || t.slug) || t.slug,
5731
+ phase: t.phase,
5732
+ progress: `${t.checklistDone}/${t.checklistTotal}`
5733
+ }))
5734
+ );
5818
5735
  }
5819
- function globBodyToRegex(glob) {
5820
- let out = "";
5821
- for (let i = 0; i < glob.length; ) {
5822
- const ch = glob[i];
5823
- if (ch === "*" && glob[i + 1] === "*") {
5824
- if (glob[i + 2] === "/") {
5825
- out += "(?:.*/)?";
5826
- i += 3;
5827
- continue;
5736
+ function resolveRunSlug(store, session, projectRoot, plansDir, requestedSlug) {
5737
+ const runnable = listTracks(projectRoot, store, "runnable", plansDir).filter(
5738
+ (t) => isSafeTrackSlug(t.slug)
5739
+ );
5740
+ if (requestedSlug) {
5741
+ if (!isSafeTrackSlug(requestedSlug)) {
5742
+ return {
5743
+ kind: "none",
5744
+ userMessage: `Invalid track slug "${displayUntrusted(requestedSlug)}".`
5745
+ };
5746
+ }
5747
+ const hit = runnable.find((t) => t.slug === requestedSlug);
5748
+ if (!hit) {
5749
+ const all = listTracks(projectRoot, store, "all", plansDir);
5750
+ if (!all.some((t) => t.slug === requestedSlug)) {
5751
+ return {
5752
+ kind: "none",
5753
+ userMessage: `Track "${displayUntrusted(requestedSlug)}" not found or has no unchecked checklist items.`
5754
+ };
5828
5755
  }
5829
- out += ".*";
5830
- i += 2;
5831
- continue;
5756
+ return {
5757
+ kind: "none",
5758
+ userMessage: `Track "${displayUntrusted(requestedSlug)}" is not runnable (paused or no unchecked items).`
5759
+ };
5832
5760
  }
5833
- if (ch === "*") {
5834
- out += "[^/]*";
5835
- i += 1;
5836
- continue;
5761
+ return { kind: "slug", slug: requestedSlug };
5762
+ }
5763
+ const midPick = session.pending_action === "run" || session.pending_action === "replan";
5764
+ if (!midPick && isBoundRunTrackId(session.track_id)) {
5765
+ const bound = runnable.find((t) => t.slug === session.track_id);
5766
+ if (bound) {
5767
+ return { kind: "slug", slug: bound.slug };
5837
5768
  }
5838
- if (ch === "?") {
5839
- out += "[^/]";
5840
- i += 1;
5841
- continue;
5769
+ }
5770
+ if (runnable.length === 0) {
5771
+ return {
5772
+ kind: "none",
5773
+ userMessage: "No runnable plan. Use /autopilot-on to plan, then finalize a checklist with unchecked items."
5774
+ };
5775
+ }
5776
+ if (runnable.length === 1) {
5777
+ if (session.pending_action === "replan") {
5778
+ return { kind: "pick", candidates: runnable };
5842
5779
  }
5843
- out += escapeRegexChar(ch);
5844
- i += 1;
5780
+ return { kind: "slug", slug: runnable[0].slug };
5845
5781
  }
5846
- return out;
5782
+ return { kind: "pick", candidates: runnable };
5847
5783
  }
5848
- function compilePattern(raw) {
5849
- let line = raw.trim();
5850
- if (!line || line.startsWith("#")) return null;
5851
- if (line.length > MAX_AUTOPILOT_IGNORE_LINE_CHARS) return null;
5852
- let negated = false;
5853
- if (line.startsWith("!")) {
5854
- negated = true;
5855
- line = line.slice(1).trim();
5856
- if (!line || line.startsWith("#")) return null;
5784
+ function requireProjectRoot(store, projectRoot) {
5785
+ return normalizeProjectRoot(store.projectRoot) ?? normalizeProjectRoot(projectRoot);
5786
+ }
5787
+ function requirePlansDir(projectRoot, plansDir) {
5788
+ return normalizeInProjectPlansDir(projectRoot, plansDir);
5789
+ }
5790
+ function applyRun(store, conversationId, projectRoot, opts) {
5791
+ const root = requireProjectRoot(store, projectRoot);
5792
+ if (!root) {
5793
+ return { ok: false, userMessage: "Invalid project root." };
5857
5794
  }
5858
- const dirOnly = line.endsWith("/");
5859
- if (dirOnly) line = line.slice(0, -1);
5860
- if (!line) return null;
5861
- if (line.length > MAX_AUTOPILOT_IGNORE_LINE_CHARS) return null;
5862
- let anchored = false;
5863
- if (line.startsWith("/")) {
5864
- anchored = true;
5865
- line = line.slice(1);
5795
+ projectRoot = root;
5796
+ const plansDir = requirePlansDir(projectRoot, opts?.config?.plansDir);
5797
+ if (!plansDir) {
5798
+ return { ok: false, userMessage: "Invalid plans directory." };
5866
5799
  }
5867
- const body = globBodyToRegex(line);
5868
- let regexSource;
5869
- if (anchored) {
5870
- regexSource = `^${body}`;
5871
- if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
5872
- regexSource += "(?:/.*)?";
5800
+ const concurrencyMode = opts?.config?.concurrencyMode ?? "one_executor";
5801
+ const session = ensureSession(
5802
+ store,
5803
+ conversationId,
5804
+ projectRoot,
5805
+ opts?.platform
5806
+ );
5807
+ const resolved = resolveRunSlug(
5808
+ store,
5809
+ session,
5810
+ projectRoot,
5811
+ plansDir,
5812
+ opts?.slug
5813
+ );
5814
+ if (resolved.kind === "none") {
5815
+ return { ok: false, userMessage: resolved.userMessage };
5816
+ }
5817
+ if (resolved.kind === "pick") {
5818
+ const clearingStaleBind = isBoundRunTrackId(session.track_id);
5819
+ const trackForPick = clearingStaleBind ? "_pending" : session.track_id;
5820
+ store.upsertSession({
5821
+ conversation_id: conversationId,
5822
+ project_root: session.project_root,
5823
+ code_root: session.code_root,
5824
+ track_id: trackForPick,
5825
+ // Drop checklist binding with the stale track so planning state stays consistent.
5826
+ ...clearingStaleBind ? { checklist_path: "" } : {},
5827
+ pending_action: "run",
5828
+ track_candidates_json: candidatePayload(resolved.candidates),
5829
+ armed: 0
5830
+ // phase unchanged — do not write executing
5831
+ });
5832
+ const lines = resolved.candidates.map((t, i) => {
5833
+ const title = sanitizeSessionDisplayText(t.title || t.slug) || t.slug;
5834
+ const left = t.checklistTotal - t.checklistDone;
5835
+ return ` ${i + 1}. ${t.slug} \u2014 ${title} (${left}/${t.checklistTotal} left)`;
5836
+ }).join("\n");
5837
+ return {
5838
+ ok: false,
5839
+ needPick: true,
5840
+ candidates: resolved.candidates,
5841
+ userMessage: `Select a plan to execute:
5842
+
5843
+ ${lines}
5844
+
5845
+ Reply with a number or /autopilot-run <slug>.`
5846
+ };
5847
+ }
5848
+ const slug = resolved.slug;
5849
+ if (!isSafeTrackSlug(slug)) {
5850
+ return {
5851
+ ok: false,
5852
+ userMessage: `Invalid track slug "${displayUntrusted(slug)}".`
5853
+ };
5854
+ }
5855
+ const checklistPath = checklistPathFor(projectRoot, slug, plansDir);
5856
+ const gate = canEnterExecuting({
5857
+ slug,
5858
+ checklistPath,
5859
+ paused: session.paused === 1,
5860
+ projectRoot
5861
+ });
5862
+ if (!gate.ok) {
5863
+ return {
5864
+ ok: false,
5865
+ userMessage: `Cannot start executing: ${gate.reason}.`
5866
+ };
5867
+ }
5868
+ try {
5869
+ const result = store.exclusiveWrite(() => {
5870
+ if (concurrencyMode === "one_executor") {
5871
+ const other = store.findExecutingSession(conversationId);
5872
+ if (other) {
5873
+ const userMessage = formatOneExecutorBusyMessage(
5874
+ other.track_id,
5875
+ other.conversation_id
5876
+ );
5877
+ return {
5878
+ commit: false,
5879
+ value: {
5880
+ ok: false,
5881
+ busy: true,
5882
+ userMessage
5883
+ }
5884
+ };
5885
+ }
5886
+ }
5887
+ const fresh = store.getSession(conversationId);
5888
+ const alreadyExecutingSameTrack = fresh?.phase === "executing" && fresh.armed === 1 && fresh.paused === 0 && fresh.track_id === slug && sameChecklistBinding(fresh.checklist_path, checklistPath, projectRoot);
5889
+ upsertTrack(store, slug, checklistPath, plansDir, projectRoot);
5890
+ const updated = store.upsertSession({
5891
+ conversation_id: conversationId,
5892
+ project_root: projectRoot,
5893
+ code_root: projectRoot,
5894
+ track_id: slug,
5895
+ checklist_path: checklistPath,
5896
+ phase: "executing",
5897
+ armed: 1,
5898
+ paused: 0,
5899
+ paused_reason: null,
5900
+ pending_action: null,
5901
+ track_candidates_json: null,
5902
+ error_count: 0,
5903
+ idle_stop_count: 0
5904
+ });
5905
+ if (!alreadyExecutingSameTrack) {
5906
+ let reviewingItemId = null;
5907
+ try {
5908
+ const cl = parseChecklist(checklistPath, { projectRoot });
5909
+ reviewingItemId = firstUnchecked(cl)?.id ?? null;
5910
+ } catch {
5911
+ }
5912
+ store.updateReviewChain(conversationId, {
5913
+ fix_round: 0,
5914
+ confirm_left: null,
5915
+ chain_pending: 0,
5916
+ code_edited: 0,
5917
+ item_confirm_complete: 0,
5918
+ reviewing_item_id: reviewingItemId,
5919
+ pending_followup: null,
5920
+ pending_followup_at: null,
5921
+ pending_redeliver_at: null
5922
+ });
5923
+ } else {
5924
+ store.ensureReviewChain(conversationId);
5925
+ }
5926
+ return { commit: true, value: { ok: true, session: updated } };
5927
+ });
5928
+ if (!result.ok && result.busy === true) {
5929
+ logBusyOccupier(result.userMessage);
5873
5930
  }
5874
- regexSource += "$";
5875
- } else if (line.includes("/")) {
5876
- regexSource = `(?:^|.*/)${body}`;
5877
- if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
5878
- regexSource += "(?:/.*)?";
5931
+ return result;
5932
+ } catch (err) {
5933
+ const msg = err instanceof Error ? err.message : String(err);
5934
+ if (/busy|locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(msg)) {
5935
+ const userMessage = "State database is busy; retry Autopilot RUN in a moment.";
5936
+ logBusyOccupier(userMessage);
5937
+ return {
5938
+ ok: false,
5939
+ busy: true,
5940
+ userMessage
5941
+ };
5879
5942
  }
5880
- regexSource += "$";
5881
- } else {
5882
- regexSource = `(?:^|.*/)?${body}$`;
5943
+ throw err;
5883
5944
  }
5884
- return { negated, regex: new RegExp(regexSource) };
5885
5945
  }
5886
- function parseAutopilotIgnore(text) {
5887
- const patterns = [];
5888
- for (const rawLine of text.split(/\r?\n/)) {
5889
- const compiled = compilePattern(rawLine);
5890
- if (!compiled) continue;
5891
- patterns.push(compiled);
5892
- if (patterns.length >= MAX_AUTOPILOT_IGNORE_PATTERNS) break;
5946
+ function applyReplan(store, conversationId, projectRoot, opts) {
5947
+ const root = requireProjectRoot(store, projectRoot);
5948
+ if (!root) {
5949
+ return { ok: false, userMessage: "Invalid project root." };
5893
5950
  }
5894
- return patterns;
5895
- }
5896
- var DEFAULT_AUTOPILOT_IGNORE_PATTERNS = parseAutopilotIgnore(
5897
- DEFAULT_AUTOPILOT_IGNORE_TEXT
5898
- );
5899
- function normalizeRelativePath(filePath) {
5900
- return filePath.replace(/\\/g, "/").replace(/^\.\//, "");
5901
- }
5902
- function isAutopilotIgnoredPath(relativePath, patterns) {
5903
- const norm = normalizeRelativePath(relativePath);
5904
- let ignored = false;
5905
- for (const pat of patterns) {
5906
- if (pat.regex.test(norm)) {
5907
- ignored = !pat.negated;
5908
- }
5951
+ projectRoot = root;
5952
+ const plansDir = requirePlansDir(projectRoot, opts?.config?.plansDir);
5953
+ if (!plansDir) {
5954
+ return { ok: false, userMessage: "Invalid plans directory." };
5909
5955
  }
5910
- return ignored;
5911
- }
5912
- function realpathForCompare(absPath) {
5913
- try {
5914
- return fs10.realpathSync(absPath);
5915
- } catch {
5956
+ const session = ensureSession(
5957
+ store,
5958
+ conversationId,
5959
+ projectRoot,
5960
+ opts?.platform
5961
+ );
5962
+ let slug = opts?.slug;
5963
+ if (!slug) {
5964
+ const tid = session.track_id;
5965
+ const midPick = session.pending_action === "run" || session.pending_action === "replan";
5966
+ slug = !midPick && isBoundRunTrackId(tid) ? tid : void 0;
5916
5967
  }
5917
- let dir = path9.dirname(absPath);
5918
- const base = path9.basename(absPath);
5919
- const missing = [];
5920
- for (; ; ) {
5921
- try {
5922
- const realDir = fs10.realpathSync(dir);
5923
- return path9.join(realDir, ...missing, base);
5924
- } catch {
5925
- const parent = path9.dirname(dir);
5926
- if (parent === dir) return absPath;
5927
- missing.unshift(path9.basename(dir));
5928
- dir = parent;
5929
- }
5968
+ if (slug && !isSafeTrackSlug(slug)) {
5969
+ return {
5970
+ ok: false,
5971
+ userMessage: `Invalid track slug "${displayUntrusted(slug)}".`
5972
+ };
5930
5973
  }
5931
- }
5932
- function toProjectRelativePath(filePath, projectRoot) {
5933
- const posix = filePath.replace(/\\/g, "/");
5934
- if (!projectRoot?.trim()) {
5935
- return normalizeRelativePath(posix);
5974
+ if (!slug) {
5975
+ const all = listTracks(projectRoot, store, "all", plansDir).filter(
5976
+ (t) => isSafeTrackSlug(t.slug)
5977
+ );
5978
+ if (all.length === 1 && session.pending_action !== "run") {
5979
+ slug = all[0].slug;
5980
+ } else if (all.length > 1 || all.length === 1 && session.pending_action === "run") {
5981
+ const clearingStaleBind = isBoundRunTrackId(session.track_id);
5982
+ store.upsertSession({
5983
+ conversation_id: conversationId,
5984
+ project_root: session.project_root,
5985
+ code_root: session.code_root,
5986
+ track_id: clearingStaleBind ? "_pending" : session.track_id,
5987
+ ...clearingStaleBind ? { checklist_path: "" } : {},
5988
+ pending_action: "replan",
5989
+ track_candidates_json: candidatePayload(all),
5990
+ armed: 0
5991
+ });
5992
+ const lines = all.map((t, i) => {
5993
+ const title = sanitizeSessionDisplayText(t.title || t.slug) || t.slug;
5994
+ return ` ${i + 1}. ${t.slug} \u2014 ${title}`;
5995
+ }).join("\n");
5996
+ return {
5997
+ ok: false,
5998
+ needPick: true,
5999
+ candidates: all,
6000
+ userMessage: `Select a plan to replan:
6001
+
6002
+ ${lines}
6003
+
6004
+ Reply with a number or /autopilot-replan <slug>.`
6005
+ };
6006
+ } else {
6007
+ return {
6008
+ ok: false,
6009
+ userMessage: "No plan to replan. Use /autopilot-on first."
6010
+ };
6011
+ }
5936
6012
  }
5937
- const root = realpathForCompare(path9.resolve(projectRoot));
5938
- const abs = realpathForCompare(
5939
- path9.isAbsolute(posix) ? path9.resolve(posix) : path9.resolve(root, posix)
6013
+ const checklistPath = trustedChecklistPath(
6014
+ projectRoot,
6015
+ plansDir,
6016
+ slug,
6017
+ session.checklist_path,
6018
+ session.track_id
5940
6019
  );
5941
- const rel = path9.relative(root, abs);
5942
- if (rel.startsWith("..") || path9.isAbsolute(rel)) {
5943
- return "";
5944
- }
5945
- return normalizeRelativePath(rel.replace(/\\/g, "/"));
6020
+ const updated = store.upsertSession({
6021
+ conversation_id: conversationId,
6022
+ project_root: projectRoot,
6023
+ code_root: projectRoot,
6024
+ track_id: slug,
6025
+ checklist_path: checklistPath,
6026
+ phase: "planning",
6027
+ armed: 0,
6028
+ paused: 0,
6029
+ paused_reason: null,
6030
+ pending_action: null,
6031
+ track_candidates_json: null
6032
+ });
6033
+ store.updateReviewChain(conversationId, {
6034
+ fix_round: 0,
6035
+ confirm_left: null,
6036
+ chain_pending: 0,
6037
+ code_edited: 0,
6038
+ item_confirm_complete: 0,
6039
+ reviewing_item_id: null,
6040
+ pending_followup: null,
6041
+ pending_followup_at: null,
6042
+ pending_redeliver_at: null
6043
+ });
6044
+ return { ok: true, session: updated };
5946
6045
  }
5947
- function loadAutopilotIgnorePatterns(projectRoot) {
5948
- const root = path9.resolve(projectRoot);
5949
- const filePath = path9.join(root, ".autopilotignore");
5950
- let st;
5951
- try {
5952
- st = fs10.lstatSync(filePath);
5953
- } catch {
5954
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6046
+ function applyTrackPick(store, conversationId, projectRoot, pick, opts) {
6047
+ const root = requireProjectRoot(store, projectRoot);
6048
+ if (!root) {
6049
+ return { ok: false, userMessage: "Invalid project root." };
5955
6050
  }
5956
- if (st.isSymbolicLink() || !st.isFile()) {
5957
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6051
+ projectRoot = root;
6052
+ const session = store.getSession(conversationId);
6053
+ if (!session?.pending_action) {
6054
+ return {
6055
+ ok: false,
6056
+ userMessage: "No pending track selection."
6057
+ };
5958
6058
  }
5959
- if (st.size > MAX_AUTOPILOT_IGNORE_BYTES) {
5960
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6059
+ const pending = session.pending_action;
6060
+ if (pending !== "run" && pending !== "replan") {
6061
+ return {
6062
+ ok: false,
6063
+ userMessage: `Unknown pending action "${displayUntrusted(pending)}".`
6064
+ };
5961
6065
  }
5962
- const cached = ignoreCache.get(root);
5963
- if (cached && cached.mtimeMs === st.mtimeMs) {
5964
- return cached.patterns;
6066
+ if (!session.track_candidates_json) {
6067
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
5965
6068
  }
5966
- let text;
6069
+ let candidates = [];
5967
6070
  try {
5968
- text = fs10.readFileSync(filePath, "utf8");
6071
+ const parsed = JSON.parse(session.track_candidates_json);
6072
+ if (!Array.isArray(parsed) || parsed.length === 0) {
6073
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
6074
+ }
6075
+ const hasValidSlug = parsed.some(
6076
+ (c) => !!c && typeof c === "object" && !Array.isArray(c) && typeof c.slug === "string" && isSafeTrackSlug(c.slug)
6077
+ );
6078
+ if (!hasValidSlug) {
6079
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
6080
+ }
6081
+ candidates = parsed;
5969
6082
  } catch {
5970
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6083
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
5971
6084
  }
5972
- if (text.length > MAX_AUTOPILOT_IGNORE_BYTES) {
5973
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6085
+ let slug;
6086
+ if (/^\d+$/.test(pick)) {
6087
+ const idx = Number.parseInt(pick, 10) - 1;
6088
+ const entry = candidates[idx];
6089
+ slug = entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.slug === "string" ? entry.slug : void 0;
6090
+ if (!slug || !isSafeTrackSlug(slug)) {
6091
+ return {
6092
+ ok: false,
6093
+ userMessage: `Invalid selection "${displayUntrusted(pick)}". Choose 1\u2013${candidates.length}.`
6094
+ };
6095
+ }
6096
+ } else {
6097
+ if (!isSafeTrackSlug(pick)) {
6098
+ return {
6099
+ ok: false,
6100
+ userMessage: `Invalid track slug "${displayUntrusted(pick)}".`
6101
+ };
6102
+ }
6103
+ slug = pick;
6104
+ if (!candidates.some(
6105
+ (c) => !!c && typeof c === "object" && !Array.isArray(c) && c.slug === slug
6106
+ )) {
6107
+ return {
6108
+ ok: false,
6109
+ userMessage: `Unknown slug "${displayUntrusted(pick)}".`
6110
+ };
6111
+ }
5974
6112
  }
5975
- const patterns = parseAutopilotIgnore(text);
5976
- ignoreCache.set(root, { mtimeMs: st.mtimeMs, patterns });
5977
- return patterns;
5978
- }
5979
-
5980
- // ../core/src/code-edit-detector.ts
5981
- function isUntrackedGitIgnored(projectRoot, relativePath) {
5982
- if (!relativePath || relativePath.includes("\0")) return false;
5983
- try {
5984
- const r = spawnSync(
5985
- "git",
5986
- ["check-ignore", "-q", "--", relativePath],
5987
- {
5988
- cwd: projectRoot,
5989
- encoding: "utf8",
5990
- timeout: 5e3,
5991
- windowsHide: true,
5992
- shell: false
5993
- }
5994
- );
5995
- return r.status === 0;
5996
- } catch {
5997
- return false;
6113
+ if (pending === "replan") {
6114
+ return applyReplan(store, conversationId, projectRoot, {
6115
+ slug,
6116
+ config: opts?.config,
6117
+ platform: opts?.platform
6118
+ });
5998
6119
  }
5999
- }
6000
- function isProductCodeEdit(filePath, opts) {
6001
- const relative = toProjectRelativePath(filePath, opts?.projectRoot);
6002
- if (!relative) return false;
6003
- const patterns = opts?.projectRoot?.trim() ? loadAutopilotIgnorePatterns(opts.projectRoot) : DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6004
- if (isAutopilotIgnoredPath(relative, patterns)) return false;
6005
- const root = opts?.projectRoot?.trim();
6006
- if (root && isUntrackedGitIgnored(root, relative)) return false;
6007
- return true;
6120
+ return applyRun(store, conversationId, projectRoot, {
6121
+ slug,
6122
+ config: opts?.config,
6123
+ platform: opts?.platform
6124
+ });
6008
6125
  }
6009
6126
 
6010
6127
  // ../ports/cursor/src/index.ts