oh-my-codex 0.18.11 → 0.18.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.
Files changed (196) hide show
  1. package/Cargo.lock +6 -6
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -1
  4. package/dist/autopilot/__tests__/ralplan-gate.test.js +668 -0
  5. package/dist/autopilot/__tests__/ralplan-gate.test.js.map +1 -1
  6. package/dist/autopilot/completion-gate.d.ts +10 -0
  7. package/dist/autopilot/completion-gate.d.ts.map +1 -0
  8. package/dist/autopilot/completion-gate.js +154 -0
  9. package/dist/autopilot/completion-gate.js.map +1 -0
  10. package/dist/autopilot/ralplan-gate.d.ts.map +1 -1
  11. package/dist/autopilot/ralplan-gate.js +42 -21
  12. package/dist/autopilot/ralplan-gate.js.map +1 -1
  13. package/dist/cli/__tests__/codex-plugin-layout.test.js +46 -3
  14. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  15. package/dist/cli/__tests__/doctor-invalid-config.test.js +35 -0
  16. package/dist/cli/__tests__/doctor-invalid-config.test.js.map +1 -1
  17. package/dist/cli/__tests__/doctor-warning-copy.test.js +317 -0
  18. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  19. package/dist/cli/__tests__/index.test.js +120 -2
  20. package/dist/cli/__tests__/index.test.js.map +1 -1
  21. package/dist/cli/__tests__/launch-fallback.test.js +1 -1
  22. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  23. package/dist/cli/__tests__/resume.test.js +217 -1
  24. package/dist/cli/__tests__/resume.test.js.map +1 -1
  25. package/dist/cli/__tests__/session-scoped-runtime.test.js +101 -0
  26. package/dist/cli/__tests__/session-scoped-runtime.test.js.map +1 -1
  27. package/dist/cli/__tests__/session-search-help.test.js +3 -2
  28. package/dist/cli/__tests__/session-search-help.test.js.map +1 -1
  29. package/dist/cli/__tests__/session-search.test.js +64 -2
  30. package/dist/cli/__tests__/session-search.test.js.map +1 -1
  31. package/dist/cli/__tests__/setup-agents-overwrite.test.js +289 -1
  32. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  33. package/dist/cli/__tests__/setup-install-mode.test.js +290 -17
  34. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  35. package/dist/cli/__tests__/setup-prompts-overwrite.test.js +74 -0
  36. package/dist/cli/__tests__/setup-prompts-overwrite.test.js.map +1 -1
  37. package/dist/cli/__tests__/setup-scope.test.js +45 -0
  38. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  39. package/dist/cli/__tests__/state.test.js +93 -0
  40. package/dist/cli/__tests__/state.test.js.map +1 -1
  41. package/dist/cli/__tests__/update.test.js +157 -3
  42. package/dist/cli/__tests__/update.test.js.map +1 -1
  43. package/dist/cli/__tests__/version-sync-contract.test.js +2 -0
  44. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  45. package/dist/cli/doctor.d.ts.map +1 -1
  46. package/dist/cli/doctor.js +90 -12
  47. package/dist/cli/doctor.js.map +1 -1
  48. package/dist/cli/index.d.ts +13 -4
  49. package/dist/cli/index.d.ts.map +1 -1
  50. package/dist/cli/index.js +439 -46
  51. package/dist/cli/index.js.map +1 -1
  52. package/dist/cli/project-runtime-codex-homes.d.ts +6 -0
  53. package/dist/cli/project-runtime-codex-homes.d.ts.map +1 -0
  54. package/dist/cli/project-runtime-codex-homes.js +27 -0
  55. package/dist/cli/project-runtime-codex-homes.js.map +1 -0
  56. package/dist/cli/session-search.d.ts.map +1 -1
  57. package/dist/cli/session-search.js +8 -1
  58. package/dist/cli/session-search.js.map +1 -1
  59. package/dist/cli/setup.d.ts +2 -2
  60. package/dist/cli/setup.d.ts.map +1 -1
  61. package/dist/cli/setup.js +482 -126
  62. package/dist/cli/setup.js.map +1 -1
  63. package/dist/cli/state.d.ts.map +1 -1
  64. package/dist/cli/state.js +79 -8
  65. package/dist/cli/state.js.map +1 -1
  66. package/dist/cli/update.d.ts +1 -0
  67. package/dist/cli/update.d.ts.map +1 -1
  68. package/dist/cli/update.js +42 -10
  69. package/dist/cli/update.js.map +1 -1
  70. package/dist/config/__tests__/codex-hooks.test.js +73 -29
  71. package/dist/config/__tests__/codex-hooks.test.js.map +1 -1
  72. package/dist/config/codex-hooks.d.ts +14 -0
  73. package/dist/config/codex-hooks.d.ts.map +1 -1
  74. package/dist/config/codex-hooks.js +54 -51
  75. package/dist/config/codex-hooks.js.map +1 -1
  76. package/dist/config/generator.d.ts +1 -1
  77. package/dist/config/generator.d.ts.map +1 -1
  78. package/dist/config/generator.js +1 -1
  79. package/dist/config/generator.js.map +1 -1
  80. package/dist/hooks/__tests__/best-practice-research-skill.test.js +12 -0
  81. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -1
  82. package/dist/hud/__tests__/authority.test.js +45 -12
  83. package/dist/hud/__tests__/authority.test.js.map +1 -1
  84. package/dist/hud/__tests__/reconcile.test.js +95 -0
  85. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  86. package/dist/hud/__tests__/render.test.js +6 -6
  87. package/dist/hud/__tests__/render.test.js.map +1 -1
  88. package/dist/hud/__tests__/tmux.test.js +2 -2
  89. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  90. package/dist/hud/authority.d.ts.map +1 -1
  91. package/dist/hud/authority.js +17 -2
  92. package/dist/hud/authority.js.map +1 -1
  93. package/dist/hud/index.js +1 -4
  94. package/dist/hud/index.js.map +1 -1
  95. package/dist/hud/reconcile.d.ts.map +1 -1
  96. package/dist/hud/reconcile.js +42 -0
  97. package/dist/hud/reconcile.js.map +1 -1
  98. package/dist/hud/render.d.ts.map +1 -1
  99. package/dist/hud/render.js +6 -0
  100. package/dist/hud/render.js.map +1 -1
  101. package/dist/hud/tmux.d.ts.map +1 -1
  102. package/dist/hud/tmux.js +5 -4
  103. package/dist/hud/tmux.js.map +1 -1
  104. package/dist/mcp/__tests__/bootstrap.test.js +31 -1
  105. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  106. package/dist/mcp/bootstrap.d.ts +1 -0
  107. package/dist/mcp/bootstrap.d.ts.map +1 -1
  108. package/dist/mcp/bootstrap.js +32 -0
  109. package/dist/mcp/bootstrap.js.map +1 -1
  110. package/dist/modes/__tests__/base-autopilot-gates.test.d.ts +2 -0
  111. package/dist/modes/__tests__/base-autopilot-gates.test.d.ts.map +1 -0
  112. package/dist/modes/__tests__/base-autopilot-gates.test.js +154 -0
  113. package/dist/modes/__tests__/base-autopilot-gates.test.js.map +1 -0
  114. package/dist/modes/base.d.ts +4 -1
  115. package/dist/modes/base.d.ts.map +1 -1
  116. package/dist/modes/base.js +71 -1
  117. package/dist/modes/base.js.map +1 -1
  118. package/dist/pipeline/__tests__/orchestrator.test.js +144 -3
  119. package/dist/pipeline/__tests__/orchestrator.test.js.map +1 -1
  120. package/dist/pipeline/__tests__/stages.test.js +109 -0
  121. package/dist/pipeline/__tests__/stages.test.js.map +1 -1
  122. package/dist/pipeline/orchestrator.d.ts.map +1 -1
  123. package/dist/pipeline/orchestrator.js +11 -4
  124. package/dist/pipeline/orchestrator.js.map +1 -1
  125. package/dist/pipeline/stages/code-review.d.ts +2 -0
  126. package/dist/pipeline/stages/code-review.d.ts.map +1 -1
  127. package/dist/pipeline/stages/code-review.js +2 -0
  128. package/dist/pipeline/stages/code-review.js.map +1 -1
  129. package/dist/pipeline/stages/ultraqa.d.ts +3 -0
  130. package/dist/pipeline/stages/ultraqa.d.ts.map +1 -1
  131. package/dist/pipeline/stages/ultraqa.js +3 -0
  132. package/dist/pipeline/stages/ultraqa.js.map +1 -1
  133. package/dist/ralplan/__tests__/consensus-gate.test.d.ts +2 -0
  134. package/dist/ralplan/__tests__/consensus-gate.test.d.ts.map +1 -0
  135. package/dist/ralplan/__tests__/consensus-gate.test.js +631 -0
  136. package/dist/ralplan/__tests__/consensus-gate.test.js.map +1 -0
  137. package/dist/ralplan/consensus-gate.d.ts +9 -1
  138. package/dist/ralplan/consensus-gate.d.ts.map +1 -1
  139. package/dist/ralplan/consensus-gate.js +287 -65
  140. package/dist/ralplan/consensus-gate.js.map +1 -1
  141. package/dist/scripts/__tests__/codex-native-hook.test.js +481 -0
  142. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  143. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  144. package/dist/scripts/codex-native-hook.js +145 -25
  145. package/dist/scripts/codex-native-hook.js.map +1 -1
  146. package/dist/scripts/codex-native-pre-post.d.ts +1 -0
  147. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  148. package/dist/scripts/codex-native-pre-post.js +130 -0
  149. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  150. package/dist/session-history/__tests__/search.test.js +166 -0
  151. package/dist/session-history/__tests__/search.test.js.map +1 -1
  152. package/dist/session-history/search.d.ts +7 -0
  153. package/dist/session-history/search.d.ts.map +1 -1
  154. package/dist/session-history/search.js +83 -24
  155. package/dist/session-history/search.js.map +1 -1
  156. package/dist/sidecar/__tests__/collector.test.js +60 -0
  157. package/dist/sidecar/__tests__/collector.test.js.map +1 -1
  158. package/dist/sidecar/collector.d.ts.map +1 -1
  159. package/dist/sidecar/collector.js +3 -6
  160. package/dist/sidecar/collector.js.map +1 -1
  161. package/dist/state/__tests__/operations.test.js +622 -0
  162. package/dist/state/__tests__/operations.test.js.map +1 -1
  163. package/dist/state/__tests__/skill-active.test.js +82 -0
  164. package/dist/state/__tests__/skill-active.test.js.map +1 -1
  165. package/dist/state/operations.d.ts.map +1 -1
  166. package/dist/state/operations.js +31 -9
  167. package/dist/state/operations.js.map +1 -1
  168. package/dist/state/skill-active.d.ts.map +1 -1
  169. package/dist/state/skill-active.js +41 -1
  170. package/dist/state/skill-active.js.map +1 -1
  171. package/dist/team/__tests__/runtime.test.js +81 -57
  172. package/dist/team/__tests__/runtime.test.js.map +1 -1
  173. package/dist/team/runtime.js +4 -4
  174. package/dist/team/runtime.js.map +1 -1
  175. package/dist/utils/__tests__/paths.test.js +23 -0
  176. package/dist/utils/__tests__/paths.test.js.map +1 -1
  177. package/dist/utils/__tests__/version.test.js +27 -0
  178. package/dist/utils/__tests__/version.test.js.map +1 -1
  179. package/dist/utils/paths.d.ts.map +1 -1
  180. package/dist/utils/paths.js +4 -2
  181. package/dist/utils/paths.js.map +1 -1
  182. package/dist/utils/version.d.ts.map +1 -1
  183. package/dist/utils/version.js +7 -2
  184. package/dist/utils/version.js.map +1 -1
  185. package/dist/verification/__tests__/ci-rust-gates.test.js +4 -2
  186. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  187. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js +71 -3
  188. package/dist/verification/__tests__/dev-merge-issue-close-workflow.test.js.map +1 -1
  189. package/package.json +1 -1
  190. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  191. package/plugins/oh-my-codex/hooks/codex-native-hook.mjs +53 -2
  192. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +6 -1
  193. package/skills/best-practice-research/SKILL.md +6 -1
  194. package/src/scripts/__tests__/codex-native-hook.test.ts +615 -0
  195. package/src/scripts/codex-native-hook.ts +162 -32
  196. package/src/scripts/codex-native-pre-post.ts +137 -0
