oh-my-codex 0.20.0 → 0.20.1

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 (56) hide show
  1. package/Cargo.lock +6 -6
  2. package/Cargo.toml +1 -1
  3. package/dist/cli/__tests__/codex-plugin-layout.test.js +27 -0
  4. package/dist/cli/__tests__/codex-plugin-layout.test.js.map +1 -1
  5. package/dist/cli/__tests__/doctor-context-window-warning.test.js +83 -78
  6. package/dist/cli/__tests__/doctor-context-window-warning.test.js.map +1 -1
  7. package/dist/cli/__tests__/doctor-invalid-config.test.js +9 -0
  8. package/dist/cli/__tests__/doctor-invalid-config.test.js.map +1 -1
  9. package/dist/cli/__tests__/doctor-warning-copy.test.js +87 -1
  10. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  11. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js +54 -2
  12. package/dist/cli/__tests__/setup-hooks-shared-ownership.test.js.map +1 -1
  13. package/dist/cli/__tests__/setup-install-mode.test.js +3 -3
  14. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  15. package/dist/cli/__tests__/setup-refresh.test.js +137 -5
  16. package/dist/cli/__tests__/setup-refresh.test.js.map +1 -1
  17. package/dist/cli/__tests__/setup-scope.test.js +27 -3
  18. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  19. package/dist/cli/__tests__/uninstall.test.js +297 -3
  20. package/dist/cli/__tests__/uninstall.test.js.map +1 -1
  21. package/dist/cli/doctor.d.ts +2 -0
  22. package/dist/cli/doctor.d.ts.map +1 -1
  23. package/dist/cli/doctor.js +42 -31
  24. package/dist/cli/doctor.js.map +1 -1
  25. package/dist/cli/setup.js +2 -2
  26. package/dist/cli/setup.js.map +1 -1
  27. package/dist/cli/uninstall.d.ts.map +1 -1
  28. package/dist/cli/uninstall.js +3 -4
  29. package/dist/cli/uninstall.js.map +1 -1
  30. package/dist/config/__tests__/generator-idempotent.test.js +200 -104
  31. package/dist/config/__tests__/generator-idempotent.test.js.map +1 -1
  32. package/dist/config/__tests__/generator-notify.test.js +22 -48
  33. package/dist/config/__tests__/generator-notify.test.js.map +1 -1
  34. package/dist/config/generator.d.ts +19 -10
  35. package/dist/config/generator.d.ts.map +1 -1
  36. package/dist/config/generator.js +169 -152
  37. package/dist/config/generator.js.map +1 -1
  38. package/dist/leader/__tests__/contract.test.js +43 -1
  39. package/dist/leader/__tests__/contract.test.js.map +1 -1
  40. package/dist/leader/contract.d.ts.map +1 -1
  41. package/dist/leader/contract.js +23 -5
  42. package/dist/leader/contract.js.map +1 -1
  43. package/dist/scripts/__tests__/codex-native-hook.test.js +1104 -20
  44. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  45. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  46. package/dist/scripts/codex-native-hook.js +139 -19
  47. package/dist/scripts/codex-native-hook.js.map +1 -1
  48. package/dist/utils/__tests__/agents-md.test.js +16 -0
  49. package/dist/utils/__tests__/agents-md.test.js.map +1 -1
  50. package/dist/utils/agents-md.d.ts.map +1 -1
  51. package/dist/utils/agents-md.js +4 -3
  52. package/dist/utils/agents-md.js.map +1 -1
  53. package/package.json +1 -1
  54. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  55. package/src/scripts/__tests__/codex-native-hook.test.ts +1281 -53
  56. package/src/scripts/codex-native-hook.ts +142 -16
@@ -130,7 +130,6 @@ import {
130
130
  } from "../question/deep-interview.js";
131
131
  import { readAutopilotDeepInterviewQuestionWaitState } from "../question/autopilot-wait.js";
132
132
  import {
133
- buildDocumentRefreshAdvisoryOutput,
134
133
  evaluateFinalHandoffDocumentRefresh,
135
134
  isFinalHandoffDocumentRefreshCandidate,
136
135
  } from "../document-refresh/enforcer.js";
