@steipete/oracle 0.4.4 → 0.5.0

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 (52) hide show
  1. package/README.md +11 -9
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +16 -48
  4. package/dist/scripts/agent-send.js +147 -0
  5. package/dist/scripts/docs-list.js +110 -0
  6. package/dist/scripts/git-policy.js +125 -0
  7. package/dist/scripts/runner.js +1378 -0
  8. package/dist/scripts/test-browser.js +103 -0
  9. package/dist/scripts/test-remote-chrome.js +68 -0
  10. package/dist/src/browser/actions/attachments.js +47 -16
  11. package/dist/src/browser/actions/promptComposer.js +29 -18
  12. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  13. package/dist/src/browser/chromeCookies.js +37 -6
  14. package/dist/src/browser/chromeLifecycle.js +166 -25
  15. package/dist/src/browser/config.js +25 -1
  16. package/dist/src/browser/constants.js +22 -3
  17. package/dist/src/browser/index.js +301 -21
  18. package/dist/src/browser/prompt.js +3 -1
  19. package/dist/src/browser/reattach.js +59 -0
  20. package/dist/src/browser/sessionRunner.js +15 -1
  21. package/dist/src/browser/windowsCookies.js +2 -1
  22. package/dist/src/cli/browserConfig.js +11 -0
  23. package/dist/src/cli/browserDefaults.js +41 -0
  24. package/dist/src/cli/detach.js +2 -2
  25. package/dist/src/cli/dryRun.js +4 -2
  26. package/dist/src/cli/engine.js +2 -2
  27. package/dist/src/cli/help.js +2 -2
  28. package/dist/src/cli/options.js +2 -1
  29. package/dist/src/cli/runOptions.js +1 -1
  30. package/dist/src/cli/sessionDisplay.js +98 -5
  31. package/dist/src/cli/sessionRunner.js +39 -6
  32. package/dist/src/cli/tui/index.js +15 -18
  33. package/dist/src/heartbeat.js +2 -2
  34. package/dist/src/oracle/background.js +10 -2
  35. package/dist/src/oracle/client.js +17 -0
  36. package/dist/src/oracle/config.js +10 -2
  37. package/dist/src/oracle/errors.js +24 -4
  38. package/dist/src/oracle/modelResolver.js +144 -0
  39. package/dist/src/oracle/oscProgress.js +1 -1
  40. package/dist/src/oracle/run.js +82 -34
  41. package/dist/src/oracle/runUtils.js +12 -8
  42. package/dist/src/remote/server.js +214 -23
  43. package/dist/src/sessionManager.js +5 -2
  44. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  45. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  46. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  47. package/dist/vendor/oracle-notifier/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  48. package/dist/vendor/oracle-notifier/oracle-notifier/build-notifier.sh +0 -0
  49. package/package.json +47 -46
  50. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  51. package/vendor/oracle-notifier/build-notifier.sh +0 -0
  52. package/vendor/oracle-notifier/README.md +0 -24
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="MIT License"></a>
12
12
  </p>
13
13
 
14
- Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default), GPT-5.1 Codex (API-only), GPT-5.1, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation exists but is **experimental**; prefer API or `--copy` and paste into ChatGPT yourself.
14
+ Oracle bundles your prompt and files so another AI can answer with real context. It speaks GPT-5.1 Pro (default), GPT-5.1 Codex (API-only), GPT-5.1, Gemini 3 Pro, Claude Sonnet 4.5, Claude Opus 4.1, and more—and it can ask one or multiple models in a single run. Browser automation is available; API remains the most reliable path, and `--copy` is an easy manual fallback.
15
15
 
16
16
  ## Quick start
17
17
 
@@ -30,7 +30,7 @@ npx @steipete/oracle -p "Cross-check the data layer assumptions" --models gpt-5.
30
30
  # Preview without spending tokens
31
31
  npx @steipete/oracle --dry-run summary -p "Check release notes" --file docs/release-notes.md
32
32
 
33
- # Experimental browser run (no API key, will open ChatGPT)
33
+ # Browser run (no API key, will open ChatGPT)
34
34
  npx @steipete/oracle --engine browser -p "Walk through the UI smoke test" --file "src/**/*.ts"
35
35
 
36
36
  # Sessions (list and replay)
@@ -41,14 +41,14 @@ npx @steipete/oracle session <id> --render
41
41
  npx @steipete/oracle
