@autopilot-harness/cli 0.2.12 → 0.2.14

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.
@@ -5,12 +5,12 @@ var en_default = {
5
5
  help: "Autopilot Harness \u2014 Planning \u2192 Executing agent harness"
6
6
  },
7
7
  triggers: {
8
- on: ["Autopilot ON", "Enable autopilot"],
9
- run: ["Autopilot RUN", "Start execution"],
10
- off: ["Autopilot OFF", "Disable autopilot"],
11
- resume: ["Autopilot RESUME"],
12
- replan: ["Autopilot REPLAN"],
13
- resume_review: ["Resume review"]
8
+ on: ["Autopilot ON", "Enable autopilot", "\u5F00\u542F\u81EA\u52A8\u9A7E\u9A76"],
9
+ run: ["Autopilot RUN", "Start execution", "\u5F00\u59CB\u6267\u884C"],
10
+ off: ["Autopilot OFF", "Disable autopilot", "\u5173\u95ED\u81EA\u52A8\u9A7E\u9A76"],
11
+ resume: ["Autopilot RESUME", "\u7EE7\u7EED\u6267\u884C"],
12
+ replan: ["Autopilot REPLAN", "\u4FEE\u6539\u65B9\u6848"],
13
+ resume_review: ["Resume review", "\u7EE7\u7EED\u81EA\u5BA1"]
14
14
  },
