Skip to main content

heddle_cli_contract/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 the authority-aware loop
6//! from the command contract table. Advanced affordances, automation,
7//! admin commands, and Git projection commands are reachable
8//! via `heddle help advanced` or `heddle help <topic>`. Per-verb help
9//! via `heddle <verb> --help` continues to derive from clap
10//! doc-comments.
11//!
12//! # Cultural deliverable
13//!
14//! The default help is **curated, not auto-generated**. Tier metadata
15//! lives in the command contract table so human help and machine
16//! command catalog output cannot drift. See `AGENTS.md` "CLI surface
17//! curation" for the full doctrine.
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Tier {
21    /// Front-door verbs in the core loop: setup, orient, isolate work,
22    /// commit, check readiness, inspect, integrate, recover, and
23    /// diagnose. See [`everyday_verbs`] for the authoritative list.
24    Everyday,
25    /// Reachable via `heddle help advanced` or `heddle help <topic>`.
26    /// Most agent-loop and operational verbs land here.
27    Advanced,
28    /// Hidden verbs that should not be advertised at all.
29    Hidden,
30}
31
32/// Stable name for each verb. Pure presentation — has no relation to the
33/// clap variant identifier so doc-comment regenerations don't churn this
34/// file.
35pub fn tier_of(verb: &str) -> Tier {
36    match crate::cli::commands::command_help_tier(verb) {
37        "everyday" => Tier::Everyday,
38        "hidden" => Tier::Hidden,
39        _ => Tier::Advanced,
40    }
41}
42
43/// Verbs that show in `heddle help`, in command-contract order. Blurbs
44/// are looked up at print time from the command catalog.
45pub fn everyday_verbs() -> Vec<&'static str> {
46    crate::cli::commands::root_commands_for_help_visibility("everyday")
47}
48
49/// The first screen of help is smaller than the complete everyday set:
50/// it shows the primary work loop, then points at setup, sync, proof,
51/// and recovery as nearby verbs.
52fn primary_loop_verbs(catalog: &crate::cli::commands::CommandCatalogOutput) -> Vec<&'static str> {
53    everyday_verbs()
54        .into_iter()
55        .filter(|verb| {
56            catalog
57                .command_by_display(verb)
58                .is_some_and(|entry| entry.help_rank <= 70)
59        })
60        .collect()
61}
62
63/// Verbs surfaced by `heddle help advanced`, in command-contract order.
64/// This includes power surfaces, automation/admin commands, and
65/// Git interop surfaces, each labeled by the contract table.
66pub fn advanced_verbs() -> Vec<&'static str> {
67    crate::cli::commands::root_commands_for_advanced_help()
68}
69
70/// Look up the catalog summary for a top-level command. Returns an empty
71/// string when the verb is feature-gated out of the current build.
72fn catalog_summary(catalog: &crate::cli::commands::CommandCatalogOutput, verb: &str) -> String {
73    catalog
74        .command_by_display(verb)
75        .map(|entry| entry.summary.clone())
76        .unwrap_or_default()
77}
78
79/// Entry point for the `Commands::Help { topics }` dispatch arm
80/// AND the bare-help intercept in `main.rs`. Routes between everyday
81/// / advanced / topic surfaces and falls through to clap-derived help
82/// for command paths without a dedicated topic.
83///
84/// All output goes to stdout (this is help, not diagnostic). Returns
85/// `Ok(())` even for unknown topics; the printer surfaces the
86/// suggestion text rather than erroring.
87pub fn print_help(cmd: &clap::Command, topic: &[String]) -> std::io::Result<()> {
88    crate::cli::render::write_stdout(&render_help(cmd, topic))
89        .map_err(|err| std::io::Error::other(err.to_string()))
90}
91
92/// Render the curated help surface to a `String` instead of stdout.
93///
94/// [`print_help`] is a thin `write_stdout(&render_help(..))` wrapper over
95/// this, so the bytes are identical. Extracted so in-process tests can
96/// assert on help prose without spawning the binary (HeddleCo/heddle#381).
97pub fn render_help(cmd: &clap::Command, topic: &[String]) -> String {
98    use std::fmt::Write;
99    let mut out = String::new();
100    match topic {
101        [] => {
102            let catalog = crate::cli::commands::build_command_catalog();
103            let _ = writeln!(out, "Heddle — agent-native version control");
104            let _ = writeln!(out);
105            let _ = writeln!(out, "Common loop:");
106            for name in primary_loop_verbs(&catalog) {
107                let blurb = catalog_summary(&catalog, name);
108                if blurb.is_empty() {
109                    continue;
110                }
111                let _ = writeln!(out, "  {:<10}  {}", name, blurb);
112            }
113            let _ = writeln!(out);
114            let _ = writeln!(
115                out,
116                "Existing Git: heddle status -> heddle init -> heddle capture -m \"...\" -> heddle commit -> heddle push"
117            );
118            let _ = writeln!(
119                out,
120                "Isolated work: heddle start <name> --path ../<name> -> heddle capture -m \"...\" -> heddle ready -> heddle land"
121            );
122            let _ = writeln!(out);
123            let _ = writeln!(
124                out,
125                "Nearby: `heddle pull`, `heddle undo`, and `heddle verify`."
126            );
127            let _ = writeln!(
128                out,
129                "Start here: `heddle init`, `heddle clone`, or `heddle capture`."
130            );
131            let _ = writeln!(
132                out,
133                "In Git Overlay, Heddle uses its embedded Sley engine to operate directly on the checkout's `.git`."
134            );
135            let _ = writeln!(
136                out,
137                "Coming from Git? Run `heddle help git-concepts` for the concept map."
138            );
139            let _ = writeln!(out);
140            // The ONE place the --output machine-contract blurb is stated
141            // in full on a help screen; per-command --help carries only the
142            // one-line global flag plus the `heddle help output-formats`
143            // breadcrumb (heddle#652).
144            let _ = writeln!(
145                out,
146                "Output: text is the default; pass `--output json` for the \
147                 full machine contract (stable `output_kind`, exit codes, recovery \
148                 templates), or `--output json-compact` for the decision surface \
149                 only (fewer tokens, same `output_kind`). No TTY/pipe auto-detection. \
150                 Details: `heddle help output-formats`."
151            );
152            let _ = writeln!(out);
153            let _ = writeln!(
154                out,
155                "Run `heddle help model` for the short mental model, \
156                 `heddle help advanced` for power surfaces, automation, and Git interop, \
157                 or `heddle help <topic>` for a topic page (e.g. `git-concepts`, \
158                 `git-overlay`, \
159                 `threads`, `daemon`, `signals`, `git-projection`, `operation-ids`, \
160                 `remotes`, `output-formats`, `git-dependencies`)."
161            );
162        }
163        [name] if name == "advanced" => {
164            let catalog = crate::cli::commands::build_command_catalog();
165            let _ = writeln!(out, "{}", ADVANCED_HELP);
166            // Grouped by area from the command contract table — the
167            // group is registration data (`help_category` / surface),
168            // not a hand-maintained help string (heddle#652). The group
169            // header replaces the old per-line `[advanced]`-style
170            // surface label; only canonical redirects stay on the line.
171            for (title, verbs) in crate::cli::commands::advanced_help_groups() {
172                let mut lines = Vec::new();
173                for name in verbs {
174                    let blurb = catalog_summary(&catalog, name);
175                    if blurb.is_empty() {
176                        continue;
177                    }
178                    let canonical = crate::cli::commands::command_canonical_command(name)
179                        .map(|canonical| format!(" [use `{canonical}`]"))
180                        .unwrap_or_default();
181                    lines.push(format!("  {name:<14}  {blurb}{canonical}"));
182                }
183                if lines.is_empty() {
184                    continue;
185                }
186                let _ = writeln!(out, "{title}:");
187                for line in lines {
188                    let _ = writeln!(out, "{line}");
189                }
190                let _ = writeln!(out);
191            }
192        }
193        [name] if topic_text(name).is_some() => {
194            let _ = writeln!(out, "{}", topic_text(name).expect("checked above"));
195        }
196        path => {
197            if let Some(mut subcommand) = help_command_for_path(cmd, path) {
198                // `heddle help <command path>` falls through to that
199                // command's clap-derived help so the contract on
200                // `Commands::Help` holds for nested public paths too.
201                let _ = write!(out, "{}", subcommand.render_help());
202            } else {
203                let name = path.join(" ");
204                let _ = writeln!(
205                    out,
206                    "no topic or command '{name}'. Run `heddle help advanced` for \
207                     the full advanced list, or `heddle help` for the \
208                     curated everyday surface."
209                );
210            }
211        }
212    }
213    out
214}
215
216pub fn print_direct_help_for_raw(
217    cmd: &clap::Command,
218    raw: &[String],
219) -> Option<std::io::Result<()>> {
220    let rendered = render_direct_help_for_raw(cmd, raw)?;
221    Some(
222        crate::cli::render::write_stdout(&rendered)
223            .map_err(|err| std::io::Error::other(err.to_string())),
224    )
225}
226
227/// Render the `heddle <path> --help` pre-parse help to a `String`.
228///
229/// In-process sibling of [`print_direct_help_for_raw`]; the printer is a
230/// `write_stdout` wrapper over this so the bytes match (HeddleCo/heddle#381).
231pub fn render_direct_help_for_raw(cmd: &clap::Command, raw: &[String]) -> Option<String> {
232    let path = command_path_from_raw_help_request(cmd, raw)?;
233    Some(match help_command_for_path(cmd, &path) {
234        Some(mut subcommand) => {
235            // clap's long renderer unconditionally uses the spaced
236            // next-line layout (one blank line per flag), tripling the
237            // height of helps whose flags are all one-liners. Render the
238            // compact layout whenever the long layout would add no
239            // information; commands with real long-form flag docs keep
240            // the spaced layout so their exposition stays readable
241            // (heddle#652).
242            if command_has_long_help_content(&subcommand) {
243                subcommand.render_long_help().to_string()
244            } else {
245                subcommand.render_help().to_string()
246            }
247        }
248        None => render_help(cmd, &path),
249    })
250}
251
252/// Whether `command`'s help carries long-form content that clap's compact
253/// (`-h`-style) renderer would drop: a long about, a long after-help, or
254/// a visible argument with a multi-paragraph long help or per-value docs.
255/// When this is false the compact and spaced layouts carry identical
256/// information, so [`render_direct_help_for_raw`] picks the compact one.
257fn command_has_long_help_content(command: &clap::Command) -> bool {
258    command.get_long_about().is_some()
259        || command.get_after_long_help().is_some()
260        || command.get_arguments().any(|arg| {
261            !arg.is_hide_set()
262                && (arg.get_long_help().is_some()
263                    || arg
264                        .get_possible_values()
265                        .iter()
266                        .any(|value| value.get_help().is_some()))
267        })
268}
269
270/// Clap arg ids of the agent-automation flags on `capture` that are
271/// `hide = true` in the everyday surface. Kept in sync with
272/// `SnapshotArgs` (see `cli_args/commands_args.rs`); the
273/// `capture_agent_flags_hidden_by_default_revealed_on_demand` test fails
274/// if an id here no longer maps to a hidden `capture` arg.
275const CAPTURE_AGENT_FLAG_IDS: &[&str] = &[
276    "agent_provider",
277    "agent_model",
278    "agent_session",
279    "agent_segment",
280    "policy",
281    "no_policy",
282    "no_agent",
283    "split",
284    "into",
285    "paths",
286];
287
288/// Return a copy of `command` with the agent-automation flags un-hidden,
289/// so `print_long_help` renders them. Used by `capture --help-agent`.
290fn reveal_capture_agent_flags(command: clap::Command) -> clap::Command {
291    command.mut_args(|arg| {
292        if CAPTURE_AGENT_FLAG_IDS.contains(&arg.get_id().as_str()) {
293            arg.hide(false)
294        } else {
295            arg
296        }
297    })
298}
299
300/// Render `capture`'s clap-derived help with the hidden agent-automation
301/// flags revealed. Called from the `Commands::Capture` dispatch arm once
302/// clap has parsed `--help-agent` (a first-class flag on `capture`).
303///
304/// Because clap owns the parsing, every global spelling it accepts —
305/// `-C <path>`, `--output <fmt>`, clustered `-vC <path>`, attached forms, in
306/// any legal position — is handled natively; there is no hand-rolled
307/// pre-parse token scan to keep in sync with clap's grammar.
308pub fn print_capture_agent_help(cmd: &clap::Command) -> std::io::Result<()> {
309    crate::cli::render::write_stdout(&render_capture_agent_help(cmd))
310        .map_err(|err| std::io::Error::other(err.to_string()))
311}
312
313/// Render `capture --help-agent` to a `String` (the reveal-help variant).
314///
315/// In-process sibling of [`print_capture_agent_help`]; the printer is a
316/// `write_stdout` wrapper over this so the bytes match (HeddleCo/heddle#381).
317pub fn render_capture_agent_help(cmd: &clap::Command) -> String {
318    let capture = find_subcommand_or_alias(cmd, "capture")
319        .expect("capture subcommand exists in the clap command tree");
320    let bin_name = format!("{} {}", cmd.get_name(), capture.get_name());
321    let mut help = reveal_capture_agent_flags(capture.clone()).bin_name(bin_name);
322    help.render_long_help().to_string()
323}
324
325fn help_command_for_path(cmd: &clap::Command, path: &[String]) -> Option<clap::Command> {
326    if path.is_empty() {
327        return None;
328    }
329
330    let mut current = cmd;
331    let mut bin_name = cmd.get_name().to_string();
332    let mut canonical_path = Vec::new();
333    for part in path {
334        let subcommand = find_subcommand_or_alias(current, part)?;
335        bin_name.push(' ');
336        bin_name.push_str(part);
337        canonical_path.push(subcommand.get_name().to_string());
338        current = subcommand;
339    }
340
341    let mut help = current.clone().bin_name(bin_name);
342    for arg in cmd
343        .get_arguments()
344        .filter(|arg| arg.is_global_set() && !arg.is_hide_set())
345    {
346        help = help.arg(arg.clone());
347    }
348    if crate::cli::commands::command_runtime_contract(&canonical_path.join(" "))
349        .is_some_and(|contract| contract.supports_op_id)
350        && let Some(arg) = cmd
351            .get_arguments()
352            .find(|arg| arg.get_long() == Some("op-id"))
353    {
354        help = help.arg(arg.clone().hide(false).value_name("UUID"));
355    }
356    Some(help)
357}
358
359/// Whether `token` names an option on `command` — in either its `--long`
360/// or `-x` separated spelling — and, if so, whether the following token is
361/// that option's value. Used by the `heddle <path> --help` pre-parse scan
362/// (`command_path_from_raw_help_request`) so a valued global before the verb
363/// (`-C <path>`, `--output <fmt>`) isn't mistaken for the command path.
364/// Derived from clap's own arg definitions, so short and long forms stay
365/// covered as globals are added or renamed.
366///
367/// Only the *separated* spellings (`-C path`, `--output fmt`) need a
368/// following-value skip. Attached spellings (`--output=fmt`, `-Cpath`,
369/// `-C=path`) carry the value in the same token, so they never set
370/// `skip_next`; the leading-dash fall-through in the scan loop already
371/// drops them without consuming the next token.
372fn global_option_takes_value(command: &clap::Command, token: &str) -> Option<bool> {
373    command
374        .get_arguments()
375        .find(|arg| {
376            arg.get_long()
377                .is_some_and(|long| token == format!("--{long}"))
378                || arg
379                    .get_short()
380                    .is_some_and(|short| token == format!("-{short}"))
381        })
382        .map(|arg| arg.get_action().takes_values())
383}
384
385fn find_subcommand_or_alias<'a>(
386    command: &'a clap::Command,
387    name: &str,
388) -> Option<&'a clap::Command> {
389    command.find_subcommand(name).or_else(|| {
390        command
391            .get_subcommands()
392            .find(|subcommand| subcommand.get_all_aliases().any(|alias| alias == name))
393    })
394}
395
396fn command_path_from_raw_help_request(cmd: &clap::Command, raw: &[String]) -> Option<Vec<String>> {
397    if !raw.iter().any(|arg| arg == "--help" || arg == "-h") {
398        return None;
399    }
400    if raw
401        .iter()
402        .all(|arg| arg == "--help" || arg == "-h" || arg.starts_with('-'))
403    {
404        return None;
405    }
406
407    let mut current = cmd;
408    let mut path = Vec::new();
409    let mut skip_next = false;
410    for token in raw {
411        if skip_next {
412            skip_next = false;
413            continue;
414        }
415        if token == "--help" || token == "-h" {
416            continue;
417        }
418        if let Some(takes_value) = global_option_takes_value(current, token) {
419            skip_next = takes_value;
420            continue;
421        }
422        if token.starts_with('-') {
423            continue;
424        }
425        if let Some(subcommand) = find_subcommand_or_alias(current, token) {
426            path.push(subcommand.get_name().to_string());
427            current = subcommand;
428        }
429    }
430
431    (!path.is_empty()).then_some(path)
432}
433
434/// Render the help that `heddle <args>` would print, **in-process**, for the
435/// help-shaped argv forms — without spawning the binary.
436///
437/// `args` is the argv *after* the program name (e.g. `["clone", "--help"]`,
438/// `["help", "threads"]`, `["capture", "--help-agent"]`). Returns `Some(text)`
439/// with the exact bytes the binary writes to stdout for that request, or
440/// `None` when the argv is not a pure help request this renderer serves (the
441/// caller should fall back to spawning the binary).
442///
443/// This mirrors `main.rs`'s help-dispatch routing precisely: the bare-help
444/// intercept, the `heddle <path> --help` pre-parse, the `capture --help-agent`
445/// reveal, and the `heddle help <topics>` arm. Because the underlying printers
446/// are now `write_stdout(&render_*(..))` wrappers, the in-process text is
447/// byte-identical to the spawned binary's stdout — only the execution
448/// mechanism differs (HeddleCo/heddle#381). Help is repo-, cwd-, and
449/// env-independent, so this is safe to call from parallel tests.
450pub fn render_for_args(args: &[&str]) -> Option<String> {
451    use clap::{CommandFactory, Parser};
452
453    use crate::cli::cli_args::{Cli, Commands};
454
455    let command = Cli::command();
456    let raw: Vec<String> = args.iter().map(|arg| (*arg).to_string()).collect();
457
458    // Bare-help shapes: `heddle`, `heddle help`. (`--help`/`-h` alone are
459    // clap-driven help that exits during parse; serve them too since they
460    // render the curated everyday surface.)
461    if raw.is_empty() || raw == ["--help"] || raw == ["-h"] || raw == ["help"] {
462        return Some(render_help(&command, &[]));
463    }
464
465    // `heddle <path> --help` pre-parse direct help (e.g. `clone --help`,
466    // `import git --help`).
467    if let Some(rendered) = render_direct_help_for_raw(&command, &raw) {
468        return Some(rendered);
469    }
470
471    // `capture --help-agent` reveal: clap owns the parse, so every global
472    // spelling it accepts is handled natively.
473    if let Ok(cli) = Cli::try_parse_from(std::iter::once("heddle".to_string()).chain(raw.clone()))
474        && let Commands::Help { topics } = &cli.command
475    {
476        // `heddle help <topics>` — curated topic / advanced / command-path help.
477        return Some(render_help(&command, topics));
478    }
479    if let Ok(cli) = Cli::try_parse_from(std::iter::once("heddle".to_string()).chain(raw.clone()))
480        && let Commands::Capture(args) = &cli.command
481        && args.help_agent
482    {
483        return Some(render_capture_agent_help(&command));
484    }
485
486    None
487}
488
489/// Static per-topic help. Topics are addressed via `heddle help <topic>`.
490pub fn topic_text(topic: &str) -> Option<&'static str> {
491    Some(match topic {
492        "advanced" => ADVANCED_HELP,
493        "agent-flags" => AGENT_FLAGS_TOPIC,
494        "agent" | "daemon" => DAEMON_TOPIC,
495        "output-formats" | "output-format" | "output" => OUTPUT_FORMATS_TOPIC,
496        "clone" => CLONE_TOPIC,
497        "git-overlay" => GIT_OVERLAY_TOPIC,
498        "git-concepts" | "git-concept-map" | "git-veteran" => GIT_CONCEPTS_TOPIC,
499        "model" | "mental-model" | "concepts" => MODEL_TOPIC,
500        "threads" => THREADS_TOPIC,
501        "operation-ids" | "idempotency" => OPERATION_IDS_TOPIC,
502        "remotes" => REMOTES_TOPIC,
503        "git-dependencies" | "git-deps" | "git-dependency" => GIT_DEPENDENCIES_TOPIC,
504        "review" => REVIEW_TOPIC,
505        "discuss" | "discussions" => DISCUSS_TOPIC,
506        "git-projection" | "git-projections" | "footer" | "notes" => GIT_PROJECTION_TOPIC,
507        "signals" | "risk-signals" => SIGNALS_TOPIC,
508        _ => return None,
509    })
510}
511
512const ADVANCED_HELP: &str = "Advanced commands for power users, agents, automation, Git interop, and recovery.\n\
513\n\
514The default `heddle help` curates the authority-aware loop: init/adopt/clone,\n\
515status/diff/commit/start, ready/land/push/pull, resolve/continue/abort,\n\
516doctor/verify. Power nouns such as thread/remote/Git projection/agent and\n\
517Git projection commands live behind this topic. Use `heddle help\n\
518<verb>` for curated topics or `heddle <verb> --help` for the full clap-derived\n\
519docs.\n\
520\n\
521This is intentional. The everyday surface stays minimal so first-time users aren't\n\
522overwhelmed; agents and power users reach for the advanced affordances when they\n\
523need them.\n";
524
525// The single full statement of the --output machine contract (plus the
526// one-paragraph version on the top-level `heddle help`). The global
527// `--output` flag's own help is one line pointing here, so the contract
528// is not restated on every command's --help (heddle#652).
529const OUTPUT_FORMATS_TOPIC: &str = r#"Output formats — `--output text | json | json-compact`.
530
531`text` is the default, always. There is no TTY/pipe auto-detection — the
532default never switches under you, so scripts and humans see the same thing
533until a flag says otherwise.
534
535`--output json` emits the full machine contract: a stable `output_kind`
536discriminator, exit codes, and recovery templates. Schemas per verb:
537`heddle schemas <verb>`; the catalog of which commands emit what:
538`heddle help --output json`.
539
540`--output json-compact` emits only the decision-surface fields —
541`output_kind`, `status`/`coordination_status`, `blockers`, `next_action`,
542`changed_paths`, `conflicts` — fewer tokens, same `output_kind`, so callers
543can still dispatch on it. Commands advertise `supports_json_compact` in the
544command catalog.
545
546Related: `heddle help operation-ids` for idempotent retries, `heddle help
547agent-flags` for capture attribution overrides.
548"#;
549
550// `heddle clone --help` keeps the signature + flags + a one-screen
551// summary; this topic carries the full default-thread fallback chain and
552// --depth exposition that used to bloat the after-help (heddle#652).
553const CLONE_TOPIC: &str = r#"Cloning — Git Overlay and native Heddle repositories.
554
555    heddle clone <remote> <dir> [--thread <name>] [--depth <n>]
556
557Run `heddle clone --help` for the flag list.
558
559# Repository authority
560
561- A Git source is streamed by Sley directly into the destination `.git`, then
562  initialized as Git Overlay. No Git executable or `.heddle/git` mirror is used.
563- A native source is cloned into Heddle-owned storage.
564- Native clones target `main` directly; if the remote has no `main` thread,
565  pass `--thread <name>` to select one.
566- Clone never prompts.
567
568# Shallow clones (--depth)
569
570--depth 0 (the default) clones full history. --depth N fetches only the
571tip plus N generations of ancestry (--depth 1: the tip plus its immediate parents),
572so `heddle log` stops at the depth boundary; history older than that is
573not present locally — re-clone at a greater --depth (or --depth 0) to
574obtain it.
575
576Depth controls native Heddle history extent only — how many states the clone fetches —
577and says nothing about object contents. Whether a state's blobs are
578present locally or fetched lazily is a separate concern that `--depth`
579never governs. Git Overlay clones ingest full history and reject partial-history
580options. Advanced/planned flags `--lazy` and `--filter blob:none`
581skip blob content and hydrate it on demand for hosted/network Heddle
582remotes; local clone paths reject them today.
583
584See `heddle help threads` for the thread model and `heddle help remotes`
585for remote management.
586"#;
587
588const AGENT_FLAGS_TOPIC: &str = r#"Agent automation flags for `heddle capture`.
589
590These flags are hidden from the everyday `heddle capture --help` so it stays
591terse for human use. They let an automated caller override agent attribution
592and split captures across threads. Run `heddle capture --help-agent` to see
593them inline in capture's own help.
594
595Attribution overrides:
596
597  --agent-provider <NAME>   Override HEDDLE_AGENT_PROVIDER.
598  --agent-model <NAME>      Override HEDDLE_AGENT_MODEL.
599  --agent-session <ID>      Set the session id for this capture.
600  --agent-segment <ID>      Set the session segment for this capture.
601  --policy <ID>             Override HEDDLE_AGENT_POLICY.
602  --no-policy               Omit policy attribution.
603  --no-agent                Omit agent attribution.
604
605Path splitting (no env equivalent):
606
607  --split                   Split selected paths into another thread instead of
608                            capturing the whole worktree.
609  --into <THREAD>           Target thread when using --split.
610  --path <PATH>             Repository-relative path prefix to include with
611                            --split (repeatable).
612
613Attribution precedence (highest first): explicit flag, active thread actor,
614supported agent env var, harness probe, active session, user config, repo config. See
615`crates/cli/src/cli/commands/snapshot.rs` for the full cascade.
616"#;
617
618const DAEMON_TOPIC: &str = "The mount daemon owns virtualized workspace sessions.\n\
619\n\
620`heddle daemon`        — FUSE mount-daemon control plane. Owns FUSE sessions for\n\
621                         `--workspace virtualized --daemon` threads. Linux only.\n\
622                         Subcommands: serve | status | stop.\n";
623
624const MODEL_TOPIC: &str = r#"Heddle mental model — the everyday loop in one screen.
625
626Heddle is built around saved states and isolated threads. Git compatibility is
627an output and interop layer, not the thing you have to think about first.
628
629Core nouns:
630
631- State: a captured tree with a stable change id, attribution, intent, and
632  provenance. States are what `log`, `show`, `diff`, `undo`, and agents can
633  reason about.
634- Thread: a named line of work with its own checkout and captured history.
635  Use it for risky edits, agent work, or parallel experiments without
636  serializing everything through one checkout.
637- Capture: the Heddle save boundary for provenance, undo, and review.
638- Commit: publish captured source history to `.git` in Git Overlay.
639- Verify: the proof surface. It says whether Heddle, Git mapping, worktree,
640  remotes, active operations, clone state, and machine contracts agree.
641
642Everyday loop:
643
644    heddle status
645    heddle diff
646    heddle capture -m "..."
647    heddle commit
648    heddle start <name> --path ../<name>
649    heddle ready
650    heddle land --thread <name>
651    heddle undo
652    heddle verify
653
654Existing Git checkout:
655
656    heddle status
657    heddle init                  # initialize Heddle metadata; Git commits stay in .git
658    heddle capture -m "..."
659    heddle commit                # Sley writes directly to .git
660    heddle verify
661
662If a command refuses, read the first `Next:` line. Heddle fails closed when it
663cannot prove the move is safe.
664"#;
665
666const GIT_CONCEPTS_TOPIC: &str = r#"Git and Heddle own different layers.
667
668In a Git Overlay repository, the checkout's real `.git` owns commits, refs,
669packs, the index, and worktree state. Heddle's thin Git surface — `clone`,
670`commit`, `pull`, `push`, and `remote` — uses the embedded Sley engine directly
671against that store. Heddle owns coordination and durable metadata in `.heddle`:
672captures, provenance, threads, readiness, review, and safe landing. Normal
673overlay operation neither creates nor uses `.heddle/git`. Legacy explicit Git
674Projection maintenance can still use an existing Bridge Mirror while its
675retirement is completed.
676
677Use `heddle init` to add that sidecar to an existing Git checkout. Use
678`heddle adopt` when you want one atomic transition that imports source history,
679makes Heddle the repository authority, and enables the full native feature set.
680
681Common mappings:
682
683| Intent | Git Overlay | Native Heddle |
684|--------|-------------|---------------|
685| Save source history | `heddle capture`, then `heddle commit` | `heddle capture` |
686| Isolate coordinated work | `heddle start` | `heddle start` |
687| Record a granular Heddle savepoint | `heddle capture` | `heddle capture` |
688| Check integration readiness | `heddle ready` | `heddle ready` |
689| Integrate a managed thread | `heddle land` | `heddle land` |
690| Synchronize source | `heddle pull` / `heddle push` | `heddle pull` / `heddle push` |
691| Configure remotes | `heddle remote` | `heddle remote` |
692| Inspect source history | another Git-compatible client | `heddle log` |
693
694Heddle intentionally does not reproduce the full Git command surface. An
695optional Git-compatible client can perform unsupported Git operations against
696the same `.git`; it is not a Heddle dependency. Explicit `import git`, `export
697git`, and `sync git` translate data between authorities. After `heddle adopt`,
698the retained `.git` is an explicit Git Projection adapter; it no longer selects
699repository source authority.
700"#;
701
702const THREADS_TOPIC: &str = "Threads — Heddle's unit of in-progress work.\n\
703\n\
704A thread is a named line of work with its own checkout, its own captured\n\
705history, and a target it eventually merges into. It is not a Git branch. In\n\
706Git Overlay, the branch remains part of Git source storage; the Heddle thread\n\
707owns coordination and captured history. You start isolated work with\n\
708`heddle start <name> --path <dir>`, switch\n\
709between threads with `heddle thread switch <name>`, and integrate with\n\
710`heddle land` (or check readiness without merging via `heddle ready`).\n\
711\n\
712# Threads vs. git branches\n\
713\n\
714- A thread carries an isolated checkout (its own directory), captured\n\
715  state history, agent/task metadata, a freshness verdict against its\n\
716  target, and a workflow state (Ready/Blocked/Merged/...). A git branch\n\
717  is just a ref.\n\
718- Multiple threads coexist on disk simultaneously. Each thread's working\n\
719  tree is its own.\n\
720- `heddle capture` records Heddle metadata; `heddle commit` asks Sley to\n\
721  write source history directly to `.git` in Git Overlay.\n\
722\n\
723# Workspace modes (`--workspace`)\n\
724\n\
725The `--workspace` flag on `heddle start` selects how the thread's\n\
726checkout is realized on disk. These are storage strategies, not\n\
727workflow states:\n\
728\n\
729- `materialized` — clonefile/reflink the captured tree into the thread's\n\
730  directory (APFS / btrfs / XFS-with-reflinks / bcachefs / ReFS). Real\n\
731  `read(2)`-able bytes; ~zero disk cost until the agent diverges blocks.\n\
732  Day-one default on reflink-capable hosts.\n\
733- `virtualized` — project the captured tree through a content-addressed\n\
734  FUSE/FSKit/ProjFS mount. Nothing on disk until the kernel asks.\n\
735  Requires the `mount` feature.\n\
736- `solid` — full file copies, no shared extents. Strong isolation;\n\
737  the right choice on ext4/NTFS hosts that have neither reflinks nor a\n\
738  usable mount API.\n\
739- `auto` (default) — pick `materialized` when reflinks are available,\n\
740  `virtualized` when a mount is available, otherwise `solid`.\n\
741\n\
742A `solid` thread and a `materialized` thread are interchangeable from\n\
743the workflow's point of view — `capture`, `ready`, and `land` behave\n\
744identically. The mode only controls bytes-on-disk semantics.\n\
745\n\
746# Isolated checkout path\n\
747\n\
748- Use `heddle start <name> --path <dir>` when you want an isolated\n\
749  checkout. It creates the thread ref and materializes the checkout in\n\
750  one step.\n\
751- Advanced split form: `heddle thread create <name>` creates only the\n\
752  ref, and `heddle thread promote <name> --path <dir>` materializes it\n\
753  later. Use this only when you intentionally need to create the ref\n\
754  now and materialize the checkout later.\n\
755- `--workspace` on `heddle start` selects byte storage for that checkout;\n\
756  it is not a separate workflow path.\n\
757\n\
758# Sync: stale\n\
759\n\
760A thread is `current` when its base is the tip of its target, and\n\
761`stale` once the target has advanced past it. `heddle status` and\n\
762`heddle thread show` print this as `Sync: stale`.\n\
763\n\
764Resolution paths:\n\
765\n\
766- `heddle sync` — refresh the current thread onto its target when\n\
767  the replay is clean. The fast path for a stale thread with no\n\
768  conflicts.\n\
769- If `sync` reports conflicts or other blockers, use\n\
770  `heddle resolve` or `heddle continue` to handle the conflicts.\n\
771- `heddle land` will refresh-then-merge for you when the replay is\n\
772  clean; it fails closed when manual resolution is required.\n\
773\n\
774# Changing active work\n\
775\n\
776- `heddle thread switch <name>` changes which *thread* is active.\n\
777  Each thread has its own checkout; switching may auto-capture\n\
778  outstanding work on the thread you're leaving. Pair with the shell\n\
779  hook (`heddle shell init`) to auto-cd into the target thread's\n\
780  directory.\n\
781- In Git Overlay, branch switching remains outside Heddle's deliberately\n\
782  narrow Git surface. An optional Git-compatible client can update `.git`\n\
783  without changing Heddle's active thread or coordination metadata.\n\
784\n\
785# Capture and Git commits\n\
786\n\
787- `heddle capture` records a recoverable Heddle step on the current\n\
788  thread — for undo, provenance, and review. Captures are\n\
789  fine-grained and accumulate freely as work progresses.\n\
790- In Git Overlay, run `heddle commit` when captured source history is ready.\n\
791- Agents and tools can take many small captures without producing noisy Git history.\n\
792\n\
793See also: `heddle help advanced` for the full operational surface,\n\
794`heddle thread --help` for the thread subcommand list.\n";
795
796const OPERATION_IDS_TOPIC: &str = "Idempotency — machine retries for supported mutating commands.\n\
797\n\
798Commands that advertise `supports_op_id: true` in `heddle help --output json`\n\
799accept `--op-id <UUID>` or `HEDDLE_OPERATION_ID`. Replaying the same id\n\
800with the same body returns the recorded outcome; with a different body it\n\
801returns a typed conflict.\n\
802\n\
803`op_id_behavior: explicit_replay` means the caller must provide the id.\n\
804`op_id_behavior: generated_resume` is reserved for commands that also\n\
805advertise `persists_op_id: true` and can save a generated id across an\n\
806interrupted retry loop. Commands with `op_id_behavior: none` reject --op-id.\n\
807\n\
808The dedup store is file-backed locally (`.heddle/state/operation_dedup.bin`,\n\
809rmp-serde, 7-day default retention) and Postgres-backed in hosted deployments.\n\
810\n\
811Without an id, dedup is bypassed and the call executes normally. For the\n\
812authoritative per-command contract, use `heddle help --output json`.\n";
813
814const REMOTES_TOPIC: &str = r#"Remotes — dispatched by repository source authority.
815
816Common loop:
817
818    heddle remote add origin <url-or-path>
819    heddle remote set-default origin
820    heddle pull
821    heddle push
822    heddle verify
823
824Remote values may be Git URLs, hosted endpoints, or local paths. `push` and
825`pull` use the default remote unless a positional remote is supplied. In Git
826Overlay, Sley reads and edits the repository's Git configuration and streams
827objects directly between the remote and `.git`. In Native Heddle, the same
828verbs use Heddle transport and storage. The Git executable is not involved.
829
830When a remote action is unsafe, Heddle reports the blocker and one primary next
831command for the active source authority. `heddle verify` reports repository
832verification state before and after remote operations.
833"#;
834
835const GIT_DEPENDENCIES_TOPIC: &str = r#"Git executable dependencies — Heddle does not have one.
836
837Heddle never requires the `git` executable. Sley is its embedded Git engine. In
838Git Overlay, `heddle clone`, `commit`, `pull`, `push`, and `remote`
839operate on the checkout's real `.git` directly; `.heddle` contains Heddle
840metadata, not a second Git object store.
841
842If Heddle detects an externally-started Git sequencer operation, it leaves Git
843metadata, refs, index, and worktree files unchanged and reports a Heddle
844preservation command. Finish or abort that operation with the optional
845Git-compatible client that started it, then run `heddle verify`.
846
847Operations outside Heddle's narrow Git surface can be performed by another
848Git-compatible client, but that client is optional and is never invoked by
849Heddle. Unsupported capabilities fail closed.
850
851Run `heddle help --output json` to inspect the public command surface, and
852`heddle doctor` / `heddle fsck --full` when a repository reports integrity
853or Git Projection state problems.
854"#;
855
856const REVIEW_TOPIC: &str = "Review surface — `heddle review show | sign | next | health`.\n\
857\n\
858`show <state>`    — render the review payload (summary, agent narrative,\n\
859                    in-budget signals, anchored discussions).\n\
860                    `--all-signals` also surfaces hidden ones.\n\
861`sign <state>`    — submit a `read | agent_preview | agent_co_review`\n\
862                    signature. `--symbols file:symbol` scopes to\n\
863                    specific symbols; default is the whole change.\n\
864`next`            — show the next locally discoverable review item, or explain\n\
865                    why none is available.\n\
866`health [--window N]`\n\
867                  — per-module signal fire-rate over the last N states.\n\
868\n\
869Tick budget: at most 3 signals per state by default. Priority:\n\
870invariant_adjacency > self_flagged_uncertainty > pattern_deviation >\n\
871novelty > test_reachability.\n";
872
873const DISCUSS_TOPIC: &str = "`heddle discuss open | append | resolve | reopen | list | show`\n\
874\n\
875Discussions are stable records in the repository collaboration log. Turns,\n\
876resolutions, and reopenings append immutable operations; concurrent turns\n\
877converge without rewriting source history. Symbol anchors record the state,\n\
878file, and symbol where the discussion began:\n\
879\n\
880- `resolve <id> --mode by-edit`          with `--state` (defaults to HEAD).\n\
881  Records that a subsequent edit addressed the discussion.\n\
882- `resolve <id> --mode dismiss`          requires non-empty `--reason`.\n\
883- `reopen <id> --reason <text>`          compensates a prior resolution.\n\
884\n\
885Visibility: `--visibility public|internal|team:NAME|restricted:LABEL|private:LABEL`.\n\
886Empty visibility uses the configured discussion visibility policy.\n";
887
888const GIT_OVERLAY_TOPIC: &str = r#"Git Overlay workflow
889
890Use this when `.git` should remain the source authority while Heddle adds
891captures, isolated threads, merge previews, undo, provenance, and machine-safe
892JSON in `.heddle`. Sley is the embedded Git engine; no Git executable is
893required. Normal Git Overlay operation neither creates nor reads `.heddle/git`.
894
895Start in an existing Git checkout:
896
897    heddle status
898    heddle init                               # add Heddle metadata
899    heddle verify
900
901Save and synchronize ordinary work:
902
903    heddle diff
904    heddle capture -m "..."                   # save Heddle metadata and provenance
905    heddle commit                             # Sley writes source history to .git
906    heddle pull
907    heddle push
908
909Isolate risky work:
910
911    heddle start <name> --path ../<name>
912    cd ../<name>
913    heddle capture -m "..."
914    heddle ready
915    cd -
916    heddle land --thread <name>
917    heddle push
918
919Recover or prove state:
920
921    heddle undo
922    heddle verify
923
924State-specific recovery:
925
926    Worktree has unsaved edits: heddle capture -m "...", then heddle commit
927    Move atomically to the full Native Heddle feature set: heddle adopt --ref <branch>
928"#;
929
930const GIT_PROJECTION_TOPIC: &str = r#"Git Projection — translate between Native Heddle and Git.
931
932Git Projection is the explicit interoperability adapter for Native Heddle
933repositories. It is not Git Overlay: when `.git` remains authoritative, Sley
934operates on that repository directly and normal operation never reads or creates
935`.heddle/git`. Heddle does not require the Git executable in either mode.
936
937Move an existing Git repository to Native Heddle atomically:
938
939    heddle adopt --ref <branch>
940
941Translate explicitly without changing source authority:
942
943    heddle import git --path <git-repository> --ref <branch>
944    heddle export git --destination <bare-git-repository>
945    heddle sync git --path <git-repository>
946
947`import git` and `sync git` accept a local path or Git URL. Omit `--ref` to
948import all local branches and tags. `export git` writes a bare Git repository.
949Legacy migration and repair may still read an existing Bridge Mirror at
950`.heddle/git` while ADR 0042's retirement work remains incomplete.
951
952Export metadata for Git readers:
953
954Every exported commit carries a footer at the tail of the commit message:
955
956    Heddle-State: <state_id>
957    Heddle-URL: <hosted_url>/state/<state_id>     (omitted if no hosted URL)
958    Heddle-Annotations-Omitted: <count>
959
960This is the durable record — every reader on every host sees it regardless
961of remote configuration.
962
963Per-scope annotation drop counts and signal counts ride on the opt-in
964Git note at `refs/notes/heddle`. Heddle reads and writes that ref natively;
965people who still inspect the repository through another Git client can opt
966that client into showing notes, but Heddle itself does not require a Git
967executable on the system.
968"#;
969
970const SIGNALS_TOPIC: &str = "Risk signals — five modules behind a pure trait.\n\
971\n\
972- `invariant_adjacency`        — fires when a changed symbol carries an\n\
973                                  Invariant or `enforces`-tagged annotation.\n\
974- `self_flagged_uncertainty`   — passthrough of agent-emitted self-flags\n\
975                                  from the captured state's intent.\n\
976- `pattern_deviation`          — fires when a symbol's body diverges\n\
977                                  from siblings or the prior version\n\
978                                  (tree-sitter token similarity).\n\
979- `novelty`                    — fires when a function shape is unique\n\
980                                  in the repo corpus.\n\
981- `test_reachability`          — fires when no test statically reaches\n\
982                                  the changed symbol via tree-sitter\n\
983                                  call-graph traversal. The reason text\n\
984                                  is honest: this is *not* runtime\n\
985                                  coverage.\n\
986\n\
987Configure under `[review.signals]` in `.heddle/config.toml`. Each module\n\
988ships fires-correctly + stays-quiet tests; defaults are conservative\n\
989so a fresh repo isn't noisy.\n";
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994
995    #[test]
996    fn everyday_verbs_in_curated_list_have_everyday_tier() {
997        for verb in everyday_verbs() {
998            assert_eq!(tier_of(verb), Tier::Everyday, "{verb}");
999        }
1000    }
1001
1002    #[test]
1003    fn topic_text_returns_none_for_unknown() {
1004        assert!(topic_text("definitely-not-a-topic").is_none());
1005    }
1006
1007    #[test]
1008    fn topic_text_returns_some_for_advertised_topics() {
1009        for topic in [
1010            "advanced",
1011            "agent-flags",
1012            "git-concepts",
1013            "git-concept-map",
1014            "git-veteran",
1015            "git-overlay",
1016            "agent",
1017            "daemon",
1018            "threads",
1019            "model",
1020            "mental-model",
1021            "concepts",
1022            "operation-ids",
1023            "idempotency",
1024            "remotes",
1025            "git-dependencies",
1026            "review",
1027            "discuss",
1028            "discussions",
1029            "git-projection",
1030            "git-projections",
1031            "footer",
1032            "notes",
1033            "signals",
1034            "risk-signals",
1035            "output-formats",
1036            "output-format",
1037            "output",
1038            "clone",
1039        ] {
1040            assert!(topic_text(topic).is_some(), "{topic}");
1041        }
1042    }
1043
1044    #[test]
1045    fn tier_of_advanced_verbs_classifies_correctly() {
1046        for verb in advanced_verbs() {
1047            let t = tier_of(verb);
1048            assert!(
1049                matches!(t, Tier::Advanced),
1050                "expected Advanced for {verb}, got {t:?}"
1051            );
1052        }
1053    }
1054
1055    /// Regression: heddle#150. Commands referenced in inline tips and
1056    /// error messages must remain discoverable on the advanced surface.
1057    #[test]
1058    fn advanced_verbs_lists_tip_referenced_commands() {
1059        let advanced: std::collections::HashSet<&str> = advanced_verbs().into_iter().collect();
1060        for verb in ["query", "continue", "abort", "shell"] {
1061            assert!(
1062                advanced.contains(verb),
1063                "`{verb}` is referenced in user-facing tips but is not \
1064                 advertised by `heddle help advanced`"
1065            );
1066        }
1067    }
1068
1069    /// The everyday surface should mirror the core loop rather than
1070    /// mixing in every collaboration feature. A first-time user should
1071    /// be able to orient, commit work, check readiness, inspect,
1072    /// integrate, recover, and diagnose from the first help screen.
1073    #[test]
1074    fn everyday_verbs_surface_the_core_loop() {
1075        let everyday: std::collections::HashSet<&str> = everyday_verbs().into_iter().collect();
1076        for verb in [
1077            "init", "clone", "status", "start", "capture", "commit", "ready", "diff", "land",
1078            "resolve", "undo", "log", "show", "pull", "push", "doctor", "verify",
1079        ] {
1080            assert!(
1081                everyday.contains(verb),
1082                "`{verb}` is part of the core loop but is not advertised on \
1083                 the everyday surface"
1084            );
1085        }
1086        for verb in ["review", "discuss", "context", "thread", "git-projection"] {
1087            assert!(
1088                !everyday.contains(verb),
1089                "`{verb}` belongs behind advanced/topic help, not the core-loop surface"
1090            );
1091        }
1092    }
1093
1094    /// heddle#278. The hidden agent-automation flags on `capture` need a
1095    /// discovery route: `heddle help agent-flags` must list them without
1096    /// presenting internal session transport variables as user configuration.
1097    #[test]
1098    fn agent_flags_topic_lists_hidden_capture_flags() {
1099        let text = topic_text("agent-flags").expect("agent-flags topic should exist");
1100        for flag in [
1101            "--agent-provider",
1102            "--agent-model",
1103            "--agent-session",
1104            "--agent-segment",
1105            "--policy",
1106            "--no-policy",
1107            "--no-agent",
1108            "--split",
1109            "--into",
1110            "--path",
1111        ] {
1112            assert!(text.contains(flag), "agent-flags topic missing `{flag}`");
1113        }
1114        for env in [
1115            "HEDDLE_AGENT_PROVIDER",
1116            "HEDDLE_AGENT_MODEL",
1117            "HEDDLE_AGENT_POLICY",
1118        ] {
1119            assert!(text.contains(env), "agent-flags topic missing env `{env}`");
1120        }
1121        for internal_env in ["HEDDLE_SESSION_ID", "HEDDLE_SESSION_SEGMENT"] {
1122            assert!(
1123                !text.contains(internal_env),
1124                "agent-flags topic must not advertise internal env `{internal_env}`"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn git_help_distinguishes_overlay_from_projection_storage() {
1131        let overlay = topic_text("git-overlay").expect("git-overlay topic should exist");
1132        assert!(overlay.contains("neither creates nor reads `.heddle/git`"));
1133
1134        let projection = topic_text("git-projection").expect("git-projection topic should exist");
1135        assert!(projection.contains("It is not Git Overlay"));
1136        assert!(projection.contains("heddle adopt --ref <branch>"));
1137        assert!(projection.contains("heddle export git --destination"));
1138        assert!(projection.contains("Bridge Mirror"));
1139    }
1140
1141    /// heddle#278. The agent flags stay `hide = true` in the default surface
1142    /// but `--help-agent` reveals every one of them.
1143    #[test]
1144    fn capture_agent_flags_hidden_by_default_revealed_on_demand() {
1145        use clap::CommandFactory;
1146        let cmd = crate::cli::cli_args::Cli::command();
1147        let capture = cmd
1148            .find_subcommand("capture")
1149            .expect("capture subcommand exists");
1150        for id in CAPTURE_AGENT_FLAG_IDS {
1151            let arg = capture
1152                .get_arguments()
1153                .find(|arg| arg.get_id().as_str() == *id)
1154                .unwrap_or_else(|| panic!("capture has no `{id}` arg"));
1155            assert!(arg.is_hide_set(), "`{id}` should be hidden by default");
1156        }
1157        let revealed = reveal_capture_agent_flags(capture.clone());
1158        for id in CAPTURE_AGENT_FLAG_IDS {
1159            let arg = revealed
1160                .get_arguments()
1161                .find(|arg| arg.get_id().as_str() == *id)
1162                .expect("revealed arg present");
1163            assert!(
1164                !arg.is_hide_set(),
1165                "`{id}` should be revealed by --help-agent"
1166            );
1167        }
1168    }
1169
1170    /// heddle#278 r4 (cid 3327325850). Close-the-class: `--help-agent` is a
1171    /// first-class clap flag on `capture`, so clap parses the whole command
1172    /// line and the dispatch arm inspects the parsed result. Whether the
1173    /// reveal help shows is exactly "did clap resolve `capture` with
1174    /// `--help-agent` set?" — no hand-rolled pre-parse verb scan, so every
1175    /// global spelling clap accepts is handled for free.
1176    ///
1177    /// `wants_reveal` mirrors the decision the `Commands::Capture` arm in
1178    /// `main.rs` makes (`matches!(cli.command, Commands::Capture(a) if
1179    /// a.help_agent)`), driven entirely by clap's parse.
1180    fn wants_reveal(args: &[&str]) -> bool {
1181        use clap::Parser;
1182
1183        use crate::cli::cli_args::{Cli, Commands};
1184        let argv = std::iter::once("heddle").chain(args.iter().copied());
1185        match Cli::try_parse_from(argv) {
1186            Ok(cli) => matches!(&cli.command, Commands::Capture(a) if a.help_agent),
1187            // A parse error (e.g. `--help-agent` on a verb that has no such
1188            // flag) means: don't reveal — clap reports it as it would any
1189            // other invalid invocation.
1190            Err(_) => false,
1191        }
1192    }
1193
1194    /// heddle#278. `--help-agent` reveals capture's agent flags only for the
1195    /// `capture` verb; plain `--help` and `--help-agent` on another verb do
1196    /// not.
1197    #[test]
1198    fn capture_help_agent_is_capture_scoped() {
1199        assert!(
1200            wants_reveal(&["capture", "--help-agent"]),
1201            "capture --help-agent should request the reveal help"
1202        );
1203        assert!(
1204            !wants_reveal(&["status", "--help-agent"]),
1205            "--help-agent on a non-capture verb is not a capture reveal request"
1206        );
1207        // `capture --help` is clap's own help; it exits during parse (a
1208        // DisplayHelp error), so it is never a `help_agent` reveal request.
1209        assert!(
1210            !wants_reveal(&["capture", "--help"]),
1211            "plain --help is clap's help, not the agent reveal"
1212        );
1213    }
1214
1215    /// heddle#278 r2/r3/r4 (cids 3327112975 / 3327231819 / 3327325850).
1216    /// Because clap now owns parsing, every global spelling it accepts —
1217    /// long valued (`--output <fmt>`), short valued (`-C <path>`), and
1218    /// clustered short (`-vC <path>`) — is handled natively. With a repo dir
1219    /// literally named `capture`, the `-C` VALUE must not be read as the verb.
1220    /// This is the whole class the per-form pre-scan kept missing.
1221    #[test]
1222    fn capture_help_agent_handles_every_global_form_clap_accepts() {
1223        // Valued global before the verb: long and short, separated forms.
1224        assert!(
1225            wants_reveal(&["-C", "/tmp/repo", "capture", "--help-agent"]),
1226            "`-C <path> capture --help-agent` should reveal — clap parses the path"
1227        );
1228        assert!(
1229            wants_reveal(&["--output", "text", "capture", "--help-agent"]),
1230            "`--output text capture --help-agent` should reveal"
1231        );
1232        // Repo dir named `capture`: the `-C` value is `capture`, the verb is
1233        // the following `capture`.
1234        assert!(
1235            wants_reveal(&["-C", "capture", "capture", "--help-agent"]),
1236            "`-C capture capture --help-agent` (repo dir named `capture`) should reveal"
1237        );
1238        // r4: clustered short global `-vC <path>` — `-v` then valued `-C`.
1239        assert!(
1240            wants_reveal(&["-vC", "/tmp/repo", "capture", "--help-agent"]),
1241            "`-vC <path> capture --help-agent` (clustered short globals) should reveal"
1242        );
1243        assert!(
1244            wants_reveal(&["-vC", "capture", "capture", "--help-agent"]),
1245            "`-vC capture capture --help-agent` (clustered, repo dir named `capture`) should reveal"
1246        );
1247        // Attached short form `-C<path>`: value rides in the token.
1248        assert!(
1249            wants_reveal(&["-C/tmp/repo", "capture", "--help-agent"]),
1250            "`-C<path> capture --help-agent` (attached) should reveal"
1251        );
1252        // Plain invocation still reveals.
1253        assert!(
1254            wants_reveal(&["capture", "--help-agent"]),
1255            "plain `capture --help-agent` should reveal"
1256        );
1257
1258        // Fall-through cases: the verb is NOT capture, so no reveal. With a
1259        // repo dir named `capture`, the `-C` value is consumed by clap and
1260        // `status` is the verb — `status` has no `--help-agent`, so clap
1261        // errors and we do not reveal.
1262        assert!(
1263            !wants_reveal(&["-C", "capture", "status", "--help-agent"]),
1264            "`-C capture status --help-agent` — verb is `status`, no reveal"
1265        );
1266        assert!(
1267            !wants_reveal(&["-vC", "capture", "status", "--help-agent"]),
1268            "`-vC capture status --help-agent` (clustered) — verb is `status`, no reveal"
1269        );
1270        assert!(
1271            !wants_reveal(&["--output", "text", "status", "--help-agent"]),
1272            "`--output text status --help-agent` — verb is `status`, no reveal"
1273        );
1274    }
1275
1276    /// heddle#652. The `--output` machine-contract blurb (json vs
1277    /// json-compact fields, recovery templates) is stated in full exactly
1278    /// once on the top-level help — plus the dedicated `output-formats`
1279    /// topic — instead of being stamped by the global arg onto every
1280    /// command's --help. Per-command help carries only the one-line flag
1281    /// summary with the topic breadcrumb.
1282    #[test]
1283    fn output_blurb_stated_once_not_per_command() {
1284        let marker = "full machine contract";
1285        let top = render_for_args(&["--help"]).expect("top-level help renders");
1286        assert_eq!(
1287            top.matches(marker).count(),
1288            1,
1289            "top-level help should state the --output contract exactly once: {top}"
1290        );
1291        assert!(
1292            topic_text("output-formats")
1293                .expect("output-formats topic exists")
1294                .contains(marker),
1295            "the output-formats topic carries the full contract"
1296        );
1297        for argv in [
1298            &["clone", "--help"][..],
1299            &["status", "--help"][..],
1300            &["capture", "--help"][..],
1301            &["thread", "--help"][..],
1302            &["push", "--help"][..],
1303        ] {
1304            let help = render_for_args(argv).expect("command help renders");
1305            assert!(
1306                !help.contains(marker),
1307                "`{argv:?}` should not restate the --output machine contract: {help}"
1308            );
1309            assert!(
1310                help.contains("heddle help output-formats"),
1311                "`{argv:?}` should breadcrumb to the output-formats topic: {help}"
1312            );
1313        }
1314    }
1315
1316    /// heddle#652. `clone --help` stays within one screen: signature,
1317    /// flags, a short Behavior summary, the hidden-flags breadcrumb
1318    /// (heddle#646), and examples. The full default-thread / --depth
1319    /// exposition lives in `heddle help clone`.
1320    #[test]
1321    fn clone_help_fits_one_screen() {
1322        let help = render_for_args(&["clone", "--help"]).expect("clone help renders");
1323        let lines = help.lines().count();
1324        assert!(
1325            lines <= 40,
1326            "clone --help should fit one screen (<= 40 lines), got {lines}:\n{help}"
1327        );
1328        // The trim must not cost discoverability: the hidden-flag
1329        // affordance and the deep-dive breadcrumb both survive.
1330        assert!(
1331            help.contains("Advanced/planned flags: see `heddle help clone`."),
1332            "clone --help keeps the hidden-flags affordance: {help}"
1333        );
1334        assert!(
1335            help.contains("heddle help clone"),
1336            "clone --help points at the clone topic for the full behavior: {help}"
1337        );
1338    }
1339
1340    /// heddle#652. `heddle help advanced` renders area groups instead of
1341    /// one flat alphabetical wall of commands.
1342    #[test]
1343    fn advanced_help_renders_area_groups() {
1344        use clap::CommandFactory;
1345        let cmd = crate::cli::cli_args::Cli::command();
1346        let advanced = render_help(&cmd, &["advanced".to_string()]);
1347        for header in [
1348            "Threads and integration:",
1349            "States and history:",
1350            "Recovery and integrity:",
1351            "Repo and environment:",
1352            "Agents and automation:",
1353            "Git interop:",
1354            "Admin and maintenance:",
1355        ] {
1356            assert!(
1357                advanced.contains(&format!("\n{header}\n")),
1358                "advanced help should render the `{header}` group: {advanced}"
1359            );
1360        }
1361        assert!(
1362            !advanced.contains("Advanced commands:"),
1363            "the flat list header is replaced by area groups: {advanced}"
1364        );
1365    }
1366
1367    /// heddle#652. The grouped advanced surface is exhaustive and
1368    /// non-overlapping: every advanced verb appears in exactly one group.
1369    /// Because native commands group by the `help_category` on their
1370    /// contract registration, a new advanced native root command that
1371    /// forgets to pick a category fails here instead of silently
1372    /// vanishing from `heddle help advanced`.
1373    #[test]
1374    fn advanced_help_groups_cover_every_advanced_verb() {
1375        let grouped: Vec<&str> = crate::cli::commands::advanced_help_groups()
1376            .into_iter()
1377            .flat_map(|(_, verbs)| verbs)
1378            .collect();
1379        let mut deduped = grouped.clone();
1380        deduped.sort_unstable();
1381        deduped.dedup();
1382        assert_eq!(
1383            deduped.len(),
1384            grouped.len(),
1385            "no verb may appear in two advanced-help groups: {grouped:?}"
1386        );
1387        let grouped: std::collections::HashSet<&str> = grouped.into_iter().collect();
1388        let flat: std::collections::HashSet<&str> = advanced_verbs().into_iter().collect();
1389        let missing: Vec<&&str> = flat.difference(&grouped).collect();
1390        assert!(
1391            missing.is_empty(),
1392            "advanced verbs missing from every group — native advanced root \
1393             commands must register a help_category in the command contract \
1394             table: {missing:?}"
1395        );
1396        let extra: Vec<&&str> = grouped.difference(&flat).collect();
1397        assert!(
1398            extra.is_empty(),
1399            "grouped verbs not on the advanced surface: {extra:?}"
1400        );
1401    }
1402
1403    /// Build-break property: every verb listed in `everyday_verbs` and
1404    /// `advanced_verbs` that's compiled into the current build MUST
1405    /// resolve to a command catalog entry with a non-empty summary. Verbs
1406    /// gated behind a feature that isn't enabled (e.g. `semantic` when
1407    /// the `semantic` feature is off) are skipped — `print_help`
1408    /// already skips them at render time. If a verb is renamed in the
1409    /// contract table without a matching catalog entry, this test fails
1410    /// for whichever feature combo the variant lives in.
1411    #[test]
1412    fn verb_blurbs_resolve_from_command_catalog() {
1413        let catalog = crate::cli::commands::build_command_catalog();
1414        for verb in everyday_verbs().into_iter().chain(advanced_verbs()) {
1415            // Feature-gated verbs may not be present in this build —
1416            // skip them. The render path mirrors this.
1417            if catalog.command_by_display(verb).is_none() {
1418                continue;
1419            }
1420            let blurb = catalog_summary(&catalog, verb);
1421            assert!(
1422                !blurb.is_empty(),
1423                "verb `{verb}` is cataloged but its summary is empty. \
1424                 The curated help printer needs a non-empty catalog summary."
1425            );
1426        }
1427    }
1428
1429    /// heddle#646. Close-the-class: every `hide = true` flag on a visible
1430    /// command must carry a discovery affordance, so no flag is learnable
1431    /// only by reading the source (the clone `--lazy`/`--filter` gap).
1432    /// Two affordances are recognized:
1433    ///
1434    /// (a) internal plumbing — the flag's own help text starts with
1435    ///     "Internal", declaring it not-for-users (test helpers, hints
1436    ///     automation sets on the user's behalf); or
1437    /// (b) a help breadcrumb — the command's after-help carries an
1438    ///     advanced/hidden-flags note (mentions "hidden" or "advanced
1439    ///     flag") that either names the flag (`--<long>`) inline or
1440    ///     points at a reveal surface (`heddle help <topic>` /
1441    ///     `--help-agent`).
1442    ///
1443    /// Hidden commands (debug surfaces like `index`/`monitor`) are
1444    /// skipped wholesale — their entire surface is intentionally
1445    /// unadvertised. Global args are skipped (`--op-id` has its own
1446    /// contract-driven reveal in `help_command_for_path` plus the
1447    /// `operation-ids` topic).
1448    #[test]
1449    fn hidden_flags_carry_discovery_affordances() {
1450        use clap::CommandFactory;
1451
1452        fn walk(cmd: &clap::Command, path: &str, violations: &mut Vec<String>) {
1453            let after_help = [
1454                cmd.get_after_help().map(ToString::to_string),
1455                cmd.get_after_long_help().map(ToString::to_string),
1456            ]
1457            .into_iter()
1458            .flatten()
1459            .collect::<Vec<_>>()
1460            .join("\n");
1461            let after_lower = after_help.to_lowercase();
1462            let breadcrumb_marker = after_lower.contains("hidden")
1463                || after_lower.contains("advanced flag")
1464                || after_lower.contains("advanced/planned flags");
1465            let points_at_reveal =
1466                after_help.contains("heddle help ") || after_help.contains("--help-agent");
1467
1468            for arg in cmd.get_arguments() {
1469                if !arg.is_hide_set() || arg.is_global_set() {
1470                    continue;
1471                }
1472                let flag_help = arg
1473                    .get_long_help()
1474                    .or_else(|| arg.get_help())
1475                    .map(ToString::to_string)
1476                    .unwrap_or_default();
1477                if flag_help.starts_with("Internal") {
1478                    continue;
1479                }
1480                let named_inline = arg
1481                    .get_long()
1482                    .is_some_and(|long| after_help.contains(&format!("--{long}")));
1483                if breadcrumb_marker && (points_at_reveal || named_inline) {
1484                    continue;
1485                }
1486                let name = arg
1487                    .get_long()
1488                    .map(|long| format!("--{long}"))
1489                    .unwrap_or_else(|| arg.get_id().to_string());
1490                violations.push(format!("`{path}` hides `{name}`"));
1491            }
1492
1493            for sub in cmd.get_subcommands() {
1494                if sub.is_hide_set() {
1495                    continue;
1496                }
1497                walk(sub, &format!("{path} {}", sub.get_name()), violations);
1498            }
1499        }
1500
1501        let cmd = crate::cli::cli_args::Cli::command();
1502        let mut violations = Vec::new();
1503        walk(&cmd, cmd.get_name(), &mut violations);
1504        assert!(
1505            violations.is_empty(),
1506            "hidden flags without a discovery affordance (heddle#646): either \
1507             prefix the flag's help with `Internal` (plumbing, not for users) \
1508             or add an after-help breadcrumb that mentions the hidden/advanced \
1509             flags and names the flag or a reveal surface:\n  {}",
1510            violations.join("\n  ")
1511        );
1512    }
1513}