42
42
  ```
43
43
 
44
- Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS, works on Linux with `--browser-chrome-path/--browser-cookie-path`, and is partial/experimental on Windows.
44
+ Engine auto-picks API when `OPENAI_API_KEY` is set, otherwise browser; browser is stable on macOS and works on Linux and Windows. On Linux pass `--browser-chrome-path/--browser-cookie-path` if detection fails; on Windows prefer `--browser-manual-login` or inline cookies if decryption is blocked.
45
45
 
46
46
  ## Integration
47
47
 
48
48
  **CLI**
49
49
  - API mode expects API keys in your environment: `OPENAI_API_KEY` (GPT-5.x), `GEMINI_API_KEY` (Gemini 3 Pro), `ANTHROPIC_API_KEY` (Claude Sonnet 4.5 / Opus 4.1).
50
50
  - Prefer API mode or `--copy` + manual paste; browser automation is experimental.
51
- - Browser support: stable on macOS; works on Linux with `--browser-chrome-path/--browser-cookie-path`; Windows is partial/experimental.
51
+ - Browser support: stable on macOS; works on Linux (add `--browser-chrome-path/--browser-cookie-path` when needed) and Windows (manual-login or inline cookies recommended when app-bound cookies block decryption).
52
52
  - Remote browser service: `oracle serve` on a signed-in host; clients use `--remote-host/--remote-token`.
53
53
  - AGENTS.md/CLAUDE.md:
54
54
  ```
@@ -78,7 +78,7 @@ npx -y @steipete/oracle oracle-mcp
78
78
  ## Highlights
79
79
 
80
80
  - Bundle once, reuse anywhere (API or experimental browser).
81
- - Multi-model API runs with aggregated cost/usage.
81
+ - Multi-model API runs with aggregated cost/usage, including OpenRouter IDs alongside first-party models.
82
82
  - Render/copy bundles for manual paste into ChatGPT when automation is blocked.
83
83
  - GPT‑5 Pro API runs detach by default; reattach via `oracle session <id>` / `oracle status` or block with `--wait`.
84
84
  - Azure endpoints supported via `--azure-endpoint/--azure-deployment/--azure-api-version` or `AZURE_OPENAI_*` envs.
@@ -93,10 +93,11 @@ npx -y @steipete/oracle oracle-mcp
93
93
  | `-p, --prompt <text>` | Required prompt. |
94
94
  | `-f, --file <paths...>` | Attach files/dirs (globs + `!` excludes). |
95
95
  | `-e, --engine <api\|browser>` | Choose API or browser (browser is experimental). |
96
- | `-m, --model <name>` | `gpt-5.1-pro` (default), `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex` (API-only), `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`, plus documented aliases. |
97
- | `--models <list>` | Comma-separated API models for multi-model runs. |
98
- | `--base-url <url>` | Point API runs at LiteLLM/Azure/etc. |
96
+ | `-m, --model <name>` | Built-ins (`gpt-5.1-pro` default, `gpt-5-pro`, `gpt-5.1`, `gpt-5.1-codex`, `gemini-3-pro`, `claude-4.5-sonnet`, `claude-4.1-opus`) plus any OpenRouter id (e.g., `minimax/minimax-m2`, `openai/gpt-4o-mini`). |
97
+ | `--models <list>` | Comma-separated API models (mix built-ins and OpenRouter ids) for multi-model runs. |
98
+ | `--base-url <url>` | Point API runs at LiteLLM/Azure/OpenRouter/etc. |
99
99
  | `--chatgpt-url <url>` | Target a ChatGPT workspace/folder (browser). |
100
+ | `--browser-port <port>` | Pin the Chrome DevTools port (WSL/Windows firewall helper). |
100
101
  | `--browser-inline-cookies[(-file)] <payload|path>` | Supply cookies without Chrome/Keychain (browser). |
