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\n\
525Configuration environment:\n\
526  HEDDLE_HOME    Relocates global Heddle state (credentials, device identity,\n\
527                 session state, and the default user config).\n\
528  HEDDLE_CONFIG  Overrides the user config file. Takes precedence over\n\
529                 HEDDLE_HOME; otherwise HEDDLE_HOME uses <path>/config.toml.\n\
530\n\
531Principal resolution (highest first): HEDDLE_PRINCIPAL_NAME and\n\
532HEDDLE_PRINCIPAL_EMAIL, repository config, Git config, user config, then\n\
533Unknown <unknown@example.com>. Init, status, and capture use this same order.\n";
534
535// The single full statement of the --output machine contract (plus the
536// one-paragraph version on the top-level `heddle help`). The global
537// `--output` flag's own help is one line pointing here, so the contract
538// is not restated on every command's --help (heddle#652).
539const OUTPUT_FORMATS_TOPIC: &str = r#"Output formats — `--output text | json | json-compact`.
540
541`text` is the default, always. There is no TTY/pipe auto-detection — the
542default never switches under you, so scripts and humans see the same thing
543until a flag says otherwise.
544
545`--output json` emits the full machine contract: a stable `output_kind`
546discriminator, exit codes, and recovery templates. Schemas per verb:
547`heddle schemas <verb>`; the catalog of which commands emit what:
548`heddle help --output json`.
549
550`--output json-compact` emits only the decision-surface fields —
551`output_kind`, `status`/`coordination_status`, `blockers`, `next_action`,
552`changed_paths`, `conflicts` — fewer tokens, same `output_kind`, so callers
553can still dispatch on it. Commands advertise `supports_json_compact` in the
554command catalog.
555
556Related: `heddle help operation-ids` for idempotent retries, `heddle help
557agent-flags` for capture attribution overrides.
558"#;
559
560// `heddle clone --help` keeps the signature + flags + a one-screen
561// summary; this topic carries the full default-thread fallback chain and
562// --depth exposition that used to bloat the after-help (heddle#652).
563const CLONE_TOPIC: &str = r#"Cloning — Git Overlay and native Heddle repositories.
564
565    heddle clone <remote> <dir> [--thread <name>] [--depth <n>]
566
567Run `heddle clone --help` for the flag list.
568
569# Repository authority
570
571- A Git source is streamed by Sley directly into the destination `.git`, then
572  initialized as Git Overlay. No Git executable or `.heddle/git` mirror is used.
573- A native source is cloned into Heddle-owned storage.
574- Native clones target `main` directly; if the remote has no `main` thread,
575  pass `--thread <name>` to select one.
576- Clone never prompts.
577
578# Shallow clones (--depth)
579
580--depth 0 (the default) clones full history. --depth N fetches only the
581tip plus N generations of ancestry (--depth 1: the tip plus its immediate parents),
582so `heddle log` stops at the depth boundary; history older than that is
583not present locally — re-clone at a greater --depth (or --depth 0) to
584obtain it.
585
586Depth controls native Heddle history extent only — how many states the clone fetches —
587and says nothing about object contents. Whether a state's blobs are
588present locally or fetched lazily is a separate concern that `--depth`
589never governs. Git Overlay clones ingest full history and reject partial-history
590options. Advanced/planned flags `--lazy` and `--filter blob:none`
591skip blob content and hydrate it on demand for hosted/network Heddle
592remotes; local clone paths reject them today.
593
594See `heddle help threads` for the thread model and `heddle help remotes`
595for remote management.
596"#;
597
598const AGENT_FLAGS_TOPIC: &str = r#"Agent automation flags for `heddle capture`.
599
600These flags are hidden from the everyday `heddle capture --help` so it stays
601terse for human use. They let an automated caller override agent attribution
602and split captures across threads. Run `heddle capture --help-agent` to see
603them inline in capture's own help.
604
605Attribution overrides:
606
607  --agent-provider <NAME>   Override HEDDLE_AGENT_PROVIDER.
608  --agent-model <NAME>      Override HEDDLE_AGENT_MODEL.
609  --agent-session <ID>      Set the session id for this capture.
610  --agent-segment <ID>      Set the session segment for this capture.
611  --policy <ID>             Override HEDDLE_AGENT_POLICY.
612  --no-policy               Omit policy attribution.
613  --no-agent                Omit agent attribution.
614
615Path splitting (no env equivalent):
616
617  --split                   Split selected paths into another thread instead of
618                            capturing the whole worktree.
619  --into <THREAD>           Target thread when using --split.
620  --path <PATH>             Repository-relative path prefix to include with
621                            --split (repeatable).
622
623Attribution precedence (highest first): explicit flag, active thread actor,
624supported agent env var, harness probe, active session, user config, repo config. See
625`crates/cli/src/cli/commands/snapshot.rs` for the full cascade.
626"#;
627
628const DAEMON_TOPIC: &str = "The mount daemon owns virtualized workspace sessions.\n\
629\n\
630`heddle daemon`        — FUSE mount-daemon control plane. Owns FUSE sessions for\n\
631                         `--workspace virtualized --daemon` threads. Linux only.\n\
632                         Subcommands: serve | status | stop.\n";
633
634const MODEL_TOPIC: &str = r#"Heddle mental model — the everyday loop in one screen.
635
636Heddle is built around saved states and isolated threads. Git compatibility is
637an output and interop layer, not the thing you have to think about first.
638
639Core nouns:
640
641- State: a captured tree with a stable change id, attribution, intent, and
642  provenance. States are what `log`, `show`, `diff`, `undo`, and agents can
643  reason about.
644- Thread: a named line of work with its own checkout and captured history.
645  Use it for risky edits, agent work, or parallel experiments without
646  serializing everything through one checkout.
647- Capture: the Heddle save boundary for provenance, undo, and review.
648- Commit: publish captured source history to `.git` in Git Overlay.
649- Verify: the proof surface. It says whether Heddle, Git mapping, worktree,
650  remotes, active operations, clone state, and machine contracts agree.
651
652Everyday loop:
653
654    heddle status
655    heddle diff
656    heddle capture -m "..."
657    heddle commit
658    heddle start <name> --path ../<name>
659    heddle ready
660    heddle land --thread <name>
661    heddle undo
662    heddle verify
663
664Existing Git checkout:
665
666    heddle status
667    heddle init                  # initialize Heddle metadata; Git commits stay in .git
668    heddle capture -m "..."
669    heddle commit                # Sley writes directly to .git
670    heddle verify
671
672If a command refuses, read the first `Next:` line. Heddle fails closed when it
673cannot prove the move is safe.
674"#;
675
676const GIT_CONCEPTS_TOPIC: &str = r#"Git and Heddle own different layers.
677
678In a Git Overlay repository, the checkout's real `.git` owns commits, refs,
679packs, the index, and worktree state. Heddle's thin Git surface — `clone`,
680`commit`, `pull`, `push`, and `remote` — uses the embedded Sley engine directly
681against that store. Heddle owns coordination and durable metadata in `.heddle`:
682captures, provenance, threads, readiness, review, and safe landing. Normal
683overlay operation neither creates nor uses `.heddle/git`. Legacy explicit Git
684Projection maintenance can still use an existing Bridge Mirror while its
685retirement is completed.
686
687Use `heddle init` to add that sidecar to an existing Git checkout. Use
688`heddle adopt` when you want one atomic transition that imports source history,
689makes Heddle the repository authority, and enables the full native feature set.
690
691Common mappings:
692
693| Intent | Git Overlay | Native Heddle |
694|--------|-------------|---------------|
695| Save source history | `heddle capture`, then `heddle commit` | `heddle capture` |
696| Isolate coordinated work | `heddle start` | `heddle start` |
697| Record a granular Heddle savepoint | `heddle capture` | `heddle capture` |
698| Check integration readiness | `heddle ready` | `heddle ready` |
699| Integrate a managed thread | `heddle land` | `heddle land` |
700| Synchronize source | `heddle pull` / `heddle push` | `heddle pull` / `heddle push` |
701| Configure remotes | `heddle remote` | `heddle remote` |
702| Inspect source history | another Git-compatible client | `heddle log` |
703
704Heddle intentionally does not reproduce the full Git command surface. An
705optional Git-compatible client can perform unsupported Git operations against
706the same `.git`; it is not a Heddle dependency. Explicit `import git`, `export
707git`, and `sync git` translate data between authorities. After `heddle adopt`,
708the retained `.git` is an explicit Git Projection adapter; it no longer selects
709repository source authority.
710"#;
711
712const THREADS_TOPIC: &str = "Threads — Heddle's unit of in-progress work.\n\
713\n\
714A thread is a named line of work with its own checkout, its own captured\n\
715history, and a target it eventually merges into. It is not a Git branch. In\n\
716Git Overlay, the branch remains part of Git source storage; the Heddle thread\n\
717owns coordination and captured history. You start isolated work with\n\
718`heddle start <name> --path <dir>`, switch\n\
719between threads with `heddle thread switch <name>`, and integrate with\n\
720`heddle land` (or check readiness without merging via `heddle ready`).\n\
721\n\
722# Threads vs. git branches\n\
723\n\
724- A thread carries an isolated checkout (its own directory), captured\n\
725  state history, agent/task metadata, a freshness verdict against its\n\
726  target, and a workflow state (Ready/Blocked/Merged/...). A git branch\n\
727  is just a ref.\n\
728- Multiple threads coexist on disk simultaneously. Each thread's working\n\
729  tree is its own.\n\
730- `heddle capture` records Heddle metadata; `heddle commit` asks Sley to\n\
731  write source history directly to `.git` in Git Overlay.\n\
732\n\
733# Workspace modes (`--workspace`)\n\
734\n\
735The `--workspace` flag on `heddle start` selects how the thread's\n\
736checkout is realized on disk. These are storage strategies, not\n\
737workflow states:\n\
738\n\
739- `materialized` — clonefile/reflink the captured tree into the thread's\n\
740  directory (APFS / btrfs / XFS-with-reflinks / bcachefs / ReFS). Real\n\
741  `read(2)`-able bytes; ~zero disk cost until the agent diverges blocks.\n\
742  Day-one default on reflink-capable hosts.\n\
743- `virtualized` — project the captured tree through a content-addressed\n\
744  FUSE/FSKit/ProjFS mount. Nothing on disk until the kernel asks.\n\
745  Requires the `mount` feature.\n\
746- `solid` — full file copies, no shared extents. Strong isolation;\n\
747  the right choice on ext4/NTFS hosts that have neither reflinks nor a\n\
748  usable mount API.\n\
749- `auto` (default) — pick `materialized` when reflinks are available,\n\
750  `virtualized` when a mount is available, otherwise `solid`.\n\
751\n\
752A `solid` thread and a `materialized` thread are interchangeable from\n\
753the workflow's point of view — `capture`, `ready`, and `land` behave\n\
754identically. The mode only controls bytes-on-disk semantics.\n\
755\n\
756# Isolated checkout path\n\
757\n\
758- Use `heddle start <name> --path <dir>` when you want an isolated\n\
759  checkout. It creates the thread ref and materializes the checkout in\n\
760  one step.\n\
761- Advanced split form: `heddle thread create <name>` creates only the\n\
762  ref, and `heddle thread promote <name> --path <dir>` materializes it\n\
763  later. Use this only when you intentionally need to create the ref\n\
764  now and materialize the checkout later.\n\
765- `--workspace` on `heddle start` selects byte storage for that checkout;\n\
766  it is not a separate workflow path.\n\
767\n\
768# Sync: stale\n\
769\n\
770A thread is `current` when its base is the tip of its target, and\n\
771`stale` once the target has advanced past it. `heddle status` and\n\
772`heddle thread show` print this as `Sync: stale`.\n\
773\n\
774Resolution paths:\n\
775\n\
776- `heddle sync` — refresh the current thread onto its target when\n\
777  the replay is clean. The fast path for a stale thread with no\n\
778  conflicts.\n\
779- If `sync` reports conflicts or other blockers, use\n\
780  `heddle resolve` or `heddle continue` to handle the conflicts.\n\
781- `heddle land` will refresh-then-merge for you when the replay is\n\
782  clean; it fails closed when manual resolution is required.\n\
783\n\
784# Changing active work\n\
785\n\
786- `heddle thread switch <name>` changes which *thread* is active.\n\
787  Each thread has its own checkout; switching may auto-capture\n\
788  outstanding work on the thread you're leaving. Pair with the shell\n\
789  hook (`heddle shell init`) to auto-cd into the target thread's\n\
790  directory.\n\
791- In Git Overlay, branch switching remains outside Heddle's deliberately\n\
792  narrow Git surface. An optional Git-compatible client can update `.git`\n\
793  without changing Heddle's active thread or coordination metadata.\n\
794\n\
795# Capture and Git commits\n\
796\n\
797- `heddle capture` records a recoverable Heddle step on the current\n\
798  thread — for undo, provenance, and review. Captures are\n\
799  fine-grained and accumulate freely as work progresses.\n\
800- In Git Overlay, run `heddle commit` when captured source history is ready.\n\
801- Agents and tools can take many small captures without producing noisy Git history.\n\
802\n\
803See also: `heddle help advanced` for the full operational surface,\n\
804`heddle thread --help` for the thread subcommand list.\n";
805
806const OPERATION_IDS_TOPIC: &str = "Idempotency — machine retries for supported mutating commands.\n\
807\n\
808Commands that advertise `supports_op_id: true` in `heddle help --output json`\n\
809accept `--op-id <UUID>` or `HEDDLE_OPERATION_ID`. Replaying the same id\n\
810with the same body returns the recorded outcome; with a different body it\n\
811returns a typed conflict.\n\
812\n\
813`op_id_behavior: explicit_replay` means the caller must provide the id.\n\
814`op_id_behavior: generated_resume` is reserved for commands that also\n\
815advertise `persists_op_id: true` and can save a generated id across an\n\
816interrupted retry loop. Commands with `op_id_behavior: none` reject --op-id.\n\
817\n\
818The dedup store is file-backed locally (`.heddle/state/operation_dedup.bin`,\n\
819rmp-serde, 7-day default retention) and Postgres-backed in hosted deployments.\n\
820\n\
821Without an id, dedup is bypassed and the call executes normally. For the\n\
822authoritative per-command contract, use `heddle help --output json`.\n";
823
824const REMOTES_TOPIC: &str = r#"Remotes — dispatched by repository source authority.
825
826Common loop:
827
828    heddle remote add origin <url-or-path>
829    heddle remote set-default origin
830    heddle pull
831    heddle push
832    heddle verify
833
834Remote values may be Git URLs, hosted endpoints, or local paths. `push` and
835`pull` use the default remote unless a positional remote is supplied. In Git
836Overlay, Sley reads and edits the repository's Git configuration and streams
837objects directly between the remote and `.git`. In Native Heddle, the same
838verbs use Heddle transport and storage. The Git executable is not involved.
839
840When a remote action is unsafe, Heddle reports the blocker and one primary next
841command for the active source authority. `heddle verify` reports repository
842verification state before and after remote operations.
843"#;
844
845const GIT_DEPENDENCIES_TOPIC: &str = r#"Git executable dependencies — Heddle does not have one.
846
847Heddle never requires the `git` executable. Sley is its embedded Git engine. In
848Git Overlay, `heddle clone`, `commit`, `pull`, `push`, and `remote`
849operate on the checkout's real `.git` directly; `.heddle` contains Heddle
850metadata, not a second Git object store.
851
852If Heddle detects an externally-started Git sequencer operation, it leaves Git
853metadata, refs, index, and worktree files unchanged and reports a Heddle
854preservation command. Finish or abort that operation with the optional
855Git-compatible client that started it, then run `heddle verify`.
856
857Operations outside Heddle's narrow Git surface can be performed by another
858Git-compatible client, but that client is optional and is never invoked by
859Heddle. Unsupported capabilities fail closed.
860
861Run `heddle help --output json` to inspect the public command surface, and
862`heddle doctor` / `heddle fsck --full` when a repository reports integrity
863or Git Projection state problems.
864"#;
865
866const REVIEW_TOPIC: &str = "Review surface — `heddle review show | sign | next | health`.\n\
867\n\
868`show <state>`    — render the review payload (summary, agent narrative,\n\
869                    in-budget signals, anchored discussions).\n\
870                    `--all-signals` also surfaces hidden ones.\n\
871`sign <state>`    — submit a `read | agent_preview | agent_co_review`\n\
872                    signature. `--symbols file:symbol` scopes to\n\
873                    specific symbols; default is the whole change.\n\
874`next`            — show the next locally discoverable review item, or explain\n\
875                    why none is available.\n\
876`health [--window N]`\n\
877                  — per-module signal fire-rate over the last N states.\n\
878\n\
879Tick budget: at most 3 signals per state by default. Priority:\n\
880invariant_adjacency > self_flagged_uncertainty > pattern_deviation >\n\
881novelty > test_reachability.\n";
882
883const DISCUSS_TOPIC: &str = "`heddle discuss open | append | resolve | reopen | list | show`\n\
884\n\
885Discussions are stable records in the repository collaboration log. Turns,\n\
886resolutions, and reopenings append immutable operations; concurrent turns\n\
887converge without rewriting source history. Symbol anchors record the state,\n\
888file, and symbol where the discussion began:\n\
889\n\
890- `resolve <id> --mode by-edit`          with `--state` (defaults to HEAD).\n\
891  Records that a subsequent edit addressed the discussion.\n\
892- `resolve <id> --mode dismiss`          requires non-empty `--reason`.\n\
893- `reopen <id> --reason <text>`          compensates a prior resolution.\n\
894\n\
895Visibility: `--visibility public|internal|team:NAME|restricted:LABEL|private:LABEL`.\n\
896Empty visibility uses the configured discussion visibility policy.\n";
897
898const GIT_OVERLAY_TOPIC: &str = r#"Git Overlay workflow
899
900Use this when `.git` should remain the source authority while Heddle adds
901captures, isolated threads, merge previews, undo, provenance, and machine-safe
902JSON in `.heddle`. Sley is the embedded Git engine; no Git executable is
903required. Normal Git Overlay operation neither creates nor reads `.heddle/git`.
904
905Start in an existing Git checkout:
906
907    heddle status
908    heddle init                               # add Heddle metadata
909    heddle verify
910
911Save and synchronize ordinary work:
912
913    heddle diff
914    heddle capture -m "..."                   # save Heddle metadata and provenance
915    heddle commit                             # Sley writes source history to .git
916    heddle pull
917    heddle push
918
919Isolate risky work:
920
921    heddle start <name> --path ../<name>
922    cd ../<name>
923    heddle capture -m "..."
924    heddle ready
925    cd -
926    heddle land --thread <name>
927    heddle push
928
929Recover or prove state:
930
931    heddle undo
932    heddle verify
933
934State-specific recovery:
935
936    Worktree has unsaved edits: heddle capture -m "...", then heddle commit
937    Move atomically to the full Native Heddle feature set: heddle adopt --ref <branch>
938"#;
939
940const GIT_PROJECTION_TOPIC: &str = r#"Git Projection — translate between Native Heddle and Git.
941
942Git Projection is the explicit interoperability adapter for Native Heddle
943repositories. It is not Git Overlay: when `.git` remains authoritative, Sley
944operates on that repository directly and normal operation never reads or creates
945`.heddle/git`. Heddle does not require the Git executable in either mode.
946
947Move an existing Git repository to Native Heddle atomically:
948
949    heddle adopt --ref <branch>
950
951Translate explicitly without changing source authority:
952
953    heddle import git --path <git-repository> --ref <branch>
954    heddle export git --destination <bare-git-repository>
955    heddle sync git --path <git-repository>
956
957`import git` and `sync git` accept a local path or Git URL. Omit `--ref` to
958import all local branches and tags. `export git` writes a bare Git repository.
959Legacy migration and repair may still read an existing Bridge Mirror at
960`.heddle/git` while ADR 0042's retirement work remains incomplete.
961
962Export metadata for Git readers:
963
964Every exported commit carries a footer at the tail of the commit message:
965
966    Heddle-State: <state_id>
967    Heddle-URL: <hosted_url>/state/<state_id>     (omitted if no hosted URL)
968    Heddle-Annotations-Omitted: <count>
969
970This is the durable record — every reader on every host sees it regardless
971of remote configuration.
972
973Per-scope annotation drop counts and signal counts ride on the opt-in
974Git note at `refs/notes/heddle`. Heddle reads and writes that ref natively;
975people who still inspect the repository through another Git client can opt
976that client into showing notes, but Heddle itself does not require a Git
977executable on the system.
978"#;
979
980const SIGNALS_TOPIC: &str = "Risk signals — five modules behind a pure trait.\n\
981\n\
982- `invariant_adjacency`        — fires when a changed symbol carries an\n\
983                                  Invariant or `enforces`-tagged annotation.\n\
984- `self_flagged_uncertainty`   — passthrough of agent-emitted self-flags\n\
985                                  from the captured state's intent.\n\
986- `pattern_deviation`          — fires when a symbol's body diverges\n\
987                                  from siblings or the prior version\n\
988                                  (tree-sitter token similarity).\n\
989- `novelty`                    — fires when a function shape is unique\n\
990                                  in the repo corpus.\n\
991- `test_reachability`          — fires when no test statically reaches\n\
992                                  the changed symbol via tree-sitter\n\
993                                  call-graph traversal. The reason text\n\
994                                  is honest: this is *not* runtime\n\
995                                  coverage.\n\
996\n\
997Configure under `[review.signals]` in `.heddle/config.toml`. Each module\n\
998ships fires-correctly + stays-quiet tests; defaults are conservative\n\
999so a fresh repo isn't noisy.\n";
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    #[test]
1006    fn everyday_verbs_in_curated_list_have_everyday_tier() {
1007        for verb in everyday_verbs() {
1008            assert_eq!(tier_of(verb), Tier::Everyday, "{verb}");
1009        }
1010    }
1011
1012    #[test]
1013    fn topic_text_returns_none_for_unknown() {
1014        assert!(topic_text("definitely-not-a-topic").is_none());
1015    }
1016
1017    #[test]
1018    fn topic_text_returns_some_for_advertised_topics() {
1019        for topic in [
1020            "advanced",
1021            "agent-flags",
1022            "git-concepts",
1023            "git-concept-map",
1024            "git-veteran",
1025            "git-overlay",
1026            "agent",
1027            "daemon",
1028            "threads",
1029            "model",
1030            "mental-model",
1031            "concepts",
1032            "operation-ids",
1033            "idempotency",
1034            "remotes",
1035            "git-dependencies",
1036            "review",
1037            "discuss",
1038            "discussions",
1039            "git-projection",
1040            "git-projections",
1041            "footer",
1042            "notes",
1043            "signals",
1044            "risk-signals",
1045            "output-formats",
1046            "output-format",
1047            "output",
1048            "clone",
1049        ] {
1050            assert!(topic_text(topic).is_some(), "{topic}");
1051        }
1052    }
1053
1054    #[test]
1055    fn tier_of_advanced_verbs_classifies_correctly() {
1056        for verb in advanced_verbs() {
1057            let t = tier_of(verb);
1058            assert!(
1059                matches!(t, Tier::Advanced),
1060                "expected Advanced for {verb}, got {t:?}"
1061            );
1062        }
1063    }
1064
1065    /// Regression: heddle#150. Commands referenced in inline tips and
1066    /// error messages must remain discoverable on the advanced surface.
1067    #[test]
1068    fn advanced_verbs_lists_tip_referenced_commands() {
1069        let advanced: std::collections::HashSet<&str> = advanced_verbs().into_iter().collect();
1070        for verb in ["query", "continue", "abort", "shell"] {
1071            assert!(
1072                advanced.contains(verb),
1073                "`{verb}` is referenced in user-facing tips but is not \
1074                 advertised by `heddle help advanced`"
1075            );
1076        }
1077    }
1078
1079    /// The everyday surface should mirror the core loop rather than
1080    /// mixing in every collaboration feature. A first-time user should
1081    /// be able to orient, commit work, check readiness, inspect,
1082    /// integrate, recover, and diagnose from the first help screen.
1083    #[test]
1084    fn everyday_verbs_surface_the_core_loop() {
1085        let everyday: std::collections::HashSet<&str> = everyday_verbs().into_iter().collect();
1086        for verb in [
1087            "init", "clone", "status", "start", "capture", "commit", "ready", "diff", "land",
1088            "resolve", "undo", "log", "show", "pull", "push", "doctor", "verify",
1089        ] {
1090            assert!(
1091                everyday.contains(verb),
1092                "`{verb}` is part of the core loop but is not advertised on \
1093                 the everyday surface"
1094            );
1095        }
1096        for verb in ["review", "discuss", "context", "thread", "git-projection"] {
1097            assert!(
1098                !everyday.contains(verb),
1099                "`{verb}` belongs behind advanced/topic help, not the core-loop surface"
1100            );
1101        }
1102    }
1103
1104    /// heddle#278. The hidden agent-automation flags on `capture` need a
1105    /// discovery route: `heddle help agent-flags` must list them without
1106    /// presenting internal session transport variables as user configuration.
1107    #[test]
1108    fn agent_flags_topic_lists_hidden_capture_flags() {
1109        let text = topic_text("agent-flags").expect("agent-flags topic should exist");
1110        for flag in [
1111            "--agent-provider",
1112            "--agent-model",
1113            "--agent-session",
1114            "--agent-segment",
1115            "--policy",
1116            "--no-policy",
1117            "--no-agent",
1118            "--split",
1119            "--into",
1120            "--path",
1121        ] {
1122            assert!(text.contains(flag), "agent-flags topic missing `{flag}`");
1123        }
1124        for env in [
1125            "HEDDLE_AGENT_PROVIDER",
1126            "HEDDLE_AGENT_MODEL",
1127            "HEDDLE_AGENT_POLICY",
1128        ] {
1129            assert!(text.contains(env), "agent-flags topic missing env `{env}`");
1130        }
1131        for internal_env in ["HEDDLE_SESSION_ID", "HEDDLE_SESSION_SEGMENT"] {
1132            assert!(
1133                !text.contains(internal_env),
1134                "agent-flags topic must not advertise internal env `{internal_env}`"
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn git_help_distinguishes_overlay_from_projection_storage() {
1141        let overlay = topic_text("git-overlay").expect("git-overlay topic should exist");
1142        assert!(overlay.contains("neither creates nor reads `.heddle/git`"));
1143
1144        let projection = topic_text("git-projection").expect("git-projection topic should exist");
1145        assert!(projection.contains("It is not Git Overlay"));
1146        assert!(projection.contains("heddle adopt --ref <branch>"));
1147        assert!(projection.contains("heddle export git --destination"));
1148        assert!(projection.contains("Bridge Mirror"));
1149    }
1150
1151    /// heddle#278. The agent flags stay `hide = true` in the default surface
1152    /// but `--help-agent` reveals every one of them.
1153    #[test]
1154    fn capture_agent_flags_hidden_by_default_revealed_on_demand() {
1155        use clap::CommandFactory;
1156        let cmd = crate::cli::cli_args::Cli::command();
1157        let capture = cmd
1158            .find_subcommand("capture")
1159            .expect("capture subcommand exists");
1160        for id in CAPTURE_AGENT_FLAG_IDS {
1161            let arg = capture
1162                .get_arguments()
1163                .find(|arg| arg.get_id().as_str() == *id)
1164                .unwrap_or_else(|| panic!("capture has no `{id}` arg"));
1165            assert!(arg.is_hide_set(), "`{id}` should be hidden by default");
1166        }
1167        let revealed = reveal_capture_agent_flags(capture.clone());
1168        for id in CAPTURE_AGENT_FLAG_IDS {
1169            let arg = revealed
1170                .get_arguments()
1171                .find(|arg| arg.get_id().as_str() == *id)
1172                .expect("revealed arg present");
1173            assert!(
1174                !arg.is_hide_set(),
1175                "`{id}` should be revealed by --help-agent"
1176            );
1177        }
1178    }
1179
1180    /// heddle#278 r4 (cid 3327325850). Close-the-class: `--help-agent` is a
1181    /// first-class clap flag on `capture`, so clap parses the whole command
1182    /// line and the dispatch arm inspects the parsed result. Whether the
1183    /// reveal help shows is exactly "did clap resolve `capture` with
1184    /// `--help-agent` set?" — no hand-rolled pre-parse verb scan, so every
1185    /// global spelling clap accepts is handled for free.
1186    ///
1187    /// `wants_reveal` mirrors the decision the `Commands::Capture` arm in
1188    /// `main.rs` makes (`matches!(cli.command, Commands::Capture(a) if
1189    /// a.help_agent)`), driven entirely by clap's parse.
1190    fn wants_reveal(args: &[&str]) -> bool {
1191        use clap::Parser;
1192
1193        use crate::cli::cli_args::{Cli, Commands};
1194        let argv = std::iter::once("heddle").chain(args.iter().copied());
1195        match Cli::try_parse_from(argv) {
1196            Ok(cli) => matches!(&cli.command, Commands::Capture(a) if a.help_agent),
1197            // A parse error (e.g. `--help-agent` on a verb that has no such
1198            // flag) means: don't reveal — clap reports it as it would any
1199            // other invalid invocation.
1200            Err(_) => false,
1201        }
1202    }
1203
1204    /// heddle#278. `--help-agent` reveals capture's agent flags only for the
1205    /// `capture` verb; plain `--help` and `--help-agent` on another verb do
1206    /// not.
1207    #[test]
1208    fn capture_help_agent_is_capture_scoped() {
1209        assert!(
1210            wants_reveal(&["capture", "--help-agent"]),
1211            "capture --help-agent should request the reveal help"
1212        );
1213        assert!(
1214            !wants_reveal(&["status", "--help-agent"]),
1215            "--help-agent on a non-capture verb is not a capture reveal request"
1216        );
1217        // `capture --help` is clap's own help; it exits during parse (a
1218        // DisplayHelp error), so it is never a `help_agent` reveal request.
1219        assert!(
1220            !wants_reveal(&["capture", "--help"]),
1221            "plain --help is clap's help, not the agent reveal"
1222        );
1223    }
1224
1225    /// heddle#278 r2/r3/r4 (cids 3327112975 / 3327231819 / 3327325850).
1226    /// Because clap now owns parsing, every global spelling it accepts —
1227    /// long valued (`--output <fmt>`), short valued (`-C <path>`), and
1228    /// clustered short (`-vC <path>`) — is handled natively. With a repo dir
1229    /// literally named `capture`, the `-C` VALUE must not be read as the verb.
1230    /// This is the whole class the per-form pre-scan kept missing.
1231    #[test]
1232    fn capture_help_agent_handles_every_global_form_clap_accepts() {
1233        // Valued global before the verb: long and short, separated forms.
1234        assert!(
1235            wants_reveal(&["-C", "/tmp/repo", "capture", "--help-agent"]),
1236            "`-C <path> capture --help-agent` should reveal — clap parses the path"
1237        );
1238        assert!(
1239            wants_reveal(&["--output", "text", "capture", "--help-agent"]),
1240            "`--output text capture --help-agent` should reveal"
1241        );
1242        // Repo dir named `capture`: the `-C` value is `capture`, the verb is
1243        // the following `capture`.
1244        assert!(
1245            wants_reveal(&["-C", "capture", "capture", "--help-agent"]),
1246            "`-C capture capture --help-agent` (repo dir named `capture`) should reveal"
1247        );
1248        // r4: clustered short global `-vC <path>` — `-v` then valued `-C`.
1249        assert!(
1250            wants_reveal(&["-vC", "/tmp/repo", "capture", "--help-agent"]),
1251            "`-vC <path> capture --help-agent` (clustered short globals) should reveal"
1252        );
1253        assert!(
1254            wants_reveal(&["-vC", "capture", "capture", "--help-agent"]),
1255            "`-vC capture capture --help-agent` (clustered, repo dir named `capture`) should reveal"
1256        );
1257        // Attached short form `-C<path>`: value rides in the token.
1258        assert!(
1259            wants_reveal(&["-C/tmp/repo", "capture", "--help-agent"]),
1260            "`-C<path> capture --help-agent` (attached) should reveal"
1261        );
1262        // Plain invocation still reveals.
1263        assert!(
1264            wants_reveal(&["capture", "--help-agent"]),
1265            "plain `capture --help-agent` should reveal"
1266        );
1267
1268        // Fall-through cases: the verb is NOT capture, so no reveal. With a
1269        // repo dir named `capture`, the `-C` value is consumed by clap and
1270        // `status` is the verb — `status` has no `--help-agent`, so clap
1271        // errors and we do not reveal.
1272        assert!(
1273            !wants_reveal(&["-C", "capture", "status", "--help-agent"]),
1274            "`-C capture status --help-agent` — verb is `status`, no reveal"
1275        );
1276        assert!(
1277            !wants_reveal(&["-vC", "capture", "status", "--help-agent"]),
1278            "`-vC capture status --help-agent` (clustered) — verb is `status`, no reveal"
1279        );
1280        assert!(
1281            !wants_reveal(&["--output", "text", "status", "--help-agent"]),
1282            "`--output text status --help-agent` — verb is `status`, no reveal"
1283        );
1284    }
1285
1286    /// heddle#652. The `--output` machine-contract blurb (json vs
1287    /// json-compact fields, recovery templates) is stated in full exactly
1288    /// once on the top-level help — plus the dedicated `output-formats`
1289    /// topic — instead of being stamped by the global arg onto every
1290    /// command's --help. Per-command help carries only the one-line flag
1291    /// summary with the topic breadcrumb.
1292    #[test]
1293    fn output_blurb_stated_once_not_per_command() {
1294        let marker = "full machine contract";
1295        let top = render_for_args(&["--help"]).expect("top-level help renders");
1296        assert_eq!(
1297            top.matches(marker).count(),
1298            1,
1299            "top-level help should state the --output contract exactly once: {top}"
1300        );
1301        assert!(
1302            topic_text("output-formats")
1303                .expect("output-formats topic exists")
1304                .contains(marker),
1305            "the output-formats topic carries the full contract"
1306        );
1307        for argv in [
1308            &["clone", "--help"][..],
1309            &["status", "--help"][..],
1310            &["capture", "--help"][..],
1311            &["thread", "--help"][..],
1312            &["push", "--help"][..],
1313        ] {
1314            let help = render_for_args(argv).expect("command help renders");
1315            assert!(
1316                !help.contains(marker),
1317                "`{argv:?}` should not restate the --output machine contract: {help}"
1318            );
1319            assert!(
1320                help.contains("heddle help output-formats"),
1321                "`{argv:?}` should breadcrumb to the output-formats topic: {help}"
1322            );
1323        }
1324    }
1325
1326    /// heddle#652. `clone --help` stays within one screen: signature,
1327    /// flags, a short Behavior summary, the hidden-flags breadcrumb
1328    /// (heddle#646), and examples. The full default-thread / --depth
1329    /// exposition lives in `heddle help clone`.
1330    #[test]
1331    fn clone_help_fits_one_screen() {
1332        let help = render_for_args(&["clone", "--help"]).expect("clone help renders");
1333        let lines = help.lines().count();
1334        assert!(
1335            lines <= 40,
1336            "clone --help should fit one screen (<= 40 lines), got {lines}:\n{help}"
1337        );
1338        // The trim must not cost discoverability: the hidden-flag
1339        // affordance and the deep-dive breadcrumb both survive.
1340        assert!(
1341            help.contains("Advanced/planned flags: see `heddle help clone`."),
1342            "clone --help keeps the hidden-flags affordance: {help}"
1343        );
1344        assert!(
1345            help.contains("heddle help clone"),
1346            "clone --help points at the clone topic for the full behavior: {help}"
1347        );
1348    }
1349
1350    /// heddle#652. `heddle help advanced` renders area groups instead of
1351    /// one flat alphabetical wall of commands.
1352    #[test]
1353    fn advanced_help_renders_area_groups() {
1354        use clap::CommandFactory;
1355        let cmd = crate::cli::cli_args::Cli::command();
1356        let advanced = render_help(&cmd, &["advanced".to_string()]);
1357        for header in [
1358            "Threads and integration:",
1359            "States and history:",
1360            "Recovery and integrity:",
1361            "Repo and environment:",
1362            "Agents and automation:",
1363            "Git interop:",
1364            "Admin and maintenance:",
1365        ] {
1366            assert!(
1367                advanced.contains(&format!("\n{header}\n")),
1368                "advanced help should render the `{header}` group: {advanced}"
1369            );
1370        }
1371        assert!(
1372            !advanced.contains("Advanced commands:"),
1373            "the flat list header is replaced by area groups: {advanced}"
1374        );
1375    }
1376
1377    #[test]
1378    fn advanced_help_documents_home_and_config_overrides() {
1379        use clap::CommandFactory;
1380        let cmd = crate::cli::cli_args::Cli::command();
1381        let advanced = render_help(&cmd, &["advanced".to_string()]);
1382        assert!(advanced.contains("HEDDLE_HOME"));
1383        assert!(advanced.contains("HEDDLE_CONFIG"));
1384        assert!(advanced.contains("<path>/config.toml"));
1385        assert!(advanced.contains("Principal resolution (highest first)"));
1386    }
1387
1388    /// heddle#652. The grouped advanced surface is exhaustive and
1389    /// non-overlapping: every advanced verb appears in exactly one group.
1390    /// Because native commands group by the `help_category` on their
1391    /// contract registration, a new advanced native root command that
1392    /// forgets to pick a category fails here instead of silently
1393    /// vanishing from `heddle help advanced`.
1394    #[test]
1395    fn advanced_help_groups_cover_every_advanced_verb() {
1396        let grouped: Vec<&str> = crate::cli::commands::advanced_help_groups()
1397            .into_iter()
1398            .flat_map(|(_, verbs)| verbs)
1399            .collect();
1400        let mut deduped = grouped.clone();
1401        deduped.sort_unstable();
1402        deduped.dedup();
1403        assert_eq!(
1404            deduped.len(),
1405            grouped.len(),
1406            "no verb may appear in two advanced-help groups: {grouped:?}"
1407        );
1408        let grouped: std::collections::HashSet<&str> = grouped.into_iter().collect();
1409        let flat: std::collections::HashSet<&str> = advanced_verbs().into_iter().collect();
1410        let missing: Vec<&&str> = flat.difference(&grouped).collect();
1411        assert!(
1412            missing.is_empty(),
1413            "advanced verbs missing from every group — native advanced root \
1414             commands must register a help_category in the command contract \
1415             table: {missing:?}"
1416        );
1417        let extra: Vec<&&str> = grouped.difference(&flat).collect();
1418        assert!(
1419            extra.is_empty(),
1420            "grouped verbs not on the advanced surface: {extra:?}"
1421        );
1422    }
1423
1424    /// Build-break property: every verb listed in `everyday_verbs` and
1425    /// `advanced_verbs` that's compiled into the current build MUST
1426    /// resolve to a command catalog entry with a non-empty summary. Verbs
1427    /// gated behind a feature that isn't enabled (e.g. `semantic` when
1428    /// the `semantic` feature is off) are skipped — `print_help`
1429    /// already skips them at render time. If a verb is renamed in the
1430    /// contract table without a matching catalog entry, this test fails
1431    /// for whichever feature combo the variant lives in.
1432    #[test]
1433    fn verb_blurbs_resolve_from_command_catalog() {
1434        let catalog = crate::cli::commands::build_command_catalog();
1435        for verb in everyday_verbs().into_iter().chain(advanced_verbs()) {
1436            // Feature-gated verbs may not be present in this build —
1437            // skip them. The render path mirrors this.
1438            if catalog.command_by_display(verb).is_none() {
1439                continue;
1440            }
1441            let blurb = catalog_summary(&catalog, verb);
1442            assert!(
1443                !blurb.is_empty(),
1444                "verb `{verb}` is cataloged but its summary is empty. \
1445                 The curated help printer needs a non-empty catalog summary."
1446            );
1447        }
1448    }
1449
1450    /// heddle#646. Close-the-class: every `hide = true` flag on a visible
1451    /// command must carry a discovery affordance, so no flag is learnable
1452    /// only by reading the source (the clone `--lazy`/`--filter` gap).
1453    /// Two affordances are recognized:
1454    ///
1455    /// (a) internal plumbing — the flag's own help text starts with
1456    ///     "Internal", declaring it not-for-users (test helpers, hints
1457    ///     automation sets on the user's behalf); or
1458    /// (b) a help breadcrumb — the command's after-help carries an
1459    ///     advanced/hidden-flags note (mentions "hidden" or "advanced
1460    ///     flag") that either names the flag (`--<long>`) inline or
1461    ///     points at a reveal surface (`heddle help <topic>` /
1462    ///     `--help-agent`).
1463    ///
1464    /// Hidden commands (debug surfaces like `index`/`monitor`) are
1465    /// skipped wholesale — their entire surface is intentionally
1466    /// unadvertised. Global args are skipped (`--op-id` has its own
1467    /// contract-driven reveal in `help_command_for_path` plus the
1468    /// `operation-ids` topic).
1469    #[test]
1470    fn hidden_flags_carry_discovery_affordances() {
1471        use clap::CommandFactory;
1472
1473        fn walk(cmd: &clap::Command, path: &str, violations: &mut Vec<String>) {
1474            let after_help = [
1475                cmd.get_after_help().map(ToString::to_string),
1476                cmd.get_after_long_help().map(ToString::to_string),
1477            ]
1478            .into_iter()
1479            .flatten()
1480            .collect::<Vec<_>>()
1481            .join("\n");
1482            let after_lower = after_help.to_lowercase();
1483            let breadcrumb_marker = after_lower.contains("hidden")
1484                || after_lower.contains("advanced flag")
1485                || after_lower.contains("advanced/planned flags");
1486            let points_at_reveal =
1487                after_help.contains("heddle help ") || after_help.contains("--help-agent");
1488
1489            for arg in cmd.get_arguments() {
1490                if !arg.is_hide_set() || arg.is_global_set() {
1491                    continue;
1492                }
1493                let flag_help = arg
1494                    .get_long_help()
1495                    .or_else(|| arg.get_help())
1496                    .map(ToString::to_string)
1497                    .unwrap_or_default();
1498                if flag_help.starts_with("Internal") {
1499                    continue;
1500                }
1501                let named_inline = arg
1502                    .get_long()
1503                    .is_some_and(|long| after_help.contains(&format!("--{long}")));
1504                if breadcrumb_marker && (points_at_reveal || named_inline) {
1505                    continue;
1506                }
1507                let name = arg
1508                    .get_long()
1509                    .map(|long| format!("--{long}"))
1510                    .unwrap_or_else(|| arg.get_id().to_string());
1511                violations.push(format!("`{path}` hides `{name}`"));
1512            }
1513
1514            for sub in cmd.get_subcommands() {
1515                if sub.is_hide_set() {
1516                    continue;
1517                }
1518                walk(sub, &format!("{path} {}", sub.get_name()), violations);
1519            }
1520        }
1521
1522        let cmd = crate::cli::cli_args::Cli::command();
1523        let mut violations = Vec::new();
1524        walk(&cmd, cmd.get_name(), &mut violations);
1525        assert!(
1526            violations.is_empty(),
1527            "hidden flags without a discovery affordance (heddle#646): either \
1528             prefix the flag's help with `Internal` (plumbing, not for users) \
1529             or add an after-help breadcrumb that mentions the hidden/advanced \
1530             flags and names the flag or a reveal surface:\n  {}",
1531            violations.join("\n  ")
1532        );
1533    }
1534}