1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Tier {
20 Everyday,
23 Advanced,
26 Hidden,
28}
29
30pub fn tier_of(verb: &str) -> Tier {
34 match verb {
35 "init" | "start" | "capture" | "merge" | "log" | "status" | "review" | "discuss"
37 | "context" | "switch" | "undo" | "bridge" | "help" => Tier::Everyday,
38 "abort" | "agent" | "actor" | "attempt" | "auth" | "bisect" | "blame" | "checkpoint"
55 | "cherry-pick" | "clean" | "clone" | "collapse" | "compare" | "completion"
56 | "conflict" | "continue" | "daemon" | "delegate" | "diagnose" | "diff" | "doctor"
57 | "fetch" | "fork" | "fsck" | "git-overlay" | "goto" | "hook" | "inspect"
58 | "integration" | "maintenance" | "marker" | "presence" | "pull" | "purge" | "push"
59 | "query" | "ready" | "rebase" | "redact" | "redo" | "remote" | "resolve" | "retro"
60 | "revert" | "run" | "schemas" | "semantic" | "session" | "ship" | "show" | "stash"
61 | "store" | "support" | "sync" | "thread" | "try" | "version" | "watch" | "workspace" => {
62 Tier::Advanced
63 }
64
65 "gc" | "harness-bridge" | "index" | "monitor" | "transaction" => Tier::Hidden,
73
74 _ => Tier::Advanced,
78 }
79}
80
81pub fn everyday_verbs() -> &'static [&'static str] {
86 &[
87 "init", "start", "capture", "merge", "log", "status", "review", "discuss", "context",
88 "undo", "bridge",
89 ]
90}
91
92pub fn advanced_verbs() -> &'static [&'static str] {
98 &[
99 "agent",
100 "daemon",
101 "hook",
102 "thread",
103 "fork",
104 "collapse",
105 "compare",
106 "stash",
107 "fetch",
108 "push",
109 "pull",
110 "remote",
111 "rebase",
112 "cherry-pick",
113 "blame",
114 "bisect",
115 "fsck",
116 "semantic",
117 "watch",
118 "redo",
119 "revert",
120 "clean",
121 "goto",
122 "ready",
123 "ship",
124 "sync",
125 "delegate",
126 "run",
127 "diff",
128 "marker",
129 "workspace",
130 "integration",
131 "maintenance",
132 "clone",
133 "auth",
134 "diagnose",
135 "show",
136 "session",
137 "actor",
138 "store",
139 "completion",
140 "resolve",
141 "presence",
142 ]
143}
144
145fn about_first_line(cmd: &clap::Command, verb: &str) -> String {
157 let raw = cmd
158 .get_subcommands()
159 .find(|sc| sc.get_name() == verb)
160 .and_then(|sc| sc.get_about())
161 .map(|about| about.to_string().lines().next().unwrap_or("").to_string())
162 .unwrap_or_default();
163 let stripped = raw
164 .trim_start_matches("Automation/workflow command:")
165 .trim_start();
166 let mut chars = stripped.chars();
167 match chars.next() {
168 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
169 None => String::new(),
170 }
171}
172
173pub fn print_help(cmd: &clap::Command, topic: Option<&str>) -> std::io::Result<()> {
182 use std::io::Write;
183 let stdout = std::io::stdout();
184 let mut out = stdout.lock();
185 match topic {
186 None => {
187 writeln!(out, "Heddle — AI-native version control")?;
188 writeln!(out)?;
189 writeln!(out, "Everyday commands:")?;
190 for &name in everyday_verbs() {
191 let blurb = about_first_line(cmd, name);
192 if blurb.is_empty() {
193 continue;
194 }
195 writeln!(out, " {:<10} {}", name, blurb)?;
196 }
197 writeln!(out)?;
198 writeln!(
199 out,
200 "Run `heddle help advanced` for advanced commands or \
201 `heddle help <topic>` for a topic page (e.g. `daemon`, \
202 `signals`, `bridge`, `operation-ids`)."
203 )?;
204 }
205 Some("advanced") => {
206 writeln!(out, "{}", ADVANCED_HELP)?;
207 writeln!(out, "Advanced commands:")?;
208 for &name in advanced_verbs() {
209 let blurb = about_first_line(cmd, name);
210 if blurb.is_empty() {
211 continue;
212 }
213 writeln!(out, " {:<14} {}", name, blurb)?;
214 }
215 }
216 Some(name) => {
217 if let Some(body) = topic_text(name) {
218 writeln!(out, "{}", body)?;
219 } else if let Some(subcommand) = cmd.find_subcommand(name) {
220 drop(out);
229 let mut subcommand =
230 subcommand
231 .clone()
232 .bin_name(format!("{} {}", cmd.get_name(), name));
233 subcommand.print_help()?;
234 } else {
235 writeln!(
236 out,
237 "no topic '{name}'. Run `heddle help advanced` for \
238 the full advanced list, or `heddle help` for the \
239 curated everyday surface."
240 )?;
241 }
242 }
243 }
244 Ok(())
245}
246
247pub fn topic_text(topic: &str) -> Option<&'static str> {
249 Some(match topic {
250 "advanced" => ADVANCED_HELP,
251 "agent" | "daemon" => DAEMON_TOPIC,
252 "operation-ids" | "idempotency" => OPERATION_IDS_TOPIC,
253 "review" => REVIEW_TOPIC,
254 "discuss" | "discussions" => DISCUSS_TOPIC,
255 "bridge" | "footer" | "notes" => BRIDGE_TOPIC,
256 "signals" | "risk-signals" => SIGNALS_TOPIC,
257 _ => return None,
258 })
259}
260
261const ADVANCED_HELP: &str = "Advanced verbs — see `heddle help advanced` for the complete list.\n\
262\n\
263The default `heddle help` curates the everyday surface (init, start, capture, merge,\n\
264log, status, review, discuss, context, undo, bridge). Everything else lives behind\n\
265this topic and `heddle help <verb> --help` for the full clap-derived docs.\n\
266\n\
267This is intentional. The everyday surface stays minimal so first-time users aren't\n\
268overwhelmed; agents and power users reach for the advanced affordances when they\n\
269need them.\n";
270
271const DAEMON_TOPIC: &str = "Two daemons — both have legitimate uses; they are not interchangeable.\n\
272\n\
273`heddle daemon` — FUSE mount-daemon control plane. Owns FUSE sessions for\n\
274 `--workspace light --daemon` threads. Linux only.\n\
275 Subcommands: serve | status | stop.\n\
276\n\
277`heddle agent serve` — Local gRPC daemon over a Unix socket inside the repo's\n\
278 `.heddle/sockets/`. Hosts the local agent\n\
279 services (state-review, discussion, signal, operation-log\n\
280 query, hook) so agents avoid per-command\n\
281 process startup latency. Mode: same-user only,\n\
282 peer-cred check enforced. Out of scope for first ship:\n\
283 multi-user, remote, TLS.\n";
284
285const OPERATION_IDS_TOPIC: &str = "Idempotency — every state-changing call accepts a `client_operation_id`.\n\
286\n\
287The same id replayed with the same body returns the original outcome\n\
288bit-identical; with a different body it returns FAILED_PRECONDITION.\n\
289\n\
290The dedup store is file-backed locally (`.heddle/state/operation_dedup.bin`,\n\
291rmp-serde, 7-day default retention) and Postgres-backed in hosted deployments.\n\
292\n\
293The CLI accepts `--op-id <UUID>` on every state-changing verb (or honours\n\
294`HEDDLE_OPERATION_ID`). Without an id, dedup is bypassed and the call\n\
295executes normally.\n";
296
297const REVIEW_TOPIC: &str = "Review surface — `heddle review show | sign | next | health`.\n\
298\n\
299`show <state>` — render the review payload (summary, agent narrative,\n\
300 in-budget signals, anchored discussions).\n\
301 `--all-signals` also surfaces hidden ones.\n\
302`sign <state>` — submit a `read | agent_preview | agent_co_review`\n\
303 signature. `--symbols file:symbol` scopes to\n\
304 specific symbols; default is the whole change.\n\
305`next` — placeholder until the operation-log query layer wires real\n\
306 pending-review selection.\n\
307`health [--window N]`\n\
308 — per-module signal fire-rate over the last N states.\n\
309\n\
310Tick budget: at most 3 signals per state by default. Priority:\n\
311invariant_adjacency > self_flagged_uncertainty > pattern_deviation >\n\
312novelty > test_reachability.\n";
313
314const DISCUSS_TOPIC: &str = "`heddle discuss open | append | resolve | list | show`\n\
315\n\
316Discussions anchor at the symbol level (file + symbol name, no line range)\n\
317so they survive renames and cross-file moves. Each discussion accumulates\n\
318turns and resolves into one of three terminal states:\n\
319\n\
320- `resolve <id> --mode into-annotation` with `--annotation-kind`,\n\
321 `--annotation-content`, optional `--annotation-tags`. Atomically\n\
322 creates the annotation and bidirectionally links it.\n\
323- `resolve <id> --mode by-edit` with `--state` (defaults to HEAD).\n\
324 Records that a subsequent edit addressed the discussion.\n\
325- `resolve <id> --mode dismiss` requires non-empty `--reason`.\n\
326\n\
327Visibility: `--visibility public|internal|team:NAME|restricted:LABEL`.\n\
328Defaults to the repo's namespace policy.\n";
329
330const BRIDGE_TOPIC: &str = "Bridge export footer + git notes.\n\
331\n\
332Every exported commit carries a footer at the tail of the commit message:\n\
333\n\
334 Heddle-State: <change_id>\n\
335 Heddle-URL: <hosted_url>/state/<change_id> (omitted if no hosted URL)\n\
336 Heddle-Annotations-Omitted: <count>\n\
337\n\
338This is the durable record — every reader on every host sees it regardless\n\
339of remote configuration.\n\
340\n\
341Per-scope annotation drop counts and signal counts ride on the opt-in\n\
342git note at `refs/notes/heddle`. To fetch + push notes:\n\
343\n\
344 git config --add remote.origin.fetch '+refs/notes/heddle:refs/notes/heddle'\n\
345 git config --add remote.origin.push 'refs/notes/heddle:refs/notes/heddle'\n\
346\n\
347Then `git log --notes=heddle` displays the rich metadata inline.\n";
348
349const SIGNALS_TOPIC: &str = "Risk signals — five modules behind a pure trait.\n\
350\n\
351- `invariant_adjacency` — fires when a changed symbol carries an\n\
352 Invariant or `enforces`-tagged annotation.\n\
353- `self_flagged_uncertainty` — passthrough of agent-emitted self-flags\n\
354 from the captured state's intent.\n\
355- `pattern_deviation` — fires when a symbol's body diverges\n\
356 from siblings or the prior version\n\
357 (tree-sitter token similarity).\n\
358- `novelty` — fires when a function shape is unique\n\
359 in the repo corpus.\n\
360- `test_reachability` — fires when no test statically reaches\n\
361 the changed symbol via tree-sitter\n\
362 call-graph traversal. The reason text\n\
363 is honest: this is *not* runtime\n\
364 coverage.\n\
365\n\
366Configure under `[review.signals]` in `.heddle/config.toml`. Each module\n\
367ships fires-correctly + stays-quiet tests; defaults are conservative\n\
368so a fresh repo isn't noisy.\n";
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 #[test]
375 fn everyday_verbs_in_curated_list_have_everyday_tier() {
376 for &verb in everyday_verbs() {
377 assert_eq!(tier_of(verb), Tier::Everyday, "{verb}");
378 }
379 }
380
381 #[test]
382 fn topic_text_returns_none_for_unknown() {
383 assert!(topic_text("definitely-not-a-topic").is_none());
384 }
385
386 #[test]
387 fn topic_text_returns_some_for_advertised_topics() {
388 for topic in [
389 "advanced",
390 "agent",
391 "daemon",
392 "operation-ids",
393 "idempotency",
394 "review",
395 "discuss",
396 "discussions",
397 "bridge",
398 "footer",
399 "notes",
400 "signals",
401 "risk-signals",
402 ] {
403 assert!(topic_text(topic).is_some(), "{topic}");
404 }
405 }
406
407 #[test]
408 fn tier_of_advanced_verbs_classifies_correctly() {
409 for &verb in advanced_verbs() {
410 let t = tier_of(verb);
411 assert!(
412 matches!(t, Tier::Advanced),
413 "expected Advanced for {verb}, got {t:?}"
414 );
415 }
416 }
417
418 #[test]
419 fn hidden_aliases_are_hidden() {
420 for verb in ["gc", "index", "monitor"] {
421 assert_eq!(tier_of(verb), Tier::Hidden, "{verb}");
422 }
423 }
424
425 #[test]
434 fn verb_blurbs_resolve_from_clap() {
435 use clap::CommandFactory;
436 let cmd = crate::cli::Cli::command();
437 for &verb in everyday_verbs().iter().chain(advanced_verbs().iter()) {
438 let Some(subcommand) = cmd.get_subcommands().find(|sc| sc.get_name() == verb) else {
441 continue;
442 };
443 let blurb = about_first_line(&cmd, verb);
444 assert!(
445 !blurb.is_empty(),
446 "verb `{verb}` is a clap subcommand but its `about` \
447 doc-comment is empty. The curated help printer needs \
448 a non-empty first line. (subcommand seen: {:?})",
449 subcommand.get_name()
450 );
451 }
452 }
453}