Skip to main content

cli/cli/
help.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Progressive-disclosure help: curated default, advanced surface,
3//! topic-scoped help.
4//!
5//! The Heddle CLI's default `heddle help` lists only everyday verbs.
6//! Advanced affordances (checkpoint, query, conflict, hook, agent serve,
7//! ephemeral threads) are reachable via
8//! `heddle help advanced` or `heddle help <topic>`. Per-verb help via
9//! `heddle <verb> --help` continues to derive from clap doc-comments.
10//!
11//! # Cultural deliverable
12//!
13//! The default help is **curated, not auto-generated**. Adding a verb
14//! means picking a tier in [`tier_of`]; the exhaustive match is the
15//! enforcement mechanism — forgetting a new verb is a build break.
16//! See `AGENTS.md` "CLI surface curation" for the full doctrine.
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Tier {
20    /// Front-door verbs: `init, start, capture, merge, log, status,
21    /// review, discuss, annotate, switch, undo, bridge`.
22    Everyday,
23    /// Reachable via `heddle help advanced` or `heddle help <topic>`.
24    /// Most agent-loop and operational verbs land here.
25    Advanced,
26    /// Compatibility aliases that should not be advertised at all.
27    Hidden,
28}
29
30/// Stable name for each verb. Pure presentation — has no relation to the
31/// clap variant identifier so doc-comment regenerations don't churn this
32/// file.
33pub fn tier_of(verb: &str) -> Tier {
34    match verb {
35        // ── Everyday ──────────────────────────────────────────────
36        "init" | "start" | "capture" | "merge" | "log" | "status" | "review" | "discuss"
37        | "context" | "switch" | "undo" | "bridge" | "help" => Tier::Everyday,
38        // The curated everyday set includes `annotate`. Heddle today
39        // exposes annotation management under `context`
40        // (set/get/list/edit). Map the missing front-door intent to
41        // that subcommand surface for now.
42
43        // ── Advanced ──────────────────────────────────────────────
44        // `abort`, `continue`, `doctor`, `git-overlay`, `version` were
45        // added by the codex git-overlay foundation; classified
46        // Advanced because they appear in operator-loop scripts and
47        // diagnostic flows rather than the everyday top-of-funnel.
48        // `attempt`, `try`, `retro`, `schemas`, `redact`, `purge`
49        // followed the same path — operator/security/diagnostic verbs
50        // that script around the everyday loop without being part of
51        // it. `redact` and `purge` in particular are security ops
52        // (Biscuit-gated `redact:repo`/`purge:repo` capabilities);
53        // they're explicitly NOT everyday verbs.
54        "abort" | "agent" | "actor" | "attempt" | "auth" | "bisect" | "blame" | "checkpoint"
55        | "cherry-pick" | "clean" | "clone" | "collapse" | "compare" | "completion"
56        | "conflict" | "continue" | "daemon" | "delegate" | "diagnose" | "diff" | "doctor"
57        | "fetch" | "fork" | "fsck" | "git-overlay" | "goto" | "hook" | "inspect"
58        | "integration" | "maintenance" | "marker" | "presence" | "pull" | "purge" | "push"
59        | "query" | "ready" | "rebase" | "redact" | "redo" | "remote" | "resolve" | "retro"
60        | "revert" | "run" | "schemas" | "semantic" | "session" | "ship" | "show" | "stash"
61        | "store" | "support" | "sync" | "thread" | "try" | "version" | "watch" | "workspace" => {
62            Tier::Advanced
63        }
64
65        // ── Hidden ────────────────────────────────────────────────
66        // `transaction` is hidden in alpha — buffered-op replay at
67        // commit and rewind-on-abort are still follow-on work; the
68        // verb stays available for testing but is not advertised.
69        // `harness-bridge` is internal harness plumbing invoked via
70        // env vars by `heddle run` and adapter shims — not a
71        // user-facing verb.
72        "gc" | "harness-bridge" | "index" | "monitor" | "transaction" => Tier::Hidden,
73
74        // Anything unrecognised is treated as Advanced rather than
75        // panicking. This preserves forward-compatibility for tools
76        // that script around new verbs before the tier table catches up.
77        _ => Tier::Advanced,
78    }
79}
80
81/// Verbs that show in `heddle help`, in editorial order. Blurbs are
82/// looked up at print time from each verb's clap `about` (its first
83/// doc-comment line) — see [`about_first_line`]. Keeping only names
84/// here means there's a single source of truth for command summaries.
85pub fn everyday_verbs() -> &'static [&'static str] {
86    &[
87        "init", "start", "capture", "merge", "log", "status", "review", "discuss", "context",
88        "undo", "bridge",
89    ]
90}
91
92/// Verbs surfaced by `heddle help advanced`, in editorial order. Not
93/// exhaustive of every existing verb (see [`tier_of`] for the full
94/// table) — focuses on the agent-loop surface plus the
95/// operational verbs power users reach for. As with [`everyday_verbs`],
96/// blurbs come from clap at print time.
97pub fn advanced_verbs() -> &'static [&'static str] {
98    &[
99        "agent",
100        "daemon",
101        "hook",
102        "thread",
103        "fork",
104        "collapse",
105        "compare",
106        "stash",
107        "fetch",
108        "push",
109        "pull",
110        "remote",
111        "rebase",
112        "cherry-pick",
113        "blame",
114        "bisect",
115        "fsck",
116        "semantic",
117        "watch",
118        "redo",
119        "revert",
120        "clean",
121        "goto",
122        "ready",
123        "ship",
124        "sync",
125        "delegate",
126        "run",
127        "diff",
128        "marker",
129        "workspace",
130        "integration",
131        "maintenance",
132        "clone",
133        "auth",
134        "diagnose",
135        "show",
136        "session",
137        "actor",
138        "store",
139        "completion",
140        "resolve",
141        "presence",
142    ]
143}
144
145/// Look up the first line of a top-level subcommand's clap `about`
146/// text. Returns an empty string when the verb is not a direct
147/// subcommand of `cmd` or has no `about` set — `print_help` skips
148/// rows with empty blurbs so feature-gated verbs (e.g. `semantic`
149/// without the `semantic` feature) don't advertise themselves. The
150/// `verb_blurbs_resolve_from_clap` test enforces that, under
151/// `--all-features`, every advertised verb resolves.
152///
153/// The "Automation/workflow command:" prefix in `--help` is useful
154/// framing on the per-verb page but pure noise in the curated
155/// summary column, so it gets stripped here.
156fn about_first_line(cmd: &clap::Command, verb: &str) -> String {
157    let raw = cmd
158        .get_subcommands()
159        .find(|sc| sc.get_name() == verb)
160        .and_then(|sc| sc.get_about())
161        .map(|about| about.to_string().lines().next().unwrap_or("").to_string())
162        .unwrap_or_default();
163    let stripped = raw
164        .trim_start_matches("Automation/workflow command:")
165        .trim_start();
166    let mut chars = stripped.chars();
167    match chars.next() {
168        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
169        None => String::new(),
170    }
171}
172
173/// Entry point for the `Commands::Help { topic }` dispatch arm
174/// AND the bare-help intercept in `main.rs`. Routes between everyday
175/// / advanced / topic surfaces and falls through to "use `--help`"
176/// for verb names without a dedicated topic.
177///
178/// All output goes to stdout (this is help, not diagnostic). Returns
179/// `Ok(())` even for unknown topics; the printer surfaces the
180/// suggestion text rather than erroring.
181pub fn print_help(cmd: &clap::Command, topic: Option<&str>) -> std::io::Result<()> {
182    use std::io::Write;
183    let stdout = std::io::stdout();
184    let mut out = stdout.lock();
185    match topic {
186        None => {
187            writeln!(out, "Heddle — AI-native version control")?;
188            writeln!(out)?;
189            writeln!(out, "Everyday commands:")?;
190            for &name in everyday_verbs() {
191                let blurb = about_first_line(cmd, name);
192                if blurb.is_empty() {
193                    continue;
194                }
195                writeln!(out, "  {:<10}  {}", name, blurb)?;
196            }
197            writeln!(out)?;
198            writeln!(
199                out,
200                "Run `heddle help advanced` for advanced commands or \
201                 `heddle help <topic>` for a topic page (e.g. `daemon`, \
202                 `signals`, `bridge`, `operation-ids`)."
203            )?;
204        }
205        Some("advanced") => {
206            writeln!(out, "{}", ADVANCED_HELP)?;
207            writeln!(out, "Advanced commands:")?;
208            for &name in advanced_verbs() {
209                let blurb = about_first_line(cmd, name);
210                if blurb.is_empty() {
211                    continue;
212                }
213                writeln!(out, "  {:<14}  {}", name, blurb)?;
214            }
215        }
216        Some(name) => {
217            if let Some(body) = topic_text(name) {
218                writeln!(out, "{}", body)?;
219            } else if let Some(subcommand) = cmd.find_subcommand(name) {
220                // `heddle help <verb>` falls through to that verb's
221                // clap-derived help so the contract on `Commands::Help`
222                // (`heddle help <verb>` → that verb's `--help`) holds.
223                // We clone because `print_help` takes `&mut self` and
224                // we only have a borrow of the parent. Set `bin_name`
225                // explicitly so the rendered `Usage:` line says `heddle
226                // <verb>` instead of just `<verb>` — the parent name
227                // isn't otherwise carried through the clone.
228                drop(out);
229                let mut subcommand =
230                    subcommand
231                        .clone()
232                        .bin_name(format!("{} {}", cmd.get_name(), name));
233                subcommand.print_help()?;
234            } else {
235                writeln!(
236                    out,
237                    "no topic '{name}'. Run `heddle help advanced` for \
238                     the full advanced list, or `heddle help` for the \
239                     curated everyday surface."
240                )?;
241            }
242        }
243    }
244    Ok(())
245}
246
247/// Static per-topic help. Topics are addressed via `heddle help <topic>`.
248pub fn topic_text(topic: &str) -> Option<&'static str> {
249    Some(match topic {
250        "advanced" => ADVANCED_HELP,
251        "agent" | "daemon" => DAEMON_TOPIC,
252        "operation-ids" | "idempotency" => OPERATION_IDS_TOPIC,
253        "review" => REVIEW_TOPIC,
254        "discuss" | "discussions" => DISCUSS_TOPIC,
255        "bridge" | "footer" | "notes" => BRIDGE_TOPIC,
256        "signals" | "risk-signals" => SIGNALS_TOPIC,
257        _ => return None,
258    })
259}
260
261const ADVANCED_HELP: &str = "Advanced verbs — see `heddle help advanced` for the complete list.\n\
262\n\
263The default `heddle help` curates the everyday surface (init, start, capture, merge,\n\
264log, status, review, discuss, context, undo, bridge). Everything else lives behind\n\
265this topic and `heddle help <verb> --help` for the full clap-derived docs.\n\
266\n\
267This is intentional. The everyday surface stays minimal so first-time users aren't\n\
268overwhelmed; agents and power users reach for the advanced affordances when they\n\
269need them.\n";
270
271const DAEMON_TOPIC: &str = "Two daemons — both have legitimate uses; they are not interchangeable.\n\
272\n\
273`heddle daemon`        — FUSE mount-daemon control plane. Owns FUSE sessions for\n\
274                         `--workspace light --daemon` threads. Linux only.\n\
275                         Subcommands: serve | status | stop.\n\
276\n\
277`heddle agent serve`   — Local gRPC daemon over a Unix socket inside the repo's\n\
278                         `.heddle/sockets/`. Hosts the local agent\n\
279                         services (state-review, discussion, signal, operation-log\n\
280                         query, hook) so agents avoid per-command\n\
281                         process startup latency. Mode: same-user only,\n\
282                         peer-cred check enforced. Out of scope for first ship:\n\
283                         multi-user, remote, TLS.\n";
284
285const OPERATION_IDS_TOPIC: &str = "Idempotency — every state-changing call accepts a `client_operation_id`.\n\
286\n\
287The same id replayed with the same body returns the original outcome\n\
288bit-identical; with a different body it returns FAILED_PRECONDITION.\n\
289\n\
290The dedup store is file-backed locally (`.heddle/state/operation_dedup.bin`,\n\
291rmp-serde, 7-day default retention) and Postgres-backed in hosted deployments.\n\
292\n\
293The CLI accepts `--op-id <UUID>` on every state-changing verb (or honours\n\
294`HEDDLE_OPERATION_ID`). Without an id, dedup is bypassed and the call\n\
295executes normally.\n";
296
297const REVIEW_TOPIC: &str = "Review surface — `heddle review show | sign | next | health`.\n\
298\n\
299`show <state>`    — render the review payload (summary, agent narrative,\n\
300                    in-budget signals, anchored discussions).\n\
301                    `--all-signals` also surfaces hidden ones.\n\
302`sign <state>`    — submit a `read | agent_preview | agent_co_review`\n\
303                    signature. `--symbols file:symbol` scopes to\n\
304                    specific symbols; default is the whole change.\n\
305`next`            — placeholder until the operation-log query layer wires real\n\
306                    pending-review selection.\n\
307`health [--window N]`\n\
308                  — per-module signal fire-rate over the last N states.\n\
309\n\
310Tick budget: at most 3 signals per state by default. Priority:\n\
311invariant_adjacency > self_flagged_uncertainty > pattern_deviation >\n\
312novelty > test_reachability.\n";
313
314const DISCUSS_TOPIC: &str = "`heddle discuss open | append | resolve | list | show`\n\
315\n\
316Discussions anchor at the symbol level (file + symbol name, no line range)\n\
317so they survive renames and cross-file moves. Each discussion accumulates\n\
318turns and resolves into one of three terminal states:\n\
319\n\
320- `resolve <id> --mode into-annotation`  with `--annotation-kind`,\n\
321  `--annotation-content`, optional `--annotation-tags`. Atomically\n\
322  creates the annotation and bidirectionally links it.\n\
323- `resolve <id> --mode by-edit`          with `--state` (defaults to HEAD).\n\
324  Records that a subsequent edit addressed the discussion.\n\
325- `resolve <id> --mode dismiss`          requires non-empty `--reason`.\n\
326\n\
327Visibility: `--visibility public|internal|team:NAME|restricted:LABEL`.\n\
328Defaults to the repo's namespace policy.\n";
329
330const BRIDGE_TOPIC: &str = "Bridge export footer + git notes.\n\
331\n\
332Every exported commit carries a footer at the tail of the commit message:\n\
333\n\
334    Heddle-State: <change_id>\n\
335    Heddle-URL: <hosted_url>/state/<change_id>     (omitted if no hosted URL)\n\
336    Heddle-Annotations-Omitted: <count>\n\
337\n\
338This is the durable record — every reader on every host sees it regardless\n\
339of remote configuration.\n\
340\n\
341Per-scope annotation drop counts and signal counts ride on the opt-in\n\
342git note at `refs/notes/heddle`. To fetch + push notes:\n\
343\n\
344    git config --add remote.origin.fetch '+refs/notes/heddle:refs/notes/heddle'\n\
345    git config --add remote.origin.push  'refs/notes/heddle:refs/notes/heddle'\n\
346\n\
347Then `git log --notes=heddle` displays the rich metadata inline.\n";
348
349const SIGNALS_TOPIC: &str = "Risk signals — five modules behind a pure trait.\n\
350\n\
351- `invariant_adjacency`        — fires when a changed symbol carries an\n\
352                                  Invariant or `enforces`-tagged annotation.\n\
353- `self_flagged_uncertainty`   — passthrough of agent-emitted self-flags\n\
354                                  from the captured state's intent.\n\
355- `pattern_deviation`          — fires when a symbol's body diverges\n\
356                                  from siblings or the prior version\n\
357                                  (tree-sitter token similarity).\n\
358- `novelty`                    — fires when a function shape is unique\n\
359                                  in the repo corpus.\n\
360- `test_reachability`          — fires when no test statically reaches\n\
361                                  the changed symbol via tree-sitter\n\
362                                  call-graph traversal. The reason text\n\
363                                  is honest: this is *not* runtime\n\
364                                  coverage.\n\
365\n\
366Configure under `[review.signals]` in `.heddle/config.toml`. Each module\n\
367ships fires-correctly + stays-quiet tests; defaults are conservative\n\
368so a fresh repo isn't noisy.\n";
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn everyday_verbs_in_curated_list_have_everyday_tier() {
376        for &verb in everyday_verbs() {
377            assert_eq!(tier_of(verb), Tier::Everyday, "{verb}");
378        }
379    }
380
381    #[test]
382    fn topic_text_returns_none_for_unknown() {
383        assert!(topic_text("definitely-not-a-topic").is_none());
384    }
385
386    #[test]
387    fn topic_text_returns_some_for_advertised_topics() {
388        for topic in [
389            "advanced",
390            "agent",
391            "daemon",
392            "operation-ids",
393            "idempotency",
394            "review",
395            "discuss",
396            "discussions",
397            "bridge",
398            "footer",
399            "notes",
400            "signals",
401            "risk-signals",
402        ] {
403            assert!(topic_text(topic).is_some(), "{topic}");
404        }
405    }
406
407    #[test]
408    fn tier_of_advanced_verbs_classifies_correctly() {
409        for &verb in advanced_verbs() {
410            let t = tier_of(verb);
411            assert!(
412                matches!(t, Tier::Advanced),
413                "expected Advanced for {verb}, got {t:?}"
414            );
415        }
416    }
417
418    #[test]
419    fn hidden_aliases_are_hidden() {
420        for verb in ["gc", "index", "monitor"] {
421            assert_eq!(tier_of(verb), Tier::Hidden, "{verb}");
422        }
423    }
424
425    /// Build-break property: every verb listed in `everyday_verbs` and
426    /// `advanced_verbs` that's compiled into the current build MUST
427    /// resolve to a clap subcommand with a non-empty `about`. Verbs
428    /// gated behind a feature that isn't enabled (e.g. `semantic` when
429    /// the `semantic` feature is off) are skipped — `print_help`
430    /// already skips them at render time. If a verb is renamed in the
431    /// `Commands` enum without a matching update here, this test
432    /// fails for whichever feature combo the variant lives in.
433    #[test]
434    fn verb_blurbs_resolve_from_clap() {
435        use clap::CommandFactory;
436        let cmd = crate::cli::Cli::command();
437        for &verb in everyday_verbs().iter().chain(advanced_verbs().iter()) {
438            // Feature-gated verbs may not be present in this build —
439            // skip them. The render path mirrors this.
440            let Some(subcommand) = cmd.get_subcommands().find(|sc| sc.get_name() == verb) else {
441                continue;
442            };
443            let blurb = about_first_line(&cmd, verb);
444            assert!(
445                !blurb.is_empty(),
446                "verb `{verb}` is a clap subcommand but its `about` \
447                 doc-comment is empty. The curated help printer needs \
448                 a non-empty first line. (subcommand seen: {:?})",
449                subcommand.get_name()
450            );
451        }
452    }
453}