omh 0.2.0

Launch any coding harness, in a sandbox, with your setup already there.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
//! Detection — how `omh init` decides without asking.
//!
//! Every question init would ask is hassle we promised to remove, and the
//! answers are mostly lying around already: manifests name the stack, git log
//! names what you work on, the README names the project. Deriving beats
//! interrogating twice over — no wizard, and the facts refresh themselves when
//! the repo changes instead of going stale in a config file.

use std::path::Path;

/// A detected stack and the commands that go with it. The commands are what
/// make detection useful: they become the base hooks and the AGENTS.md body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stack {
    pub name: &'static str,
    pub marker: &'static str,
    pub test: &'static str,
    pub format: &'static str,
}

const KNOWN: [Stack; 4] = [
    Stack {
        name: "rust",
        marker: "Cargo.toml",
        test: "cargo test",
        format: "cargo fmt",
    },
    Stack {
        name: "node",
        marker: "package.json",
        test: "npm test",
        format: "npm run format",
    },
    Stack {
        name: "python",
        marker: "pyproject.toml",
        test: "pytest",
        format: "ruff format .",
    },
    Stack {
        name: "go",
        marker: "go.mod",
        test: "go test ./...",
        format: "gofmt -w .",
    },
];

/// A derived fact, with the source that produced it. The source is not
/// decoration — `omh why` has to be able to explain every default.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Seed {
    pub source: String,
    pub fact: String,
}

pub fn stacks(repo: &Path) -> Vec<Stack> {
    KNOWN
        .into_iter()
        .filter(|s| repo.join(s.marker).exists())
        .collect()
}

/// Which harness to default to. Host evidence is only a *hint*: the harness
/// itself runs in the sandbox, so this picks a preference, never an install.
pub fn preferred_harness(
    candidates: &[String],
    installed_on_host: &dyn Fn(&str) -> bool,
) -> Option<String> {
    candidates
        .iter()
        .find(|c| installed_on_host(c))
        .or_else(|| candidates.first())
        .cloned()
}

/// Body for the generated project `AGENTS.md`.
pub fn agents_md(stacks: &[Stack]) -> String {
    let mut out = String::from(
        "# Project rules\n\n<!-- generated by `omh init`; edit freely, omh will not overwrite -->\n\n\
         ## Code graph\n\n\
         This repo is indexed as a graph, refreshed after every turn. Prefer it over\n\
         reading or grepping files when the question is structural:\n\n\
         - `search_graph` — where is X defined, what is named like Y\n\
         - `trace_path` — how does A reach B\n\
         - `get_architecture` — what the modules are and how they depend on each other\n\
         - `get_code_snippet` — read one symbol instead of a whole file\n\n\
         Grep is still right for literal text: a string, a config value, a TODO.\n\n\
         **Use the project named by `$OMH_GRAPH_PROJECT`.** Other sessions of this\n\
         repo have their own graphs in the same store; querying one of those answers\n\
         confidently about code that is not in this worktree.\n\n\
         ## Which graph to ask\n\n\
         There are two, and they do not overlap:\n\n\
         - **the code graph** knows **what the code is** — where a symbol lives, how\n\
           one module reaches another. Re-derived from the code every turn, so it is\n\
           never out of date and never needs to be told anything.\n\
         - **`recall`** knows **why** it is that way — what was tried and failed, what\n\
           turned out not to work, what surprised somebody. None of that is in the\n\
           code, so no amount of reading will recover it.\n\n\
         A *where* or *what* question goes to the code graph. A *why*, *is this safe*,\n\
         or *has this been tried* question goes to `recall`. When you are about to\n\
         assume how something here behaves, ask `recall` first — that is exactly the\n\
         assumption somebody already got wrong once.\n\n\
         They compose: find the code with the code graph, then ask `recall` what is\n\
         known about it before changing it.\n",
    );
    out.push_str(&git_rules());
    out.push_str(&memory_rules());
    if stacks.is_empty() {
        out.push_str(
            "\nNo stack detected. Add your build, test, and lint commands here so\n\
             every harness runs the same ones.\n",
        );
        return out;
    }
    for s in stacks {
        out.push_str(&format!(
            "\n## {}\n\nDetected from `{}`.\n\n- test: `{}`\n- format: `{}`\n",
            s.name, s.marker, s.test, s.format
        ));
    }
    out
}