15
15
  skill: {
16
16
  autopilot_on: {
@@ -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."
@@ -81,12 +82,12 @@ var zh_CN_default = {
81
82
  help: "Autopilot Harness \u2014 \u4E24\u9636\u6BB5 Agent \u5916\u9AA8\u9ABC\uFF1A\u89C4\u5212 \u2192 \u6267\u884C"
82
83
  },
83
84
  triggers: {
84
- on: ["Autopilot ON", "\u5F00\u542F\u81EA\u52A8\u9A7E\u9A76"],
85
- run: ["Autopilot RUN", "\u5F00\u59CB\u6267\u884C"],
86
- off: ["Autopilot OFF", "\u5173\u95ED\u81EA\u52A8\u9A7E\u9A76"],
85
+ on: ["Autopilot ON", "Enable autopilot", "\u5F00\u542F\u81EA\u52A8\u9A7E\u9A76"],
86
+ run: ["Autopilot RUN", "Start execution", "\u5F00\u59CB\u6267\u884C"],
87
+ off: ["Autopilot OFF", "Disable autopilot", "\u5173\u95ED\u81EA\u52A8\u9A7E\u9A76"],
87
88
  resume: ["Autopilot RESUME", "\u7EE7\u7EED\u6267\u884C"],
88
89
  replan: ["Autopilot REPLAN", "\u4FEE\u6539\u65B9\u6848"],
89
- resume_review: ["\u7EE7\u7EED\u81EA\u5BA1", "Resume review"]
90
+ resume_review: ["Resume review", "\u7EE7\u7EED\u81EA\u5BA1"]
90
91
  },
91
92
  skill: {
92
93
  autopilot_on: {
@@ -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,94 +2400,470 @@ function pendingRedeliverAllowed(lastRedeliverAt) {
2398
2400
  return Date.now() - t >= PENDING_REDELIVER_COOLDOWN_MS;
2399
2401
  }
2400
2402
 
2401
- // ../core/src/review-engine.ts
2402
- function defaultRender(kind, vars) {
2403
- switch (kind) {
2404
- case "review.fix":
2405
- return `Review fix round ${vars.round} (no hard cap; confirm needs ${vars.total} consecutive no-edit rounds). Code changed this turn. Defect-first self-review and fix now: 1) when using git diff / git status, skip paths matching .autopilotignore, and skip untracked paths ignored by .gitignore; review only the remaining paths; 2) check correctness, null/boundaries, concurrency, security, regression, missing tests; 3) CRITICAL/HIGH must fix, MEDIUM preferably; 4) run relevant tests; 5) briefly state what you reviewed and changed (or "self-review clean"). Do not commit/push. If no further code changes, next stop enters multi-lens confirm.`;
2406
- case "review.confirm":
2407
- return `Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). Lens \u3010${vars.lensTitle}\u3011 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} Previous turn had no further code edits. Recheck under this lens only: 1) git diff / git status \u2014 no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; 2) dig into this lens only; ban vague "fully rechecked, all good"; 3) CRITICAL/HIGH under this lens must fix; MEDIUM preferably; 4) if you edit, fix and run related tests; 5) close with: "Lens (${vars.lensTitle}): self-review clean" or a short list of fixes; if clean, do not edit further. Do not commit/push.`;
2408
- case "review.confirm_final":
2409
- return `Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). Lens \u3010${vars.lensTitle}\u3011 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} Previous turn had no further code edits. Recheck under this lens only: 1) git diff / git status \u2014 no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; 2) dig into this lens only; ban vague "fully rechecked, all good"; 3) read-only: record CRITICAL/HIGH/missing tests \u2014 do not change code, add tests, or commit; if you already edited, accept returning to a fix round; never commit this turn; 4) do not run commands that mutate the repo; 5) close with: "Lens (${vars.lensTitle}): self-review clean" or list issues (no fixes); if clean, do not edit further. Do not commit/push. Handoff (commit) and next checklist item are handled by Advance/Done after the chain \u2014 not this turn.`;
2410
- case "advance":
2411
- return `Advance checklist: confirm chain passed cleanly (confirm rounds do not commit). First mark the completed current item ${vars.currentId ?? ""} [x] in checklist.md (do not mark the next item yet). Never mark an item [x] while you are still implementing it \u2014 only this Advance/Done followup checks off the completed item. Then, if the working tree still has uncommitted changes for this item (including checklist.md when plans/ is committed), local conventional commit only: git status/diff \u2192 stage only this checklist item's paths; never git add -A, never stage .env/secrets/.autopilot runtime; one conventional commit; no push/--no-verify/amend/force unless the user explicitly asks. If already clean after marking, skip commit. Then implement next: ${vars.nextId ?? ""} \u2014 ${vars.nextTitle ?? ""}.`;
2412
- case "done":
2413
- return `All checklist items done. Confirm chain passed (confirm rounds do not commit). Mark the last item [x]. If the working tree still has uncommitted changes for this item (including checklist.md when plans/ is committed), local conventional commit only (never stage .env/secrets/.autopilot runtime; no push unless the user asks); if clean, just confirm briefly. Phase is done.`;
2414
- case "recover":
2415
- return `Recover: the previous turn ended with an error. Continue the current task.`;
2416
- case "recover_planning":
2417
- return `Recover: the previous turn ended with an error. Continue planning; do not RUN or write product code.`;
2418
- case "recover_ambient":
2419
- return `Recover: the previous turn ended with an error. Continue your current work; Autopilot RUN is not active.`;
2420
- case "review_complete":
2421
- 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
- case "stuck":
2423
- return `Stuck: no progress for several stops. Change strategy or send Autopilot RESUME after fixing.`;
2424
- case "verify_fix":
2425
- return `Verify failed (${vars.reason ?? "unknown"}). Fix verify commands and rewrite verify-last.json; do not advance.`;
2426
- case "need_evidence":
2427
- return `Need evidence: no-code item ${vars.currentId ?? ""}${vars.currentTitle ? ` \u2014 ${vars.currentTitle}` : ""} cannot advance without matching soft completion evidence. Write .autopilot/verify-last.json with itemId "${vars.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.`;
2428
- default:
2429
- return "";
2430
- }
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, "\\$&");
2431
2509
  }
2432
- var ReviewEngine = class {
2433
- constructor(store, config) {
2434
- this.store = store;
2435
- this.config = config;
2436
- }
2437
- render(kind, vars) {
2438
- return (this.config.renderFollowup ?? defaultRender)(kind, vars);
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;
2439
2536
  }
2440
- lens(roundIndex) {
2441
- const rounds = this.config.confirmRounds;
2442
- return (this.config.resolveLens ?? getLens)(roundIndex, rounds);
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;
2443
2548
  }
2444
- /**
2445
- * FS trust root: StateStore is authoritative.
2446
- * config.projectRoot must not widen the boundary (mismatched/evil config).
2447
- */
2448
- trustedProjectRoot() {
2449
- return normalizeProjectRoot(this.store.projectRoot) ?? normalizeProjectRoot(this.config.projectRoot);
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);
2450
2557
  }
2451
- /**
2452
- * Parse session checklist only when realpath stays under the project root.
2453
- * O_NOFOLLOW alone cannot stop intermediate directory symlink escapes or a
2454
- * poisoned absolute checklist_path in the session row.
2455
- */
2456
- parseSessionChecklist(session) {
2457
- const checklistPath = session.checklist_path;
2458
- if (!checklistPath) return null;
2459
- const root = this.trustedProjectRoot();
2460
- if (!root || !isRealpathInsideProject(root, checklistPath)) {
2461
- return null;
2558
+ const body = globBodyToRegex(line);
2559
+ let regexSource;
2560
+ if (anchored) {
2561
+ regexSource = `^${body}`;
2562
+ if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
2563
+ regexSource += "(?:/.*)?";
2462
2564
  }
2463
- try {
2464
- const cl = parseChecklist(checklistPath, { projectRoot: root });
2465
- return {
2466
- unchecked: countUnchecked(cl),
2467
- currentItem: firstUnchecked(cl),
2468
- followingItem: secondUnchecked(cl),
2469
- checklist: cl
2470
- };
2471
- } catch {
2472
- return null;
2565
+ regexSource += "$";
2566
+ } else if (line.includes("/")) {
2567
+ regexSource = `(?:^|.*/)${body}`;
2568
+ if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
2569
+ regexSource += "(?:/.*)?";
2473
2570
  }
2571
+ regexSource += "$";
2572
+ } else {
2573
+ regexSource = `(?:^|.*/)?${body}$`;
2474
2574
  }
2475
- reviewChainResetFields() {
2476
- return {
2477
- confirm_left: null,
2478
- fix_round: 0,
2479
- code_edited: 0,
2480
- item_confirm_complete: 0,
2481
- reviewing_item_id: null
2482
- };
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;
2483
2584
  }
2484
- renderAdvanceOrDone(targets) {
2485
- const isAdvance = targets.next != null;
2486
- const message = isAdvance ? this.render("advance", {
2487
- currentId: targets.current?.id ?? "",
2488
- currentTitle: targets.current?.title ?? "",
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
+
2777
+ // ../core/src/review-engine.ts
2778
+ function defaultRender(kind, vars) {
2779
+ switch (kind) {
2780
+ case "review.fix":
2781
+ return `Review fix round ${vars.round} (no hard cap; confirm needs ${vars.total} consecutive no-edit rounds). Code changed this turn. Defect-first self-review and fix now: 1) when using git diff / git status, skip paths matching .autopilotignore, and skip untracked paths ignored by .gitignore; review only the remaining paths; 2) check correctness, null/boundaries, concurrency, security, regression, missing tests; 3) CRITICAL/HIGH must fix, MEDIUM preferably; 4) run relevant tests; 5) briefly state what you reviewed and changed (or "self-review clean"). Do not commit/push. If no further code changes, next stop enters multi-lens confirm.`;
2782
+ case "review.confirm":
2783
+ return `Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). Lens \u3010${vars.lensTitle}\u3011 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} Previous turn had no further code edits. Recheck under this lens only: 1) git diff / git status \u2014 no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; 2) dig into this lens only; ban vague "fully rechecked, all good"; 3) CRITICAL/HIGH under this lens must fix; MEDIUM preferably; 4) if you edit, fix and run related tests; 5) close with: "Lens (${vars.lensTitle}): self-review clean" or a short list of fixes; if clean, do not edit further. Do not commit/push.`;
2784
+ case "review.confirm_final":
2785
+ return `Review confirm ${vars.n}/${vars.total} (session round ${vars.sessionRound}; consecutive no-edit confirms, counted on the fix-round counter). Lens \u3010${vars.lensTitle}\u3011 (multi-lens confirm, not the same checklist again). ${vars.lensFocus} Previous turn had no further code edits. Recheck under this lens only: 1) git diff / git status \u2014 no new edits vs prior turn (or only already-reviewed edits); likewise skip paths matching .autopilotignore and untracked paths ignored by .gitignore; judge only from remaining paths; 2) dig into this lens only; ban vague "fully rechecked, all good"; 3) read-only: record CRITICAL/HIGH/missing tests \u2014 do not change code, add tests, or commit; if you already edited, accept returning to a fix round; never commit this turn; 4) do not run commands that mutate the repo; 5) close with: "Lens (${vars.lensTitle}): self-review clean" or list issues (no fixes); if clean, do not edit further. Do not commit/push. Handoff (commit) and next checklist item are handled by Advance/Done after the chain \u2014 not this turn.`;
2786
+ case "advance":
2787
+ return `Advance checklist: confirm chain passed cleanly (confirm rounds do not commit). First mark the completed current item ${vars.currentId ?? ""} [x] in checklist.md (do not mark the next item yet). Never mark an item [x] while you are still implementing it \u2014 only this Advance/Done followup checks off the completed item. Then, if the working tree still has uncommitted changes for this item (including checklist.md when plans/ is committed), local conventional commit only: git status/diff \u2192 stage only this checklist item's paths; never git add -A, never stage .env/secrets/.autopilot runtime; one conventional commit; no push/--no-verify/amend/force unless the user explicitly asks. If already clean after marking, skip commit. Then implement next: ${vars.nextId ?? ""} \u2014 ${vars.nextTitle ?? ""}.`;
2788
+ case "done":
2789
+ return `All checklist items done. Confirm chain passed (confirm rounds do not commit). Mark the last item [x]. If the working tree still has uncommitted changes for this item (including checklist.md when plans/ is committed), local conventional commit only (never stage .env/secrets/.autopilot runtime; no push unless the user asks); if clean, just confirm briefly. Phase is done.`;
2790
+ case "recover":
2791
+ return `Recover: the previous turn ended with an error. Continue the current task.`;
2792
+ case "recover_planning":
2793
+ return `Recover: the previous turn ended with an error. Continue planning; do not RUN or write product code.`;
2794
+ case "recover_ambient":
2795
+ return `Recover: the previous turn ended with an error. Continue your current work; Autopilot RUN is not active.`;
2796
+ case "review_complete":
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.`;
2798
+ case "stuck":
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.`;
2802
+ case "verify_fix":
2803
+ return `Verify failed (${vars.reason ?? "unknown"}). Fix verify commands and rewrite verify-last.json; do not advance.`;
2804
+ case "need_evidence":
2805
+ return `Need evidence: no-code item ${vars.currentId ?? ""}${vars.currentTitle ? ` \u2014 ${vars.currentTitle}` : ""} cannot advance without matching soft completion evidence. Write .autopilot/verify-last.json with itemId "${vars.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.`;
2806
+ default:
2807
+ return "";
2808
+ }
2809
+ }
2810
+ var ReviewEngine = class {
2811
+ constructor(store, config) {
2812
+ this.store = store;
2813
+ this.config = config;
2814
+ }
2815
+ render(kind, vars) {
2816
+ return (this.config.renderFollowup ?? defaultRender)(kind, vars);
2817
+ }
2818
+ lens(roundIndex) {
2819
+ const rounds = this.config.confirmRounds;
2820
+ return (this.config.resolveLens ?? getLens)(roundIndex, rounds);
2821
+ }
2822
+ /**
2823
+ * FS trust root: StateStore is authoritative.
2824
+ * config.projectRoot must not widen the boundary (mismatched/evil config).
2825
+ */
2826
+ trustedProjectRoot() {
2827
+ return normalizeProjectRoot(this.store.projectRoot) ?? normalizeProjectRoot(this.config.projectRoot);
2828
+ }
2829
+ /**
2830
+ * Parse session checklist only when realpath stays under the project root.
2831
+ * O_NOFOLLOW alone cannot stop intermediate directory symlink escapes or a
2832
+ * poisoned absolute checklist_path in the session row.
2833
+ */
2834
+ parseSessionChecklist(session) {
2835
+ const checklistPath = session.checklist_path;
2836
+ if (!checklistPath) return null;
2837
+ const root = this.trustedProjectRoot();
2838
+ if (!root || !isRealpathInsideProject(root, checklistPath)) {
2839
+ return null;
2840
+ }
2841
+ try {
2842
+ const cl = parseChecklist(checklistPath, { projectRoot: root });
2843
+ return {
2844
+ unchecked: countUnchecked(cl),
2845
+ currentItem: firstUnchecked(cl),
2846
+ followingItem: secondUnchecked(cl),
2847
+ checklist: cl
2848
+ };
2849
+ } catch {
2850
+ return null;
2851
+ }
2852
+ }
2853
+ reviewChainResetFields() {
2854
+ return {
2855
+ confirm_left: null,
2856
+ fix_round: 0,
2857
+ code_edited: 0,
2858
+ item_confirm_complete: 0,
2859
+ reviewing_item_id: null
2860
+ };
2861
+ }
2862
+ renderAdvanceOrDone(targets) {
2863
+ const isAdvance = targets.next != null;
2864
+ const message = isAdvance ? this.render("advance", {
2865
+ currentId: targets.current?.id ?? "",
2866
+ currentTitle: targets.current?.title ?? "",
2489
2867
  nextId: targets.next?.id ?? "",
2490
2868
  nextTitle: targets.next?.title ?? ""
2491
2869
  }) : this.render("done", {});
@@ -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;
@@ -2692,23 +3095,61 @@ var ReviewEngine = class {
2692
3095
  if (chainNow.confirm_left === null && chainNow.item_confirm_complete === 0 && inChain) {
2693
3096
  return this.e3ArmConfirm(session, chainNow);
2694
3097
  }
2695
- return this.e0NoCodeContinue(session, chainNow);
2696
- } catch (err) {
2697
- const msg = err instanceof Error ? err.message : String(err);
2698
- if (msg.includes("No session for conversation") || msg.includes("Invalid conversation id")) {
2699
- return null;
2700
- }
2701
- throw err;
2702
- }
2703
- }
2704
- pendingBlocksAdvance(conversationId, chain, events, transcriptPath) {
2705
- const pending = chain.pending_followup?.trim();
2706
- if (!pending) return false;
2707
- if (events.length > 0 && automationFollowupPresent(events, pending)) {
2708
- const cleared = this.clearMatchingPendingAndArmAmbient(
2709
- conversationId,
2710
- pending
2711
- );
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
+ }
3136
+ return this.e0NoCodeContinue(session, chainNow);
3137
+ } catch (err) {
3138
+ const msg = err instanceof Error ? err.message : String(err);
3139
+ if (msg.includes("No session for conversation") || msg.includes("Invalid conversation id")) {
3140
+ return null;
3141
+ }
3142
+ throw err;
3143
+ }
3144
+ }
3145
+ pendingBlocksAdvance(conversationId, chain, events, transcriptPath) {
3146
+ const pending = chain.pending_followup?.trim();
3147
+ if (!pending) return false;
3148
+ if (events.length > 0 && automationFollowupPresent(events, pending)) {
3149
+ const cleared = this.clearMatchingPendingAndArmAmbient(
3150
+ conversationId,
3151
+ pending
3152
+ );
2712
3153
  if (!cleared) {
2713
3154
  try {
2714
3155
  const live = this.store.getReviewChain(conversationId)?.pending_followup?.trim() ?? "";
@@ -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,
@@ -4676,6 +5084,31 @@ var DEFAULT_PROJECT_REVIEW_CONFIG = {
4676
5084
  maxErrorsBeforePause: 0,
4677
5085
  locale: "en"
4678
5086
  };
5087
+ var TRIGGER_PHRASE_KEYS = [
5088
+ "on",
5089
+ "run",
5090
+ "off",
5091
+ "resume",
5092
+ "replan",
5093
+ "resume_review"
5094
+ ];
5095
+ function cloneDefaultTriggers() {
5096
+ return {
5097
+ match: "line_start",
5098
+ on: [...DEFAULT_TRIGGERS.on],
5099
+ run: [...DEFAULT_TRIGGERS.run],
5100
+ off: [...DEFAULT_TRIGGERS.off],
5101
+ resume: [...DEFAULT_TRIGGERS.resume],
5102
+ replan: [...DEFAULT_TRIGGERS.replan],
5103
+ resume_review: [...DEFAULT_TRIGGERS.resume_review]
5104
+ };
5105
+ }
5106
+ function cloneDefaultHookConfig() {
5107
+ return {
5108
+ triggers: cloneDefaultTriggers(),
5109
+ plansDir: "plans"
5110
+ };
5111
+ }
4679
5112
  function parseReviewScope(raw) {
4680
5113
  const s = typeof raw === "string" ? raw.trim().toLowerCase() : "";
4681
5114
  if (s === "project" || s === "always" || s === "all") return "project";
@@ -4716,6 +5149,16 @@ function coerceScalar(value) {
4716
5149
  if (value === "true") return true;
4717
5150
  if (value === "false") return false;
4718
5151
  if (value === "null" || value === "~") return null;
5152
+ const trimmed = value.trim();
5153
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
5154
+ try {
5155
+ const parsed = JSON.parse(trimmed);
5156
+ if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
5157
+ return parsed;
5158
+ }
5159
+ } catch {
5160
+ }
5161
+ }
4719
5162
  return unquote(value);
4720
5163
  }
4721
5164
  function lineIndent(line) {
@@ -4819,70 +5262,95 @@ function parseVerifyCommands(raw) {
4819
5262
  }
4820
5263
  return out;
4821
5264
  }
4822
- function loadProjectReviewConfig(projectRoot) {
5265
+ function readProjectConfigYaml(projectRoot) {
4823
5266
  const root = normalizeProjectRoot(projectRoot);
4824
- if (!root) {
4825
- return cloneDefaultProjectReviewConfig();
4826
- }
4827
- const configPath = path6.join(root, ".autopilot", "config.yml");
5267
+ if (!root) return null;
5268
+ const configPath = path8.join(root, ".autopilot", "config.yml");
4828
5269
  try {
4829
- const nofollow = typeof fs7.constants.O_NOFOLLOW === "number" ? fs7.constants.O_NOFOLLOW : 0;
5270
+ const nofollow = typeof fs8.constants.O_NOFOLLOW === "number" ? fs8.constants.O_NOFOLLOW : 0;
4830
5271
  if (nofollow === 0) {
4831
- if (!fs7.existsSync(configPath)) return cloneDefaultProjectReviewConfig();
4832
- if (fs7.lstatSync(configPath).isSymbolicLink()) {
4833
- return cloneDefaultProjectReviewConfig();
4834
- }
5272
+ if (!fs8.existsSync(configPath)) return null;
5273
+ if (fs8.lstatSync(configPath).isSymbolicLink()) return null;
4835
5274
  }
4836
5275
  let fd;
4837
5276
  try {
4838
- fd = fs7.openSync(configPath, fs7.constants.O_RDONLY | nofollow);
5277
+ fd = fs8.openSync(configPath, fs8.constants.O_RDONLY | nofollow);
4839
5278
  } catch {
4840
- return cloneDefaultProjectReviewConfig();
5279
+ return null;
4841
5280
  }
4842
5281
  let raw;
4843
5282
  try {
4844
- const st = fs7.fstatSync(fd);
4845
- if (!st.isFile() || st.size > MAX_CONFIG_BYTES) {
4846
- return cloneDefaultProjectReviewConfig();
4847
- }
4848
- const lst = fs7.lstatSync(configPath);
4849
- if (lst.isSymbolicLink() || !lst.isFile()) {
4850
- return cloneDefaultProjectReviewConfig();
4851
- }
4852
- if (lst.ino !== st.ino || lst.dev !== st.dev) {
4853
- return cloneDefaultProjectReviewConfig();
4854
- }
4855
- if (!isRealpathInsideProject(root, configPath)) {
4856
- return cloneDefaultProjectReviewConfig();
4857
- }
5283
+ const st = fs8.fstatSync(fd);
5284
+ if (!st.isFile() || st.size > MAX_CONFIG_BYTES) return null;
5285
+ const lst = fs8.lstatSync(configPath);
5286
+ if (lst.isSymbolicLink() || !lst.isFile()) return null;
5287
+ if (lst.ino !== st.ino || lst.dev !== st.dev) return null;
5288
+ if (!isRealpathInsideProject(root, configPath)) return null;
4858
5289
  const buf = Buffer.alloc(st.size);
4859
- const n = fs7.readSync(fd, buf, 0, st.size, 0);
5290
+ const n = fs8.readSync(fd, buf, 0, st.size, 0);
4860
5291
  raw = buf.subarray(0, n).toString("utf8");
4861
5292
  } finally {
4862
- fs7.closeSync(fd);
4863
- }
4864
- if (Buffer.byteLength(raw, "utf8") > MAX_CONFIG_BYTES) {
4865
- return cloneDefaultProjectReviewConfig();
5293
+ fs8.closeSync(fd);
4866
5294
  }
5295
+ if (Buffer.byteLength(raw, "utf8") > MAX_CONFIG_BYTES) return null;
4867
5296
  const text = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
4868
5297
  const parsed = parseSimpleYaml(text);
4869
- if (!isPlainObject(parsed)) return cloneDefaultProjectReviewConfig();
4870
- const review = isPlainObject(parsed.review) ? parsed.review : {};
4871
- const verify = isPlainObject(review.verify) ? review.verify : {};
4872
- const stuck = isPlainObject(review.stuck) ? review.stuck : {};
4873
- const errors = isPlainObject(review.errors) ? review.errors : {};
4874
- return normalizeProjectReviewConfig({
4875
- confirmRounds: review.confirm_rounds,
4876
- reviewScope: review.scope,
4877
- verifyEnabled: verify.enabled,
4878
- verifyCommands: verify.commands,
4879
- maxIdleStops: stuck.max_idle_stops,
4880
- maxErrorsBeforePause: errors.max_before_pause,
4881
- locale: parsed.locale
4882
- });
5298
+ if (!isPlainObject(parsed)) return null;
5299
+ return { root, parsed };
4883
5300
  } catch {
4884
- return cloneDefaultProjectReviewConfig();
5301
+ return null;
5302
+ }
5303
+ }
5304
+ function nonEmptyPhraseList(raw) {
5305
+ if (!Array.isArray(raw)) return null;
5306
+ const out = [];
5307
+ for (const item of raw) {
5308
+ if (typeof item !== "string") continue;
5309
+ const t = item.trim();
5310
+ if (t) out.push(t);
5311
+ }
5312
+ return out.length > 0 ? out : null;
5313
+ }
5314
+ function triggersFromParsed(parsed) {
5315
+ const base = cloneDefaultTriggers();
5316
+ const triggers = isPlainObject(parsed.triggers) ? parsed.triggers : {};
5317
+ for (const key of TRIGGER_PHRASE_KEYS) {
5318
+ const phrases = nonEmptyPhraseList(triggers[key]);
5319
+ if (phrases) base[key] = phrases;
4885
5320
  }
5321
+ return base;
5322
+ }
5323
+ function plansDirFromParsed(root, parsed) {
5324
+ const artifacts = isPlainObject(parsed.artifacts) ? parsed.artifacts : {};
5325
+ const raw = artifacts.plans_dir;
5326
+ const candidate = typeof raw === "string" ? raw : "plans";
5327
+ return normalizeInProjectPlansDir(root, candidate) ?? "plans";
5328
+ }
5329
+ function loadProjectReviewConfig(projectRoot) {
5330
+ const loaded = readProjectConfigYaml(projectRoot);
5331
+ if (!loaded) return cloneDefaultProjectReviewConfig();
5332
+ const { parsed } = loaded;
5333
+ const review = isPlainObject(parsed.review) ? parsed.review : {};
5334
+ const verify = isPlainObject(review.verify) ? review.verify : {};
5335
+ const stuck = isPlainObject(review.stuck) ? review.stuck : {};
5336
+ const errors = isPlainObject(review.errors) ? review.errors : {};
5337
+ return normalizeProjectReviewConfig({
5338
+ confirmRounds: review.confirm_rounds,
5339
+ reviewScope: review.scope,
5340
+ verifyEnabled: verify.enabled,
5341
+ verifyCommands: verify.commands,
5342
+ maxIdleStops: stuck.max_idle_stops,
5343
+ maxErrorsBeforePause: errors.max_before_pause,
5344
+ locale: parsed.locale
5345
+ });
5346
+ }
5347
+ function loadProjectHookConfig(projectRoot) {
5348
+ const loaded = readProjectConfigYaml(projectRoot);
5349
+ if (!loaded) return cloneDefaultHookConfig();
5350
+ return {
5351
+ triggers: triggersFromParsed(loaded.parsed),
5352
+ plansDir: plansDirFromParsed(loaded.root, loaded.parsed)
5353
+ };
4886
5354
  }
4887
5355
  function normalizeProjectReviewConfig(raw) {
4888
5356
  if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -4959,6 +5427,12 @@ function createRenderFollowup(bundle) {
4959
5427
  );
4960
5428
  case "stuck":
4961
5429
  return renderTemplate(f.stuck ?? "", vars);
5430
+ case "stuck_soft":
5431
+ return renderTemplate(
5432
+ // Never fall back to hard `stuck` (RESUME-required) — that defeats C2.
5433
+ 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.",
5434
+ vars
5435
+ );
4962
5436
  case "verify_fix":
4963
5437
  return renderTemplate(
4964
5438
  f.verify_fix ?? "Verify failed ({reason}). Fix verify commands and rewrite verify-last.json; do not advance.",
@@ -5011,12 +5485,12 @@ function createConfiguredReviewEngine(store, projectRoot, localeBundle, preloade
5011
5485
  }
5012
5486
 
5013
5487
  // ../core/src/phase-actions.ts
5014
- import fs9 from "node:fs";
5015
- import path8 from "node:path";
5488
+ import fs10 from "node:fs";
5489
+ import path10 from "node:path";
5016
5490
 
5017
5491
  // ../core/src/list-tracks.ts
5018
- import fs8 from "node:fs";
5019
- import path7 from "node:path";
5492
+ import fs9 from "node:fs";
5493
+ import path9 from "node:path";
5020
5494
  function isRunnableTrack(t) {
5021
5495
  if (t.paused) return false;
5022
5496
  const unchecked = t.checklistTotal - t.checklistDone;
@@ -5027,19 +5501,19 @@ function readPlansDir(root, plansDir = "plans") {
5027
5501
  if (typeof plansDir !== "string" || !root || root.includes("\0") || plansDir.includes("\0")) {
5028
5502
  return [];
5029
5503
  }
5030
- const dir = path7.join(root, plansDir);
5504
+ const dir = path9.join(root, plansDir);
5031
5505
  try {
5032
- const lst = fs8.lstatSync(dir);
5506
+ const lst = fs9.lstatSync(dir);
5033
5507
  if (lst.isSymbolicLink() || !lst.isDirectory()) return [];
5034
5508
  if (!isRealpathInsideProject(root, dir)) return [];
5035
5509
  } catch {
5036
5510
  return [];
5037
5511
  }
5038
- const names = fs8.readdirSync(dir, { withFileTypes: true }).filter(
5512
+ const names = fs9.readdirSync(dir, { withFileTypes: true }).filter(
5039
5513
  (d) => d.isDirectory() && !d.isSymbolicLink() && isSafeTrackSlug(d.name)
5040
5514
  ).map((d) => d.name);
5041
5515
  try {
5042
- const lst = fs8.lstatSync(dir);
5516
+ const lst = fs9.lstatSync(dir);
5043
5517
  if (lst.isSymbolicLink() || !lst.isDirectory()) return [];
5044
5518
  if (!isRealpathInsideProject(root, dir)) return [];
5045
5519
  } catch {
@@ -5053,27 +5527,27 @@ function titleFromPlan(planPath, slug, projectRoot) {
5053
5527
  if (!root) return slug;
5054
5528
  const maxBytes = 65536;
5055
5529
  try {
5056
- const nofollow = typeof fs8.constants.O_NOFOLLOW === "number" ? fs8.constants.O_NOFOLLOW : 0;
5530
+ const nofollow = typeof fs9.constants.O_NOFOLLOW === "number" ? fs9.constants.O_NOFOLLOW : 0;
5057
5531
  if (nofollow === 0) {
5058
- const lst = fs8.lstatSync(planPath);
5532
+ const lst = fs9.lstatSync(planPath);
5059
5533
  if (lst.isSymbolicLink() || !lst.isFile()) return slug;
5060
5534
  }
5061
- const fd = fs8.openSync(planPath, fs8.constants.O_RDONLY | nofollow);
5535
+ const fd = fs9.openSync(planPath, fs9.constants.O_RDONLY | nofollow);
5062
5536
  try {
5063
- const st = fs8.fstatSync(fd);
5537
+ const st = fs9.fstatSync(fd);
5064
5538
  if (!st.isFile() || st.size <= 0) return slug;
5065
- const lst = fs8.lstatSync(planPath);
5539
+ const lst = fs9.lstatSync(planPath);
5066
5540
  if (lst.isSymbolicLink() || !lst.isFile()) return slug;
5067
5541
  if (lst.ino !== st.ino || lst.dev !== st.dev) return slug;
5068
5542
  if (!isRealpathInsideProject(root, planPath)) return slug;
5069
5543
  const len = Math.min(st.size, maxBytes);
5070
5544
  const buf = Buffer.alloc(len);
5071
- const n = fs8.readSync(fd, buf, 0, len, 0);
5545
+ const n = fs9.readSync(fd, buf, 0, len, 0);
5072
5546
  const first = buf.subarray(0, n).toString("utf8").split(/\r?\n/)[0] ?? "";
5073
5547
  const m = first.match(/^#\s+(.+)/);
5074
5548
  return m?.[1]?.trim() ?? slug;
5075
5549
  } finally {
5076
- fs8.closeSync(fd);
5550
+ fs9.closeSync(fd);
5077
5551
  }
5078
5552
  } catch {
5079
5553
  return slug;
@@ -5089,16 +5563,16 @@ function listTracks(root, store, filter = "all", plansDir = "plans") {
5089
5563
  const slugs = readPlansDir(root, plansDir);
5090
5564
  const tracks = [];
5091
5565
  for (const slug of slugs) {
5092
- const trackDir = path7.join(root, plansDir, slug);
5566
+ const trackDir = path9.join(root, plansDir, slug);
5093
5567
  try {
5094
- const lst = fs8.lstatSync(trackDir);
5568
+ const lst = fs9.lstatSync(trackDir);
5095
5569
  if (lst.isSymbolicLink() || !lst.isDirectory()) continue;
5096
5570
  if (!isRealpathInsideProject(root, trackDir)) continue;
5097
5571
  } catch {
5098
5572
  continue;
5099
5573
  }
5100
- const planPath = path7.join(trackDir, "plan.md");
5101
- const checklistPath = path7.join(trackDir, "checklist.md");
5574
+ const planPath = path9.join(trackDir, "plan.md");
5575
+ const checklistPath = path9.join(trackDir, "checklist.md");
5102
5576
  let checklistTotal = 0;
5103
5577
  let checklistDone = 0;
5104
5578
  const checklistInProject = isRealpathInsideProject(root, checklistPath);
@@ -5170,7 +5644,7 @@ function canEnterExecuting(options) {
5170
5644
  if (!root) {
5171
5645
  return { ok: false, reason: "invalid project root" };
5172
5646
  }
5173
- if (!checklistPath || !fs8.existsSync(checklistPath)) {
5647
+ if (!checklistPath || !fs9.existsSync(checklistPath)) {
5174
5648
  return { ok: false, reason: "checklist missing" };
5175
5649
  }
5176
5650
  if (!isRealpathInsideProject(root, checklistPath)) {
@@ -5219,8 +5693,8 @@ function nowIso2() {
5219
5693
  }
5220
5694
  function checklistPathFor(projectRoot, slug, plansDir) {
5221
5695
  const root = normalizeProjectRoot(projectRoot);
5222
- if (!root) return path8.join(plansDir, slug, "checklist.md");
5223
- return path8.join(root, plansDir, slug, "checklist.md");
5696
+ if (!root) return path10.join(plansDir, slug, "checklist.md");
5697
+ return path10.join(root, plansDir, slug, "checklist.md");
5224
5698
  }
5225
5699
  function sameChecklistBinding(stored, rebuilt, projectRoot) {
5226
5700
  if (stored === rebuilt) return true;
@@ -5230,8 +5704,8 @@ function sameChecklistBinding(stored, rebuilt, projectRoot) {
5230
5704
  const root = normalizeProjectRoot(projectRoot);
5231
5705
  if (!root) return false;
5232
5706
  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);
5707
+ const absStored = path10.isAbsolute(stored) ? path10.resolve(stored) : path10.resolve(root, stored);
5708
+ const absRebuilt = path10.isAbsolute(rebuilt) ? path10.resolve(rebuilt) : path10.resolve(root, rebuilt);
5235
5709
  return absStored === absRebuilt;
5236
5710
  } catch {
5237
5711
  return false;
@@ -5251,8 +5725,8 @@ function isChecklistPathAllowed(projectRoot, checklistPath) {
5251
5725
  const root = normalizeProjectRoot(projectRoot);
5252
5726
  if (!root) return false;
5253
5727
  try {
5254
- fs9.lstatSync(
5255
- path8.isAbsolute(checklistPath) ? checklistPath : path8.resolve(root, checklistPath)
5728
+ fs10.lstatSync(
5729
+ path10.isAbsolute(checklistPath) ? checklistPath : path10.resolve(root, checklistPath)
5256
5730
  );
5257
5731
  return isRealpathInsideProject(root, checklistPath);
5258
5732
  } catch {
@@ -5289,8 +5763,8 @@ function upsertTrack(store, slug, checklistPath, plansDir, projectRoot) {
5289
5763
  const ts = nowIso2();
5290
5764
  const root = normalizeProjectRoot(projectRoot);
5291
5765
  if (!root) return;
5292
- const planPath = path8.join(root, plansDir, slug, "plan.md");
5293
- const briefPath = path8.join(root, plansDir, slug, "brief.md");
5766
+ const planPath = path10.join(root, plansDir, slug, "plan.md");
5767
+ const briefPath = path10.join(root, plansDir, slug, "brief.md");
5294
5768
  store.db.prepare(
5295
5769
  `INSERT INTO tracks (track_id, slug, checklist_path, plan_path, brief_path, updated_at)
5296
5770
  VALUES (?, ?, ?, ?, ?, ?)
@@ -5304,8 +5778,8 @@ function upsertTrack(store, slug, checklistPath, plansDir, projectRoot) {
5304
5778
  slug,
5305
5779
  slug,
5306
5780
  checklistPath,
5307
- fs9.existsSync(planPath) ? planPath : null,
5308
- fs9.existsSync(briefPath) ? briefPath : null,
5781
+ fs10.existsSync(planPath) ? planPath : null,
5782
+ fs10.existsSync(briefPath) ? briefPath : null,
5309
5783
  ts
5310
5784
  );
5311
5785
  }
@@ -5514,497 +5988,200 @@ Reply with a number or /autopilot-run <slug>.`
5514
5988
  if (!result.ok && result.busy === true) {
5515
5989
  logBusyOccupier(result.userMessage);
5516
5990
  }
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
- }
5529
- throw err;
5530
- }
5531
- }
5532
- function applyReplan(store, conversationId, projectRoot, opts) {
5533
- const root = requireProjectRoot(store, projectRoot);
5534
- if (!root) {
5535
- return { ok: false, userMessage: "Invalid project root." };
5536
- }
5537
- projectRoot = root;
5538
- const plansDir = requirePlansDir(projectRoot, opts?.config?.plansDir);
5539
- if (!plansDir) {
5540
- return { ok: false, userMessage: "Invalid plans directory." };
5541
- }
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;
5553
- }
5554
- if (slug && !isSafeTrackSlug(slug)) {
5555
- return {
5556
- ok: false,
5557
- userMessage: `Invalid track slug "${displayUntrusted(slug)}".`
5558
- };
5559
- }
5560
- if (!slug) {
5561
- const all = listTracks(projectRoot, store, "all", plansDir).filter(
5562
- (t) => isSafeTrackSlug(t.slug)
5563
- );
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
- }
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 };
5631
- }
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." };
5636
- }
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
- };
5644
- }
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
- };
5651
- }
5652
- if (!session.track_candidates_json) {
5653
- return { ok: false, userMessage: "Invalid track candidates JSON." };
5654
- }
5655
- let candidates = [];
5656
- 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)
5663
- );
5664
- if (!hasValidSlug) {
5665
- return { ok: false, userMessage: "Invalid track candidates JSON." };
5666
- }
5667
- candidates = parsed;
5668
- } catch {
5669
- return { ok: false, userMessage: "Invalid track candidates JSON." };
5670
- }
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
- };
5697
- }
5698
- }
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
5710
- });
5711
- }
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, "\\$&");
5818
- }
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;
5828
- }
5829
- out += ".*";
5830
- i += 2;
5831
- continue;
5832
- }
5833
- if (ch === "*") {
5834
- out += "[^/]*";
5835
- i += 1;
5836
- continue;
5837
- }
5838
- if (ch === "?") {
5839
- out += "[^/]";
5840
- i += 1;
5841
- continue;
5842
- }
5843
- out += escapeRegexChar(ch);
5844
- i += 1;
5845
- }
5846
- return out;
5847
- }
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;
5857
- }
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);
5866
- }
5867
- const body = globBodyToRegex(line);
5868
- let regexSource;
5869
- if (anchored) {
5870
- regexSource = `^${body}`;
5871
- if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
5872
- regexSource += "(?:/.*)?";
5873
- }
5874
- regexSource += "$";
5875
- } else if (line.includes("/")) {
5876
- regexSource = `(?:^|.*/)${body}`;
5877
- if (dirOnly || line.endsWith("/**") || line.endsWith("/*")) {
5878
- regexSource += "(?:/.*)?";
5879
- }
5880
- regexSource += "$";
5881
- } else {
5882
- regexSource = `(?:^|.*/)?${body}$`;
5883
- }
5884
- return { negated, regex: new RegExp(regexSource) };
5885
- }
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;
5893
- }
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
- }
5909
- }
5910
- return ignored;
5911
- }
5912
- function realpathForCompare(absPath) {
5913
- try {
5914
- return fs10.realpathSync(absPath);
5915
- } catch {
5916
- }
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;
5991
+ return result;
5992
+ } catch (err) {
5993
+ const msg = err instanceof Error ? err.message : String(err);
5994
+ if (/busy|locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(msg)) {
5995
+ const userMessage = "State database is busy; retry Autopilot RUN in a moment.";
5996
+ logBusyOccupier(userMessage);
5997
+ return {
5998
+ ok: false,
5999
+ busy: true,
6000
+ userMessage
6001
+ };
5929
6002
  }
6003
+ throw err;
5930
6004
  }