@@ -2740,8 +2739,6 @@ async function buildModeBasedStopOutput(
2740
2739
  reason: `OMX ${mode} is still active (phase: ${phase}; ${diagnostic}); continue the task and gather fresh verification evidence before stopping.`,
2741
2740
  stopReason: `${mode}_${phase}`,
2742
2741
  systemMessage,
2743
- statePath,
2744
- canonicalDisagreement,
2745
2742
  };
2746
2743
  }
2747
2744
 
@@ -3653,8 +3650,14 @@ function isAllowedDeepInterviewArtifactPath(cwd: string, rawPath: string): boole
3653
3650
  return isAllowedPlanningArtifactPath(cwd, rawPath, DEEP_INTERVIEW_ALLOWED_WRITE_PREFIXES);
3654
3651
  }
3655
3652
 
3653
+ function isAllowedRalplanDraftPath(cwd: string, rawPath: string): boolean {
3654
+ const relativePath = normalizePlanningArtifactRelativePath(cwd, rawPath);
3655
+ return relativePath !== null && /^\.omx\/drafts\/[^/]+\.md$/.test(relativePath);
3656
+ }
3657
+
3656
3658
  function isAllowedRalplanArtifactPath(cwd: string, rawPath: string): boolean {
3657
- return isAllowedPlanningArtifactPath(cwd, rawPath, RALPLAN_ALLOWED_WRITE_PREFIXES);
3659
+ return isAllowedRalplanDraftPath(cwd, rawPath)
3660
+ || isAllowedPlanningArtifactPath(cwd, rawPath, RALPLAN_ALLOWED_WRITE_PREFIXES);
3658
3661
  }
3659
3662
 