/// What the agent needs to write a note before there is a tool to write one.
///
/// This is the part of the surface that cannot move into a tool description:
/// *record what surprised you* is a trigger, and an agent cannot look up a rule
/// it does not know it needs. The note **shape** is here only because the
/// agent has no *tool* to write through yet — `remember` already enforces the
/// schema at the write, but nothing inside the sandbox can call it, so the
/// agent writes the file by hand and needs to be told the shape. When the MCP
/// surface lands, this shrinks back to the trigger.
///
/// The condition is the MCP surface, not `remember`'s existence: `remember`
/// exists, so a reader checking the wrong one deletes the staged shape while
/// the agent still has no way to reach it.
pub fn memory_rules() -> String {
    format!(
        "\n## Memory\n\n\
         When something surprises you — you expected one thing and the repo did\n\
         another — record it. Not what you did; what you were wrong about.\n\n\
         Write a Markdown file into `{}/`, named after the\n\
         observation, in this shape:\n\n\
         ```markdown\n\
         ---\n\
         key: <the filename, without .md>\n\
         type: surprise\n\
         source: session $OMH_SESSION, <this harness>\n\
         recorded: <YYYY-MM-DD, the day it happened>\n\
         ---\n\n\
         # One line naming the surprise\n\n\
         ## Expected\n\n\
         ## Observed\n\n\
         ## Evidence\n\n\
         ## Answers\n\n\
         - <the question somebody would later ask to find this>\n\n\
         ## Related\n\n\
         - [[another-notes-key]]\n\
         ```\n\n\
         **Answers** is what makes the note findable later, and only you know it:\n\
         write the question you would have asked five minutes ago, in the words you\n\
         would have used. A note nobody can find is a note nobody wrote.\n\n\
         Store uncertainty rather than false precision, and date by when the thing\n\
         happened rather than when you mentioned it. If you have nothing to put\n\
         under **Expected**, there is nothing here worth recording.\n\n\
         Rename a note by rewriting its `key` and its filename together — never\n\
         one without the other.\n",
        crate::memory::GUEST_LOCAL_NOTES,
    )
}

/// What the agent is told about git, in one place.
///
/// Both deliveries read from here — the `## Git` section `init` writes and the
/// `git-unavailable` hook — because two copies of a safety notice drift, and
/// the one that drifts is never the one you are reading.
///
/// Written as a claim about *this session*, not about git: an agent told "git
/// is broken" spends its turns trying to fix it, and the repair cannot work.
///
/// It also does no harm, which was checked rather than assumed — `git init`
/// against an unreachable gitdir refuses (git 2.55.0), naming the missing
/// directory, and leaves the pointer file exactly as it was. So this says the attempt is
/// futile, not that it is dangerous. The expensive failure here is the agent
/// promising a commit it cannot make.
pub const GIT_ABSENT: &str = "git does not work in this session, by design and not by fault. \
     The worktree's .git is a pointer at an admin directory on the host, which omh does not \
     mount — so every git command fails with `fatal: not a git repository`. Do not try to \
     repair it: `git init` refuses for the same reason, and re-cloning would only give you a \
     second repository nobody is reviewing. Nothing here is broken and nothing is lost. Your \
     work is already visible outside the sandbox, where the person you are working with \
     reviews it with `omh s diff`, commits it with `omh s commit`, and pushes it with \
     `omh s push`. Say that rather than offering to commit yourself.";

/// The `## Git` section of the generated rules.
///
/// Orientation, where the hook is interception: a hook can only fire once the
/// agent has decided to run git, and by then it may already have promised the
/// user a commit. This is what stops the plan being made.
pub fn git_rules() -> String {
    format!("\n## Git\n\n{GIT_ABSENT}\n")
}

