oh-my-codex 0.17.3 → 0.18.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 (158) hide show
  1. package/Cargo.lock +13 -5
  2. package/Cargo.toml +2 -1
  3. package/README.md +1 -0
  4. package/crates/omx-api/Cargo.toml +19 -0
  5. package/crates/omx-api/src/lib.rs +2940 -0
  6. package/crates/omx-api/src/main.rs +10 -0
  7. package/crates/omx-api/tests/cli.rs +558 -0
  8. package/crates/omx-explore/src/main.rs +4 -0
  9. package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
  10. package/crates/omx-sparkshell/src/exec.rs +4 -0
  11. package/crates/omx-sparkshell/src/main.rs +738 -29
  12. package/crates/omx-sparkshell/src/prompt.rs +25 -3
  13. package/crates/omx-sparkshell/src/redaction.rs +241 -0
  14. package/crates/omx-sparkshell/tests/execution.rs +479 -238
  15. package/dist/cli/__tests__/api.test.d.ts +2 -0
  16. package/dist/cli/__tests__/api.test.d.ts.map +1 -0
  17. package/dist/cli/__tests__/api.test.js +175 -0
  18. package/dist/cli/__tests__/api.test.js.map +1 -0
  19. package/dist/cli/__tests__/ask.test.js +72 -5
  20. package/dist/cli/__tests__/ask.test.js.map +1 -1
  21. package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
  22. package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
  23. package/dist/cli/__tests__/explore.test.js +23 -0
  24. package/dist/cli/__tests__/explore.test.js.map +1 -1
  25. package/dist/cli/__tests__/index.test.js +123 -5
  26. package/dist/cli/__tests__/index.test.js.map +1 -1
  27. package/dist/cli/__tests__/launch-fallback.test.js +76 -0
  28. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  29. package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
  30. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  31. package/dist/cli/__tests__/setup-install-mode.test.js +138 -0
  32. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  33. package/dist/cli/__tests__/sparkshell-cli.test.js +5 -0
  34. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  35. package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
  36. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  37. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
  38. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  39. package/dist/cli/api.d.ts +26 -0
  40. package/dist/cli/api.d.ts.map +1 -0
  41. package/dist/cli/api.js +153 -0
  42. package/dist/cli/api.js.map +1 -0
  43. package/dist/cli/explore.d.ts +2 -0
  44. package/dist/cli/explore.d.ts.map +1 -1
  45. package/dist/cli/explore.js +43 -1
  46. package/dist/cli/explore.js.map +1 -1
  47. package/dist/cli/index.d.ts +10 -4
  48. package/dist/cli/index.d.ts.map +1 -1
  49. package/dist/cli/index.js +128 -10
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/cli/native-assets.d.ts +2 -1
  52. package/dist/cli/native-assets.d.ts.map +1 -1
  53. package/dist/cli/native-assets.js +1 -0
  54. package/dist/cli/native-assets.js.map +1 -1
  55. package/dist/cli/sparkshell.d.ts.map +1 -1
  56. package/dist/cli/sparkshell.js +20 -3
  57. package/dist/cli/sparkshell.js.map +1 -1
  58. package/dist/config/generator.d.ts.map +1 -1
  59. package/dist/config/generator.js +90 -0
  60. package/dist/config/generator.js.map +1 -1
  61. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
  62. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
  63. package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
  64. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
  65. package/dist/hooks/__tests__/keyword-detector.test.js +11 -0
  66. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  67. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
  68. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  69. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
  70. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  71. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  72. package/dist/hooks/keyword-registry.js +1 -0
  73. package/dist/hooks/keyword-registry.js.map +1 -1
  74. package/dist/hud/__tests__/reconcile.test.js +2 -2
  75. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  76. package/dist/hud/__tests__/tmux.test.js +23 -18
  77. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  78. package/dist/hud/tmux.d.ts.map +1 -1
  79. package/dist/hud/tmux.js +7 -6
  80. package/dist/hud/tmux.js.map +1 -1
  81. package/dist/mcp/__tests__/bootstrap.test.js +75 -1
  82. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  83. package/dist/mcp/bootstrap.d.ts +3 -1
  84. package/dist/mcp/bootstrap.d.ts.map +1 -1
  85. package/dist/mcp/bootstrap.js +71 -2
  86. package/dist/mcp/bootstrap.js.map +1 -1
  87. package/dist/scripts/__tests__/codex-native-hook.test.js +737 -26
  88. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  89. package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
  90. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  91. package/dist/scripts/__tests__/smoke-packed-install.test.js +4 -1
  92. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  93. package/dist/scripts/build-api.d.ts +2 -0
  94. package/dist/scripts/build-api.d.ts.map +1 -0
  95. package/dist/scripts/build-api.js +44 -0
  96. package/dist/scripts/build-api.js.map +1 -0
  97. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  98. package/dist/scripts/codex-native-hook.js +208 -8
  99. package/dist/scripts/codex-native-hook.js.map +1 -1
  100. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  101. package/dist/scripts/codex-native-pre-post.js +89 -24
  102. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  103. package/dist/scripts/notify-dispatcher.js +88 -0
  104. package/dist/scripts/notify-dispatcher.js.map +1 -1
  105. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  106. package/dist/scripts/notify-hook/team-dispatch.js +27 -9
  107. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  108. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  109. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
  110. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  111. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +1 -0
  112. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  113. package/dist/scripts/notify-hook/team-tmux-guard.js +38 -0
  114. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  115. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  116. package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
  117. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  118. package/dist/scripts/run-provider-advisor.js +9 -3
  119. package/dist/scripts/run-provider-advisor.js.map +1 -1
  120. package/dist/scripts/smoke-packed-install.d.ts +1 -1
  121. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  122. package/dist/scripts/smoke-packed-install.js +2 -0
  123. package/dist/scripts/smoke-packed-install.js.map +1 -1
  124. package/dist/team/__tests__/runtime.test.js +2 -2
  125. package/dist/team/__tests__/runtime.test.js.map +1 -1
  126. package/dist/team/__tests__/tmux-session.test.js +96 -19
  127. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  128. package/dist/team/tmux-session.d.ts +1 -0
  129. package/dist/team/tmux-session.d.ts.map +1 -1
  130. package/dist/team/tmux-session.js +34 -10
  131. package/dist/team/tmux-session.js.map +1 -1
  132. package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
  133. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  134. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
  135. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  136. package/package.json +4 -3
  137. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  138. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
  139. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +1 -0
  140. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +1 -1
  141. package/prompts/researcher.md +15 -10
  142. package/skills/best-practice-research/SKILL.md +83 -0
  143. package/skills/deep-interview/SKILL.md +1 -0
  144. package/skills/ralplan/SKILL.md +1 -1
  145. package/src/scripts/__tests__/codex-native-hook.test.ts +810 -4
  146. package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
  147. package/src/scripts/__tests__/smoke-packed-install.test.ts +8 -2
  148. package/src/scripts/build-api.ts +48 -0
  149. package/src/scripts/codex-native-hook.ts +262 -10
  150. package/src/scripts/codex-native-pre-post.ts +103 -24
  151. package/src/scripts/notify-dispatcher.ts +97 -0
  152. package/src/scripts/notify-hook/team-dispatch.ts +27 -8
  153. package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
  154. package/src/scripts/notify-hook/team-tmux-guard.ts +42 -0
  155. package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
  156. package/src/scripts/run-provider-advisor.ts +11 -3
  157. package/src/scripts/smoke-packed-install.ts +2 -0
  158. package/templates/catalog-manifest.json +7 -0
