sneakoscope 0.9.1 → 0.9.2

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.
package/README.md CHANGED
@@ -288,7 +288,9 @@ For headless remotely controllable Codex App/server sessions on Codex CLI 0.130.
288
288
  sks codex-app remote-control -- --help
289
289
  ```
290
290
 
291
- `sks codex-app check` reports whether the installed Codex CLI is new enough, whether the required app flags are visible, whether Fast/speed-selector config is unlocked, and whether installed OpenAI default plugins such as Browser, Chrome, Computer Use, Documents, Presentations, Spreadsheets, and LaTeX are enabled. When codex-lb is configured, SKS keeps it selected as the top-level Codex App provider while still preserving required app flags and plugin settings. Codex CLI 0.130.0+ app-server/remote-control threads can pick up config changes live; older CLI/TUI sessions should still be restarted after `.codex/config.toml` or MCP/plugin changes.
291
+ `sks codex-app check` reports whether the installed Codex CLI is new enough, whether the required app flags are visible, whether Fast/speed-selector config is unlocked, whether Codex App Git Actions can use Commit, Push, Commit and Push, and PR flows, and whether installed OpenAI default plugins such as Browser, Chrome, Computer Use, Documents, Presentations, Spreadsheets, and LaTeX are enabled. When codex-lb is configured, SKS keeps it selected as the top-level Codex App provider while still preserving required app flags and plugin settings. Codex CLI 0.130.0+ app-server/remote-control threads can pick up config changes live; older CLI/TUI sessions should still be restarted after `.codex/config.toml` or MCP/plugin changes.
292
+
293
+ Image-review routes are intentionally strict. `$Image-UX-Review`, `$UX-Review`, `$Visual-Review`, and `$UI-UX-Review` require real Codex App `$imagegen`/`gpt-image-2` generated annotated review images before `image-ux-review-gate.json` can pass; disabled or missing `image_generation` remains a blocker that `sks codex-app check` and selftest cover.
292
294
 
293
295
  Then open Codex App and use prompt commands directly in the chat. Examples:
294
296
 
@@ -297,6 +299,7 @@ $Team implement the checkout fix and verify it
297
299
  $DFix change this label and spacing only
298
300
  $QA-LOOP dogfood localhost:3000 and fix safe issues
299
301
  $PPT create an investor deck as HTML/PDF
302
+ $UX-Review this screenshot with gpt-image-2 callouts, then fix the issues
300
303
  $Goal persist this migration workflow with native /goal continuation
301
304
  $Research investigate this mechanism with source-backed scout lenses
302
305
  $Wiki refresh and validate the context pack
@@ -420,6 +423,15 @@ codex mcp list
420
423
 
421
424
  Codex App workflows need the app installed. UI/browser evidence requires first-party Codex Computer Use, and generated raster/image-review evidence requires real `$imagegen`/`gpt-image-2` output. After setup/upgrade, start a fresh thread so Codex reloads plugin tools.
422
425
 
426
+ ### Codex App commit/push is blocked
427
+
428
+ ```sh
429
+ sks doctor --fix
430
+ sks codex-app check
431
+ ```
432
+
433
+ `sks codex-app check` now prints `Git Actions`. It should be `ok` for Codex App Commit, Push, Commit and Push, and PR buttons to bypass SKS route gates. If it is blocked, repair config with `sks doctor --fix`; if the blocker mentions remote-control, update Codex CLI to `0.130.0` or newer and restart older app-server/TUI sessions.
434
+
423
435
  ### Codex App UI looks stale after codex-lb changes
424
436
 
425
437
  If Codex App UI panels or auth-dependent controls still look wrong after codex-lb setup, repair, or upgrade, restart the app first. If the UI still does not recover, sign out of Codex App, sign back in, then run `sks codex-app check` or `sks codex-lb repair` as needed.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sneakoscope",
3
3
  "displayName": "ㅅㅋㅅ",
4
- "version": "0.9.1",
4
+ "version": "0.9.2",
5
5
  "description": "Sneakoscope Codex: database-safe Codex CLI/App harness with Team, Goal, AutoResearch, TriWiki, and Honest Mode.",
6
6
  "type": "module",
7
7
  "homepage": "https://github.com/mandarange/Sneakoscope-Codex#readme",
package/src/cli/main.mjs CHANGED
@@ -1648,7 +1648,7 @@ async function setup(args) {
1648
1648
  else console.log('Git: .gitignore ignores SKS generated files');
1649
1649
  console.log(`Codex App: .codex/config.toml, .codex/hooks.json, .agents/skills, .codex/agents, .codex/SNEAKOSCOPE.md`);
1650
1650
  console.log(`Global $: ${globalSkills.status === 'installed' ? 'ok' : globalSkills.status} ${globalSkills.root || ''}`.trimEnd());
1651
- console.log(`App tools: ${appRuntime.ok ? 'ok' : 'needs setup'} Codex App=${appRuntime.app.installed ? 'ok' : 'missing'} Browser=${appRuntime.features?.browser_tool_ready ? 'ok' : 'missing'} Computer Use=${appRuntime.mcp.has_computer_use ? 'ok' : 'missing'} Image Gen=${appRuntime.features?.image_generation ? 'ok' : 'missing'}`);
1651
+ console.log(`App tools: ${appRuntime.ok ? 'ok' : 'needs setup'} Codex App=${appRuntime.app.installed ? 'ok' : 'missing'} Browser=${appRuntime.features?.browser_tool_ready ? 'ok' : 'missing'} Computer Use=${appRuntime.mcp.has_computer_use ? 'ok' : 'missing'} Image Gen=${appRuntime.features?.image_generation ? 'ok' : 'missing'} Git Actions=${appRuntime.features?.git_actions?.ok ? 'ok' : 'missing'}`);
1652
1652
  console.log(`Prompt: intent-first routing, $Answer fact-check route, $DFix ultralight Direct Fix route, $PPT HTML/PDF presentation route, Context7 gate`);
1653
1653
  console.log(`Skills: .agents/skills`);
1654
1654
  console.log(`Next: sks context7 check; sks selftest --mock; sks commands; sks dollar-commands`);
@@ -3086,7 +3086,9 @@ async function selftest() {
3086
3086
  await fsp.chmod(fakeCodex, 0o755);
3087
3087
  const codexAppFixtureOpts = { codex: { bin: fakeCodex, version: 'codex-cli 99.0.0' }, home: appFeatureTmp, cwd: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } };
3088
3088
  const codexAppFeatureStatus = await codexAppIntegrationStatus(codexAppFixtureOpts);
3089
- if (!codexAppFeatureStatus.ok || !codexAppFeatureStatus.features?.required_flags_ok || !codexAppFeatureStatus.features?.codex_git_commit || !codexAppFeatureStatus.features?.remote_control || !codexAppFeatureStatus.features?.fast_mode_config?.ok) throw new Error('selftest: codex-app check did not accept required app feature flags, remote_control, and unlocked Fast UI config');
3089
+ if (!codexAppFeatureStatus.ok || !codexAppFeatureStatus.features?.required_flags_ok || !codexAppFeatureStatus.features?.codex_git_commit || !codexAppFeatureStatus.features?.remote_control || !codexAppFeatureStatus.features?.git_actions?.ok || !codexAppFeatureStatus.features?.fast_mode_config?.ok) throw new Error('selftest: codex-app check did not accept required app feature flags, git actions, remote_control, and unlocked Fast UI config');
3090
+ const codexAppOldCliStatus = await codexAppIntegrationStatus({ codex: { bin: fakeCodex, version: 'codex-cli 0.129.0' }, home: appFeatureTmp, cwd: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } });
3091
+ if (codexAppOldCliStatus.ok || codexAppOldCliStatus.features?.git_actions?.ok || !codexAppOldCliStatus.guidance.some((line) => line.includes('git commit/push actions are blocked'))) throw new Error('selftest: codex-app check did not block commit/push actions on old Codex CLI remote-control');
3090
3092
  const missingDefaultPluginTmp = tmpdir();
3091
3093
  await ensureDir(path.join(missingDefaultPluginTmp, '.codex'));
3092
3094
  const codexConfigWithoutMarketplaceSources = codexConfigText.replace(/(?:^|\n)\[marketplaces\.[^\]\r\n]+\][\s\S]*?(?=\n\[[^\]]+\]|\s*$)/g, '').trim();
@@ -3107,7 +3109,12 @@ async function selftest() {
3107
3109
  await writeTextAtomic(fakeCodexMissing, '#!/bin/sh\nif [ "$1" = "mcp" ] && [ "$2" = "list" ]; then printf "%s\\n" "computer-use enabled" "browser-use enabled"; exit 0; fi\nif [ "$1" = "features" ] && [ "$2" = "list" ]; then cat <<EOF\napps stable true\nbrowser_use stable true\nbrowser_use_external stable true\ncodex_git_commit under development false\ncomputer_use stable true\nfast_mode stable true\nguardian_approval stable true\nhooks stable true\nimage_generation stable true\nin_app_browser stable true\nplugins stable true\nremote_control under development true\ntool_suggest stable true\nEOF\nexit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
3108
3110
  await fsp.chmod(fakeCodexMissing, 0o755);
3109
3111
  const codexAppMissingFeatureStatus = await codexAppIntegrationStatus({ codex: { bin: fakeCodexMissing, version: 'codex-cli 99.0.0' }, home: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } });
3110
- if (codexAppMissingFeatureStatus.ok || codexAppMissingFeatureStatus.features?.required_flags_ok || codexAppMissingFeatureStatus.features?.codex_git_commit) throw new Error('selftest: codex-app check did not block disabled codex_git_commit feature flag');
3112
+ if (codexAppMissingFeatureStatus.ok || codexAppMissingFeatureStatus.features?.required_flags_ok || codexAppMissingFeatureStatus.features?.codex_git_commit || codexAppMissingFeatureStatus.features?.git_actions?.ok) throw new Error('selftest: codex-app check did not block disabled codex_git_commit feature flag');
3113
+ const fakeCodexMissingImageGen = path.join(fakeCodexBinDir, 'codex-missing-imagegen');
3114
+ await writeTextAtomic(fakeCodexMissingImageGen, '#!/bin/sh\nif [ "$1" = "mcp" ] && [ "$2" = "list" ]; then printf "%s\\n" "computer-use enabled" "browser-use enabled"; exit 0; fi\nif [ "$1" = "features" ] && [ "$2" = "list" ]; then cat <<EOF\napps stable true\nbrowser_use stable true\nbrowser_use_external stable true\ncodex_git_commit under development true\ncomputer_use stable true\nfast_mode stable true\nguardian_approval stable true\nhooks stable true\nimage_generation stable false\nin_app_browser stable true\nplugins stable true\nremote_control under development true\ntool_suggest stable true\nEOF\nexit 0; fi\necho "unexpected codex $*" >&2\nexit 2\n');
3115
+ await fsp.chmod(fakeCodexMissingImageGen, 0o755);
3116
+ const codexAppMissingImageGenStatus = await codexAppIntegrationStatus({ codex: { bin: fakeCodexMissingImageGen, version: 'codex-cli 99.0.0' }, home: appFeatureTmp, env: { SKS_CODEX_APP_PATH: fakeCodexApp } });
3117
+ if (codexAppMissingImageGenStatus.ok || codexAppMissingImageGenStatus.features?.required_flags_ok || codexAppMissingImageGenStatus.features?.image_generation || !codexAppMissingImageGenStatus.guidance.some((line) => line.includes('image_generation'))) throw new Error('selftest: codex-app check did not block disabled image_generation for imagegen pipelines');
3111
3118
  const autoReviewHome = path.join(tmp, 'auto-review-home');
3112
3119
  const autoReviewEnv = { HOME: autoReviewHome };
3113
3120
  const autoReviewEnabled = await enableAutoReview({ env: autoReviewEnv, high: true });
@@ -135,7 +135,8 @@ export async function codexAppIntegrationStatus(opts = {}) {
135
135
  const browserToolReady = inAppBrowserReady || browserUseFeatureReady || browserUseReady;
136
136
  const appInstalled = Boolean(appPath);
137
137
  const pluginPickerReady = requiredFeatureFlags.tool_suggest && requiredFeatureFlags.plugins && requiredFeatureFlags.apps && defaultPlugins.ok && pluginSkillShadows.ok && fastModeConfig.ok;
138
- const ready = appInstalled && Boolean(codex.bin) && mcpList.ok && featureList.ok && requiredFeatureFlagsOk && pluginPickerReady && fastModeConfig.ok && imageGenerationReady && computerUseReady && browserToolReady;
138
+ const gitActions = codexGitActionReadiness({ requiredFeatureFlags, remoteControl });
139
+ const ready = appInstalled && Boolean(codex.bin) && mcpList.ok && featureList.ok && requiredFeatureFlagsOk && pluginPickerReady && fastModeConfig.ok && imageGenerationReady && gitActions.ok && computerUseReady && browserToolReady;
139
140
  return {
140
141
  ok: ready,
141
142
  app: {
@@ -166,6 +167,7 @@ export async function codexAppIntegrationStatus(opts = {}) {
166
167
  required_flags: requiredFeatureFlags,
167
168
  required_flags_ok: requiredFeatureFlagsOk,
168
169
  fast_mode_config: fastModeConfig,
170
+ git_actions: gitActions,
169
171
  image_generation: imageGenerationReady,
170
172
  image_generation_source: imageGenerationReady ? 'codex_features_list' : 'missing',
171
173
  in_app_browser: inAppBrowserReady,
@@ -196,7 +198,7 @@ export async function codexAppIntegrationStatus(opts = {}) {
196
198
  fast_mode_config_ok: fastModeConfig.ok
197
199
  }
198
200
  },
199
- guidance: codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags, requiredFeatureFlagsOk, defaultPlugins, pluginSkillShadows, fastModeConfig, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl })
201
+ guidance: codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags, requiredFeatureFlagsOk, defaultPlugins, pluginSkillShadows, fastModeConfig, gitActions, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl })
200
202
  };
201
203
  }
202
204
 
@@ -251,7 +253,7 @@ export function formatCodexRemoteControlStatus(status) {
251
253
  return lines.filter(Boolean).join('\n');
252
254
  }
253
255
 
254
- export function codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags = {}, requiredFeatureFlagsOk = true, defaultPlugins = { ok: true, missing_enabled: [] }, pluginSkillShadows = { ok: true, blocking: [] }, fastModeConfig = { ok: true, blockers: [] }, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl }) {
256
+ export function codexAppGuidance({ appInstalled, codex, mcpList, featureList, requiredFeatureFlags = {}, requiredFeatureFlagsOk = true, defaultPlugins = { ok: true, missing_enabled: [] }, pluginSkillShadows = { ok: true, blocking: [] }, fastModeConfig = { ok: true, blockers: [] }, gitActions = { ok: true, blockers: [] }, imageGenerationReady, inAppBrowserReady, browserUseFeatureReady, computerUseReady, browserUseReady, browserToolReady, computerUseMcpListed, browserUseMcpListed, remoteControl }) {
255
257
  const lines = [];
256
258
  if (!appInstalled) {
257
259
  lines.push('Install and open Codex App for first-party MCP/plugin tools. SKS tmux launch can still run with Codex CLI alone, but Codex Computer Use and imagegen/gpt-image-2 evidence will be unavailable until Codex App is ready.');
@@ -298,6 +300,12 @@ export function codexAppGuidance({ appInstalled, codex, mcpList, featureList, re
298
300
  lines.push(`Codex App speed selector can be hidden or locked by config: ${fastModeConfig.blockers.join(', ')}.`);
299
301
  lines.push('Run: sks doctor --fix');
300
302
  }
303
+ if (!gitActions?.ok) {
304
+ lines.push(`Codex App git commit/push actions are blocked: ${gitActions?.blockers?.join(', ') || 'git action readiness'}. The app Commit, Push, Commit and Push, and PR flows need codex_git_commit, hooks, remote_control, and Codex CLI remote-control support.`);
305
+ lines.push(`Run: sks doctor --fix; if remote-control is still blocked, update Codex CLI to ${CODEX_REMOTE_CONTROL_MIN_VERSION}+ and restart older app-server/TUI sessions.`);
306
+ } else {
307
+ lines.push('Codex App git actions are enabled for Commit, Push, Commit and Push, and PR flows; SKS hooks treat those app metadata actions as lightweight git UI actions.');
308
+ }
301
309
  if (appInstalled && (!computerUseReady || !browserToolReady)) {
302
310
  lines.push('Open Codex App settings and enable recommended MCP/plugin tools. Codex CLI 0.130.0+ remote-control/app-server sessions can pick up config changes live; restart older CLI/TUI sessions.');
303
311
  lines.push('Required for SKS QA-LOOP UI/browser evidence: Codex Computer Use only. Browser tools can support browsing context, but they do not satisfy UI-level E2E verification.');
@@ -333,6 +341,7 @@ export function formatCodexAppStatus(status, { includeRaw = false } = {}) {
333
341
  `Fast UI: ${status.features?.fast_mode_config?.ok ? 'ok' : `locked ${(status.features?.fast_mode_config?.blockers || []).join(', ') || 'config'}`}`,
334
342
  `Default Plugins:${status.plugins?.default_plugins?.ok ? ' ok' : ` missing ${defaultPluginMissingSummary(status.plugins?.default_plugins) || 'plugin install/config'}`}`,
335
343
  `Plugin Picker:${status.plugins?.picker?.ok ? ' ok' : ` blocked ${pluginPickerBlockers(status).join(', ') || 'config'}`}`,
344
+ `Git Actions:${status.features?.git_actions?.ok ? ' ok' : ` blocked ${(status.features?.git_actions?.blockers || []).join(', ') || 'config'}`}`,
336
345
  `Computer Use:${status.mcp.has_computer_use ? status.mcp.computer_use_source === 'plugin_cache' ? ' installed (verify @Computer in thread)' : ' ok' : ' missing'}`,
337
346
  `Browser: ${status.features?.browser_tool_ready ? `ok (${status.features.browser_tool_source})` : status.mcp.has_browser_use ? status.mcp.browser_use_source === 'plugin_cache' ? 'installed (plugin scoped)' : 'ok' : 'missing'}`,
338
347
  `Image Gen: ${status.features?.image_generation ? 'ok ($imagegen/gpt-image-2)' : status.features?.checked ? 'missing' : 'not checked'}`,
@@ -369,6 +378,25 @@ function missingRequiredFeatureFlags(flags = {}) {
369
378
  return REQUIRED_CODEX_APP_FEATURE_FLAGS.filter((name) => flags?.[name] !== true);
370
379
  }
371
380
 
381
+ function codexGitActionReadiness({ requiredFeatureFlags = {}, remoteControl = {} } = {}) {
382
+ const blockers = [];
383
+ if (requiredFeatureFlags.codex_git_commit !== true) blockers.push('codex_git_commit');
384
+ if (requiredFeatureFlags.hooks !== true) blockers.push('hooks');
385
+ if (requiredFeatureFlags.remote_control !== true) blockers.push('remote_control_feature');
386
+ if (!remoteControl?.ok) blockers.push(remoteControl?.reason || 'codex_cli_remote_control');
387
+ const ok = blockers.length === 0;
388
+ return {
389
+ ok,
390
+ blockers,
391
+ commit: ok,
392
+ push: ok,
393
+ commit_push: ok,
394
+ pull_request: ok,
395
+ required_flags: ['codex_git_commit', 'hooks', 'remote_control'],
396
+ remote_control_min_version: CODEX_REMOTE_CONTROL_MIN_VERSION
397
+ };
398
+ }
399
+
372
400
  async function codexDefaultPluginStatus(opts = {}) {
373
401
  const home = opts.home || os.homedir();
374
402
  const cwd = opts.cwd || process.cwd();
package/src/core/fsx.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
6
  import { spawn } from 'node:child_process';
7
7
 
8
- export const PACKAGE_VERSION = '0.9.1';
8
+ export const PACKAGE_VERSION = '0.9.2';
9
9
  export const DEFAULT_PROCESS_TAIL_BYTES = 256 * 1024;
10
10
  export const DEFAULT_PROCESS_TIMEOUT_MS = 30 * 60 * 1000;
11
11
 
@@ -59,6 +59,83 @@ function extractCommand(payload) {
59
59
  return payload.command || payload.tool_input?.command || payload.toolInput?.command || payload.input?.command || payload.tool?.input?.command || '';
60
60
  }
61
61
 
62
+ function codexGitActionMetadataText(payload = {}) {
63
+ const seen = new Set();
64
+ const out = [];
65
+ const interesting = new Set([
66
+ 'action',
67
+ 'intent',
68
+ 'operation',
69
+ 'permission',
70
+ 'description',
71
+ 'kind',
72
+ 'type',
73
+ 'feature',
74
+ 'tool_name',
75
+ 'toolName',
76
+ 'name',
77
+ 'label',
78
+ 'title',
79
+ 'source',
80
+ 'event',
81
+ 'hook',
82
+ 'hook_name',
83
+ 'hookName',
84
+ 'hook_event_name',
85
+ 'hookEventName',
86
+ 'id',
87
+ 'command'
88
+ ]);
89
+ const noisy = new Set([
90
+ 'prompt',
91
+ 'user_prompt',
92
+ 'userPrompt',
93
+ 'message',
94
+ 'assistant_message',
95
+ 'last_assistant_message',
96
+ 'response',
97
+ 'raw',
98
+ 'stdout',
99
+ 'stderr'
100
+ ]);
101
+ function walk(value, depth = 0, parentKey = '') {
102
+ if (!value || typeof value !== 'object' || depth > 5 || seen.has(value)) return;
103
+ seen.add(value);
104
+ for (const [key, candidate] of Object.entries(value)) {
105
+ if (noisy.has(key)) continue;
106
+ if (typeof candidate === 'string') {
107
+ if (interesting.has(key) || /\b(?:codex[_\s-]*app|git[_\s-]*actions?|codex_git_|gitCommit|gitPush|pull\s+request)\b/i.test(candidate)) {
108
+ out.push(`${key}:${candidate}`);
109
+ }
110
+ continue;
111
+ }
112
+ if (candidate && typeof candidate === 'object') {
113
+ const allowedContainer = interesting.has(key)
114
+ || /^(?:input|metadata|context|client|thread|session|request|payload|tool|tool_input|toolInput|permission_request|permissionRequest)$/i.test(key)
115
+ || parentKey;
116
+ if (allowedContainer) walk(candidate, depth + 1, key);
117
+ }
118
+ }
119
+ }
120
+ walk(payload);
121
+ return out.join(' ');
122
+ }
123
+
124
+ function codexGitActionMetadataSignal(text = '') {
125
+ const s = String(text || '');
126
+ if (!s) return false;
127
+ const action = String(s)
128
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
129
+ .replace(/[_-]+/g, ' ');
130
+ if (/\bcodex\s*app\b[\s\S]{0,120}\bgit\b[\s\S]{0,120}\b(?:action|actions|commit|push|pr|pull request)\b/i.test(action)) return true;
131
+ if (/\bgit\s*actions?\b[\s\S]{0,120}\b(?:commit|push|pr|pull request|commit\s*(?:and|&)\s*push)\b/i.test(action)) return true;
132
+ if (/\bcodex\s*git\s*(?:commit|push|pr|pull request|commit\s*(?:and|&)\s*push)\b/i.test(action)) return true;
133
+ if (/\b(?:git\s*)?(?:commit|push|commit\s*(?:and|&)\s*push|create\s+(?:a\s+)?pull\s+request|pull\s+request|pr)\b/i.test(action)) {
134
+ return /\b(?:action|intent|operation|permission|feature|tool\s*name|source|event|hook|name|label|title|type|kind|id)\s*:/i.test(action);
135
+ }
136
+ return false;
137
+ }
138
+
62
139
  function toolFailed(payload = {}) {
63
140
  const candidates = [
64
141
  payload.exit_code,
@@ -335,6 +412,7 @@ function looksLikeUserGitAction(payload = {}) {
335
412
  const command = extractCommand(payload);
336
413
  const haystack = [
337
414
  command,
415
+ codexGitActionMetadataText(payload),
338
416
  payload.action,
339
417
  payload.intent,
340
418
  payload.operation,
@@ -345,6 +423,7 @@ function looksLikeUserGitAction(payload = {}) {
345
423
  payload.toolName
346
424
  ].filter(Boolean).join(' ');
347
425
  if (/\b(?:reset\s+--hard|clean\s+-[^\s]*f|checkout\s+--|restore\s+|rm\s+|push\s+--force|push\s+-[^\s]*f)\b/i.test(command)) return false;
426
+ if (codexGitActionMetadataSignal(haystack)) return true;
348
427
  if (/\bcodex\b[\s_-]*(?:app\s*)?(?:git\s*)?(?:action|commit|push|pr)\b/i.test(haystack)) return true;
349
428
  if (!/^\s*git\s+/i.test(command)) return false;
350
429
  return /\bgit\s+(?:status|diff|add|commit|push|branch|remote|rev-parse|log)\b/i.test(command);
@@ -474,7 +553,9 @@ function explicitConversationId(payload = {}) {
474
553
 
475
554
  function looksLikeCodexGitAction(payload = {}) {
476
555
  const prompt = stripVisibleDecisionAnswerBlocks(extractUserPrompt(payload));
556
+ const metadataText = codexGitActionMetadataText(payload);
477
557
  const haystack = [
558
+ metadataText,
478
559
  payload.action,
479
560
  payload.intent,
480
561
  payload.operation,
@@ -502,9 +583,10 @@ function looksLikeCodexGitAction(payload = {}) {
502
583
  ].filter(Boolean).join(' ');
503
584
  const codexAppGitSignal = /\bcodex[_\s-]*app\b[\s\S]{0,80}\bgit\b[\s\S]{0,80}\b(?:action|actions|commit|push|pr)\b/i.test(haystack);
504
585
  const gitActionSignal = /\bgit[_\s-]*actions?\b[\s\S]{0,80}\b(?:commit|push|commit[\s_-]*(?:and|&)?[\s_-]*push)\b/i.test(haystack);
505
- const appSignal = codexAppGitSignal
586
+ const appSignal = codexGitActionMetadataSignal(metadataText)
587
+ || codexAppGitSignal
506
588
  || gitActionSignal
507
- || /\b(?:codex[_\s-]*(?:app[_\s-]*)?)?(?:git[_\s-]*)?(?:commit[_\s-]*message|git[_\s-]*commit|codex_git_commit)\b/i.test(haystack)
589
+ || /\b(?:codex[_\s-]*(?:app[_\s-]*)?)?(?:git[_\s-]*)?(?:commit[_\s-]*message|git[_\s-]*commit|git[_\s-]*push|git[_\s-]*pr|codex_git_commit|codex_git_push|codex_git_pr)\b/i.test(haystack)
508
590
  || /커밋\s*메시지\s*생성/i.test(haystack);
509
591
  const promptSignal = /\bgenerate(?:\s+a)?(?:\s+git)?\s+commit\s+message\b/i.test(prompt)
510
592
  || /\bcommit\s+message\b[\s\S]{0,80}\b(?:staged|diff|changes?|git)\b/i.test(prompt)
@@ -524,7 +606,9 @@ function looksLikeStockCodexGitActionPrompt(prompt = '') {
524
606
 
525
607
  function looksLikeCodexGitActionStopCompletion(last = '', payload = {}) {
526
608
  const text = String(last || '').trim();
609
+ const metadataText = codexGitActionMetadataText(payload);
527
610
  const haystack = [
611
+ metadataText,
528
612
  payload.action,
529
613
  payload.intent,
530
614
  payload.operation,
@@ -539,6 +623,7 @@ function looksLikeCodexGitActionStopCompletion(last = '', payload = {}) {
539
623
  payload.metadata?.feature,
540
624
  payload.metadata?.source
541
625
  ].filter(Boolean).join(' ');
626
+ if (codexGitActionMetadataSignal(metadataText)) return true;
542
627
  if (/\bcodex[_\s-]*app\b[\s\S]{0,80}\bgit\b[\s\S]{0,80}\b(?:action|commit|push|pr)\b/i.test(haystack)) return true;
543
628
  if (!text || text.length > 180) return false;
544
629
  return /^(?:commit(?:ted)?(?:\s+and\s+pushed)?(?:\s+changes)?(?:\s+complete[.!]?)?|push(?:ed)?(?:\s+changes)?(?:\s+complete[.!]?)?|created\s+(?:a\s+)?pull\s+request[.!]?)$/i.test(text);
@@ -1010,6 +1095,14 @@ export async function selftestCodexCommitHooks() {
1010
1095
  const appCommitPushStop = await runHook('stop', { conversation_id: commitPushId, last_assistant_message: 'Commit and push complete.' });
1011
1096
  if (appCommitPushStop.code !== 0) throw new Error(`selftest failed: app commit-push stop ${appCommitPushStop.code}: ${appCommitPushStop.stderr}`);
1012
1097
  if (JSON.parse(appCommitPushStop.stdout).decision === 'block') throw new Error('selftest failed: app commit-push stop bypass');
1098
+ const appPushId = 'app-push-selftest';
1099
+ const appPushHook = await runHook('user-prompt-submit', { conversation_id: appPushId, metadata: { source: 'codex_app', action: 'Git Actions Push' }, prompt: 'Push changes.' });
1100
+ if (appPushHook.code !== 0) throw new Error(`selftest failed: app push hook ${appPushHook.code}: ${appPushHook.stderr}`);
1101
+ const appPushJson = JSON.parse(appPushHook.stdout);
1102
+ if (appPushJson.decision === 'block' || appPushJson.hookSpecificOutput?.additionalContext || !String(appPushJson.systemMessage || '').includes('git action')) throw new Error('selftest failed: app push metadata route bypass');
1103
+ const appPushStop = await runHook('stop', { conversation_id: appPushId, metadata: { source: 'codex_app', action: 'Git Actions Push' }, last_assistant_message: 'Done.' });
1104
+ if (appPushStop.code !== 0) throw new Error(`selftest failed: app push stop ${appPushStop.code}: ${appPushStop.stderr}`);
1105
+ if (JSON.parse(appPushStop.stdout).decision === 'block') throw new Error('selftest failed: app push metadata stop bypass');
1013
1106
  const metadataLightId = 'metadata-light-commit-push-selftest';
1014
1107
  const metadataLightHook = await runHook('user-prompt-submit', { conversation_id: metadataLightId, prompt: 'Commit and push changes.' });
1015
1108
  if (metadataLightHook.code !== 0) throw new Error(`selftest failed: metadata-light commit-push hook ${metadataLightHook.code}: ${metadataLightHook.stderr}`);