3660
3663
  interface RalplanBeadsCommandClassification {
@@ -3874,9 +3877,72 @@ function resolveCommandRedirectTarget(target: string, assignments: Map<string, s
3874
3877
  return resolved !== undefined ? resolved : target;
3875
3878
  }
3876
3879
 
3880
+ // Masks redirect metacharacters (`<`/`>`) that appear INSIDE shell quotes so a
3881
+ // quoted regex/source value (e.g. `gh issue create --body '...>{1,2}...'` or
3882
+ // `omx state write --input '{"reason":"a>b"}'`) is not misread as a redirect
3883
+ // write target. Escaped quotes at the top level (`\'`, `\"`) are literal
3884
+ // characters and must NOT open a span, and `$'...'` ANSI-C quoting processes
3885
+ // backslash escapes (so `\'` does not close it) — otherwise a genuine `>`
3886
+ // redirect could be masked behind a false span and its write missed. Only
3887
+ // characters inside a real quoted span are masked; unquoted redirect operators
3888
+ // survive intact. Unterminated/ambiguous quoting fails closed: the original
3889
+ // command is returned unmasked so genuine redirects stay visible to the scan.
3890
+ function maskQuotedRedirectMetacharsForCommandScan(command: string): string {
3891
+ let masked = "";
3892
+ // null = unquoted; "'" = single quotes (no escapes); '"' = double quotes
3893
+ // (backslash escapes); "$'" = ANSI-C $'...' (backslash escapes, incl. \').
3894
+ let quote: "'" | "\"" | "$'" | null = null;
3895
+ for (let index = 0; index < command.length; index += 1) {
3896
+ const char = command[index] ?? "";
3897
+ if (quote === null) {
3898
+ // A backslash escapes the next character at the top level, so an escaped
3899
+ // quote is a literal char and must not open a quoted span.
3900
+ if (char === "\\") {
3901
+ masked += char;
3902
+ const next = command[index + 1];
3903
+ if (next !== undefined) {
3904
+ masked += next;
3905
+ index += 1;
3906
+ }
3907
+ continue;
3908
+ }
3909
+ if (char === "$" && command[index + 1] === "'") {
3910
+ quote = "$'";
3911
+ masked += "$'";
3912
+ index += 1;
3913
+ continue;
3914
+ }
3915
+ if (char === "'" || char === "\"") quote = char;
3916
+ masked += char;
3917
+ continue;
3918
+ }
3919
+ if ((quote === "\"" || quote === "$'") && char === "\\") {
3920
+ // Escapes inside double/ANSI-C spans keep the backslash and its escaped
3921
+ // char (masking a redirect metachar so quoted data cannot be a false
3922
+ // target); the escaped char never closes the span.
3923
+ masked += char;
3924
+ const next = command[index + 1];
3925
+ if (next !== undefined) {
3926
+ masked += next === "<" || next === ">" ? "_" : next;
3927
+ index += 1;
3928
+ }
3929
+ continue;
3930
+ }
3931
+ const closesSpan = quote === "$'" ? char === "'" : char === quote;
3932
+ if (closesSpan) {
3933
+ quote = null;
3934
+ masked += char;
3935
+ continue;
3936
+ }
3937
+ masked += char === "<" || char === ">" ? "_" : char;
3938
+ }
3939
+ if (quote !== null) return command;
3940
+ return masked;
3941
+ }
3942
+
3877
3943
  function extractDeepInterviewCommandRedirectTargets(command: string): string[] {
3878
3944
  const targets: string[] = [];
3879
- const commandOutsideHeredocBodies = stripHeredocBodiesForCommandScan(command);
3945
+ const commandOutsideHeredocBodies = maskQuotedRedirectMetacharsForCommandScan(stripHeredocBodiesForCommandScan(command));
3880
3946
  for (const match of commandOutsideHeredocBodies.matchAll(/(?:^|[^>])>{1,2}\s*(["']?)([^\s&|;<>]+)\1/g)) {
3881
3947
  const candidate = safeString(match[2]).trim();
3882
3948
  if (candidate && !isNullDeviceRedirectTarget(candidate)) targets.push(candidate);
@@ -3974,7 +4040,7 @@ function findGitSubcommandIndex(words: string[], startIndex: number): number | n
3974
4040
  }
3975
4041
 
3976
4042
 
3977
- function commandHasDeepInterviewWriteIntent(command: string): boolean {
4043
+ function commandHasDeepInterviewWriteIntent(command: string, depth = 0): boolean {
3978
4044
  return commandInvokesApplyPatch(command)
3979
4045
  || extractDeepInterviewCommandRedirectTargets(command).length > 0
3980
4046
  || /\btee\s+(?:-a\s+)?[^\s&|;]+/.test(command)
@@ -3985,7 +4051,19 @@ function commandHasDeepInterviewWriteIntent(command: string): boolean {
3985
4051
  || extractConductorBashMutations(command).length > 0
3986
4052
  || extractConductorInterpreterWrites(command).length > 0
3987
4053
  || commandHasDestructiveGitSubcommand(command)
3988
- || commandHasPackageInstallIntent(command);
4054
+ || commandHasPackageInstallIntent(command)
4055
+ // Recurse into wrapped shells (`bash -lc "cat > f"`, `eval`, `env`) and
4056
+ // command substitutions so a real redirect INSIDE a nested command string
4057
+ // is still classified as write intent. This is required because quoted
4058
+ // redirect metacharacters are now masked at the outer scan (#3119): the
4059
+ // mask removes false positives from quoted DATA values while nested-command
4060
+ // recursion re-detects genuine redirects, preserving fail-closed blocking.
4061
+ // The depth guard mirrors evaluateConductorBashWrite's nesting bound;
4062
+ // extractors return strict substrings, so recursion always terminates.
4063
+ || (depth < CONDUCTOR_BASH_MAX_NESTING_DEPTH && (
4064
+ extractNestedShellCommandStringsForStateScan(command).some((nested) => commandHasDeepInterviewWriteIntent(nested, depth + 1))
4065
+ || extractNestedCommandSubstitutionStringsForStateScan(command).some((nested) => commandHasDeepInterviewWriteIntent(nested, depth + 1))
4066
+ ));
3989
4067
  }
3990
4068
 
3991
4069
  function extractDeepInterviewCommandWriteTargets(command: string): string[] {
@@ -6971,7 +7049,7 @@ async function buildRalplanPreToolUseBoundaryOutput(
6971
7049
  resolvedSessionId?: string,
6972
7050
  ): Promise<Record<string, unknown> | null> {
6973
7051
  const sessionId = safeString(resolvedSessionId ?? readPayloadSessionId(payload)).trim();
6974
- if (await hasTrustedTypedSubagentProvenanceForPreToolUse(payload, cwd, sessionId)) return null;
7052
+ if (await hasTrustedTypedSubagentProvenanceForPreToolUse(payload, cwd, stateDir, sessionId)) return null;
6975
7053
  const threadId = readPayloadThreadId(payload);
6976
7054
  const activeState = await readActiveRalplanStateForPreToolUse(cwd, stateDir, sessionId, threadId);
6977
7055
  if (!activeState) return null;
@@ -7025,7 +7103,7 @@ async function buildRalplanPreToolUseBoundaryOutput(
7025
7103
  hookEventName: "PreToolUse",
7026
7104
  additionalContext:
7027
7105
  `${planningModeDescription}. `
7028
- + "Write only planning artifacts under `.omx/context/`, `.omx/plans/`, `.omx/specs/`, `.omx/tmp/`, required `.omx/state/` files, or tracker metadata under `.beads/`. "
7106
+ + "Write only planning artifacts under `.omx/context/`, `.omx/plans/`, `.omx/specs/`, `.omx/tmp/`, required `.omx/state/` files, Markdown drafts under `.omx/drafts/*.md`, or tracker metadata under `.beads/`. "
7029
7107
  + "Do not edit implementation files or run implementation-focused writes from planning phases. "
7030
7108
  + `To execute, first process an explicit handoff such as ${formatExecutionHandoffList(cwd)}, which must emit terminal planning state before implementation begins.`,
7031
7109
  },
@@ -7039,7 +7117,7 @@ async function buildDeepInterviewPreToolUseBoundaryOutput(
7039
7117
  resolvedSessionId?: string,
7040
7118
  ): Promise<Record<string, unknown> | null> {
7041
7119
  const sessionId = safeString(resolvedSessionId ?? readPayloadSessionId(payload)).trim();
7042
- if (await hasTrustedTypedSubagentProvenanceForPreToolUse(payload, cwd, sessionId)) return null;
7120
+ if (await hasTrustedTypedSubagentProvenanceForPreToolUse(payload, cwd, stateDir, sessionId)) return null;
7043
7121
  const threadId = readPayloadThreadId(payload);
7044
7122
  const activeState = await readActiveDeepInterviewStateForPreToolUse(cwd, stateDir, sessionId, threadId);
7045
7123
  if (!activeState) return null;
@@ -7210,17 +7288,67 @@ interface ActiveConductorState {
7210
7288
  async function hasTrustedTypedSubagentProvenanceForPreToolUse(
7211
7289
  payload: CodexHookPayload,
7212
7290
  cwd: string,
7291
+ stateDir: string,
7213
7292
  sessionId: string,
7293
+ options: { allowUntypedProvenance?: boolean } = {},
7214
7294
  ): Promise<boolean> {
7215
7295
  if (hasTeamWorkerEnvironment()) return true;
7216
- if (!isTypedAgentRolePayload(payload)) return false;
7217
7296
  const trackingState = await readSubagentTrackingState(cwd).catch(() => null);
7218
7297
  const session = trackingState?.sessions?.[sessionId];
7219
7298
  if (!session) return false;
7220
7299
 
7221
7300
  const payloadThreadId = readPayloadThreadId(payload);
7301
+
7302
+ // Resolve the Main-root leader THREAD identity from the tracker's leader_thread_id
7303
+ // plus the canonical session's native_session_id (the leader's native thread). Only
7304
+ // genuine leader-thread identifiers may anchor the leader: owner_*_session_id are
7305
+ // session-level ids, not thread anchors, so they must NOT populate this set — their
7306
+ // mere presence would make it non-empty and suppress the fail-closed guard below
7307
+ // without ever excluding the leader thread (#3117 P4). The native_session_id anchor is
7308
+ // honored only when the root session pointer provably maps to the sessionId under
7309
+ // evaluation, so a stale/foreign session.json cannot supply another session's leader
7310
+ // anchor (#3117 P3); the tracker alone is insufficient because it can omit
7311
+ // leader_thread_id or corruptly label the leader kind:"subagent" (#3117 P2).
7312
+ const sessionState = await readRootSessionStateFromStateDir(stateDir).catch(() => null);
7313
+ const trackerLeaderThreadId = safeString(session.leader_thread_id).trim();
7314
+ const leaderIdentityAnchors = new Set<string>();
7315
+ if (trackerLeaderThreadId) leaderIdentityAnchors.add(trackerLeaderThreadId);
7316
+ if (sessionState && sessionId && payloadMatchesSessionPointer(sessionId, sessionState)) {
7317
+ const nativeSessionId = safeString(sessionState.native_session_id).trim();
7318
+ if (nativeSessionId) leaderIdentityAnchors.add(nativeSessionId);
7319
+ }
7320
+
7321
+ // Leader self-guard: the Main-root Conductor is never a delegated executor. Block it
7322
+ // ahead of every trust path, even when tracker or payload provenance is (possibly
7323
+ // corruptly) attached to the leader thread.
7324
+ if (payloadThreadId && leaderIdentityAnchors.has(payloadThreadId)) return false;
7325
+
7326
+ // Fail closed: without an authoritative leader anchor we cannot affirm the payload is
7327
+ // a non-leader delegated performer rather than a mislabeled leader, so we refuse trust
7328
+ // instead of inferring it from a corrupt tracker kind:"subagent" alone (#3117 P2).
7329
+ if (leaderIdentityAnchors.size === 0) return false;
7330
+
7331
+ // Planning boundary guards (ralplan, deep-interview) still require a recognized typed
7332
+ // agent role, so an untyped collaboration.spawn_agent child cannot write before an
7333
+ // execution handoff/approval. Only the Main-root Conductor/Ralph executing guard opts
7334
+ // into untyped tracker/runtime provenance (#3116, #3117).
7335
+ if (options.allowUntypedProvenance !== true && !isTypedAgentRolePayload(payload)) {
7336
+ return false;
7337
+ }
7338
+
7339
+ // Tracker-backed provenance: the payload's own thread is a recorded, non-leader
7340
+ // subagent for this session — the non-spoofable anchor that lets native
7341
+ // collaboration.spawn_agent children/descendants edit under the Conductor guard even
7342
+ // without a recognized typed role (#3116). subagent-tracking.json is derived from
7343
+ // child SessionStart transcript metadata, not the live tool-call payload.
7222
7344
  if (payloadThreadId && isTrustedSubagentThread(session, payloadThreadId)) return true;
7223
7345
 
7346
+ // Runtime-attached spawn provenance: trust a genuine spawned subagent turn whose
7347
+ // declared parent maps to this session's leader or a tracked thread. parentThreadId
7348
+ // comes from the runtime-set source.subagent.thread_spawn (not agent-controlled tool
7349
+ // arguments); an absent parent is rejected, the leader self-guard above blocks the
7350
+ // main root, and cross-session parents fail because they are not in this session's
7351
+ // tracked threads (#3116).
7224
7352
  const source = safeObject(payload.source);
7225
7353
  const subagent = safeObject(source.subagent);
7226
7354
  const threadSpawn = safeObject(subagent.thread_spawn);
@@ -7231,9 +7359,7 @@ async function hasTrustedTypedSubagentProvenanceForPreToolUse(
7231
7359
  ?? threadSpawn.leaderThreadId,
7232
7360
  ).trim();
7233
7361
  if (!parentThreadId) return false;
7234
- const leaderThreadId = safeString(session.leader_thread_id).trim();
7235
- if (payloadThreadId && leaderThreadId && payloadThreadId === leaderThreadId) return false;
7236
- return leaderThreadId === parentThreadId || parentThreadId in session.threads;
7362
+ return leaderIdentityAnchors.has(parentThreadId) || parentThreadId in session.threads;
7237
7363
  }
7238
7364
 
7239
7365
  function isActiveConductorModeState(state: Record<string, unknown> | null, mode: string, sessionId: string): boolean {
@@ -7262,7 +7388,7 @@ async function readActiveMainRootConductorStateForPreToolUse(
7262
7388
  }
7263
7389
  const threadId = readPayloadThreadId(payload);
7264
7390
  if (!sessionId) return null;
7265
- if (await hasTrustedTypedSubagentProvenanceForPreToolUse(payload, cwd, sessionId)) return null;
7391
+ if (await hasTrustedTypedSubagentProvenanceForPreToolUse(payload, cwd, stateDir, sessionId, { allowUntypedProvenance: true })) return null;
7266
7392
 
7267
7393
  const canonicalState = await readVisibleSkillActiveStateForStateDir(stateDir, sessionId);
7268
7394
  if (!canonicalState) return null;
@@ -9538,7 +9664,7 @@ async function buildStopHookOutput(
9538
9664
  documentRefreshWarning.triggeringPaths.join("|"),
9539
9665
  canonicalSessionId,
9540
9666
  ),
9541
- buildDocumentRefreshAdvisoryOutput(documentRefreshWarning, "Stop"),
9667
+ { systemMessage: documentRefreshWarning.message },
9542
9668
  canonicalSessionId,
9543
9669
  { allowRepeatDuringStopHook: false },
9544
9670
  );