@@ -2,28 +2,70 @@ mod codex_bridge;
2
2
  mod error;
3
3
  mod exec;
4
4
  mod prompt;
5
+ mod redaction;
5
6
  #[cfg(test)]
6
7
  mod test_support;
7
8
  mod threshold;
8
9
 
9
10
  use crate::codex_bridge::summarize_output;
10
11
  use crate::error::SparkshellError;
11
- use crate::exec::execute_command;
12
+ use crate::exec::{execute_command, execute_shell_command, CommandOutput};
13
+ use crate::redaction::redact_output;
12
14
  use crate::threshold::{combined_visible_lines, read_line_threshold};
13
15
  use omx_mux::build_capture_pane_args;
16
+ use std::collections::hash_map::DefaultHasher;
17
+ use std::fs;
18
+ use std::hash::{Hash, Hasher};
14
19
  use std::io::{self, Write};
20
+ use std::path::{Path, PathBuf};
15
21
  use std::process;
22
+ use std::time::{SystemTime, UNIX_EPOCH};
16
23
 
17
24
  const DEFAULT_TMUX_TAIL_LINES: usize = 200;
18
25
  const MIN_TMUX_TAIL_LINES: usize = 100;
19
26
  const MAX_TMUX_TAIL_LINES: usize = 1000;
20
27
 
21
28
  #[derive(Debug, Clone, PartialEq, Eq)]