5931
6005
  }
5932
- function toProjectRelativePath(filePath, projectRoot) {
5933
- const posix = filePath.replace(/\\/g, "/");
5934
- if (!projectRoot?.trim()) {
5935
- return normalizeRelativePath(posix);
6006
+ function applyReplan(store, conversationId, projectRoot, opts) {
6007
+ const root = requireProjectRoot(store, projectRoot);
6008
+ if (!root) {
6009
+ return { ok: false, userMessage: "Invalid project root." };
5936
6010
  }
5937
- const root = realpathForCompare(path9.resolve(projectRoot));
5938
- const abs = realpathForCompare(
5939
- path9.isAbsolute(posix) ? path9.resolve(posix) : path9.resolve(root, posix)
6011
+ projectRoot = root;
6012
+ const plansDir = requirePlansDir(projectRoot, opts?.config?.plansDir);
6013
+ if (!plansDir) {
6014
+ return { ok: false, userMessage: "Invalid plans directory." };
6015
+ }
6016
+ const session = ensureSession(
6017
+ store,
6018
+ conversationId,
6019
+ projectRoot,
6020
+ opts?.platform
5940
6021
  );
5941
- const rel = path9.relative(root, abs);
5942
- if (rel.startsWith("..") || path9.isAbsolute(rel)) {
5943
- return "";
6022
+ let slug = opts?.slug;
6023
+ if (!slug) {
6024
+ const tid = session.track_id;
6025
+ const midPick = session.pending_action === "run" || session.pending_action === "replan";
6026
+ slug = !midPick && isBoundRunTrackId(tid) ? tid : void 0;
5944
6027
  }
5945
- return normalizeRelativePath(rel.replace(/\\/g, "/"));
5946
- }
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;
6028
+ if (slug && !isSafeTrackSlug(slug)) {
6029
+ return {
6030
+ ok: false,
6031
+ userMessage: `Invalid track slug "${displayUntrusted(slug)}".`
6032
+ };
5955
6033
  }
5956
- if (st.isSymbolicLink() || !st.isFile()) {
5957
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6034
+ if (!slug) {
6035
+ const all = listTracks(projectRoot, store, "all", plansDir).filter(
6036
+ (t) => isSafeTrackSlug(t.slug)
6037
+ );
6038
+ if (all.length === 1 && session.pending_action !== "run") {
6039
+ slug = all[0].slug;
6040
+ } else if (all.length > 1 || all.length === 1 && session.pending_action === "run") {
6041
+ const clearingStaleBind = isBoundRunTrackId(session.track_id);
6042
+ store.upsertSession({
6043
+ conversation_id: conversationId,
6044
+ project_root: session.project_root,
6045
+ code_root: session.code_root,
6046
+ track_id: clearingStaleBind ? "_pending" : session.track_id,
6047
+ ...clearingStaleBind ? { checklist_path: "" } : {},
6048
+ pending_action: "replan",
6049
+ track_candidates_json: candidatePayload(all),
6050
+ armed: 0
6051
+ });
6052
+ const lines = all.map((t, i) => {
6053
+ const title = sanitizeSessionDisplayText(t.title || t.slug) || t.slug;
6054
+ return ` ${i + 1}. ${t.slug} \u2014 ${title}`;
6055
+ }).join("\n");
6056
+ return {
6057
+ ok: false,
6058
+ needPick: true,
6059
+ candidates: all,
6060
+ userMessage: `Select a plan to replan:
6061
+
6062
+ ${lines}
6063
+
6064
+ Reply with a number or /autopilot-replan <slug>.`
6065
+ };
6066
+ } else {
6067
+ return {
6068
+ ok: false,
6069
+ userMessage: "No plan to replan. Use /autopilot-on first."
6070
+ };
6071
+ }
5958
6072
  }
5959
- if (st.size > MAX_AUTOPILOT_IGNORE_BYTES) {
5960
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6073
+ const checklistPath = trustedChecklistPath(
6074
+ projectRoot,
6075
+ plansDir,
6076
+ slug,
6077
+ session.checklist_path,
6078
+ session.track_id
6079
+ );
6080
+ const updated = store.upsertSession({
6081
+ conversation_id: conversationId,
6082
+ project_root: projectRoot,
6083
+ code_root: projectRoot,
6084
+ track_id: slug,
6085
+ checklist_path: checklistPath,
6086
+ phase: "planning",
6087
+ armed: 0,
6088
+ paused: 0,
6089
+ paused_reason: null,
6090
+ pending_action: null,
6091
+ track_candidates_json: null
6092
+ });
6093
+ store.updateReviewChain(conversationId, {
6094
+ fix_round: 0,
6095
+ confirm_left: null,
6096
+ chain_pending: 0,
6097
+ code_edited: 0,
6098
+ item_confirm_complete: 0,
6099
+ reviewing_item_id: null,
6100
+ pending_followup: null,
6101
+ pending_followup_at: null,
6102
+ pending_redeliver_at: null
6103
+ });
6104
+ return { ok: true, session: updated };
6105
+ }
6106
+ function applyTrackPick(store, conversationId, projectRoot, pick, opts) {
6107
+ const root = requireProjectRoot(store, projectRoot);
6108
+ if (!root) {
6109
+ return { ok: false, userMessage: "Invalid project root." };
5961
6110
  }
5962
- const cached = ignoreCache.get(root);
5963
- if (cached && cached.mtimeMs === st.mtimeMs) {
5964
- return cached.patterns;
6111
+ projectRoot = root;
6112
+ const session = store.getSession(conversationId);
6113
+ if (!session?.pending_action) {
6114
+ return {
6115
+ ok: false,
6116
+ userMessage: "No pending track selection."
6117
+ };
5965
6118
  }
5966
- let text;
5967
- try {
5968
- text = fs10.readFileSync(filePath, "utf8");
5969
- } catch {
5970
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6119
+ const pending = session.pending_action;
6120
+ if (pending !== "run" && pending !== "replan") {
6121
+ return {
6122
+ ok: false,
6123
+ userMessage: `Unknown pending action "${displayUntrusted(pending)}".`
6124
+ };
5971
6125
  }
5972
- if (text.length > MAX_AUTOPILOT_IGNORE_BYTES) {
5973
- return DEFAULT_AUTOPILOT_IGNORE_PATTERNS;
6126
+ if (!session.track_candidates_json) {
6127
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
5974
6128
  }
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;
6129
+ let candidates = [];
5983
6130
  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
- }
6131
+ const parsed = JSON.parse(session.track_candidates_json);
6132
+ if (!Array.isArray(parsed) || parsed.length === 0) {
6133
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
6134
+ }
6135
+ const hasValidSlug = parsed.some(
6136
+ (c) => !!c && typeof c === "object" && !Array.isArray(c) && typeof c.slug === "string" && isSafeTrackSlug(c.slug)
5994
6137
  );
5995
- return r.status === 0;
6138
+ if (!hasValidSlug) {
6139
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
6140
+ }
6141
+ candidates = parsed;
5996
6142
  } catch {
5997
- return false;
6143
+ return { ok: false, userMessage: "Invalid track candidates JSON." };
5998
6144
  }
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;
6145
+ let slug;
6146
+ if (/^\d+$/.test(pick)) {
6147
+ const idx = Number.parseInt(pick, 10) - 1;
6148
+ const entry = candidates[idx];
6149
+ slug = entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.slug === "string" ? entry.slug : void 0;
6150
+ if (!slug || !isSafeTrackSlug(slug)) {
6151
+ return {
6152
+ ok: false,
6153
+ userMessage: `Invalid selection "${displayUntrusted(pick)}". Choose 1\u2013${candidates.length}.`
6154
+ };
6155
+ }
6156
+ } else {
6157
+ if (!isSafeTrackSlug(pick)) {
6158
+ return {
6159
+ ok: false,
6160
+ userMessage: `Invalid track slug "${displayUntrusted(pick)}".`
6161
+ };
6162
+ }
6163
+ slug = pick;
6164
+ if (!candidates.some(
6165
+ (c) => !!c && typeof c === "object" && !Array.isArray(c) && c.slug === slug
6166
+ )) {
6167
+ return {
6168
+ ok: false,
6169
+ userMessage: `Unknown slug "${displayUntrusted(pick)}".`
6170
+ };
6171
+ }
6172
+ }
6173
+ if (pending === "replan") {
6174
+ return applyReplan(store, conversationId, projectRoot, {
6175
+ slug,
6176
+ config: opts?.config,
6177
+ platform: opts?.platform
6178
+ });
6179
+ }
6180
+ return applyRun(store, conversationId, projectRoot, {
6181
+ slug,
6182
+ config: opts?.config,
6183
+ platform: opts?.platform
6184
+ });
6008
6185
  }
6009
6186
 
6010
6187
  // ../ports/cursor/src/index.ts
@@ -6086,13 +6263,18 @@ function handleBeforeSubmitPrompt(store, payload, projectRoot, portConfig) {
6086
6263
  } catch {
6087
6264
  }
6088
6265
  const session = store.getSession(conversationId);
6266
+ const hookCfg = loadProjectHookConfig(projectRoot);
6089
6267
  const trigger = parseTrigger({
6090
6268
  prompt,
6091
6269
  conversationId,
6092
6270
  projectRoot,
6093
- pendingAction: session?.pending_action
6271
+ pendingAction: session?.pending_action,
6272
+ triggers: hookCfg.triggers
6094
6273
  });
6095
- const actionConfig = portConfig?.phaseActions;
6274
+ const actionConfig = {
6275
+ ...portConfig?.phaseActions,
6276
+ plansDir: portConfig?.phaseActions?.plansDir ?? hookCfg.plansDir
6277
+ };
6096
6278
  if (trigger) {
6097
6279
  if (trigger.kind === "off") {
6098
6280
  applyOff(store, conversationId);
@@ -6172,7 +6354,14 @@ function handleAfterFileEdit(store, payload, projectRoot) {
6172
6354
  const filePath = payload.file_path ?? payload.filePath ?? "";
6173
6355
  if (!conversationId || !filePath) return;
6174
6356
  try {
6175
- notePlansDirEdit(store, conversationId, projectRoot, filePath);
6357
+ const hookCfg = loadProjectHookConfig(projectRoot);
6358
+ notePlansDirEdit(
6359
+ store,
6360
+ conversationId,
6361
+ projectRoot,
6362
+ filePath,
6363
+ hookCfg.plansDir
6364
+ );
6176
6365
  } catch {
6177
6366
  }
6178
6367
  if (!isProductCodeEdit(filePath, { projectRoot })) return;
@@ -6369,13 +6558,18 @@ function handleUserPromptSubmit(store, payload, projectRoot, portConfig) {
6369
6558
  } catch {
6370
6559
  }
6371
6560
  const session = store.getSession(conversationId);
6561
+ const hookCfg = loadProjectHookConfig(projectRoot);
6372
6562
  const trigger = parseTrigger({
6373
6563
  prompt,
6374
6564
  conversationId,
6375
6565
  projectRoot,
6376
- pendingAction: session?.pending_action
6566
+ pendingAction: session?.pending_action,
6567
+ triggers: hookCfg.triggers
6377
6568
  });
6378
- const actionConfig = portConfig?.phaseActions;
6569
+ const actionConfig = {
6570
+ ...portConfig?.phaseActions,
6571
+ plansDir: portConfig?.phaseActions?.plansDir ?? hookCfg.plansDir
6572
+ };
6379
6573
  const gateFallback = "Autopilot rejected this prompt. Check `npx autopilot-harness status`.";
6380
6574
  if (trigger) {
6381
6575
  if (trigger.kind === "off") {
@@ -6485,7 +6679,14 @@ function handlePostToolUse(store, payload, projectRoot) {
6485
6679
  const filePath = filePathFromClaudeEdit(payload);
6486
6680
  if (!filePath) return;
6487
6681
  try {
6488
- notePlansDirEdit(store, conversationId, projectRoot, filePath);
6682
+ const hookCfg = loadProjectHookConfig(projectRoot);
6683
+ notePlansDirEdit(
6684
+ store,
6685
+ conversationId,
6686
+ projectRoot,
6687
+ filePath,
6688
+ hookCfg.plansDir
6689
+ );
6489
6690
  } catch {
6490
6691
  }
6491
6692
  if (!isProductCodeEdit(filePath, { projectRoot })) return;