101
102
  | `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
102
103
  | `--render`, `--copy` | Print and/or copy the assembled markdown bundle. |
@@ -152,8 +153,9 @@ oracle status --clear --hours 168
152
153
  ## More docs
153
154
  - Browser mode & forks: [docs/browser-mode.md](docs/browser-mode.md) (includes `oracle serve` remote service), [docs/chromium-forks.md](docs/chromium-forks.md), [docs/linux.md](docs/linux.md)
154
155
  - MCP: [docs/mcp.md](docs/mcp.md)
155
- - OpenAI/Azure endpoints: [docs/openai-endpoints.md](docs/openai-endpoints.md)
156
+ - OpenAI/Azure/OpenRouter endpoints: [docs/openai-endpoints.md](docs/openai-endpoints.md), [docs/openrouter.md](docs/openrouter.md)
156
157
  - Manual smokes: [docs/manual-tests.md](docs/manual-tests.md)
158
+ - Testing: [docs/testing.md](docs/testing.md)
157
159
 
158
160
  If you’re looking for an even more powerful context-management tool, check out https://repoprompt.com
159
161
  Name inspired by: https://ampcode.com/news/oracle
package/dist/.DS_Store ADDED
Binary file
@@ -15,7 +15,8 @@ import { shouldRequirePrompt } from '../src/cli/promptRequirement.js';
15
15
  import chalk from 'chalk';
16
16
  import { sessionStore, pruneOldSessions } from '../src/sessionStore.js';
17
17
  import { DEFAULT_MODEL, MODEL_CONFIGS, readFiles, estimateRequestTokens, buildRequestBody } from '../src/oracle.js';
18
- import { CHATGPT_URL, normalizeChatgptUrl } from '../src/browserMode.js';
18
+ import { isKnownModel } from '../src/oracle/modelResolver.js';
19
+ import { CHATGPT_URL } from '../src/browserMode.js';
19
20
  import { createRemoteBrowserExecutor } from '../src/remote/client.js';
20
21
  import { applyHelpStyling } from '../src/cli/help.js';
21
22
  import { collectPaths, collectModelList, parseFloatOption, parseIntOption, parseSearchOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, mergePathLikeOptions, } from '../src/cli/options.js';
@@ -41,6 +42,7 @@ import { runDryRunSummary, runBrowserPreview } from '../src/cli/dryRun.js';
41
42
  import { launchTui } from '../src/cli/tui/index.js';
42
43
  import { resolveNotificationSettings, deriveNotificationSettingsFromMetadata, } from '../src/cli/notifier.js';
43
44
  import { loadUserConfig } from '../src/config.js';
45
+ import { applyBrowserDefaultsFromConfig } from '../src/cli/browserDefaults.js';
44
46
  import { shouldBlockDuplicatePrompt } from '../src/cli/duplicatePromptGuard.js';
45
47
  const VERSION = getCliVersion();
46
48
  const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
@@ -160,10 +162,14 @@ program
160
162
  .addOption(new Option('--browser-url <url>', `Alias for --chatgpt-url (default ${CHATGPT_URL}).`).hideHelp())
161
163
  .addOption(new Option('--browser-timeout <ms|s|m>', 'Maximum time to wait for an answer (default 1200s / 20m).').hideHelp())
162
164
  .addOption(new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 30s).').hideHelp())
165
+ .addOption(new Option('--browser-port <port>', 'Use a fixed Chrome DevTools port (helpful on WSL firewalls).')
166
+ .argParser(parseIntOption))
167
+ .addOption(new Option('--browser-debug-port <port>', '(alias) Use a fixed Chrome DevTools port.').argParser(parseIntOption).hideHelp())
163
168
  .addOption(new Option('--browser-cookie-names <names>', 'Comma-separated cookie allowlist for sync.').hideHelp())
164
169
  .addOption(new Option('--browser-inline-cookies <jsonOrBase64>', 'Inline cookies payload (JSON array or base64-encoded JSON).').hideHelp())
165
170
  .addOption(new Option('--browser-inline-cookies-file <path>', 'Load inline cookies from file (JSON or base64 JSON).').hideHelp())
166
171
  .addOption(new Option('--browser-no-cookie-sync', 'Skip copying cookies from Chrome.').hideHelp())
172
+ .addOption(new Option('--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and wait for manual ChatGPT login.').hideHelp())
167
173
  .addOption(new Option('--browser-headless', 'Launch Chrome in headless mode.').hideHelp())
168
174
  .addOption(new Option('--browser-hide-window', 'Hide the Chrome window after launch (macOS headful only).').hideHelp())
169
175
  .addOption(new Option('--browser-keep-browser', 'Keep Chrome running after completion.').hideHelp())
@@ -363,7 +369,7 @@ function getBrowserConfigFromMetadata(metadata) {
363
369
  async function runRootCommand(options) {
364
370
  if (process.env.ORACLE_FORCE_TUI === '1') {
365
371
  await sessionStore.ensureStorage();
366
- await launchTui({ version: VERSION });
372
+ await launchTui({ version: VERSION, printIntro: false });
367
373
  return;
368
374
  }
369
375
  const userConfig = (await loadUserConfig()).config;
@@ -417,7 +423,7 @@ async function runRootCommand(options) {
417
423
  }
418
424
  if (userCliArgs.length === 0) {
419
425
  if (tuiEnabled()) {
420
- await launchTui({ version: VERSION });
426
+ await launchTui({ version: VERSION, printIntro: false });
421
427
  return;
422
428
  }
423
429
  console.log(chalk.yellow('No prompt or subcommand supplied. See `oracle --help` for usage.'));
@@ -528,7 +534,9 @@ async function runRootCommand(options) {
528
534
  const resolvedModel = normalizedMultiModels[0] ?? (isGemini ? resolveApiModel(cliModelArg) : resolvedModelCandidate);
529
535
  const effectiveModelId = resolvedModel.startsWith('gemini')
530
536
  ? resolveGeminiModelId(resolvedModel)
531
- : MODEL_CONFIGS[resolvedModel]?.apiModel ?? resolvedModel;
537
+ : isKnownModel(resolvedModel)
538
+ ? MODEL_CONFIGS[resolvedModel].apiModel ?? resolvedModel
539
+ : resolvedModel;
532
540
  const resolvedBaseUrl = normalizeBaseUrl(options.baseUrl ?? (isClaude ? process.env.ANTHROPIC_BASE_URL : process.env.OPENAI_BASE_URL));
533
541
  const { models: _rawModels, ...optionsWithoutModels } = options;
534
542
  const resolvedOptions = { ...optionsWithoutModels, model: resolvedModel };
@@ -566,7 +574,7 @@ async function runRootCommand(options) {
566
574
  throw new Error('Prompt is required when using --render-markdown or --copy-markdown.');
567
575
  }
568
576
  const bundle = await buildMarkdownBundle({ prompt: options.prompt, file: options.file, system: options.system }, { cwd: process.cwd() });
569
- const modelConfig = MODEL_CONFIGS[resolvedModel];
577
+ const modelConfig = isKnownModel(resolvedModel) ? MODEL_CONFIGS[resolvedModel] : MODEL_CONFIGS['gpt-5.1'];
570
578
  const requestBody = buildRequestBody({
571
579
  modelConfig,
572
580
  systemPrompt: bundle.systemPrompt,
@@ -658,7 +666,8 @@ async function runRootCommand(options) {
658
666
  if (options.file && options.file.length > 0) {
659
667
  await readFiles(options.file, { cwd: process.cwd() });
660
668
  }
661
- applyBrowserDefaultsFromConfig(options, userConfig);
669
+ const getSource = (key) => program.getOptionValueSource?.(key) ?? undefined;
670
+ applyBrowserDefaultsFromConfig(options, userConfig, getSource);
662
671
  const notifications = resolveNotificationSettings({
663
672
  cliNotify: options.notify,
664
673
  cliNotifySound: options.notifySound,
@@ -878,6 +887,7 @@ function printDebugHelp(cliName) {
878
887
  ['--browser-timeout <ms|s|m>', 'Cap total wait time for the assistant response.'],
879
888
  ['--browser-input-timeout <ms|s|m>', 'Cap how long we wait for the composer textarea.'],
880
889
  ['--browser-no-cookie-sync', 'Skip copying cookies from your main profile.'],
890
+ ['--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and log in manually.'],
881
891
  ['--browser-headless', 'Launch Chrome in headless mode.'],
882
892
  ['--browser-hide-window', 'Hide the Chrome window (macOS headful only).'],
883
893
  ['--browser-keep-browser', 'Leave Chrome running after completion.'],
@@ -899,48 +909,6 @@ function resolveWaitFlag({ waitFlag, noWaitFlag, model, engine, }) {
899
909
  return false;
900
910
  return defaultWaitPreference(model, engine);
901
911
  }
902
- function applyBrowserDefaultsFromConfig(options, config) {
903
- const browser = config.browser;
904
- if (!browser)
905
- return;
906
- const source = (key) => program.getOptionValueSource?.(key);
907
- const configuredChatgptUrl = browser.chatgptUrl ?? browser.url;
908
- if (source('chatgptUrl') === 'default' && configuredChatgptUrl !== undefined) {
909
- try {
910
- options.chatgptUrl = normalizeChatgptUrl(configuredChatgptUrl ?? '', CHATGPT_URL);
911
- }
912
- catch (error) {
913
- throw error instanceof Error ? error : new Error(String(error));
914
- }
915
- }
916
- if (source('browserChromeProfile') === 'default' && browser.chromeProfile !== undefined) {
917
- options.browserChromeProfile = browser.chromeProfile ?? undefined;
918
- }
919
- if (source('browserChromePath') === 'default' && browser.chromePath !== undefined) {
920
- options.browserChromePath = browser.chromePath ?? undefined;
921
- }
922
- if (source('browserCookiePath') === 'default' && browser.chromeCookiePath !== undefined) {
923
- options.browserCookiePath = browser.chromeCookiePath ?? undefined;
924
- }
925
- if (source('browserUrl') === 'default' && browser.url !== undefined) {
926
- options.browserUrl = browser.url;
927
- }
928
- if (source('browserTimeout') === 'default' && typeof browser.timeoutMs === 'number') {
929
- options.browserTimeout = String(browser.timeoutMs);
930
- }
931
- if (source('browserInputTimeout') === 'default' && typeof browser.inputTimeoutMs === 'number') {
932
- options.browserInputTimeout = String(browser.inputTimeoutMs);
933
- }
934
- if (source('browserHeadless') === 'default' && browser.headless !== undefined) {
935
- options.browserHeadless = browser.headless;
936
- }
937
- if (source('browserHideWindow') === 'default' && browser.hideWindow !== undefined) {
938
- options.browserHideWindow = browser.hideWindow;
939
- }
940
- if (source('browserKeepBrowser') === 'default' && browser.keepBrowser !== undefined) {
941
- options.browserKeepBrowser = browser.keepBrowser;
942
- }
943
- }
944
912
  program.action(async function () {
945
913
  const options = this.optsWithGlobals();
946
914
  await runRootCommand(options);
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+ // @ts-nocheck
3
+ /**
4
+ * Lightweight helper to send a one-off message to a tmux-based agent session.
5
+ *
6
+ * Usage:
7
+ * bun scripts/agent-send.ts --session claude-haiku -- "/model"
8
+ *
9
+ * Options:
10
+ * --session NAME Target tmux session (or session:window.pane)
11
+ * --entry single|double|none How many Enter keys to send (default single)
12
+ * --escape Send ESC before typing (to interrupt/resume)
13
+ * --wait-ms N Extra wait (ms) after typing before Enter
14
+ */
15
+ import { spawnSync } from 'node:child_process';
16
+ import { sleepSync } from 'bun';
17
+ function usage(message) {
18
+ if (message) {
19
+ console.error(`Error: ${message}`);
20
+ }
21
+ console.error(`\
22
+ Usage: bun scripts/agent-send.ts --session <name[:window[.pane]]> [--entry single|double|none] [--escape] [--wait-ms N] -- "<message>"
23
+
24
+ Examples:
25
+ bun scripts/agent-send.ts --session claude-haiku -- "/model"
26
+ bun scripts/agent-send.ts --session ma-worker-1 --escape --entry double -- "Continue and focus on API routes"
27
+ `);
28
+ process.exit(1);
29
+ }
30
+ function parseArgs(argv) {
31
+ let session;
32
+ let entry = 'single';
33
+ let shouldEscape = false;
34
+ let waitMs = 400;
35
+ const literalSeparator = argv.indexOf('--');
36
+ const optionPart = literalSeparator === -1 ? argv : argv.slice(0, literalSeparator);
37
+ const literalPart = literalSeparator === -1 ? [] : argv.slice(literalSeparator + 1);
38
+ for (let i = 0; i < optionPart.length; i += 1) {
39
+ const token = optionPart[i];
40
+ if (!token.startsWith('--')) {
41
+ usage(`Unexpected argument: ${token}`);
42
+ }
43
+ const key = token.slice(2);
44
+ switch (key) {
45
+ case 'session': {
46
+ const value = optionPart[i + 1];
47
+ if (!value)
48
+ usage('--session requires a value');
49
+ session = value;
50
+ i += 1;
51
+ break;
52
+ }
53
+ case 'entry': {
54
+ const value = optionPart[i + 1];
55
+ if (value !== 'single' && value !== 'double' && value !== 'none') {
56
+ usage(`Unknown entry mode: ${value}`);
57
+ }
58
+ entry = value;
59
+ i += 1;
60
+ break;
61
+ }
62
+ case 'escape': {
63
+ shouldEscape = true;
64
+ break;
65
+ }
66
+ case 'wait-ms': {
67
+ const value = optionPart[i + 1];
68
+ if (!value || Number.isNaN(Number.parseInt(value, 10))) {
69
+ usage('--wait-ms requires an integer value');
70
+ }
71
+ waitMs = Number.parseInt(value, 10);
72
+ i += 1;
73
+ break;
74
+ }
75
+ default:
76
+ usage(`Unknown option: --${key}`);
77
+ }
78
+ }
79
+ const message = literalPart.join(' ').trim();
80
+ if (!session)
81
+ usage('Missing --session');
82
+ if (!message)
83
+ usage('Missing message (provide text after -- separator)');
84
+ return { session, entry, escape: shouldEscape, waitMs, message };
85
+ }
86
+ function runTmux(args, allowFailure = false) {
87
+ const result = spawnSync('tmux', args, { encoding: 'utf8' });
88
+ if (result.error) {
89
+ if (allowFailure)
90
+ return '';
91
+ throw result.error;
92
+ }
93
+ if (result.status !== 0) {
94
+ if (allowFailure)
95
+ return result.stderr?.trim() ?? '';
96
+ throw new Error(`tmux ${args.join(' ')} failed: ${result.stderr?.trim()}`);
97
+ }
98
+ return result.stdout?.trimEnd() ?? '';
99
+ }
100
+ function ensureSession(target) {
101
+ const session = target.split(':')[0] ?? target;
102
+ const result = spawnSync('tmux', ['has-session', '-t', session]);
103
+ if (result.status !== 0) {
104
+ usage(`tmux session '${session}' not found. Start it first (e.g., tmux new-session -s ${session} ...)`);
105
+ }
106
+ }
107
+ function sendMessage(options) {
108
+ ensureSession(options.session);
109
+ if (options.escape) {
110
+ runTmux(['send-keys', '-t', options.session, 'Escape'], true);
111
+ sleepSync(200);
112
+ }
113
+ // Clear existing prompt
114
+ runTmux(['send-keys', '-t', options.session, 'Escape'], true);
115
+ sleepSync(120);
116
+ runTmux(['send-keys', '-t', options.session, 'C-u'], true);
117
+ sleepSync(120);
118
+ // Type the message
119
+ runTmux(['send-keys', '-t', options.session, '-l', options.message], true);
120
+ sleepSync(Math.max(120, options.waitMs));
121
+ // Send Enter(s)
122
+ const pressEnter = () => runTmux(['send-keys', '-t', options.session, 'C-m'], true);
123
+ switch (options.entry) {
124
+ case 'single':
125
+ pressEnter();
126
+ break;
127
+ case 'double':
128
+ pressEnter();
129
+ sleepSync(200);
130
+ pressEnter();
131
+ break;
132
+ case 'none':
133
+ break;
134
+ default:
135
+ usage(`Unsupported entry mode: ${options.entry}`);
136
+ }
137
+ sleepSync(600);
138
+ const tail = runTmux(['capture-pane', '-pt', options.session, '-S', '-6'], true);
139
+ console.log(tail);
140
+ }
141
+ try {
142
+ const options = parseArgs(process.argv.slice(2));
143
+ sendMessage(options);
144
+ }
145
+ catch (error) {
146
+ usage(error instanceof Error ? error.message : String(error));
147
+ }
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env tsx
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { dirname, join, relative } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { compact } from 'es-toolkit';
6
+ const docsListFile = fileURLToPath(import.meta.url);
7
+ const docsListDir = dirname(docsListFile);
8
+ const DOCS_DIR = join(docsListDir, '..', 'docs');
9
+ const EXCLUDED_DIRS = new Set(['archive', 'research']);
10
+ function walkMarkdownFiles(dir, base = dir) {
11
+ const entries = readdirSync(dir, { withFileTypes: true });
12
+ const files = [];
13
+ for (const entry of entries) {
14
+ if (entry.name.startsWith('.')) {
15
+ continue;
16
+ }
17
+ const fullPath = join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ if (EXCLUDED_DIRS.has(entry.name)) {
20
+ continue;
21
+ }
22
+ files.push(...walkMarkdownFiles(fullPath, base));
23
+ }
24
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
25
+ files.push(relative(base, fullPath));
26
+ }
27
+ }
28
+ return files.sort((a, b) => a.localeCompare(b));
29
+ }
30
+ function extractMetadata(fullPath) {
31
+ const content = readFileSync(fullPath, 'utf8');
32
+ if (!content.startsWith('---')) {
33
+ return { summary: null, readWhen: [], error: 'missing front matter' };
34
+ }
35
+ const endIndex = content.indexOf('\n---', 3);
36
+ if (endIndex === -1) {
37
+ return { summary: null, readWhen: [], error: 'unterminated front matter' };
38
+ }
39
+ const frontMatter = content.slice(3, endIndex).trim();
40
+ const lines = frontMatter.split('\n');
41
+ let summaryLine = null;
42
+ const readWhen = [];
43
+ let collectingField = null;
44
+ for (const rawLine of lines) {
45
+ const line = rawLine.trim();
46
+ if (line.startsWith('summary:')) {
47
+ summaryLine = line;
48
+ collectingField = null;
49
+ continue;
50
+ }
51
+ if (line.startsWith('read_when:')) {
52
+ collectingField = 'read_when';
53
+ const inline = line.slice('read_when:'.length).trim();
54
+ if (inline.startsWith('[') && inline.endsWith(']')) {
55
+ try {
56
+ const parsed = JSON.parse(inline.replace(/'/g, '"'));
57
+ if (Array.isArray(parsed)) {
58
+ readWhen.push(...compact(parsed.map((item) => String(item).trim())));
59
+ }
60
+ }
61
+ catch {
62
+ // ignore malformed inline arrays
63
+ }
64
+ }
65
+ continue;
66
+ }
67
+ if (collectingField === 'read_when') {
68
+ if (line.startsWith('- ')) {
69
+ const hint = line.slice(2).trim();
70
+ if (hint) {
71
+ readWhen.push(hint);
72
+ }
73
+ }
74
+ else if (line === '') {
75
+ }
76
+ else {
77
+ collectingField = null;
78
+ }
79
+ }
80
+ }
81
+ if (!summaryLine) {
82
+ return { summary: null, readWhen, error: 'summary key missing' };
83
+ }
84
+ const summaryValue = summaryLine.slice('summary:'.length).trim();
85
+ const normalized = summaryValue
86
+ .replace(/^['"]|['"]$/g, '')
87
+ .replace(/\s+/g, ' ')
88
+ .trim();
89
+ if (!normalized) {
90
+ return { summary: null, readWhen, error: 'summary is empty' };
91
+ }
92
+ return { summary: normalized, readWhen };
93
+ }
94
+ console.log('Listing all markdown files in docs folder:');
95
+ const markdownFiles = walkMarkdownFiles(DOCS_DIR);
96
+ for (const relativePath of markdownFiles) {
97
+ const fullPath = join(DOCS_DIR, relativePath);
98
+ const { summary, readWhen, error } = extractMetadata(fullPath);
99
+ if (summary) {
100
+ console.log(`${relativePath} - ${summary}`);
101
+ if (readWhen.length > 0) {
102
+ console.log(` Read when: ${readWhen.join('; ')}`);
103
+ }
104
+ }
105
+ else {
106
+ const reason = error ? ` - [${error}]` : '';
107
+ console.log(`${relativePath}${reason}`);
108
+ }
109
+ }
110
+ console.log('\nReminder: keep docs up to date as behavior changes. When your task matches any "Read when" hint above (React hooks, cache directives, database work, tests, etc.), read that doc before coding, and suggest new coverage when it is missing.');
@@ -0,0 +1,125 @@
1
+ import { resolve } from 'node:path';
2
+ const COMMIT_HELPER_SUBCOMMANDS = new Set(['add', 'commit']);
3
+ const GUARDED_SUBCOMMANDS = new Set(['push', 'pull', 'merge', 'rebase', 'cherry-pick']);
4
+ const DESTRUCTIVE_SUBCOMMANDS = new Set([
5
+ 'reset',
6
+ 'checkout',
7
+ 'clean',
8
+ 'restore',
9
+ 'switch',
10
+ 'stash',
11
+ 'branch',
12
+ 'filter-branch',
13
+ 'fast-import',
14
+ ]);
15
+ export function extractGitInvocation(commandArgs) {
16
+ for (const [index, token] of commandArgs.entries()) {
17
+ if (token === 'git' || token.endsWith('/git')) {
18
+ return { index, argv: commandArgs.slice(index) };
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+ export function findGitSubcommand(commandArgs) {
24
+ if (commandArgs.length <= 1) {
25
+ return null;
26
+ }
27
+ const optionsWithValue = new Set(['-C', '--git-dir', '--work-tree', '-c']);
28
+ let index = 1;
29
+ while (index < commandArgs.length) {
30
+ const token = commandArgs[index];
31
+ if (token === undefined) {
32
+ break;
33
+ }
34
+ if (token === '--') {
35
+ const next = commandArgs[index + 1];
36
+ return next ? { name: next, index: index + 1 } : null;
37
+ }
38
+ if (!token.startsWith('-')) {
39
+ return { name: token, index };
40
+ }
41
+ if (token.includes('=')) {
42
+ index += 1;
43
+ continue;
44
+ }
45
+ if (optionsWithValue.has(token)) {
46
+ index += 2;
47
+ continue;
48
+ }
49
+ index += 1;
50
+ }
51
+ return null;
52
+ }
53
+ export function determineGitWorkdir(baseDir, gitArgs, command) {
54
+ let workDir = baseDir;
55
+ const limit = command ? command.index : gitArgs.length;
56
+ let index = 1;
57
+ while (index < limit) {
58
+ const token = gitArgs[index];
59
+ if (token === undefined) {
60
+ break;
61
+ }
62
+ if (token === '-C') {
63
+ const next = gitArgs[index + 1];
64
+ if (next) {
65
+ workDir = resolve(workDir, next);
66
+ }
67
+ index += 2;
68
+ continue;
69
+ }
70
+ if (token.startsWith('-C')) {
71
+ const pathSegment = token.slice(2);
72
+ if (pathSegment.length > 0) {
73
+ workDir = resolve(workDir, pathSegment);
74
+ }
75
+ }
76
+ index += 1;
77
+ }
78
+ return workDir;
79
+ }
80
+ export function analyzeGitExecution(commandArgs, workspaceDir) {
81
+ const invocation = extractGitInvocation(commandArgs);
82
+ const command = invocation ? findGitSubcommand(invocation.argv) : null;
83
+ const workDir = invocation ? determineGitWorkdir(workspaceDir, invocation.argv, command) : workspaceDir;
84
+ return {
85
+ invocation,
86
+ command,
87
+ subcommand: command?.name ?? null,
88
+ workDir,
89
+ };
90
+ }
91
+ export function requiresCommitHelper(subcommand) {
92
+ if (!subcommand) {
93
+ return false;
94
+ }
95
+ return COMMIT_HELPER_SUBCOMMANDS.has(subcommand);
96
+ }
97
+ export function requiresExplicitGitConsent(subcommand) {
98
+ if (!subcommand) {
99
+ return false;
100
+ }
101
+ return GUARDED_SUBCOMMANDS.has(subcommand);
102
+ }
103
+ export function isDestructiveGitSubcommand(command, gitArgv) {
104
+ if (!command) {
105
+ return false;
106
+ }
107
+ const subcommand = command.name;
108
+ if (DESTRUCTIVE_SUBCOMMANDS.has(subcommand)) {
109
+ return true;
110
+ }
111
+ if (subcommand === 'bisect') {
112
+ const action = gitArgv[command.index + 1] ?? '';
113
+ return action === 'reset';
114
+ }
115
+ return false;
116
+ }
117
+ export function evaluateGitPolicies(context) {
118
+ const invocationArgv = context.invocation?.argv;
119
+ const normalizedArgv = Array.isArray(invocationArgv) ? invocationArgv : [];
120
+ return {
121
+ requiresCommitHelper: requiresCommitHelper(context.subcommand),
122
+ requiresExplicitConsent: requiresExplicitGitConsent(context.subcommand),
123
+ isDestructive: isDestructiveGitSubcommand(context.command, normalizedArgv),
124
+ };
125
+ }