@@ -61,6 +61,7 @@ import {
61
61
  SLOPPY_FALLBACK_PHRASE_PATTERNS,
62
62
  buildNativePostToolUseOutput,
63
63
  buildNativePreToolUseOutput,
64
+ commandInvokesApplyPatch,
64
65
  detectMcpTransportFailure,
65
66
  hasAnyPattern,
66
67
  } from "./codex-native-pre-post.js";
@@ -2708,11 +2709,74 @@ function readPreToolUsePathCandidates(payload: CodexHookPayload): string[] {
2708
2709
  return candidates.map((candidate) => safeString(candidate).trim()).filter(Boolean);
2709
2710
  }
2710
2711
 
2712
+ const APPLY_PATCH_TOOL_NAMES = new Set(["apply_patch", "ApplyPatch"]);
2713
+
2714
+ function isApplyPatchToolName(toolName: string): boolean {
2715
+ return APPLY_PATCH_TOOL_NAMES.has(toolName);
2716
+ }
2717
+
2718
+ function readApplyPatchText(payload: CodexHookPayload): string {
2719
+ const input = safeObject(payload.tool_input);
2720
+ for (const key of ["input", "patch", "content", "text", "command"]) {
2721
+ const value = safeString(input[key]).trim();
2722
+ if (value) return value;
2723
+ }
2724
+ return "";
2725
+ }
2726
+
2727
+ function extractApplyPatchTargetPaths(patchText: string): string[] {
2728
+ if (!patchText) return [];
2729
+ const paths: string[] = [];
2730
+ for (const match of patchText.matchAll(/^\s*\*\*\*\s+(?:Add|Update|Delete)\s+File:\s*(.+?)\s*$/gm)) {
2731
+ const candidate = safeString(match[1]).trim();
2732
+ if (candidate) paths.push(candidate);
2733
+ }
2734
+ for (const match of patchText.matchAll(/^\s*\*\*\*\s+Move\s+to:\s*(.+?)\s*$/gm)) {
2735
+ const candidate = safeString(match[1]).trim();
2736
+ if (candidate) paths.push(candidate);
2737
+ }
2738
+ return paths;
2739
+ }
2740
+
2741
+ function collectImplementationToolPathCandidates(
2742
+ payload: CodexHookPayload,
2743
+ toolName: string,
2744
+ structuredCandidates: string[],
2745
+ ): string[] {
2746
+ if (!isApplyPatchToolName(toolName)) return structuredCandidates;
2747
+ return [...structuredCandidates, ...extractApplyPatchTargetPaths(readApplyPatchText(payload))];
2748
+ }
2749
+
2711
2750
  function isNullDeviceRedirectTarget(target: string): boolean {
2712
2751
  const normalized = target.trim().replace(/^['"]|['"]$/g, "").toLowerCase();
2713
2752
  return normalized === "/dev/null" || normalized === "nul";
2714
2753
  }
2715
2754
 
2755
+ // Collects same-command literal variable assignments (`NAME="value"`), skipping
2756
+ // any value that involves expansion (`$`, backticks) so unresolved/dynamic
2757
+ // targets stay conservatively blocked.
2758
+ function extractCommandLiteralAssignments(command: string): Map<string, string> {
2759
+ const assignments = new Map<string, string>();
2760
+ const pattern = /(?:^|[\n;&|(]|&&|\|\|)\s*([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"$`]*)"|'([^']*)'|([^\s"'$`;&|<>]+))/g;
2761
+ for (const match of command.matchAll(pattern)) {
2762
+ const name = safeString(match[1]).trim();
2763
+ if (!name) continue;
2764
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
2765
+ assignments.set(name, value);
2766
+ }
2767
+ return assignments;
2768
+ }
2769
+
2770
+ // Resolves a redirect/tee target of the form `$NAME`/`${NAME}` against
2771
+ // same-command literal assignments; non-variable or unresolved targets are
2772
+ // returned unchanged so they remain subject to the allowed-path check.
2773
+ function resolveCommandRedirectTarget(target: string, assignments: Map<string, string>): string {
2774
+ const variableMatch = target.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
2775
+ if (!variableMatch) return target;
2776
+ const resolved = assignments.get(safeString(variableMatch[1]));
2777
+ return resolved !== undefined ? resolved : target;
2778
+ }
2779
+
2716
2780
  function extractDeepInterviewCommandRedirectTargets(command: string): string[] {
2717
2781
  const targets: string[] = [];
2718
2782
  for (const match of command.matchAll(/(?:^|[^>])>{1,2}\s*(["']?)([^\s&|;<>]+)\1/g)) {
@@ -2723,7 +2787,7 @@ function extractDeepInterviewCommandRedirectTargets(command: string): string[] {
2723
2787
  }
2724
2788
 
2725
2789
  function commandHasDeepInterviewWriteIntent(command: string): boolean {
2726
- return /\bapply_patch\b/.test(command)
2790
+ return commandInvokesApplyPatch(command)
2727
2791
  || extractDeepInterviewCommandRedirectTargets(command).length > 0
2728
2792
  || /\btee\s+(?:-a\s+)?[^\s&|;]+/.test(command)
2729
2793
  || /\bsed\s+(?:[^\n;&|]*\s)?-i(?:\b|['"])/.test(command)
@@ -2732,10 +2796,12 @@ function commandHasDeepInterviewWriteIntent(command: string): boolean {
2732
2796
  }
2733
2797
 
2734
2798
  function extractDeepInterviewCommandWriteTargets(command: string): string[] {
2735
- const targets = extractDeepInterviewCommandRedirectTargets(command);
2799
+ const assignments = extractCommandLiteralAssignments(command);
2800
+ const targets = extractDeepInterviewCommandRedirectTargets(command)
2801
+ .map((target) => resolveCommandRedirectTarget(target, assignments));
2736
2802
  for (const match of command.matchAll(/\btee\s+(?:-a\s+)?(["']?)([^\s&|;<>]+)\1/g)) {
2737
2803
  const candidate = safeString(match[2]).trim();
2738
- if (candidate) targets.push(candidate);
2804
+ if (candidate) targets.push(resolveCommandRedirectTarget(candidate, assignments));
2739
2805
  }
2740
2806
  return targets;
2741
2807
  }
@@ -2753,21 +2819,46 @@ async function readActiveDeepInterviewStateForPreToolUse(
2753
2819
  sessionId: string,
2754
2820
  threadId: string,
2755
2821
  ): Promise<Record<string, unknown> | null> {
2756
- const modeState = sessionId
2757
- ? await readStopSessionPinnedState("deep-interview-state.json", cwd, sessionId, stateDir)
2758
- : await readJsonIfExists(join(stateDir, "deep-interview-state.json"));
2759
- if (!isActiveDeepInterviewPhase(modeState) || !modeState) return null;
2760
- if (!modeStateMatchesSkillStopContext(modeState, cwd, sessionId)) return null;
2761
-
2762
2822
  const canonicalState = sessionId
2763
2823
  ? await readVisibleSkillActiveStateForStateDir(stateDir, sessionId)
2764
2824
  : await readSkillActiveState(join(stateDir, SKILL_ACTIVE_STATE_FILE));
2765
2825
  if (!canonicalState) return null;
2766
- const hasActiveDeepInterviewSkill = listActiveSkills(canonicalState).some((entry) => (
2767
- entry.skill === "deep-interview"
2826
+
2827
+ const modeState = sessionId
2828
+ ? await readStopSessionPinnedState("deep-interview-state.json", cwd, sessionId, stateDir)
2829
+ : await readJsonIfExists(join(stateDir, "deep-interview-state.json"));
2830
+ if (isActiveDeepInterviewPhase(modeState) && modeState && modeStateMatchesSkillStopContext(modeState, cwd, sessionId)) {
2831
+ const hasActiveDeepInterviewSkill = listActiveSkills(canonicalState).some((entry) => (
2832
+ entry.skill === "deep-interview"
2833
+ && matchesSkillStopContext(entry, canonicalState, sessionId, threadId)
2834
+ ));
2835
+ if (hasActiveDeepInterviewSkill) return modeState;
2836
+ }
2837
+
2838
+ const autopilotState = sessionId
2839
+ ? await readStopSessionPinnedState("autopilot-state.json", cwd, sessionId, stateDir)
2840
+ : await readJsonIfExists(join(stateDir, "autopilot-state.json"));
2841
+ if (!autopilotState || autopilotState.active !== true) return null;
2842
+ const autopilotMode = safeString(autopilotState.mode).trim();
2843
+ if (autopilotMode && autopilotMode !== "autopilot") return null;
2844
+ if (!modeStateMatchesSkillStopContext(autopilotState, cwd, sessionId)) return null;
2845
+ const terminalAutopilotRunState = await readCanonicalTerminalRunStateForStop(cwd, sessionId, "autopilot");
2846
+ if (terminalAutopilotRunState) return null;
2847
+
2848
+ const autopilotStatePhase = safeString(autopilotState.current_phase ?? autopilotState.currentPhase).trim().toLowerCase();
2849
+ const autopilotIsDeepInterview = normalizeAutopilotPhase(autopilotStatePhase) === "deep-interview";
2850
+ const hasDeepInterviewScopedAutopilotSkill = listActiveSkills(canonicalState).some((entry) => (
2851
+ entry.skill === "autopilot"
2852
+ && normalizeAutopilotPhase(safeString(entry.phase).trim().toLowerCase()) === "deep-interview"
2768
2853
  && matchesSkillStopContext(entry, canonicalState, sessionId, threadId)
2769
2854
  ));
2770
- return hasActiveDeepInterviewSkill ? modeState : null;
2855
+ const hasActiveAutopilotSkill = listActiveSkills(canonicalState).some((entry) => (
2856
+ entry.skill === "autopilot"
2857
+ && matchesSkillStopContext(entry, canonicalState, sessionId, threadId)
2858
+ ));
2859
+ if (!hasActiveAutopilotSkill) return null;
2860
+ if (!autopilotIsDeepInterview && !hasDeepInterviewScopedAutopilotSkill) return null;
2861
+ return autopilotState;
2771
2862
  }
2772
2863
 
2773
2864
  async function readActiveRalplanStateForPreToolUse(
@@ -2840,12 +2931,28 @@ async function buildRalplanPreToolUseBoundaryOutput(
2840
2931
  const command = readPreToolUseCommand(payload);
2841
2932
  const pathCandidates = readPreToolUsePathCandidates(payload);
2842
2933
  let blocked = false;
2934
+ let blockedDetail = "implementation/write tools are blocked until an explicit execution handoff workflow is activated";
2843
2935
 
2844
2936
  if (toolName === "Bash") {
2845
2937
  blocked = !isAllowedRalplanBashWrite(cwd, command);
2938
+ if (blocked) {
2939
+ const targets = extractDeepInterviewCommandWriteTargets(command);
2940
+ const blockedTarget = targets.find((target) => !isAllowedRalplanArtifactPath(cwd, target));
2941
+ blockedDetail = blockedTarget
2942
+ ? `write target ${blockedTarget} is not under allowed planning artifact paths (${RALPLAN_ALLOWED_WRITE_PREFIXES.join(", ")})`
2943
+ : "Bash write intent did not identify an allowed planning artifact path";
2944
+ }
2846
2945
  } else if (PLANNING_MODE_IMPLEMENTATION_TOOL_NAMES.has(toolName)) {
2847
- blocked = pathCandidates.length === 0
2848
- || !pathCandidates.every((candidate) => isAllowedRalplanArtifactPath(cwd, candidate));
2946
+ if (pathCandidates.length === 0) {
2947
+ blocked = true;
2948
+ blockedDetail = `${toolName} did not include a file path; only planning artifact paths are allowed`;
2949
+ } else {
2950
+ const blockedPath = pathCandidates.find((candidate) => !isAllowedRalplanArtifactPath(cwd, candidate));
2951
+ blocked = blockedPath !== undefined;
2952
+ if (blockedPath !== undefined) {
2953
+ blockedDetail = `path ${blockedPath} is not under allowed planning artifact paths (${RALPLAN_ALLOWED_WRITE_PREFIXES.join(", ")})`;
2954
+ }
2955
+ }
2849
2956
  }
2850
2957
 
2851
2958
  if (!blocked) return null;
@@ -2858,7 +2965,7 @@ async function buildRalplanPreToolUseBoundaryOutput(
2858
2965
  : "Ralplan is consensus-planning mode";
2859
2966
  return {
2860
2967
  decision: "block",
2861
- reason: `${planningModeLabel} is active (phase: ${phase}); implementation/write tools are blocked until an explicit execution handoff workflow is activated.`,
2968
+ reason: `${planningModeLabel} is active (phase: ${phase}); implementation/write tools are blocked until an explicit execution handoff workflow is activated; ${blockedDetail}.`,
2862
2969
  hookSpecificOutput: {
2863
2970
  hookEventName: "PreToolUse",
2864
2971
  additionalContext:
@@ -2889,8 +2996,9 @@ async function buildDeepInterviewPreToolUseBoundaryOutput(
2889
2996
  if (toolName === "Bash") {
2890
2997
  blocked = !isAllowedDeepInterviewBashWrite(cwd, command);
2891
2998
  } else if (DEEP_INTERVIEW_IMPLEMENTATION_TOOL_NAMES.has(toolName)) {
2892
- blocked = pathCandidates.length === 0
2893
- || !pathCandidates.every((candidate) => isAllowedDeepInterviewArtifactPath(cwd, candidate));
2999
+ const candidates = collectImplementationToolPathCandidates(payload, toolName, pathCandidates);
3000
+ blocked = candidates.length === 0
3001
+ || !candidates.every((candidate) => isAllowedDeepInterviewArtifactPath(cwd, candidate));
2894
3002
  }
2895
3003
 
2896
3004
  if (!blocked) return null;
@@ -4141,28 +4249,50 @@ export async function dispatchCodexNativeHook(
4141
4249
  if (hookEventName === "SessionStart" && nativeSessionId) {
4142
4250
  const transcriptPath = safeString(payload.transcript_path ?? payload.transcriptPath).trim();
4143
4251
  const subagentSessionStart = readNativeSubagentSessionStartMetadata(transcriptPath);
4144
- if (subagentSessionStart && canonicalSessionId) {
4252
+ if (subagentSessionStart) {
4253
+ // A native child/subagent SessionStart carries a parent_thread_id in its
4254
+ // transcript session_meta. Treat it as a child-agent lifecycle event for
4255
+ // notification suppression and subagent tracking even when the canonical
4256
+ // leader session has not been reconciled yet (#2831). A child start must
4257
+ // never promote itself into a root/leader session or emit an independent
4258
+ // session-start notification at session/minimal verbosity.
4145
4259
  isSubagentSessionStart = true;
4146
- const belongsToCanonicalSession = await nativeSubagentSessionStartBelongsToCanonicalSession(
4147
- cwd,
4148
- canonicalSessionId,
4149
- currentSessionState,
4150
- subagentSessionStart,
4151
- );
4152
- if (belongsToCanonicalSession) {
4153
- resolvedNativeSessionId = nativeSessionId;
4154
- await recordNativeSubagentSessionStart(
4260
+ if (canonicalSessionId) {
4261
+ const belongsToCanonicalSession = await nativeSubagentSessionStartBelongsToCanonicalSession(
4155
4262
  cwd,
4156
4263
  canonicalSessionId,
4157
- nativeSessionId,
4264
+ currentSessionState,
4158
4265
  subagentSessionStart,
4159
- transcriptPath,
4160
4266
  );
4267
+ if (belongsToCanonicalSession) {
4268
+ resolvedNativeSessionId = nativeSessionId;
4269
+ await recordNativeSubagentSessionStart(
4270
+ cwd,
4271
+ canonicalSessionId,
4272
+ nativeSessionId,
4273
+ subagentSessionStart,
4274
+ transcriptPath,
4275
+ );
4276
+ } else {
4277
+ skipCanonicalSessionStartContext = true;
4278
+ resolvedNativeSessionId =
4279
+ safeString(currentSessionState?.native_session_id).trim() || nativeSessionId;
4280
+ await recordIgnoredNativeSubagentSessionStart(
4281
+ cwd,
4282
+ canonicalSessionId,
4283
+ nativeSessionId,
4284
+ subagentSessionStart,
4285
+ transcriptPath,
4286
+ );
4287
+ }
4161
4288
  } else {
4289
+ // No canonical leader session is resolved in this worktree yet. Still
4290
+ // register the child thread under its parent so its later Stop is
4291
+ // recognized as subagent-scoped, skip leader SessionStart context, and
4292
+ // do not reconcile the child as a new root session.
4162
4293
  skipCanonicalSessionStartContext = true;
4163
- resolvedNativeSessionId =
4164
- safeString(currentSessionState?.native_session_id).trim() || nativeSessionId;
4165
- await recordIgnoredNativeSubagentSessionStart(
4294
+ resolvedNativeSessionId = nativeSessionId;
4295
+ await recordNativeSubagentSessionStart(
4166
4296
  cwd,
4167
4297
  canonicalSessionId,
4168
4298
  nativeSessionId,
@@ -325,6 +325,11 @@ function tokenizeShellCommandWithBoundaries(commandText: string): ShellToken[] |
325
325
  let quote: "'" | "\"" | null = null;
326
326
  let escaping = false;
327
327
  let nextTokenStartsCommand = false;
328
+ // Command substitution executes even inside double quotes, so we track an
329
+ // active `"$(…)"` (paren depth) or `` "`…`" `` (backtick) substitution to
330
+ // restore double-quote mode once it closes.
331
+ let dquoteSubstParenDepth = 0;
332
+ let backtickFromDquote = false;
328
333
 
329
334
  const pushCurrent = () => {
330
335
  if (!current) return;
@@ -357,6 +362,25 @@ function tokenizeShellCommandWithBoundaries(commandText: string): ShellToken[] |
357
362
  if (isDoubleQuotedShellEscapeTarget(trimmed[index + 1])) escaping = true;
358
363
  else current += char;
359
364
  }
365
+ // Command substitution runs inside double quotes, so `"$(cmd …)"` and
366
+ // `` "`cmd …`" `` are real invocations, not literal mentions. Treat the
367
+ // opener as a command-position boundary and parse the substitution body
368
+ // unquoted so the command-head walk resumes, then restore double-quote
369
+ // mode when it closes. Parameter expansion (`${VAR}`), `$HOME`, and an
370
+ // escaped `\$(` stay literal text (no false positive on `grep "(x"`).
371
+ else if (char === "$" && trimmed[index + 1] === "(") {
372
+ pushCurrent();
373
+ nextTokenStartsCommand = true;
374
+ index += 1;
375
+ dquoteSubstParenDepth = 1;
376
+ quote = null;
377
+ }
378
+ else if (char === "`") {
379
+ pushCurrent();
380
+ nextTokenStartsCommand = true;
381
+ backtickFromDquote = true;
382
+ quote = null;
383
+ }
360
384
  else current += char;
361
385
  continue;
362
386
  }
@@ -368,6 +392,48 @@ function tokenizeShellCommandWithBoundaries(commandText: string): ShellToken[] |
368
392
  continue;
369
393
  }
370
394
 
395
+ // Grouping / command-substitution openers act as command-position
396
+ // boundaries so the command-head walk resumes after them, mirroring the
397
+ // legacy `[\n;&|(]` boundary set. This catches a real command immediately
398
+ // after `(`, `((`, `$(` (command substitution), or a backtick — e.g.
399
+ // `(apply_patch …)`, `true | (apply_patch …)`, `x=$(apply_patch …)`. We
400
+ // are outside quotes here, so a literal like `grep "(apply_patch"` is left
401
+ // intact and not split.
402
+ // Closing backtick of a command substitution opened inside double quotes
403
+ // ends the substitution and restores the surrounding double-quoted literal.
404
+ if (char === "`" && backtickFromDquote) {
405
+ pushCurrent();
406
+ backtickFromDquote = false;
407
+ quote = "\"";
408
+ continue;
409
+ }
410
+
411
+ if (char === "(" || char === ")" || char === "`") {
412
+ pushCurrent();
413
+ nextTokenStartsCommand = true;
414
+ // Track paren depth of a `"$(…)"` substitution opened inside double
415
+ // quotes so the matching `)` restores double-quote mode (nested `$(`
416
+ // and `(` inside it are balanced before we return to the literal).
417
+ if (dquoteSubstParenDepth > 0) {
418
+ if (char === "(") {
419
+ dquoteSubstParenDepth += 1;
420
+ } else if (char === ")") {
421
+ dquoteSubstParenDepth -= 1;
422
+ if (dquoteSubstParenDepth === 0) quote = "\"";
423
+ }
424
+ }
425
+ continue;
426
+ }
427
+
428
+ // `{` is a group opener only as its own word (`{ apply_patch …; }`):
429
+ // require an empty pending token (preceded by start/whitespace/boundary)
430
+ // and a following whitespace/newline so brace expansion (`{a,b}`) and
431
+ // parameter expansion (`${VAR}`) are left intact.
432
+ if (char === "{" && current === "" && /\s/.test(trimmed[index + 1] ?? " ")) {
433
+ nextTokenStartsCommand = true;
434
+ continue;
435
+ }
436
+
371
437
  if (/\s/.test(char)) {
372
438
  pushCurrent();
373
439
  continue;
@@ -488,6 +554,77 @@ function findGitCommandTokenIndex(tokens: ShellToken[]): number {
488
554
  return -1;
489
555
  }
490
556
 
557
+ const APPLY_PATCH_COMMAND_WRAPPER_TOKENS = new Set(["sudo", "command", "exec"]);
558
+
559
+ // Detects a real `apply_patch` invocation at a shell command position using the
560
+ // same tokenized command-head walk the git commit guard uses
561
+ // (`findGitCommandTokenIndex`): after every statement boundary skip leading
562
+ // inline `NAME=VALUE` assignments, the `env` executable with its full option
563
+ // grammar (`-i`/`--ignore-environment`, `-u`/`--unset`/`--unset=`, `-C`/`-S`,
564
+ // `--`, interleaved assignments), and `sudo`/`command`/`exec` wrappers — in any
565
+ // order — then match when the resolved command token's basename is
566
+ // `apply_patch`. This blocks path-qualified (`/usr/bin/apply_patch`,
567
+ // `./apply_patch`), env-flag (`env -i apply_patch`, `env -u FOO apply_patch`),
568
+ // and reordered (`FOO=bar env apply_patch`, `exec env FOO=bar apply_patch`)
569
+ // forms, while read-only diagnostics that merely mention the literal token
570
+ // (e.g. `grep -n "apply_patch" file`) are not misread as write intent. Heredoc
571
+ // bodies are stripped first so patch payloads cannot break tokenization.
572
+ export function commandInvokesApplyPatch(command: string): boolean {
573
+ const tokens = tokenizeShellCommandWithBoundaries(removeHereDocBodies(command));
574
+ if (!tokens) return false;
575
+
576
+ for (let commandStart = 0; commandStart < tokens.length; commandStart = nextCommandStart(tokens, commandStart)) {
577
+ let index = commandStart;
578
+ const commandEnd = nextCommandStart(tokens, commandStart);
579
+
580
+ let advanced = true;
581
+ while (advanced && index < commandEnd) {
582
+ advanced = false;
583
+
584
+ while (index < commandEnd && isInlineShellEnvAssignment(tokens[index]?.value ?? "")) {
585
+ index += 1;
586
+ advanced = true;
587
+ }
588
+
589
+ while (index < commandEnd && isEnvExecutableToken(tokens[index]?.value ?? "")) {
590
+ index += 1;
591
+ advanced = true;
592
+ while (index < commandEnd) {
593
+ const token = tokens[index]?.value ?? "";
594
+ if (token === "--") {
595
+ index += 1;
596
+ break;
597
+ }
598
+ if (isInlineShellEnvAssignment(token)) {
599
+ index += 1;
600
+ continue;
601
+ }
602
+ if (token === "-i" || token === "--ignore-environment" || token.startsWith("--unset=")) {
603
+ index += 1;
604
+ continue;
605
+ }
606
+ if (token.startsWith("-")) {
607
+ index += envOptionConsumesNextValue(token) ? 2 : 1;
608
+ continue;
609
+ }
610
+ break;
611
+ }
612
+ }
613
+
614
+ while (index < commandEnd && APPLY_PATCH_COMMAND_WRAPPER_TOKENS.has((tokens[index]?.value ?? "").toLowerCase())) {
615
+ index += 1;
616
+ advanced = true;
617
+ }
618
+ }
619
+
620
+ if (index < commandEnd && shellCommandBasename(tokens[index]?.value ?? "") === "apply_patch") return true;
621
+ if (commandEnd <= commandStart) break;
622
+ commandStart = commandEnd - 1;
623
+ }
624
+
625
+ return false;
626
+ }
627
+
491
628
  function tokenValues(tokens: ShellToken[]): string[] {
492
629
  return tokens.map((token) => token.value);
493
630
  }