/// Facts derived for memory. No questions asked.
pub fn seeds(repo: &Path) -> Vec<Seed> {
    let mut out = Vec::new();

    // First non-heading, non-empty line of the README is the project in one line.
    if let Ok(readme) = std::fs::read_to_string(repo.join("README.md")) {
        // A tagline under the title is the commonest README shape, and it is
        // usually a blockquote. Badges sit in the same place and say nothing.
        if let Some(line) = readme
            .lines()
            .map(|l| l.trim().trim_start_matches('>').trim())
            .find(|l| {
                !l.is_empty()
                    && !l.starts_with('#')
                    && !l.starts_with("[!")
                    && !l.starts_with("![")
                    && !l.starts_with("---")
            })
        {
            out.push(Seed {
                source: "README.md".into(),
                fact: line.to_string(),
            });
        }
    }

    for s in stacks(repo) {
        out.push(Seed {
            source: s.marker.into(),
            fact: format!(
                "stack: {} (test `{}`, format `{}`)",
                s.name, s.test, s.format
            ),
        });
    }

    // Conventions the project already wrote down outlive any interview.
    for rules in ["AGENTS.md", "CLAUDE.md"] {
        if let Ok(body) = std::fs::read_to_string(repo.join(rules)) {
            if !body.trim().is_empty() {
                out.push(Seed {
                    source: rules.into(),
                    fact: format!("existing conventions ({} lines)", body.lines().count()),
                });
            }
        }
    }

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn repo(files: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().join("repo");
        std::fs::create_dir_all(&root).unwrap();
        for (name, body) in files {
            let p = root.join(name);
            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
            std::fs::write(p, body).unwrap();
        }
        (dir, root)
    }

    // ── stacks ──────────────────────────────────────────────────────────────

    #[test]
    fn a_manifest_identifies_the_stack() {
        let (_d, r) = repo(&[("Cargo.toml", "[package]")]);
        let found = stacks(&r);
        assert_eq!(found.len(), 1);
        assert_eq!(found[0].name, "rust");
    }

    #[test]
    fn a_polyglot_repo_reports_every_stack() {
        let (_d, r) = repo(&[("Cargo.toml", ""), ("package.json", "{}")]);
        let names: Vec<_> = stacks(&r).into_iter().map(|s| s.name).collect();
        assert_eq!(names, ["rust", "node"]);
    }

    /// Guessing a stack would generate wrong hooks that fail on every agent
    /// turn. Detecting nothing is the correct outcome for an unknown repo.
    #[test]
    fn no_marker_means_no_stack_rather_than_a_guess() {
        let (_d, r) = repo(&[("README.md", "hello")]);
        assert!(stacks(&r).is_empty());
    }

    /// Detection is only worth doing because it yields commands — these become
    /// the base test-on-stop and format-on-edit hooks.
    #[test]
    fn every_known_stack_supplies_commands() {
        for s in KNOWN {
            assert!(!s.test.is_empty(), "{} has no test command", s.name);
            assert!(!s.format.is_empty(), "{} has no format command", s.name);
        }
    }

    // ── generated rules ─────────────────────────────────────────────────────

    #[test]
    fn generated_rules_name_the_detected_commands() {
        let (_d, r) = repo(&[("Cargo.toml", "")]);
        let body = agents_md(&stacks(&r));
        assert!(body.contains("cargo test"), "got: {body}");
        assert!(body.contains("cargo fmt"), "got: {body}");
    }

    /// A tool the agent does not know about is a tool it will not use. This is
    /// half of what makes the graph more than an installed package.
    #[test]
    fn generated_rules_explain_the_code_graph() {
        let body = agents_md(&[]);
        assert!(body.contains("search_graph"), "must name the tools: {body}");
        assert!(
            body.to_lowercase().contains("grep"),
            "and when not to use them: {body}"
        );
        assert!(
            body.contains("OMH_GRAPH_PROJECT"),
            "and which project is its own — the store holds every session's: {body}"
        );
    }

    /// The agent gets `fatal: not a git repository: (null)` and has to explain
    /// it to itself. Left to guess it reaches for `git init`, which refuses for
    /// the same reason and changes nothing — so the notice has to say the
    /// repair is futile rather than leave it to be discovered a turn later.
    ///
    /// Naming what to run instead is the load-bearing half: an agent that knows
    /// only that git is missing still promises a commit it cannot make.
    #[test]
    fn generated_rules_say_git_is_absent_by_design_and_what_to_do_instead() {
        let body = agents_md(&[]);
        assert!(
            body.contains("git init"),
            "the move it would otherwise make has to be named: {body}"
        );
        assert!(
            body.contains("omh s commit") && body.contains("omh s push"),
            "and what the human runs instead: {body}"
        );
    }

    #[test]
    fn generated_rules_stay_useful_with_no_stack_detected() {
        let body = agents_md(&[]);
        assert!(!body.trim().is_empty(), "must still produce a usable file");
    }

    // ── harness preference ──────────────────────────────────────────────────

    fn candidates() -> Vec<String> {
        vec!["claude".into(), "opencode".into()]
    }

    #[test]
    fn host_evidence_picks_the_default_harness() {
        let pick = preferred_harness(&candidates(), &|h| h == "opencode");
        assert_eq!(pick.as_deref(), Some("opencode"));
    }

    /// The harness runs in the sandbox, so an empty host is normal, not an
    /// error — init still has to choose something and say so.
    #[test]
    fn nothing_installed_still_yields_a_default() {
        let pick = preferred_harness(&candidates(), &|_| false);
        assert_eq!(pick.as_deref(), Some("claude"));
    }

    #[test]
    fn no_adapters_means_no_preference() {
        assert_eq!(preferred_harness(&[], &|_| true), None);
    }

    // ── memory seeds ────────────────────────────────────────────────────────

    #[test]
    fn seeds_are_derived_from_what_the_repo_already_says() {
        let (_d, r) = repo(&[
            ("README.md", "# omh\n\noh-my-zsh for agentic coding.\n"),
            ("Cargo.toml", "[package]\nname = \"omh\""),
        ]);
        let s = seeds(&r);
        assert!(
            s.iter().any(|x| x.fact.contains("oh-my-zsh")),
            "README should seed the project description: {s:?}"
        );
        assert!(
            s.iter().any(|x| x.fact.contains("rust")),
            "stack should be seeded: {s:?}"
        );
    }

    /// Every seed must name where it came from, or `omh why` cannot explain it
    /// and the memory becomes unfalsifiable folklore.
    /// A tagline under the title is the commonest README shape there is, and a
    /// derived fact should be the sentence, not the markdown around it.
    #[test]
    fn a_blockquote_tagline_is_read_as_prose() {
        let (_d, r) = repo(&[("README.md", "# omh\n\n> Launch any coding harness.\n")]);
        let s = seeds(&r);
        let fact = &s
            .iter()
            .find(|x| x.source == "README.md")
            .expect("README seed")
            .fact;
        assert_eq!(
            fact, "Launch any coding harness.",
            "markdown syntax is not the fact"
        );
    }

    /// Badges are the other thing that sits under a title, and they say nothing
    /// about the project.
    #[test]
    fn a_badge_line_is_not_mistaken_for_a_description() {
        let (_d, r) = repo(&[(
            "README.md",
            "# p\n\n[![CI](https://img.shields.io/x)](https://ci)\n\nA real description.\n",
        )]);
        let s = seeds(&r);
        let fact = &s
            .iter()
            .find(|x| x.source == "README.md")
            .expect("README seed")
            .fact;
        assert_eq!(fact, "A real description.");
    }

    #[test]
    fn every_seed_cites_its_source() {
        let (_d, r) = repo(&[("README.md", "# p\n\nA thing.\n"), ("go.mod", "module x")]);
        for seed in seeds(&r) {
            assert!(!seed.source.is_empty(), "unsourced seed: {seed:?}");
        }
    }

    #[test]
    fn an_empty_repo_seeds_nothing_rather_than_inventing() {
        let (_d, r) = repo(&[]);
        assert!(seeds(&r).is_empty());
    }

    #[test]
    fn existing_rules_are_seeded_so_conventions_survive() {
        let (_d, r) = repo(&[("AGENTS.md", "# Rules\n\nTDD always.\n")]);
        let s = seeds(&r);
        assert!(
            s.iter().any(|x| x.source.contains("AGENTS.md")),
            "got: {s:?}"
        );
    }
}