22
- enum SparkShellInput {
29
+ enum SparkShellTarget {
23
30
  Command(Vec<String>),
31
+ Shell(String),
24
32
  TmuxPane { pane_id: String, tail_lines: usize },
25
33
  }
26
34
 
35
+ #[derive(Debug, Clone, PartialEq, Eq)]
36
+ struct SparkShellOptions {
37
+ target: SparkShellTarget,
38
+ json: bool,
39
+ budget: usize,
40
+ team: Option<String>,
41
+ worker: Option<String>,
42
+ since_last: bool,
43
+ cache: bool,
44
+ cache_ttl_ms: u64,
45
+ }
46
+
47
+ #[derive(Debug, Clone)]
48
+ struct Evidence {
49
+ stdout_lines: usize,
50
+ stderr_lines: usize,
51
+ raw_hash: String,
52
+ pane_id: Option<String>,
53
+ tail_lines: Option<usize>,
54
+ line_range: Option<String>,
55
+ }
56
+
57
+ #[derive(Debug, Clone)]
58
+ struct CacheMeta {
59
+ cache_hit: bool,
60
+ previous_hash: Option<String>,
61
+ current_hash: String,
62
+ changed_line_ranges: Vec<String>,
63
+ }
64
+
65
+ const DEFAULT_BUDGET: usize = 1000;
66
+ const STALE_HEARTBEAT_MS: u64 = 120_000;
67
+ const DEFAULT_CACHE_TTL_MS: u64 = 10 * 60 * 1000;
68
+
27
69
  fn main() {
28
70
  let args: Vec<String> = std::env::args().skip(1).collect();
29
71
  if args
@@ -40,31 +82,69 @@ fn main() {
40
82
  }
41
83
 
42
84
  fn run(args: Vec<String>) -> Result<(), SparkshellError> {
43
- let execution_argv = match parse_input(&args)? {
44
- SparkShellInput::Command(command) => command,
45
- SparkShellInput::TmuxPane {
85
+ let options = parse_input(&args)?;
86
+ let execution_argv = match &options.target {
87
+ SparkShellTarget::Command(command) => command.clone(),
88
+ SparkShellTarget::Shell(script) => {
89
+ vec!["bash".to_string(), "-lc".to_string(), script.clone()]
90
+ }
91
+ SparkShellTarget::TmuxPane {
46
92
  pane_id,
47
93
  tail_lines,
48
94
  } => {
49
95
  let mut argv = vec!["tmux".to_string()];
50
- argv.extend(build_capture_pane_args(&pane_id, tail_lines));
96
+ argv.extend(build_capture_pane_args(pane_id, *tail_lines));
51
97
  argv
52
98
  }
53
99
  };
54
100
 
55
- let output = execute_command(&execution_argv)?;
101
+ let raw_output = match &options.target {
102
+ SparkShellTarget::Shell(script) => execute_shell_command(script)?,
103
+ _ => execute_command(&execution_argv)?,
104
+ };
105
+ let redacted = redact_output(&raw_output);
106
+ let output = if options.json {
107
+ &redacted.output
108
+ } else {
109
+ &raw_output
110
+ };
111
+ let summary_output = &redacted.output;
56
112
  let threshold = read_line_threshold();
57
113
  let line_count = combined_visible_lines(&output.stdout, &output.stderr);
114
+ let evidence = build_evidence(&options, output);
115
+ let cache_meta = handle_cache(&options, output, &evidence.raw_hash)?;
116
+
117
+ if options.json {
118
+ let summary = if options.since_last {
119
+ since_last_summary(output, cache_meta.as_ref(), options.budget)
120
+ } else if line_count <= threshold {
121
+ compact_text(&combined_text(output), options.budget)
122
+ } else if cache_meta.as_ref().is_some_and(|meta| meta.cache_hit) {
123
+ "unchanged since previous observation".to_string()
124
+ } else {
125
+ summarize_output(&execution_argv, output)
126
+ .unwrap_or_else(|error| format!("summary unavailable: {error}"))
127
+ };
128
+ write_json_report(
129
+ &options,
130
+ output,
131
+ &summary,
132
+ &evidence,
133
+ cache_meta,
134
+ redacted.count,
135
+ )?;
136
+ process::exit(output.exit_code());
137
+ }
58
138
 
59
139
  if line_count <= threshold {
60
140
  write_raw_output(&output.stdout, &output.stderr)?;
61
141
  process::exit(output.exit_code());
62
142
  }
63
143
 
64
- match summarize_output(&execution_argv, &output) {
144
+ match summarize_output(&execution_argv, summary_output) {
65
145
  Ok(summary) => {
66
146
  let mut stdout = io::stdout().lock();
67
- stdout.write_all(summary.as_bytes())?;
147
+ stdout.write_all(compact_text(&summary, options.budget).as_bytes())?;
68
148
  if !summary.ends_with('\n') {
69
149
  stdout.write_all(b"\n")?;
70
150
  }
@@ -104,7 +184,7 @@ fn usage_text() -> String {
104
184
  )
105
185
  }
106
186
 
107
- fn parse_input(args: &[String]) -> Result<SparkShellInput, SparkshellError> {
187
+ fn parse_input(args: &[String]) -> Result<SparkShellOptions, SparkshellError> {
108
188
  if args.is_empty() {
109
189
  return Err(SparkshellError::InvalidArgs(usage_text()));
110
190
  }
@@ -113,10 +193,139 @@ fn parse_input(args: &[String]) -> Result<SparkShellInput, SparkshellError> {
113
193
  let mut tail_lines = DEFAULT_TMUX_TAIL_LINES;
114
194
  let mut explicit_tail_lines = false;
115
195
  let mut positional = Vec::new();
196
+ let mut json = false;
197
+ let mut budget = DEFAULT_BUDGET;
198
+ let mut team = None;
199
+ let mut worker = None;
200
+ let mut since_last = false;
201
+ let mut cache = true;
202
+ let mut cache_ttl_ms = DEFAULT_CACHE_TTL_MS;
203
+ let mut shell = None;
116
204
 
117
205
  let mut index = 0;
118
206
  while index < args.len() {
119
207
  let token = &args[index];
208
+ if !positional.is_empty() {
209
+ positional.extend(args[index..].iter().cloned());
210
+ break;
211
+ }
212
+ if token == "--" {
213
+ positional.extend(args[index + 1..].iter().cloned());
214
+ break;
215
+ }
216
+ if token == "--json" {
217
+ json = true;
218
+ index += 1;
219
+ continue;
220
+ }
221
+ if token == "--budget" {
222
+ let Some(next) = args.get(index + 1) else {
223
+ return Err(SparkshellError::InvalidArgs(
224
+ "--budget requires a numeric value".to_string(),
225
+ ));
226
+ };
227
+ budget = parse_positive_usize(next, "--budget")?;
228
+ index += 2;
229
+ continue;
230
+ }
231
+ if let Some(value) = token.strip_prefix("--budget=") {
232
+ budget = parse_positive_usize(value, "--budget")?;
233
+ index += 1;
234
+ continue;
235
+ }
236
+ if token == "--shell" {
237
+ let Some(next) = args.get(index + 1) else {
238
+ return Err(SparkshellError::InvalidArgs(
239
+ "--shell requires a command string".to_string(),
240
+ ));
241
+ };
242
+ shell = Some(next.clone());
243
+ index += 2;
244
+ continue;
245
+ }
246
+ if let Some(value) = token.strip_prefix("--shell=") {
247
+ if value.trim().is_empty() {
248
+ return Err(SparkshellError::InvalidArgs(
249
+ "--shell requires a command string".to_string(),
250
+ ));
251
+ }
252
+ shell = Some(value.to_string());
253
+ index += 1;
254
+ continue;
255
+ }
256
+ if token == "--since-last" {
257
+ since_last = true;
258
+ index += 1;
259
+ continue;
260
+ }
261
+ if token == "--cache-ttl-ms" {
262
+ let Some(next) = args.get(index + 1) else {
263
+ return Err(SparkshellError::InvalidArgs(
264
+ "--cache-ttl-ms requires a numeric value".to_string(),
265
+ ));
266
+ };
267
+ cache_ttl_ms = parse_positive_usize(next, "--cache-ttl-ms")? as u64;
268
+ index += 2;
269
+ continue;
270
+ }
271
+ if let Some(value) = token.strip_prefix("--cache-ttl-ms=") {
272
+ cache_ttl_ms = parse_positive_usize(value, "--cache-ttl-ms")? as u64;
273
+ index += 1;
274
+ continue;
275
+ }
276
+ if let Some(value) = token.strip_prefix("--cache=") {
277
+ cache = match value {
278
+ "on" => true,
279
+ "off" => false,
280
+ _ => {
281
+ return Err(SparkshellError::InvalidArgs(
282
+ "--cache must be on or off".to_string(),
283
+ ))
284
+ }
285
+ };
286
+ index += 1;
287
+ continue;
288
+ }
289
+ if token == "--team" {
290
+ let Some(next) = args.get(index + 1) else {
291
+ return Err(SparkshellError::InvalidArgs(
292
+ "--team requires a value".to_string(),
293
+ ));
294
+ };
295
+ team = Some(next.clone());
296
+ index += 2;
297
+ continue;
298
+ }
299
+ if let Some(value) = token.strip_prefix("--team=") {
300
+ if value.trim().is_empty() {
301
+ return Err(SparkshellError::InvalidArgs(
302
+ "--team requires a value".to_string(),
303
+ ));
304
+ }
305
+ team = Some(value.to_string());
306
+ index += 1;
307
+ continue;
308
+ }
309
+ if token == "--worker" {
310
+ let Some(next) = args.get(index + 1) else {
311
+ return Err(SparkshellError::InvalidArgs(
312
+ "--worker requires a value".to_string(),
313
+ ));
314
+ };
315
+ worker = Some(next.clone());
316
+ index += 2;
317
+ continue;
318
+ }
319
+ if let Some(value) = token.strip_prefix("--worker=") {
320
+ if value.trim().is_empty() {
321
+ return Err(SparkshellError::InvalidArgs(
322
+ "--worker requires a value".to_string(),
323
+ ));
324
+ }
325
+ worker = Some(value.to_string());
326
+ index += 1;
327
+ continue;
328
+ }
120
329
  if token == "--tmux-pane" {
121
330
  let Some(next) = args.get(index + 1) else {
122
331
  return Err(SparkshellError::InvalidArgs(
@@ -164,25 +373,525 @@ fn parse_input(args: &[String]) -> Result<SparkShellInput, SparkshellError> {
164
373
  index += 1;
165
374
  }
166
375
 
167
- if let Some(pane_id) = pane_id {
376
+ let target = if let Some(script) = shell {
377
+ if pane_id.is_some() || !positional.is_empty() {
378
+ return Err(SparkshellError::InvalidArgs(
379
+ "--shell does not accept --tmux-pane or additional argv".to_string(),
380
+ ));
381
+ }
382
+ SparkShellTarget::Shell(script)
383
+ } else if let Some(pane_id) = pane_id {
168
384
  if !positional.is_empty() {
169
385
  return Err(SparkshellError::InvalidArgs(
170
386
  "tmux pane mode does not accept an additional command".to_string(),
171
387
  ));
172
388
  }
173
- return Ok(SparkShellInput::TmuxPane {
389
+ SparkShellTarget::TmuxPane {
174
390
  pane_id,
175
391
  tail_lines,
176
- });
392
+ }
393
+ } else {
394
+ if explicit_tail_lines {
395
+ return Err(SparkshellError::InvalidArgs(
396
+ "--tail-lines requires --tmux-pane".to_string(),
397
+ ));
398
+ }
399
+ SparkShellTarget::Command(positional)
400
+ };
401
+
402
+ Ok(SparkShellOptions {
403
+ target,
404
+ json,
405
+ budget,
406
+ team,
407
+ worker,
408
+ since_last,
409
+ cache,
410
+ cache_ttl_ms,
411
+ })
412
+ }
413
+
414
+ fn parse_positive_usize(raw: &str, flag: &str) -> Result<usize, SparkshellError> {
415
+ raw.trim()
416
+ .parse::<usize>()
417
+ .ok()
418
+ .filter(|value| *value > 0)
419
+ .ok_or_else(|| SparkshellError::InvalidArgs(format!("{flag} requires a positive integer")))
420
+ }
421
+
422
+ fn build_evidence(options: &SparkShellOptions, output: &CommandOutput) -> Evidence {
423
+ let text = combined_text(output);
424
+ let lines = text.lines().count();
425
+ let (pane_id, tail_lines) = match &options.target {
426
+ SparkShellTarget::TmuxPane {
427
+ pane_id,
428
+ tail_lines,
429
+ } => (Some(pane_id.clone()), Some(*tail_lines)),
430
+ SparkShellTarget::Command(_) | SparkShellTarget::Shell(_) => (None, None),
431
+ };
432
+ Evidence {
433
+ stdout_lines: String::from_utf8_lossy(&output.stdout).lines().count(),
434
+ stderr_lines: String::from_utf8_lossy(&output.stderr).lines().count(),
435
+ raw_hash: hash_text(&text),
436
+ pane_id,
437
+ tail_lines,
438
+ line_range: (lines > 0).then(|| format!("1-{lines}")),
439
+ }
440
+ }
441
+
442
+ fn combined_text(output: &CommandOutput) -> String {
443
+ format!("{}{}", output.stdout_text(), output.stderr_text())
444
+ }
445
+
446
+ fn hash_text(text: &str) -> String {
447
+ let mut hasher = DefaultHasher::new();
448
+ text.hash(&mut hasher);
449
+ format!("{:016x}", hasher.finish())
450
+ }
451
+
452
+ fn compact_text(text: &str, budget: usize) -> String {
453
+ if text.len() <= budget {
454
+ return text.to_string();
455
+ }
456
+ let end = safe_boundary(text, budget);
457
+ format!(
458
+ "{}\n[truncated: {} chars omitted]",
459
+ &text[..end],
460
+ text.len().saturating_sub(end)
461
+ )
462
+ }
463
+
464
+ fn safe_boundary(text: &str, max: usize) -> usize {
465
+ let mut end = 0;
466
+ for (index, ch) in text.char_indices() {
467
+ let next = index + ch.len_utf8();
468
+ if next > max {
469
+ break;
470
+ }
471
+ end = next;
472
+ }
473
+ end
474
+ }
475
+
476
+ fn json_escape(value: &str) -> String {
477
+ let mut escaped = String::new();
478
+ for ch in value.chars() {
479
+ match ch {
480
+ '\\' => escaped.push_str("\\\\"),
481
+ '"' => escaped.push_str("\\\""),
482
+ '\n' => escaped.push_str("\\n"),
483
+ '\r' => escaped.push_str("\\r"),
484
+ '\t' => escaped.push_str("\\t"),
485
+ '\u{08}' => escaped.push_str("\\b"),
486
+ '\u{0c}' => escaped.push_str("\\f"),
487
+ ch if ch <= '\u{1f}' => escaped.push_str(&format!("\\u{:04x}", ch as u32)),
488
+ ch => escaped.push(ch),
489
+ }
490
+ }
491
+ escaped
492
+ }
493
+
494
+ fn json_str(value: &str) -> String {
495
+ format!("\"{}\"", json_escape(value))
496
+ }
497
+
498
+ fn json_string_array(values: &[String]) -> String {
499
+ format!(
500
+ "[{}]",
501
+ values
502
+ .iter()
503
+ .map(|value| json_str(value))
504
+ .collect::<Vec<_>>()
505
+ .join(",")
506
+ )
507
+ }
508
+
509
+ fn optional_json_string(value: &Option<String>) -> String {
510
+ value
511
+ .as_ref()
512
+ .map(|value| json_str(value))
513
+ .unwrap_or_else(|| "null".to_string())
514
+ }
515
+
516
+ fn cache_dir() -> PathBuf {
517
+ std::env::var("OMX_SPARKSHELL_CACHE_DIR")
518
+ .map(PathBuf::from)
519
+ .unwrap_or_else(|_| {
520
+ std::env::var("OMX_TEAM_STATE_ROOT")
521
+ .map(|root| PathBuf::from(root).join("../cache/sparkshell"))
522
+ .unwrap_or_else(|_| PathBuf::from(".omx/cache/sparkshell"))
523
+ })
524
+ }
525
+
526
+ fn handle_cache(
527
+ options: &SparkShellOptions,
528
+ output: &CommandOutput,
529
+ current_hash: &str,
530
+ ) -> Result<Option<CacheMeta>, SparkshellError> {
531
+ if !options.cache {
532
+ return Ok(None);
533
+ }
534
+ let key = match &options.target {
535
+ SparkShellTarget::TmuxPane { pane_id, .. } => {
536
+ format!("pane-{}", pane_id.replace('%', "pct"))
537
+ }
538
+ SparkShellTarget::Command(_) | SparkShellTarget::Shell(_) => return Ok(None),
539
+ };
540
+ let dir = cache_dir();
541
+ fs::create_dir_all(&dir)?;
542
+ handle_cache_at_path(
543
+ &dir.join(format!("{key}.txt")),
544
+ output,
545
+ current_hash,
546
+ options.cache_ttl_ms,
547
+ )
548
+ }
549
+
550
+ fn handle_cache_at_path(
551
+ path: &Path,
552
+ output: &CommandOutput,
553
+ current_hash: &str,
554
+ ttl_ms: u64,
555
+ ) -> Result<Option<CacheMeta>, SparkshellError> {
556
+ let now = now_ms();
557
+ let current = combined_text(output);
558
+ let mut previous_hash = None;
559
+ let mut cache_hit = false;
560
+ let mut changed_line_ranges = Vec::new();
561
+ if let Ok(previous) = fs::read_to_string(path) {
562
+ let mut parts = previous.splitn(3, '\n');
563
+ let timestamp = parts
564
+ .next()
565
+ .and_then(|value| value.parse::<u64>().ok())
566
+ .unwrap_or(0);
567
+ let old_hash = parts.next().unwrap_or("").to_string();
568
+ let old_body = parts.next().unwrap_or("");
569
+ if now.saturating_sub(timestamp) <= ttl_ms {
570
+ previous_hash = Some(old_hash.clone());
571
+ cache_hit = old_hash == current_hash;
572
+ if !cache_hit {
573
+ changed_line_ranges = changed_ranges(old_body, &current);
574
+ }
575
+ }
576
+ }
577
+ fs::write(path, format!("{now}\n{current_hash}\n{current}"))?;
578
+ Ok(Some(CacheMeta {
579
+ cache_hit,
580
+ previous_hash,
581
+ current_hash: current_hash.to_string(),
582
+ changed_line_ranges,
583
+ }))
584
+ }
585
+
586
+ fn since_last_summary(output: &CommandOutput, cache: Option<&CacheMeta>, budget: usize) -> String {
587
+ let Some(cache) = cache else {
588
+ return compact_text(&combined_text(output), budget);
589
+ };
590
+ if cache.cache_hit {
591
+ return "unchanged since previous observation".to_string();
592
+ }
593
+ if cache.changed_line_ranges.is_empty() {
594
+ return compact_text(&combined_text(output), budget);
595
+ }
596
+ let text = combined_text(output);
597
+ let lines: Vec<&str> = text.lines().collect();
598
+ let mut selected = Vec::new();
599
+ for range in &cache.changed_line_ranges {
600
+ if let Some((start, end)) = range.split_once('-') {
601
+ if let (Ok(start), Ok(end)) = (start.parse::<usize>(), end.parse::<usize>()) {
602
+ for line in lines
603
+ .iter()
604
+ .skip(start.saturating_sub(1))
605
+ .take(end.saturating_sub(start).saturating_add(1))
606
+ {
607
+ selected.push((*line).to_string());
608
+ }
609
+ }
610
+ }
611
+ }
612
+ if selected.is_empty() {
613
+ compact_text(&combined_text(output), budget)
614
+ } else {
615
+ compact_text(
616
+ &format!(
617
+ "new findings since last observation:\n{}",
618
+ selected.join("\n")
619
+ ),
620
+ budget,
621
+ )
622
+ }
623
+ }
624
+
625
+ fn changed_ranges(old: &str, new: &str) -> Vec<String> {
626
+ let old_count = old.lines().count();
627
+ let new_count = new.lines().count();
628
+ if new_count > old_count {
629
+ vec![format!("{}-{}", old_count + 1, new_count)]
630
+ } else if old != new {
631
+ vec!["1-*".to_string()]
632
+ } else {
633
+ Vec::new()
634
+ }
635
+ }
636
+
637
+ #[derive(Debug, Clone)]
638
+ struct Diagnostics {
639
+ classification: String,
640
+ next_action: String,
641
+ confidence: f32,
642
+ errors: Vec<String>,
643
+ warnings: Vec<String>,
644
+ }
645
+
646
+ fn classify(options: &SparkShellOptions, output: &CommandOutput) -> Diagnostics {
647
+ let text = combined_text(output).to_ascii_lowercase();
648
+ let mut diagnostics = Diagnostics {
649
+ classification: "unknown".to_string(),
650
+ next_action: "inspect raw output".to_string(),
651
+ confidence: 0.45,
652
+ errors: Vec::new(),
653
+ warnings: Vec::new(),
654
+ };
655
+
656
+ if text.contains("authorization") || text.contains("authentication") || text.contains("401") {
657
+ diagnostics.classification = "auth_error".to_string();
658
+ diagnostics.confidence = 0.8;
659
+ diagnostics
660
+ .errors
661
+ .push("authentication-like error in output".to_string());
662
+ } else if text.contains("typeerror") || text.contains("type error") {
663
+ diagnostics.classification = "type_error".to_string();
664
+ diagnostics.confidence = 0.75;
665
+ diagnostics
666
+ .errors
667
+ .push("type error pattern in output".to_string());
668
+ } else if text.contains("test failed") || text.contains("failures:") || text.contains("failed")
669
+ {
670
+ diagnostics.classification = "test_failure".to_string();
671
+ diagnostics.confidence = 0.65;
672
+ diagnostics
673
+ .errors
674
+ .push("failure pattern in output".to_string());
675
+ } else if text.contains("press enter")
676
+ || text.contains("waiting for input")
677
+ || text.contains("continue?")
678
+ {
679
+ diagnostics.classification = "waiting_for_input".to_string();
680
+ diagnostics.confidence = 0.75;
681
+ } else if text.contains("thinking") || text.contains("running") || text.contains("building") {
682
+ diagnostics.classification = "busy_processing".to_string();
683
+ diagnostics.next_action = "wait".to_string();
684
+ diagnostics.confidence = 0.65;
685
+ diagnostics.warnings.push("do not shutdown yet".to_string());
686
+ }
687
+
688
+ if let (Some(team), Some(worker)) = (&options.team, &options.worker) {
689
+ if let Some(team_diagnostics) = classify_team(team, worker) {
690
+ diagnostics = team_diagnostics;
691
+ }
692
+ }
693
+
694
+ diagnostics
695
+ }
696
+
697
+ fn classify_team(team: &str, worker: &str) -> Option<Diagnostics> {
698
+ let state_root = std::env::var("OMX_TEAM_STATE_ROOT")
699
+ .map(PathBuf::from)
700
+ .unwrap_or_else(|_| PathBuf::from(".omx/state"));
701
+ let base = state_root
702
+ .join("team")
703
+ .join(team)
704
+ .join("workers")
705
+ .join(worker);
706
+ if !base.exists() {
707
+ return None;
708
+ }
709
+
710
+ if let Ok(heartbeat) = fs::read_to_string(base.join("heartbeat.json")) {
711
+ if let Some(timestamp) = extract_heartbeat_ms(&heartbeat) {
712
+ if now_ms().saturating_sub(timestamp) > STALE_HEARTBEAT_MS {
713
+ return Some(Diagnostics {
714
+ classification: "stale_heartbeat".to_string(),
715
+ next_action: "run omx team status".to_string(),
716
+ confidence: 0.78,
717
+ errors: Vec::new(),
718
+ warnings: vec!["heartbeat is stale".to_string()],
719
+ });
720
+ }
721
+ }
177
722
  }
178
723
 
179
- if explicit_tail_lines {
180
- return Err(SparkshellError::InvalidArgs(
181
- "--tail-lines requires --tmux-pane".to_string(),
182
- ));
724
+ if let Ok(status) = fs::read_to_string(base.join("status.json")) {
725
+ let normalized = status.to_ascii_lowercase();
726
+ if normalized.contains("blocked") || normalized.contains("needs_input") {
727
+ return Some(Diagnostics {
728
+ classification: "waiting_for_input".to_string(),
729
+ next_action: "inspect raw pane".to_string(),
730
+ confidence: 0.7,
731
+ errors: Vec::new(),
732
+ warnings: Vec::new(),
733
+ });
734
+ }
735
+ if normalized.contains("busy") || normalized.contains("in_progress") {
736
+ return Some(Diagnostics {
737
+ classification: "busy_processing".to_string(),
738
+ next_action: "wait".to_string(),
739
+ confidence: 0.72,
740
+ errors: Vec::new(),
741
+ warnings: vec!["do not shutdown yet".to_string()],
742
+ });
743
+ }
183
744
  }
184
745
 
185
- Ok(SparkShellInput::Command(positional))
746
+ None
747
+ }
748
+
749
+ fn extract_heartbeat_ms(text: &str) -> Option<u64> {
750
+ extract_json_number(text, "updated_at_ms")
751
+ .or_else(|| extract_json_number(text, "timestamp"))
752
+ .or_else(|| {
753
+ extract_json_string(text, "last_turn_at")
754
+ .and_then(|value| parse_iso_timestamp_ms(&value))
755
+ })
756
+ }
757
+
758
+ fn extract_json_number(text: &str, key: &str) -> Option<u64> {
759
+ let key_pattern = format!("\"{key}\"");
760
+ let start = text.find(&key_pattern)? + key_pattern.len();
761
+ let after_colon = text[start..].split_once(':')?.1.trim_start();
762
+ let digits: String = after_colon
763
+ .chars()
764
+ .take_while(|ch| ch.is_ascii_digit())
765
+ .collect();
766
+ if digits.is_empty() {
767
+ return None;
768
+ }
769
+ digits.parse().ok()
770
+ }
771
+
772
+ fn extract_json_string(text: &str, key: &str) -> Option<String> {
773
+ let key_pattern = format!("\"{key}\"");
774
+ let start = text.find(&key_pattern)? + key_pattern.len();
775
+ let after_colon = text[start..].split_once(':')?.1.trim_start();
776
+ let after_quote = after_colon.strip_prefix('"')?;
777
+ let value = after_quote.split('"').next()?;
778
+ Some(value.to_string())
779
+ }
780
+
781
+ fn parse_iso_timestamp_ms(value: &str) -> Option<u64> {
782
+ let trimmed = value.strip_suffix('Z').unwrap_or(value);
783
+ let (date, time) = trimmed.split_once('T')?;
784
+ let mut date_parts = date.split('-').map(|part| part.parse::<i64>().ok());
785
+ let year = date_parts.next()??;
786
+ let month = date_parts.next()??;
787
+ let day = date_parts.next()??;
788
+ let mut time_parts = time.split(':');
789
+ let hour = time_parts.next()?.parse::<i64>().ok()?;
790
+ let minute = time_parts.next()?.parse::<i64>().ok()?;
791
+ let second_part = time_parts.next()?;
792
+ let second = second_part.split('.').next()?.parse::<i64>().ok()?;
793
+ let days = days_from_civil(year, month, day)?;
794
+ Some(((days * 86_400 + hour * 3_600 + minute * 60 + second) as u64) * 1000)
795
+ }
796
+
797
+ fn days_from_civil(year: i64, month: i64, day: i64) -> Option<i64> {
798
+ if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
799
+ return None;
800
+ }
801
+ let year = year - i64::from(month <= 2);
802
+ let era = if year >= 0 { year } else { year - 399 } / 400;
803
+ let yoe = year - era * 400;
804
+ let mp = month + if month > 2 { -3 } else { 9 };
805
+ let doy = (153 * mp + 2) / 5 + day - 1;
806
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
807
+ Some(era * 146_097 + doe - 719_468)
808
+ }
809
+
810
+ fn now_ms() -> u64 {
811
+ SystemTime::now()
812
+ .duration_since(UNIX_EPOCH)
813
+ .unwrap_or_default()
814
+ .as_millis() as u64
815
+ }
816
+
817
+ fn write_json_report(
818
+ options: &SparkShellOptions,
819
+ output: &CommandOutput,
820
+ summary: &str,
821
+ evidence: &Evidence,
822
+ cache: Option<CacheMeta>,
823
+ redaction_count: usize,
824
+ ) -> Result<(), SparkshellError> {
825
+ let mode = match options.target {
826
+ SparkShellTarget::Command(_) => "command",
827
+ SparkShellTarget::Shell(_) => "shell",
828
+ SparkShellTarget::TmuxPane { .. } => "tmux-pane",
829
+ };
830
+ let status = if output.status.success() {
831
+ "ok"
832
+ } else {
833
+ "failed"
834
+ };
835
+ let mut diagnostics = classify(options, output);
836
+ if !output.status.success() && diagnostics.errors.is_empty() {
837
+ diagnostics
838
+ .errors
839
+ .push(compact_text(&output.stderr_text(), options.budget));
840
+ }
841
+ let tail_lines = evidence
842
+ .tail_lines
843
+ .map(|value| value.to_string())
844
+ .unwrap_or_else(|| "null".to_string());
845
+ let cache_json = cache
846
+ .map(|cache| {
847
+ format!(
848
+ "{{\"cache_hit\":{},\"previous_hash\":{},\"current_hash\":{},\"changed_line_ranges\":{}}}",
849
+ cache.cache_hit,
850
+ optional_json_string(&cache.previous_hash),
851
+ json_str(&cache.current_hash),
852
+ json_string_array(&cache.changed_line_ranges),
853
+ )
854
+ })
855
+ .unwrap_or_else(|| "null".to_string());
856
+ let json = format!(
857
+ concat!(
858
+ "{{\n",
859
+ " \"ok\": {},\n",
860
+ " \"mode\": {},\n",
861
+ " \"status\": {},\n",
862
+ " \"exit_code\": {},\n",
863
+ " \"summary\": {},\n",
864
+ " \"errors\": {},\n",
865
+ " \"warnings\": {},\n",
866
+ " \"evidence\": {{\"stdout_lines\":{},\"stderr_lines\":{},\"raw_hash\":{},\"pane_id\":{},\"tail_lines\":{},\"line_range\":{}}},\n",
867
+ " \"next_action\": {},\n",
868
+ " \"confidence\": {:.2},\n",
869
+ " \"classification\": {},\n",
870
+ " \"cache\": {},\n",
871
+ " \"redactions\": {{\"count\": {}}}\n",
872
+ "}}\n"
873
+ ),
874
+ output.status.success(),
875
+ json_str(mode),
876
+ json_str(status),
877
+ output.exit_code(),
878
+ json_str(&compact_text(summary, options.budget)),
879
+ json_string_array(&diagnostics.errors),
880
+ json_string_array(&diagnostics.warnings),
881
+ evidence.stdout_lines,
882
+ evidence.stderr_lines,
883
+ json_str(&evidence.raw_hash),
884
+ optional_json_string(&evidence.pane_id),
885
+ tail_lines,
886
+ optional_json_string(&evidence.line_range),
887
+ json_str(&diagnostics.next_action),
888
+ diagnostics.confidence,
889
+ json_str(&diagnostics.classification),
890
+ cache_json,
891
+ redaction_count,
892
+ );
893
+ io::stdout().write_all(json.as_bytes())?;
894
+ Ok(())
186
895
  }
187
896
 
188
897
  fn parse_tail_lines(raw: &str) -> Result<usize, SparkshellError> {
@@ -201,7 +910,7 @@ fn parse_tail_lines(raw: &str) -> Result<usize, SparkshellError> {
201
910
 
202
911
  #[cfg(test)]
203
912
  mod tests {
204
- use super::{parse_input, SparkShellInput};
913
+ use super::{parse_input, SparkShellTarget};
205
914
 
206
915
  fn strings(values: &[&str]) -> Vec<String> {
207
916
  values.iter().map(|value| value.to_string()).collect()
@@ -211,8 +920,8 @@ mod tests {
211
920
  fn parses_direct_command_mode() {
212
921
  let parsed = parse_input(&strings(&["git", "status"])).expect("parsed");
213
922
  assert_eq!(
214
- parsed,
215
- SparkShellInput::Command(strings(&["git", "status"]))
923
+ parsed.target,
924
+ SparkShellTarget::Command(strings(&["git", "status"]))
216
925
  );
217
926
  }
218
927
 
@@ -220,8 +929,8 @@ mod tests {
220
929
  fn parses_tmux_pane_mode_with_default_tail_lines() {
221
930
  let parsed = parse_input(&strings(&["--tmux-pane", "%11"])).expect("parsed");
222
931
  assert_eq!(
223
- parsed,
224
- SparkShellInput::TmuxPane {
932
+ parsed.target,
933
+ SparkShellTarget::TmuxPane {
225
934
  pane_id: "%11".to_string(),
226
935
  tail_lines: 200,
227
936
  }
@@ -233,8 +942,8 @@ mod tests {
233
942
  let parsed =
234
943
  parse_input(&strings(&["--tmux-pane=%22", "--tail-lines=400"])).expect("parsed");
235
944
  assert_eq!(
236
- parsed,
237
- SparkShellInput::TmuxPane {
945
+ parsed.target,
946
+ SparkShellTarget::TmuxPane {
238
947
  pane_id: "%22".to_string(),
239
948
  tail_lines: 400,
240
949
  }
@@ -301,15 +1010,15 @@ mod tests {
301
1010
  let max = parse_input(&strings(&["--tmux-pane", "%11", "--tail-lines", "1000"]))
302
1011
  .expect("max parsed");
303
1012
  assert_eq!(
304
- min,
305
- SparkShellInput::TmuxPane {
1013
+ min.target,
1014
+ SparkShellTarget::TmuxPane {
306
1015
  pane_id: "%11".to_string(),
307
1016
  tail_lines: 100
308
1017
  }
309
1018
  );
310
1019
  assert_eq!(
311
- max,
312
- SparkShellInput::TmuxPane {
1020
+ max.target,
1021
+ SparkShellTarget::TmuxPane {
313
1022
  pane_id: "%11".to_string(),
314
1023
  tail_lines: 1000
315
1024
  }