Skip to main content

agentstatedeveloper_core/
help.rs

1//! On-demand instruction disclosure (`help`) — the asd half of the
2//! cross-binary help system shared with `ctx` (CTXone).
3//!
4//! Feature docs are compiled INTO the binary, so `help` always describes the
5//! exact code that is running (version-pinned, immutable, cannot drift). They
6//! are served through the MCP `help` tool and the `asd help` CLI. Lives in
7//! `core` because both the `-cli` and `-mcp` crates depend on it and must
8//! return byte-identical docs.
9//!
10//! Cross-binary contract (must match `ctx`): each binary publishes a
11//! lightweight manifest (feature -> synopsis -> owner -> version) into the
12//! shared index at `$AGENTSTATE_HELP_INDEX` (default
13//! `$HOME/.config/agentstate/help-index.json`), keyed by tool name so `ctx`
14//! and `asd` own separate slices. A unified `help` reads the union.
15
16use serde::Serialize;
17use serde_json::{Value, json};
18
19/// Which binary owns these docs. Written into the manifest so a unified `help`
20/// can route `help <feature>` to the tool that implements it.
21pub const OWNER: &str = "asd";
22
23/// Version of the running binary — stamped into every response so an agent (or
24/// a stale manifest) can detect a mismatch against the code it is calling.
25pub fn version() -> &'static str {
26    env!("CARGO_PKG_VERSION")
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct HelpParam {
31    pub name: &'static str,
32    pub required: bool,
33    pub desc: &'static str,
34}
35
36/// One feature's compiled documentation. `feature` matches the tool/CLI name
37/// so lookups are stable.
38#[derive(Debug, Clone, Serialize)]
39pub struct HelpDoc {
40    pub feature: &'static str,
41    pub group: &'static str,
42    pub synopsis: &'static str,
43    pub syntax: &'static str,
44    pub params: &'static [HelpParam],
45    pub examples: &'static [&'static str],
46    pub gotchas: &'static [&'static str],
47    pub related: &'static [&'static str],
48}
49
50macro_rules! p {
51    ($name:literal, $req:literal, $desc:literal) => {
52        HelpParam {
53            name: $name,
54            required: $req,
55            desc: $desc,
56        }
57    };
58}
59
60/// The compiled registry. Discoverability-forward hot core; the no-arg catalog
61/// surfaces all of it so agents reach for features they'd otherwise miss.
62pub const REGISTRY: &[HelpDoc] = &[
63    // ---- orient -----------------------------------------------------------
64    HelpDoc {
65        feature: "architecture",
66        group: "orient",
67        synopsis: "One-call cold-start overview: languages, packages, layers, call-graph communities, routes, hotspots.",
68        syntax: "asd architecture [--top <n=12>] [--agent]",
69        params: &[
70            p!(
71                "--top",
72                false,
73                "How many packages/hotspots to list (default 12)."
74            ),
75            p!("--agent", false, "Machine-readable JSON output."),
76        ],
77        examples: &["asd architecture", "asd architecture --top 20 --agent"],
78        gotchas: &["Read-only. Run this first in an unfamiliar repo, before touching anything."],
79        related: &["map", "status", "investigate"],
80    },
81    HelpDoc {
82        feature: "map",
83        group: "orient",
84        synopsis: "Initial-read project map; persists package boundaries and test-file roles as Ownership ledger entries.",
85        syntax: "asd map [--agent]",
86        params: &[p!("--agent", false, "Machine-readable JSON output.")],
87        examples: &["asd map"],
88        gotchas: &[
89            "Writes Ownership ledger entries — this is a read/orient step that also records structure.",
90        ],
91        related: &["architecture", "status"],
92    },
93    HelpDoc {
94        feature: "status",
95        group: "orient",
96        synopsis: "Workspace index health: trust score, dirty files, concept gaps, staleness.",
97        syntax: "asd status [--agent]",
98        params: &[p!("--agent", false, "Machine-readable JSON output.")],
99        examples: &["asd status"],
100        gotchas: &[
101            "For a single go/no-go reliability number use `trust`; to refresh a stale index use `index`/`reindex`.",
102        ],
103        related: &["trust", "index"],
104    },
105    HelpDoc {
106        feature: "trust",
107        group: "orient",
108        synopsis: "State Trust Score rollup — 'can I rely on asd right now?' as one number.",
109        syntax: "asd trust [--agent]",
110        params: &[p!("--agent", false, "Machine-readable JSON output.")],
111        examples: &["asd trust"],
112        gotchas: &["A low score means reindex or investigate before trusting asd's answers."],
113        related: &["status", "scorecard"],
114    },
115    // ---- understand -------------------------------------------------------
116    HelpDoc {
117        feature: "prepare_change",
118        group: "understand",
119        synopsis: "One-call agent-ready context package for a planned change (investigate + impact + checklist).",
120        syntax: "asd prepare-change <query> [--agent]",
121        params: &[p!(
122            "query",
123            true,
124            "Free-text description of the change you intend to make."
125        )],
126        examples: &["asd prepare-change \"add rate limiting to the login endpoint\""],
127        gotchas: &[
128            "Call this FIRST before any non-trivial change — it bundles the understand+impact+checklist steps.",
129        ],
130        related: &["investigate", "impact", "checklist"],
131    },
132    HelpDoc {
133        feature: "investigate",
134        group: "understand",
135        synopsis: "Feature archaeology in one pass: entry points + call chains, effects, invariants, hazards.",
136        syntax: "asd investigate <query> [--agent]",
137        params: &[p!(
138            "query",
139            true,
140            "Feature/behavior to trace through the codebase."
141        )],
142        examples: &["asd investigate \"how does session token refresh work\""],
143        gotchas: &[
144            "Understand before touching. For blast radius of ONE known symbol use `impact` instead.",
145        ],
146        related: &["prepare_change", "impact", "context_for"],
147    },
148    HelpDoc {
149        feature: "impact",
150        group: "understand",
151        synopsis: "Blast-radius for ONE known symbol before editing: transitive callers, effects, invariants, tests, git touches.",
152        syntax: "asd impact <symbol> [--agent]",
153        params: &[p!(
154            "symbol",
155            true,
156            "Fully-qualified symbol name to analyze."
157        )],
158        examples: &["asd impact myapp::auth::verify_token"],
159        gotchas: &["Needs a known symbol. To find the symbol first use `search`/`references`."],
160        related: &["investigate", "since", "references"],
161    },
162    HelpDoc {
163        feature: "checklist",
164        group: "understand",
165        synopsis: "Pre-edit checklist for a free-text query: files, invariants, tests, hazards, effects.",
166        syntax: "asd checklist <query> [--agent]",
167        params: &[p!("query", true, "The change/area you're about to edit.")],
168        examples: &["asd checklist \"refactor the payment retry loop\""],
169        gotchas: &["Lighter than `prepare_change` — no full investigate/impact bundle."],
170        related: &["prepare_change", "impact"],
171    },
172    HelpDoc {
173        feature: "context_for",
174        group: "understand",
175        synopsis: "Deep per-symbol context for comma-separated qnames: signature, callers/callees, effects, ledger, tests.",
176        syntax: "asd context-for <qnames> [--agent]",
177        params: &[p!(
178            "qnames",
179            true,
180            "Comma-separated fully-qualified symbol names."
181        )],
182        examples: &["asd context-for myapp::db::pool,myapp::db::migrate"],
183        gotchas: &["For a whole-feature trace rather than named symbols, use `investigate`."],
184        related: &["impact", "investigate", "references"],
185    },
186    HelpDoc {
187        feature: "since",
188        group: "understand",
189        synopsis: "Symbols changed since a commit + combined blast radius (PR review).",
190        syntax: "asd since <ref> [--agent]",
191        params: &[p!(
192            "ref",
193            true,
194            "Git ref/commit to diff from (e.g. main, a SHA)."
195        )],
196        examples: &["asd since main"],
197        gotchas: &["Pairs with `impact` — this is the multi-symbol version for a whole diff."],
198        related: &["impact", "task_close"],
199    },
200    // ---- search -----------------------------------------------------------
201    HelpDoc {
202        feature: "search",
203        group: "search",
204        synopsis: "Full ranked symbol search with confidence, uncertainty, and feedback adjustments (agent-grade).",
205        syntax: "asd search <query> [--agent]",
206        params: &[p!("query", true, "Concept or symbol to search for.")],
207        examples: &["asd search \"rate limiter\""],
208        gotchas: &[
209            "Heavier/ranked. For a lighter BM25 concept scan use `code_search`; for exact identifier hits use `references`.",
210        ],
211        related: &["code_search", "references", "context_for"],
212    },
213    HelpDoc {
214        feature: "code_search",
215        group: "search",
216        synopsis: "Ranked concept search (FTS5/BM25) — lighter than `search`.",
217        syntax: "asd search <query> [--agent]   (CLI alias: code_search; distinct MCP tool)",
218        params: &[p!("query", true, "Concept text to rank against the index.")],
219        examples: &["asd search \"retry backoff\""],
220        gotchas: &[
221            "At the CLI this is the `search` command (code_search/code_query are aliases); as an MCP tool it is distinct from `search`.",
222        ],
223        related: &["search", "references"],
224    },
225    HelpDoc {
226        feature: "references",
227        group: "search",
228        synopsis: "Exact-identifier occurrences across the repo (rg-style completeness).",
229        syntax: "asd references <identifier> [--agent]",
230        params: &[p!(
231            "identifier",
232            true,
233            "Exact identifier to find every occurrence of."
234        )],
235        examples: &["asd references verify_token"],
236        gotchas: &[
237            "Exact-match, not ranked concept search — use `search`/`code_search` for fuzzy concepts.",
238        ],
239        related: &["search", "impact"],
240    },
241    // ---- ledger -----------------------------------------------------------
242    HelpDoc {
243        feature: "task_close",
244        group: "ledger",
245        synopsis: "Write Proof/ValidationScenario ledger entries for the symbols affected by HEAD.",
246        syntax: "asd task-close [--agent]",
247        params: &[],
248        examples: &["asd task-close"],
249        gotchas: &[
250            "Run after making a change to record proof; pairs with `since` to know what HEAD touched.",
251        ],
252        related: &["since", "scorecard"],
253    },
254    // ---- meta -------------------------------------------------------------
255    HelpDoc {
256        feature: "help",
257        group: "meta",
258        synopsis: "Get exact syntax, examples, and gotchas for a feature on demand — call before using one you're unsure of.",
259        syntax: "asd help [topic]",
260        params: &[p!(
261            "topic",
262            false,
263            "Feature name or a phrase; omit to list the whole catalog."
264        )],
265        examples: &["asd help", "asd help impact", "asd help \"blast radius\""],
266        gotchas: &[
267            "Docs are version-pinned to the running binary, so they can't drift from the code.",
268        ],
269        related: &["architecture", "status"],
270    },
271    // ---- code -------------------------------------------------------------
272    HelpDoc {
273        feature: "code_query",
274        group: "code",
275        synopsis: "Look up indexed symbols by exact structured filters (name_contains, kind, language), AND-combined, no ranking.",
276        syntax: "asd search <query> [--kind <k>] [--language <l>] [--limit <n=50>]   (MCP tool: code_query)",
277        params: &[
278            p!("name_contains", false, "Substring match on qualified name."),
279            p!(
280                "kind",
281                false,
282                "Symbol kind: module, function, method, class, variable."
283            ),
284            p!("language", false, "Language filter (e.g. \"python\")."),
285            p!("limit", false, "Max results (default 50)."),
286        ],
287        examples: &["asd search \"pool\""],
288        gotchas: &[
289            "No relevance ranking — for concept discovery use `code_search`; for exact identifiers use `references`.",
290        ],
291        related: &["code_search", "references", "search"],
292    },
293    HelpDoc {
294        feature: "code_read",
295        group: "code",
296        synopsis: "Read one symbol by qname: returns its signature, effects, and ledger decisions.",
297        syntax: "asd read <qname>",
298        params: &[p!("qname", true, "Fully-qualified symbol name.")],
299        examples: &["asd read myapp::auth::verify_token"],
300        gotchas: &[
301            "For DEEP context (callers/callees, covering tests, token-budgeted) use `context_for`.",
302        ],
303        related: &["context_for", "effects", "ledger_get"],
304    },
305    HelpDoc {
306        feature: "callers",
307        group: "code",
308        synopsis: "List symbols that call the given symbol (inbound call edges, intra-module).",
309        syntax: "asd callers <qname>",
310        params: &[p!("qname", true, "Fully-qualified symbol name.")],
311        examples: &["asd callers myapp::auth::verify_token"],
312        gotchas: &[
313            "Intra-module edges only; for full blast radius with effects+tests use `impact`.",
314        ],
315        related: &["callees", "impact", "references"],
316    },
317    HelpDoc {
318        feature: "callees",
319        group: "code",
320        synopsis: "List symbols called by the given symbol (outbound call edges, intra-module).",
321        syntax: "asd callees <qname>",
322        params: &[p!("qname", true, "Fully-qualified symbol name.")],
323        examples: &["asd callees myapp::auth::login"],
324        gotchas: &["Intra-module edges only; misses dynamic dispatch and cross-language calls."],
325        related: &["callers", "impact"],
326    },
327    HelpDoc {
328        feature: "dead_code",
329        group: "code",
330        synopsis: "Candidate dead code: functions/methods with no inbound call edges. NOT definitive.",
331        syntax: "asd dead-code [--limit <n=50>] [--include-tests] [--agent]",
332        params: &[
333            p!("limit", false, "Max candidates to list (default 50)."),
334            p!(
335                "include_tests",
336                false,
337                "Include test functions (excluded by default)."
338            ),
339        ],
340        examples: &["asd dead-code"],
341        gotchas: &[
342            "Static graph misses reflection, framework callbacks, cross-repo API — confirm with `references` before deleting.",
343        ],
344        related: &["references", "callers", "endpoints"],
345    },
346    HelpDoc {
347        feature: "endpoints",
348        group: "code",
349        synopsis: "List cross-service endpoints (HTTP routes/clients, pub-sub) plus in-repo matched client↔route edges.",
350        syntax: "asd endpoints [--export] [--agent]",
351        params: &[],
352        examples: &["asd endpoints"],
353        gotchas: &[
354            "An unmatched client edge is a candidate contract-drift signal; cross-repo matching is Team-tier.",
355        ],
356        related: &["dead_code", "architecture"],
357    },
358    // ---- effects ----------------------------------------------------------
359    HelpDoc {
360        feature: "effects",
361        group: "effects",
362        synopsis: "Declared + transitively-inherited side effects for a symbol (I/O, network, fs, logging, mutation).",
363        syntax: "effects {qname}  (MCP tool — no CLI verb)",
364        params: &[p!("qname", true, "Fully-qualified symbol name.")],
365        examples: &["effects {qname: \"myapp::db::pool\"}"],
366        gotchas: &[
367            "To check declared vs actual use `verify_effects`; to change them use `effect_declare`.",
368        ],
369        related: &["verify_effects", "effect_declare", "code_read"],
370    },
371    HelpDoc {
372        feature: "effect_declare",
373        group: "effects",
374        synopsis: "Overwrite the declared effects list for a symbol; routes through the policy gate.",
375        syntax: "effect_declare {qname, declared}  (MCP tool — no CLI verb)",
376        params: &[
377            p!("qname", true, "Fully-qualified symbol name."),
378            p!(
379                "declared",
380                true,
381                "List of Effect objects: { effect, qualifiers, note }."
382            ),
383            p!(
384                "author_id",
385                false,
386                "Author id surfaced to the policy gate (default asd-mcp)."
387            ),
388        ],
389        examples: &[
390            "effect_declare {qname: \"myapp::io::write\", declared: [{effect: \"filesystem\"}]}",
391        ],
392        gotchas: &[
393            "Broadening (new effect categories) uses the asd.effect.declare.broadens policy action — may be denied or flagged.",
394        ],
395        related: &["effects", "verify_effects"],
396    },
397    HelpDoc {
398        feature: "verify_effects",
399        group: "effects",
400        synopsis: "Compare declared effects against what the static checker infers from source: ok/mismatch/unverified.",
401        syntax: "asd verify-effects <qname> [--write]",
402        params: &[
403            p!("qname", true, "Fully-qualified symbol name."),
404            p!(
405                "write",
406                false,
407                "Persist verification result to the store (default false)."
408            ),
409        ],
410        examples: &["asd verify-effects myapp::io::write"],
411        gotchas: &[
412            "unverified = source unreadable or no adapter for that language. Run before trusting `impact`'s effect set.",
413        ],
414        related: &["effects", "effect_declare", "impact"],
415    },
416    HelpDoc {
417        feature: "traces",
418        group: "effects",
419        synopsis: "Return execution trace records stored for a symbol (written by `asd trace`), newest-first.",
420        syntax: "traces {qname}  (MCP tool — read side; write via `asd trace`)",
421        params: &[
422            p!("qname", true, "Fully-qualified symbol name."),
423            p!("limit", false, "Max trace records (default 20)."),
424        ],
425        examples: &["traces {qname: \"myapp::pipeline::run\"}"],
426        gotchas: &[
427            "Traces are produced by `asd trace` (Python only, sys.settrace); no CLI read verb.",
428        ],
429        related: &["effects", "verify_effects"],
430    },
431    // ---- ledger -----------------------------------------------------------
432    HelpDoc {
433        feature: "ledger_get",
434        group: "ledger",
435        synopsis: "List ledger entries for a symbol, newest first; superseded entries omitted by default.",
436        syntax: "ledger_get {qname}  (MCP tool — CLI reads go through `asd read` / `asd list`)",
437        params: &[
438            p!("qname", true, "Fully-qualified symbol name."),
439            p!(
440                "include_superseded",
441                false,
442                "Include superseded entries (default false)."
443            ),
444        ],
445        examples: &["ledger_get {qname: \"myapp::auth::verify_token\"}"],
446        gotchas: &[
447            "There is no ledger-get CLI verb — the CLI reads a symbol's ledger via `asd read <qname>`.",
448        ],
449        related: &["ledger_find", "ledger_append", "code_read"],
450    },
451    HelpDoc {
452        feature: "ledger_find",
453        group: "ledger",
454        synopsis: "Search ledger entries across ALL symbols by kind/tag/author_id, without knowing the anchoring symbol.",
455        syntax: "ledger_find {kind?, tag?, author_id?}  (MCP tool — no CLI verb)",
456        params: &[
457            p!(
458                "kind",
459                false,
460                "Ledger kind (decision, assumption, constraint, rationale, hazard, tradeoff)."
461            ),
462            p!("tag", false, "Tag that must be present on the entry."),
463            p!("author_id", false, "Filter by author id."),
464            p!("limit", false, "Max results (default 50)."),
465        ],
466        examples: &["ledger_find {kind: \"hazard\"}"],
467        gotchas: &[
468            "To read ONE symbol's ledger use `ledger_get`; CLI reads go through `asd list`.",
469        ],
470        related: &["ledger_get", "ledger_append"],
471    },
472    HelpDoc {
473        feature: "ledger_append",
474        group: "ledger",
475        synopsis: "Append a ledger entry to a symbol; routes through the policy gate (may deny/allow/await-approval).",
476        syntax: "asd ledger append <qname> --kind <k> --summary <s> [--body ...] [--tags ...]",
477        params: &[
478            p!("qname", true, "Symbol the entry attaches to."),
479            p!(
480                "kind",
481                true,
482                "Ledger entry kind (decision, hazard, rationale, ...)."
483            ),
484            p!("summary", true, "One-line summary."),
485            p!("body", false, "Free-form body (markdown ok)."),
486            p!("tags", false, "Optional tags."),
487        ],
488        examples: &[
489            "asd ledger append myapp::db::pool --kind decision --summary \"pin pool size to 8\"",
490        ],
491        gotchas: &[
492            "Returns { entry_id, matched_policy, status }; status may be awaiting-approval — see `ledger_approve`.",
493        ],
494        related: &["ledger_supersede", "ledger_approve", "ledger_get"],
495    },
496    HelpDoc {
497        feature: "ledger_approve",
498        group: "ledger",
499        synopsis: "Approve a ledger entry tagged `awaiting-approval`; records approved-by/approved-at.",
500        syntax: "asd ledger approve <entry_id> --approver <id>",
501        params: &[
502            p!("entry_id", true, "Entry id from a prior ledger_append."),
503            p!(
504                "approver",
505                true,
506                "Approver identifier (recorded as approved-by:<id>)."
507            ),
508            p!(
509                "approver_kind",
510                false,
511                "Must match an approver:* tag on the entry (default human)."
512            ),
513            p!(
514                "message",
515                false,
516                "Optional approver rationale appended to the body."
517            ),
518        ],
519        examples: &["asd ledger approve led-abc123 --approver alice"],
520        gotchas: &["Approver kind/id must match one of the entry's original approver:* tags."],
521        related: &["ledger_reject", "ledger_withdraw", "ledger_append"],
522    },
523    HelpDoc {
524        feature: "ledger_reject",
525        group: "ledger",
526        synopsis: "Reject an awaiting-approval entry; records rejected-by/rejected-at with a required reason.",
527        syntax: "asd ledger reject <entry_id> --reviewer <id> --reason <text>",
528        params: &[
529            p!("entry_id", true, "Entry id to reject."),
530            p!(
531                "reviewer",
532                true,
533                "Reviewer id (recorded as rejected-by:<id>)."
534            ),
535            p!(
536                "reviewer_kind",
537                false,
538                "Same approver-match rule as approve (default human)."
539            ),
540            p!(
541                "reason",
542                true,
543                "Rejection reason (required); appended to the entry body."
544            ),
545        ],
546        examples: &[
547            "asd ledger reject led-abc123 --reviewer bob --reason \"superseded by new design\"",
548        ],
549        gotchas: &[
550            "Same approver-match rule as `ledger_approve` — reviewer must match an approver:* tag.",
551        ],
552        related: &["ledger_approve", "ledger_withdraw"],
553    },
554    HelpDoc {
555        feature: "ledger_withdraw",
556        group: "ledger",
557        synopsis: "Withdraw an awaiting-approval entry; must be called by the original author.",
558        syntax: "asd ledger withdraw <entry_id> --author-id <id>",
559        params: &[
560            p!("entry_id", true, "Entry id to withdraw."),
561            p!(
562                "author_id",
563                true,
564                "Must match the original entry.author.id."
565            ),
566        ],
567        examples: &["asd ledger withdraw led-abc123 --author-id asd-mcp"],
568        gotchas: &["Only the original author may withdraw; flips awaiting-approval → withdrawn."],
569        related: &["ledger_approve", "ledger_reject"],
570    },
571    HelpDoc {
572        feature: "ledger_supersede",
573        group: "ledger",
574        synopsis: "Append a new ledger entry that supersedes one or more existing entries for a symbol.",
575        syntax: "asd ledger supersede <qname> --supersede <ids> --kind <k> --summary <s>",
576        params: &[
577            p!("qname", true, "Symbol the new entry attaches to."),
578            p!("supersedes", true, "Entry ids superseded by the new entry."),
579            p!("kind", true, "Ledger kind for the new entry."),
580            p!("summary", true, "One-line summary."),
581            p!("body", false, "Optional body."),
582        ],
583        examples: &[
584            "asd ledger supersede myapp::db::pool --supersede led-old --kind decision --summary \"raise pool to 16\"",
585        ],
586        gotchas: &[
587            "Superseded entries are filtered out of default `ledger_get` results but remain retrievable.",
588        ],
589        related: &["ledger_append", "ledger_get"],
590    },
591    HelpDoc {
592        feature: "ledger_rebind",
593        group: "ledger",
594        synopsis: "Record a rename/move: move every ledger entry filed under the old symbol onto the new one, in one commit.",
595        syntax: "asd ledger rebind --from <symbol_id|qname> --to <qname> | --map <file.json>",
596        params: &[
597            p!(
598                "from_symbol_id",
599                true,
600                "symbol_id of the old symbol to re-parent from."
601            ),
602            p!(
603                "to_qname",
604                true,
605                "Qname of the new symbol to resolve and bind to."
606            ),
607            p!(
608                "agent_id",
609                false,
610                "Agent/user performing the rebind (default asd-mcp)."
611            ),
612        ],
613        examples: &[
614            "asd ledger rebind --from sym-old --to myapp::auth::verify",
615            "asd ledger rebind --map rebinds.json",
616        ],
617        gotchas: &[
618            "Run this whenever you rename a function/class so its ledger history isn't orphaned.",
619            "An orphaned symbol (`asd repair` lists them as `orphaned_ledger`) is no longer in the index: pass its symbol_id, not its old qname.",
620            "`--map` takes `{\"<symbol_id or qname>\": \"<new qname>\", …}`; every entry is checked before anything is written.",
621        ],
622        related: &["ledger_append", "ledger_get"],
623    },
624    HelpDoc {
625        feature: "annotate_commit",
626        group: "ledger",
627        synopsis: "Derive ledger annotations (decisions, invariants, hazards, proofs) from a git commit; dry-run by default.",
628        syntax: "asd annotate-commit [<sha=HEAD>] [--write] [--author <id>]",
629        params: &[
630            p!("sha", false, "Git commit SHA (default HEAD)."),
631            p!(
632                "write",
633                false,
634                "Actually persist entries (default false — dry-run)."
635            ),
636            p!("author", false, "Author id (defaults to git user.name)."),
637        ],
638        examples: &["asd annotate-commit", "asd annotate-commit HEAD --write"],
639        gotchas: &[
640            "Dry-run by default — pass --write to persist. Reads changed files + commit message to resolve symbols.",
641        ],
642        related: &["task_close", "ledger_append"],
643    },
644    // ---- invariant --------------------------------------------------------
645    HelpDoc {
646        feature: "invariant_add",
647        group: "invariant",
648        synopsis: "Record an invariant that must hold at a symbol (shortcut for ledger_append kind=invariant).",
649        syntax: "asd invariant add <qname> <summary>",
650        params: &[
651            p!("qname", true, "Fully-qualified symbol name."),
652            p!("summary", true, "One-line invariant summary."),
653            p!(
654                "author_id",
655                false,
656                "Author identifier (default asd-mcp-agent)."
657            ),
658        ],
659        examples: &["asd invariant add myapp::db::pool \"pool size must stay <= 16\""],
660        gotchas: &["Invariants surface in investigate, checklist, and prepare_change outputs."],
661        related: &["invariant_list", "ledger_append"],
662    },
663    HelpDoc {
664        feature: "invariant_list",
665        group: "invariant",
666        synopsis: "List invariants recorded against symbols; pass qname to filter to one symbol.",
667        syntax: "asd invariant list [<qname>]",
668        params: &[p!(
669            "qname",
670            false,
671            "Filter to one symbol; omit to list all."
672        )],
673        examples: &["asd invariant list"],
674        gotchas: &[
675            "Invariants are ledger entries of kind=invariant — also visible via `ledger_get`.",
676        ],
677        related: &["invariant_add", "ledger_get"],
678    },
679    // ---- feedback ---------------------------------------------------------
680    HelpDoc {
681        feature: "feedback_mark",
682        group: "feedback",
683        synopsis: "Record a verdict on a search result (useful/noisy/missing/wrong_layer/already_covered/diagnostic_only).",
684        syntax: "asd feedback mark <query> <qname> <verdict>",
685        params: &[
686            p!("query", true, "The search query that produced this result."),
687            p!("qname", true, "Symbol being rated."),
688            p!(
689                "verdict",
690                true,
691                "useful | noisy | missing | wrong_layer | already_covered | diagnostic_only."
692            ),
693            p!(
694                "covered_by",
695                false,
696                "For already_covered: qname that covers this one (writes a Mapping entry)."
697            ),
698            p!(
699                "ttl_days",
700                false,
701                "Optional expiry in days after which the mark stops influencing ranking."
702            ),
703        ],
704        examples: &["asd feedback mark \"rate limiter\" myapp::mw::limit useful"],
705        gotchas: &[
706            "Verdicts persist as score adjustments applied to future searches.",
707            "Got one wrong? `asd feedback expire` lapses it so ranking stops seeing it.",
708        ],
709        related: &[
710            "feedback_list",
711            "feedback_expire",
712            "feedback_promote",
713            "search",
714        ],
715    },
716    HelpDoc {
717        feature: "feedback_expire",
718        group: "feedback",
719        synopsis: "Lapse a recorded verdict so it stops influencing search ranking.",
720        syntax: "asd feedback expire <entry-id> | --symbol <qname> | --query <text>",
721        params: &[
722            p!(
723                "entry_id",
724                false,
725                "Entry to expire, from `asd feedback list`. Or use --symbol / --query."
726            ),
727            p!(
728                "symbol",
729                false,
730                "Expire every verdict recorded against this qname."
731            ),
732            p!(
733                "query",
734                false,
735                "Expire every verdict for this query (matched against the stored lowercased form)."
736            ),
737            p!(
738                "dry_run",
739                false,
740                "Show what would be expired without writing."
741            ),
742        ],
743        examples: &[
744            "asd feedback list",
745            "asd feedback expire fb_58958221c119429798e16d6b070ed5d6",
746            "asd feedback expire --symbol myapp::mw::limit --dry-run",
747        ],
748        gotchas: &[
749            "Sets expires_at=now rather than deleting: the entry stays in `feedback list` marked expired, because it still explains why a past search ranked as it did.",
750            "One selector at a time — an entry id, --symbol or --query. Expiring everything because a flag was forgotten is not recoverable.",
751            "Re-expiring an already-lapsed entry is a no-op, not a timestamp rewrite.",
752        ],
753        related: &[
754            "feedback_mark",
755            "feedback_list",
756            "feedback_withdraw",
757            "search",
758        ],
759    },
760    HelpDoc {
761        feature: "feedback_withdraw",
762        group: "feedback",
763        synopsis: "Retract a verdict that should never have been recorded.",
764        syntax: "asd feedback withdraw <entry-id> [--by <who>] [--reason <text>]",
765        params: &[
766            p!(
767                "entry_id",
768                true,
769                "Entry to retract, from `asd feedback list`."
770            ),
771            p!("by", false, "Who is retracting it (default: asd-cli)."),
772            p!("reason", false, "Why. Stored with the withdrawal."),
773        ],
774        examples: &[
775            "asd feedback list",
776            "asd feedback withdraw fb_58958221c119429798e16d6b070ed5d6 --reason \"wrong symbol\"",
777        ],
778        gotchas: &[
779            "Different from `expire`: expire means the verdict was right but is no longer relevant; withdraw means it was wrong. Only withdraw records who retracted it and why.",
780            "A withdrawal cannot be undone by future-dating an expiry — the two states are independent.",
781            "The entry stays in `feedback list`, marked withdrawn, because it still explains why a past search ranked as it did.",
782            "Withdrawing twice keeps the original timestamp rather than moving it.",
783        ],
784        related: &[
785            "feedback_expire",
786            "feedback_mark",
787            "feedback_list",
788            "feedback_purge",
789        ],
790    },
791    HelpDoc {
792        feature: "feedback_purge",
793        group: "feedback",
794        synopsis: "Permanently delete a verdict from both stores. Escape hatch — prefer `withdraw`.",
795        syntax: "asd feedback purge <entry-id> --yes",
796        params: &[
797            p!("entry_id", true, "Entry to delete permanently."),
798            p!(
799                "yes",
800                true,
801                "Required. Without it the command explains and refuses."
802            ),
803        ],
804        examples: &["asd feedback purge fb_58958221c119429798e16d6b070ed5d6 --yes"],
805        gotchas: &[
806            "This REWRITES HISTORY in a store that is otherwise append-only. `asd feedback withdraw` is almost always the right verb: it retracts the verdict while keeping the record, which still explains why a past search ranked as it did.",
807            "Purge is for data that must not exist at all — test entries, or a secret pasted into a --note.",
808            "Removes from the ASG store first, then the SQLite search cache. If the cache step fails it errors loudly and names the fix (`asd index`), because a half-purge leaves `feedback list` still showing the entry.",
809        ],
810        related: &["feedback_withdraw", "feedback_expire", "feedback_list"],
811    },
812    HelpDoc {
813        feature: "feedback_promote",
814        group: "feedback",
815        synopsis: "Designate a symbol as canonical source-of-truth for a concept (writes Ownership entry, 3x ranking boost).",
816        syntax: "asd feedback promote-as-truth <qname> --concept <text>",
817        params: &[
818            p!("qname", true, "Symbol to promote."),
819            p!(
820                "concept",
821                true,
822                "The domain concept this symbol owns (e.g. \"login rate limiting\")."
823            ),
824            p!(
825                "author_id",
826                false,
827                "Author identifier (default asd-mcp-agent)."
828            ),
829        ],
830        examples: &[
831            "asd feedback promote-as-truth myapp::mw::limit --concept \"login rate limiting\"",
832        ],
833        gotchas: &["MCP tool is feedback_promote; the CLI verb is `feedback promote-as-truth`."],
834        related: &["feedback_mark", "feedback_list"],
835    },
836    HelpDoc {
837        feature: "feedback_list",
838        group: "feedback",
839        synopsis: "List recorded feedback verdicts; pass qname to filter to one symbol.",
840        syntax: "asd feedback list [<qname>]",
841        params: &[p!(
842            "qname",
843            false,
844            "Filter to one symbol; omit to list all."
845        )],
846        examples: &["asd feedback list"],
847        gotchas: &["Use to audit search-quality signals before trusting or overriding rankings."],
848        related: &["feedback_mark", "feedback_promote"],
849    },
850    // ---- scratch ----------------------------------------------------------
851    HelpDoc {
852        feature: "scratch_write",
853        group: "scratch",
854        synopsis: "Write a local-only draft scratch note scoped to a symbol/workflow/session; no policy gate.",
855        syntax: "asd scratch write <content> [--symbol <qname>] [--workflow <name>] [--planning]",
856        params: &[
857            p!("content", true, "Working notes content (markdown ok)."),
858            p!("symbol", false, "Symbol to attach the note to."),
859            p!("workflow", false, "Named investigation context."),
860            p!(
861                "ttl_hours",
862                false,
863                "Time-to-live in hours; no expiry when unset."
864            ),
865            p!(
866                "planning",
867                false,
868                "Mark as pre-implementation notes; adds 'planning' tag, symbol need not exist yet."
869            ),
870        ],
871        examples: &[
872            "asd scratch write \"suspect cache is drift source\" --symbol myapp::cache::get",
873        ],
874        gotchas: &[
875            "Scratch is local-only and NOT synced by `asd sync`. Promote durable notes with `scratch_promote`.",
876        ],
877        related: &["scratch_list", "scratch_promote", "scratch_read"],
878    },
879    HelpDoc {
880        feature: "scratch_list",
881        group: "scratch",
882        synopsis: "List scratch entries (default: draft, non-expired); filter by symbol/workflow/session/status.",
883        syntax: "asd scratch list [--symbol <q>] [--workflow <w>] [--status <s>]",
884        params: &[
885            p!("symbol", false, "Filter by symbol qname."),
886            p!("workflow", false, "Filter by workflow name."),
887            p!("session", false, "Filter by session/agent_id."),
888            p!(
889                "status",
890                false,
891                "draft | promoted | discarded (default draft)."
892            ),
893        ],
894        examples: &["asd scratch list"],
895        gotchas: &["Pass status=null (omit filter) to see all statuses, not just drafts."],
896        related: &["scratch_write", "scratch_read", "scratch_clean"],
897    },
898    HelpDoc {
899        feature: "scratch_read",
900        group: "scratch",
901        synopsis: "Read a single scratch entry by scratch_id; returns the full ScratchEntry.",
902        syntax: "asd scratch read <scratch_id>",
903        params: &[p!("scratch_id", true, "Scratch entry id (scr_…).")],
904        examples: &["asd scratch read scr_abc123"],
905        gotchas: &["Get the id from `scratch_list`."],
906        related: &["scratch_list", "scratch_update"],
907    },
908    HelpDoc {
909        feature: "scratch_update",
910        group: "scratch",
911        synopsis: "Replace the content of an existing draft scratch entry entirely.",
912        syntax: "asd scratch update <scratch_id> <content>",
913        params: &[
914            p!("scratch_id", true, "Scratch entry id to update."),
915            p!(
916                "content",
917                true,
918                "Replacement content (replaces previous entirely)."
919            ),
920        ],
921        examples: &["asd scratch update scr_abc123 \"confirmed: cache TTL is the drift source\""],
922        gotchas: &["Only draft entries are updatable; content is replaced, not appended."],
923        related: &["scratch_write", "scratch_read"],
924    },
925    HelpDoc {
926        feature: "scratch_discard",
927        group: "scratch",
928        synopsis: "Mark a scratch entry as discarded (soft-delete); purge later with scratch_clean.",
929        syntax: "asd scratch discard <scratch_id>",
930        params: &[p!("scratch_id", true, "Scratch entry id to discard.")],
931        examples: &["asd scratch discard scr_abc123"],
932        gotchas: &["Soft-delete only — `scratch_clean` permanently purges discarded entries."],
933        related: &["scratch_clean", "scratch_list"],
934    },
935    HelpDoc {
936        feature: "scratch_promote",
937        group: "scratch",
938        synopsis: "Promote a draft scratch entry to a durable ledger entry (goes through policy + audit).",
939        syntax: "asd scratch promote <scratch_id> --kind <k>",
940        params: &[
941            p!("scratch_id", true, "Scratch entry id to promote."),
942            p!(
943                "kind",
944                true,
945                "Ledger kind (decision, hazard, invariant, proof, ...)."
946            ),
947            p!(
948                "qname",
949                false,
950                "Symbol qname; required if the scratch entry has no symbol attached."
951            ),
952            p!(
953                "summary",
954                false,
955                "One-line summary (defaults to first non-empty line of content)."
956            ),
957        ],
958        examples: &["asd scratch promote scr_abc123 --kind decision --symbol myapp::cache::get"],
959        gotchas: &[
960            "Requires a symbol via --qname or the entry's existing symbol_id; returns { promoted_to, entry_id }.",
961        ],
962        related: &["scratch_write", "ledger_append"],
963    },
964    HelpDoc {
965        feature: "scratch_clean",
966        group: "scratch",
967        synopsis: "Permanently delete scratch entries older than N hours matching a status filter; dry_run to preview.",
968        syntax: "asd scratch clean --older-than <dur> [--status <list>] [--dry-run]",
969        params: &[
970            p!(
971                "older_than_hours",
972                true,
973                "Delete entries older than this many hours."
974            ),
975            p!(
976                "statuses",
977                false,
978                "Comma-separated statuses to clean (default \"discarded,promoted\")."
979            ),
980            p!(
981                "dry_run",
982                false,
983                "Report what would be deleted without removing anything."
984            ),
985        ],
986        examples: &["asd scratch clean --older-than 7d --dry-run"],
987        gotchas: &[
988            "Permanent delete — always dry-run first. MCP param is older_than_hours; the CLI takes a duration like 7d/24h. Defaults to discarded+promoted, sparing drafts.",
989        ],
990        related: &["scratch_discard", "scratch_list"],
991    },
992    // ---- think ------------------------------------------------------------
993    HelpDoc {
994        feature: "think_speculate",
995        group: "think",
996        synopsis: "Record a Hypothesis about a symbol — a hunch with a confidence in [0.0, 1.0].",
997        syntax: "asd think speculate <qname> --conf <f> --summary <text>",
998        params: &[
999            p!("qname", true, "Symbol the hypothesis is about."),
1000            p!(
1001                "confidence",
1002                true,
1003                "Confidence in [0.0, 1.0]; below 0.3 is hidden from auto-surface by default."
1004            ),
1005            p!("summary", true, "One-line hunch."),
1006            p!("body", false, "Optional detail."),
1007        ],
1008        examples: &[
1009            "asd think speculate myapp::cache::get --conf 0.6 --summary \"probably the drift source\"",
1010        ],
1011        gotchas: &[
1012            "Marks below 0.3 are excluded from prepare_change/context_for prior-thinking. Idempotent per (qname, summary).",
1013        ],
1014        related: &["think_model", "think_list", "think_question"],
1015    },
1016    HelpDoc {
1017        feature: "think_model",
1018        group: "think",
1019        synopsis: "Record a MentalModel — a multi-symbol structural understanding spanning several qnames.",
1020        syntax: "asd think model <name> --symbols <csv> --summary <text>",
1021        params: &[
1022            p!("name", true, "Model name."),
1023            p!(
1024                "symbols",
1025                true,
1026                "Comma-separated qnames the model spans (first is anchor)."
1027            ),
1028            p!("summary", true, "The structural understanding."),
1029        ],
1030        examples: &[
1031            "asd think model \"auth flow\" --symbols myapp::auth::login,myapp::auth::verify --summary \"login calls verify then issues token\"",
1032        ],
1033        gotchas: &[
1034            "Anchored on the FIRST symbol; body carries the full list. Idempotent by (name, summary).",
1035        ],
1036        related: &["think_speculate", "think_list"],
1037    },
1038    HelpDoc {
1039        feature: "think_failed",
1040        group: "think",
1041        synopsis: "Record a FailedAttempt — what you tried and why it didn't work, so the next session doesn't re-tread it.",
1042        syntax: "asd think failed <qname> --tried <text> --because <text>",
1043        params: &[
1044            p!("qname", true, "Symbol the attempt concerned."),
1045            p!("tried", true, "What you tried."),
1046            p!("because", true, "Why it didn't work."),
1047        ],
1048        examples: &[
1049            "asd think failed myapp::cache::get --tried \"raise TTL\" --because \"drift persisted\"",
1050        ],
1051        gotchas: &["Idempotent by (qname, tried)."],
1052        related: &["think_question", "think_list"],
1053    },
1054    HelpDoc {
1055        feature: "think_question",
1056        group: "think",
1057        synopsis: "Record an OpenQuestion — a known unknown blocking confident action.",
1058        syntax: "asd think question <qname> --q <text>",
1059        params: &[
1060            p!("qname", true, "Symbol the question concerns."),
1061            p!("question", true, "The known unknown."),
1062        ],
1063        examples: &["asd think question myapp::cache::get --q \"is eviction concurrent-safe?\""],
1064        gotchas: &[
1065            "Be generous — every recorded question is one the next session won't re-ask. Idempotent by (qname, question).",
1066        ],
1067        related: &["think_failed", "think_list"],
1068    },
1069    HelpDoc {
1070        feature: "think_list",
1071        group: "think",
1072        synopsis: "List captured thinking (Hypothesis/MentalModel/FailedAttempt/OpenQuestion); filter by kind or symbol.",
1073        syntax: "asd think list [--kind <k>] [--symbol <qname>]",
1074        params: &[
1075            p!(
1076                "kind",
1077                false,
1078                "hypothesis | mental_model | failed_attempt | open_question."
1079            ),
1080            p!("symbol", false, "Filter to one symbol."),
1081        ],
1082        examples: &["asd think list"],
1083        gotchas: &[
1084            "Call when resuming work on an area to see what prior sessions concluded or got stuck on.",
1085        ],
1086        related: &[
1087            "think_speculate",
1088            "think_model",
1089            "think_failed",
1090            "think_question",
1091        ],
1092    },
1093    // ---- conclusions ------------------------------------------------------
1094    HelpDoc {
1095        feature: "conclusions_list",
1096        group: "conclusions",
1097        synopsis: "List ledger entries bucketed by the six conclusion classes (decisions, classifications, mappings, hazards, recipes, followups).",
1098        syntax: "asd conclusions list [--class <c>] [--symbol <qname>]",
1099        params: &[
1100            p!(
1101                "class",
1102                false,
1103                "One of: decisions | classifications | mappings | hazards | recipes | followups."
1104            ),
1105            p!("symbol", false, "Filter to one symbol qname."),
1106        ],
1107        examples: &["asd conclusions list"],
1108        gotchas: &["Audit what conclusions exist before exporting to .asd/conclusions/*.jsonl."],
1109        related: &["conclusions_export", "conclusions_import"],
1110    },
1111    HelpDoc {
1112        feature: "conclusions_export",
1113        group: "conclusions",
1114        synopsis: "Write all ledger conclusions to compact JSONL files (one per class) under .asd/conclusions/.",
1115        syntax: "asd conclusions export [--out <dir>]",
1116        params: &[p!(
1117            "out",
1118            false,
1119            "Output dir (default .asd/conclusions/ beside the db)."
1120        )],
1121        examples: &["asd conclusions export"],
1122        gotchas: &["Byte-stable when no new entries — safe to run from a pre-commit hook."],
1123        related: &["conclusions_import", "conclusions_list", "sync"],
1124    },
1125    HelpDoc {
1126        feature: "conclusions_import",
1127        group: "conclusions",
1128        synopsis: "Read .asd/conclusions/*.jsonl back into the local ledger; idempotent (keyed by entry_id).",
1129        syntax: "asd conclusions import [--in <dir>]",
1130        params: &[p!(
1131            "in",
1132            false,
1133            "Input dir with *.jsonl (default .asd/conclusions/)."
1134        )],
1135        examples: &["asd conclusions import"],
1136        gotchas: &[
1137            "Run after `git pull` or on a fresh clone to populate ASG with committed conclusions.",
1138        ],
1139        related: &["conclusions_export", "conclusions_list"],
1140    },
1141    // ---- recipes ----------------------------------------------------------
1142    HelpDoc {
1143        feature: "recipe_classify_test_migration",
1144        group: "recipes",
1145        synopsis: "Classify test-tier symbols matching a query into migration actions (Delete/Gate/Run/KeepAsCovered/Review).",
1146        syntax: "asd recipe classify-test-migration <query> [--limit <n=50>]",
1147        params: &[
1148            p!(
1149                "query",
1150                true,
1151                "Search query — finds candidate test symbols."
1152            ),
1153            p!("limit", false, "Max candidates to classify (default 50)."),
1154        ],
1155        examples: &["asd recipe classify-test-migration \"song player tests\""],
1156        gotchas: &["For stale test FILES that may need moving, use `recipe_migrate_stale_tests`."],
1157        related: &["recipe_migrate_stale_tests", "test_summary"],
1158    },
1159    HelpDoc {
1160        feature: "recipe_migrate_stale_tests",
1161        group: "recipes",
1162        synopsis: "Build a migration plan for stale test files; adds a Move action when a Mapping entry carries move_to.",
1163        syntax: "asd recipe migrate-stale-tests <query> [--limit <n=50>]",
1164        params: &[
1165            p!("query", true, "Search query — finds candidate test files."),
1166            p!("limit", false, "Max candidates (default 50)."),
1167        ],
1168        examples: &["asd recipe migrate-stale-tests \"song player tests\""],
1169        gotchas: &[
1170            "Same output shape as classify-test-migration plus Move; falls back to the classify tree without a move_to.",
1171        ],
1172        related: &["recipe_classify_test_migration"],
1173    },
1174    // ---- audit ------------------------------------------------------------
1175    HelpDoc {
1176        feature: "audit_tail",
1177        group: "audit",
1178        synopsis: "Read back audit events from the configured JSONL log; filter by event_type, actor, outcome, or since cursor.",
1179        syntax: "asd audit tail [--event-type <t>] [--actor <id>] [--outcome <o>] [--since <event_id>]",
1180        params: &[
1181            p!(
1182                "event_type",
1183                false,
1184                "Substring on event_type (e.g. \"ledger.\" for all ledger events)."
1185            ),
1186            p!(
1187                "since",
1188                false,
1189                "Return only events after this event_id (exclusive)."
1190            ),
1191            p!("actor", false, "Exact match on actor_id."),
1192            p!("outcome", false, "Exact match on outcome."),
1193            p!("limit", false, "Max events (default 200, max 1000)."),
1194        ],
1195        examples: &["asd audit tail --event-type ledger."],
1196        gotchas: &["Returns configured:false when ASD_AUDIT_LOG was not set at server startup."],
1197        related: &["audit_verify", "ledger_append"],
1198    },
1199    HelpDoc {
1200        feature: "audit_verify",
1201        group: "audit",
1202        synopsis: "Verify the hash-chain integrity of the configured audit log (Enterprise tier — requires asd-pro).",
1203        syntax: "asd audit verify",
1204        params: &[],
1205        examples: &["asd audit verify"],
1206        gotchas: &[
1207            "Commercial feature (Enterprise tier); needs asd-pro and a configured ASD_AUDIT_LOG.",
1208        ],
1209        related: &["audit_tail"],
1210    },
1211    // ---- quality ----------------------------------------------------------
1212    HelpDoc {
1213        feature: "health",
1214        group: "quality",
1215        synopsis: "Liveness check for the MCP server: status, db path, indexed symbol count, and total artifact counts.",
1216        syntax: "health  (MCP tool — no CLI verb)",
1217        params: &[],
1218        examples: &["health {}"],
1219        gotchas: &[
1220            "For index freshness/dirty files use `status`; for a go/no-go reliability score use `trust`.",
1221        ],
1222        related: &["status", "trust"],
1223    },
1224    HelpDoc {
1225        feature: "reindex",
1226        group: "quality",
1227        synopsis: "Re-parse a file or directory and refresh the symbol index, effects, and call graph.",
1228        syntax: "asd reindex <path>",
1229        params: &[p!(
1230            "path",
1231            true,
1232            "Absolute or relative path to a source file or directory."
1233        )],
1234        examples: &["asd reindex src/", "asd index src/"],
1235        gotchas: &[
1236            "Alias of `asd index`. After indexing, run `sync` so updated state travels with the next commit.",
1237        ],
1238        related: &["sync", "status"],
1239    },
1240    HelpDoc {
1241        feature: "sync",
1242        group: "quality",
1243        synopsis: "Flush live ASG state (symbols, effects, ledger, rebinds) into the .asd/v1/ sidecar for git commit.",
1244        syntax: "asd sync [--prune] [--dir <path>]",
1245        params: &[
1246            p!(
1247                "dir",
1248                false,
1249                "Project root to sync into (.asd/v1/ appended); defaults to the active db's dir."
1250            ),
1251            p!(
1252                "prune",
1253                false,
1254                "Also drop orphaned sidecar files for symbols that no longer exist."
1255            ),
1256        ],
1257        examples: &["asd sync", "asd sync --prune"],
1258        gotchas: &[
1259            "Run after `reindex` or edits so committed state stays current; the pre-commit hook runs sync --prune.",
1260        ],
1261        related: &["reindex", "conclusions_export"],
1262    },
1263    HelpDoc {
1264        feature: "test_summary",
1265        group: "quality",
1266        synopsis: "Summarize raw test-runner output into a compact failures-only report: { runner, passed, failed, failures[] }.",
1267        syntax: "cargo test 2>&1 | asd test-summary   (reads output on stdin)",
1268        params: &[p!(
1269            "output",
1270            true,
1271            "Raw test-runner output (cargo/pytest parsed precisely; others via generic scan)."
1272        )],
1273        examples: &["cargo test 2>&1 | asd test-summary"],
1274        gotchas: &[
1275            "MCP passes output as a param; the CLI reads it on stdin. Cargo and pytest are parsed precisely.",
1276        ],
1277        related: &["recipe_classify_test_migration", "scorecard"],
1278    },
1279    HelpDoc {
1280        feature: "scopes_list",
1281        group: "quality",
1282        synopsis: "List named scope aliases from .asd/scopes.toml — narrow noisy searches with --scope / --paths.",
1283        syntax: "asd scopes list",
1284        params: &[],
1285        examples: &["asd scopes list"],
1286        gotchas: &[
1287            "Scopes feed the --scope/--paths flags on search, prepare_change, investigate, impact, checklist, since.",
1288        ],
1289        related: &["search", "investigate"],
1290    },
1291    HelpDoc {
1292        feature: "scorecard",
1293        group: "quality",
1294        synopsis: "Five-dimension benchmark scorecard: truth, feedback, change, uncertainty, workflow — each 0-100.",
1295        syntax: "asd scorecard [--drill-down <dim>] [--scope <s>] [--paths <glob>]",
1296        params: &[
1297            p!("scope", false, "Named scope alias."),
1298            p!("paths", false, "Comma-separated glob patterns."),
1299            p!(
1300                "drill_down",
1301                false,
1302                "truth | feedback | change | uncertainty | workflow — show gap symbols."
1303            ),
1304            p!("limit", false, "Max symbols in drill-down (default 10)."),
1305        ],
1306        examples: &["asd scorecard", "asd scorecard --drill-down truth"],
1307        gotchas: &["Use drill_down to see which symbols are dragging a dimension down."],
1308        related: &["trust", "status", "test_summary"],
1309    },
1310    HelpDoc {
1311        feature: "worktree",
1312        group: "workflow",
1313        synopsis: "Plan-scoped git worktrees: isolated files + HEAD per unit of work so parallel agents don't clobber each other.",
1314        syntax: "asd worktree start|list|finish <plan> [--from <ref>] [--shared-target] [--clone [--url <remote>]] [--push] [--keep]",
1315        params: &[
1316            p!(
1317                "plan",
1318                true,
1319                "Plan name; the worktree is ../<repo>-wt-<plan> on branch plan/<plan>."
1320            ),
1321            p!(
1322                "--shared-target",
1323                false,
1324                "start: share one Rust build cache (<repo>/.wt-target) instead of a per-worktree target/."
1325            ),
1326            p!(
1327                "--clone",
1328                false,
1329                "isolate via a fresh clone (own .git, ../<repo>-clone-<plan>) for remote agents; merge-back is via origin."
1330            ),
1331            p!(
1332                "--push",
1333                false,
1334                "finish: git push the branch (with --clone, pushes to origin before teardown)."
1335            ),
1336        ],
1337        examples: &[
1338            "asd worktree start add-oauth",
1339            "asd worktree list",
1340            "asd worktree finish add-oauth --push",
1341            "asd worktree start add-oauth --clone",
1342        ],
1343        gotchas: &[
1344            "The CLI can't cd you — `start` prints the path; open your session there.",
1345            "`finish` refuses on a dirty worktree/clone and never switches the main checkout's HEAD; it force-tears-down (removes target/ too).",
1346            "worktree = local isolation (shared .git); --clone = remote isolation (own .git, merge-back via origin).",
1347        ],
1348        related: &["status", "trust"],
1349    },
1350];
1351
1352/// A score at or above this means we matched the feature NAME (exact or
1353/// substring) — confident enough to return that one doc. Below it, we only had
1354/// weak synopsis-word overlap, so we disambiguate instead of guessing.
1355const STRONG_MATCH: u32 = 200;
1356
1357fn est_tokens(doc: &HelpDoc) -> usize {
1358    let mut chars = doc.synopsis.len() + doc.syntax.len();
1359    for p in doc.params {
1360        chars += p.name.len() + p.desc.len();
1361    }
1362    for e in doc.examples {
1363        chars += e.len();
1364    }
1365    for g in doc.gotchas {
1366        chars += g.len();
1367    }
1368    for r in doc.related {
1369        chars += r.len();
1370    }
1371    chars / 4
1372}
1373
1374fn doc_json(doc: &HelpDoc) -> Value {
1375    json!({
1376        "feature": doc.feature,
1377        "owner": OWNER,
1378        "version": version(),
1379        "synopsis": doc.synopsis,
1380        "syntax": doc.syntax,
1381        "params": doc.params,
1382        "examples": doc.examples,
1383        "gotchas": doc.gotchas,
1384        "related": doc.related,
1385        "help_tokens": est_tokens(doc),
1386    })
1387}
1388
1389/// Score a doc against a lowercased query. Feature-name matches dominate;
1390/// synopsis-word overlap only breaks ties. `group` is deliberately excluded
1391/// (identical across a group, it would make every sibling tie on the group word).
1392fn score(doc: &HelpDoc, q: &str) -> u32 {
1393    let feat = doc.feature.to_lowercase();
1394    if feat == q {
1395        return 1000;
1396    }
1397    let mut s = 0u32;
1398    if feat.contains(q) || q.contains(&feat) {
1399        s += STRONG_MATCH;
1400    }
1401    let syn = doc.synopsis.to_lowercase();
1402    for tok in q
1403        .split(|c: char| !c.is_alphanumeric())
1404        .filter(|t| t.len() > 2)
1405    {
1406        if feat.contains(tok) {
1407            s += 100;
1408        }
1409        if syn.contains(tok) {
1410            s += 20;
1411        }
1412    }
1413    s
1414}
1415
1416/// Grouped catalog for the no-arg overview — every feature.
1417pub fn catalog() -> Value {
1418    use std::collections::BTreeMap;
1419    let mut groups: BTreeMap<&str, Vec<Value>> = BTreeMap::new();
1420    for doc in REGISTRY {
1421        groups.entry(doc.group).or_default().push(json!({
1422            "feature": doc.feature,
1423            "synopsis": doc.synopsis,
1424        }));
1425    }
1426    json!({
1427        "owner": OWNER,
1428        "version": version(),
1429        "usage": "Call `help <feature>` for exact syntax, examples, and gotchas before using a feature.",
1430        "groups": groups,
1431        "feature_count": REGISTRY.len(),
1432    })
1433}
1434
1435/// The response for the `help` tool / CLI. `topic` None or empty -> catalog.
1436pub fn respond(topic: Option<&str>) -> Value {
1437    let q = topic.map(|t| t.trim().to_lowercase()).unwrap_or_default();
1438    if q.is_empty() {
1439        return catalog();
1440    }
1441    let mut ranked: Vec<(&HelpDoc, u32)> = REGISTRY
1442        .iter()
1443        .map(|d| (d, score(d, &q)))
1444        .filter(|(_, s)| *s > 0)
1445        .collect();
1446    ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.feature.cmp(b.0.feature)));
1447
1448    match ranked.first() {
1449        Some((doc, s)) if *s >= STRONG_MATCH => {
1450            let mut out = doc_json(doc);
1451            let also: Vec<&str> = ranked
1452                .iter()
1453                .skip(1)
1454                .take(4)
1455                .map(|(d, _)| d.feature)
1456                .collect();
1457            if let Some(obj) = out.as_object_mut() {
1458                obj.insert("also".into(), json!(also));
1459            }
1460            out
1461        }
1462        Some(_) => json!({
1463            "query": q,
1464            "owner": OWNER,
1465            "matches": ranked.iter().take(6).map(|(d, _)| json!({
1466                "feature": d.feature,
1467                "synopsis": d.synopsis,
1468            })).collect::<Vec<_>>(),
1469            "hint": "No exact feature match — `help <feature>` for one of these, or `help` for the full catalog.",
1470        }),
1471        None => json!({
1472            "not_found": q,
1473            "owner": OWNER,
1474            "did_you_mean": REGISTRY.iter().map(|d| d.feature).collect::<Vec<_>>(),
1475            "hint": "No asd feature matched. It may be a `ctx` (CTXone) feature — try `ctx help <topic>`.",
1476        }),
1477    }
1478}
1479
1480/// Local resolve with a cross-binary proxy fallback: if `topic` doesn't match
1481/// any local feature, consult the shared index for other tools and ask the
1482/// owning binary directly (e.g. asd proxies an unknown topic to `ctx help`).
1483///
1484/// `allow_proxy` is false for the proxied child call (via `--no-proxy`), which
1485/// makes this collapse to a pure local `respond` — the single-hop loop guard.
1486/// A successful proxy returns the owner's doc annotated with `proxied_from`.
1487pub fn resolve(topic: Option<&str>, allow_proxy: bool) -> Value {
1488    let local = respond(topic);
1489    if !allow_proxy || local.get("not_found").is_none() {
1490        return local;
1491    }
1492    let Some(t) = topic.map(str::trim).filter(|t| !t.is_empty()) else {
1493        return local;
1494    };
1495    let Some(index) = read_index() else {
1496        return local;
1497    };
1498    let Some(tools) = index.get("tools").and_then(|v| v.as_object()) else {
1499        return local;
1500    };
1501    // Let the owning binary do its own matching — just ask each other tool.
1502    for tool in tools.keys().filter(|k| k.as_str() != OWNER) {
1503        let Some((bin, args)) = json_invocation(tool, t) else {
1504            continue;
1505        };
1506        let Ok(out) = std::process::Command::new(bin).args(&args).output() else {
1507            continue;
1508        };
1509        if !out.status.success() {
1510            continue;
1511        }
1512        if let Ok(mut v) = serde_json::from_slice::<Value>(&out.stdout)
1513            && v.get("feature").is_some()
1514            && let Some(obj) = v.as_object_mut()
1515        {
1516            obj.insert("proxied_from".into(), json!(tool));
1517            return v;
1518        }
1519    }
1520    local
1521}
1522
1523/// Read the shared cross-tool help index, if present.
1524fn read_index() -> Option<Value> {
1525    let path = if let Some(p) = std::env::var_os("AGENTSTATE_HELP_INDEX") {
1526        std::path::PathBuf::from(p)
1527    } else {
1528        std::path::PathBuf::from(std::env::var_os("HOME")?)
1529            .join(".config/agentstate/help-index.json")
1530    };
1531    std::fs::read_to_string(path)
1532        .ok()
1533        .and_then(|s| serde_json::from_str(&s).ok())
1534}
1535
1536/// How to invoke a given tool's `help` for JSON output, with proxy disabled so
1537/// the child never bounces back (single-hop guard). Returns None for unknown
1538/// tools (nothing to exec).
1539fn json_invocation(tool: &str, topic: &str) -> Option<(&'static str, Vec<String>)> {
1540    match tool {
1541        "ctx" => Some((
1542            "ctx",
1543            vec![
1544                "help".into(),
1545                topic.into(),
1546                "--format".into(),
1547                "json".into(),
1548                "--no-proxy".into(),
1549            ],
1550        )),
1551        "asd" => Some((
1552            "asd",
1553            vec![
1554                "help".into(),
1555                topic.into(),
1556                "--agent".into(),
1557                "--no-proxy".into(),
1558            ],
1559        )),
1560        _ => None,
1561    }
1562}
1563
1564/// The lightweight manifest this binary publishes to the shared help index.
1565pub fn manifest() -> Value {
1566    json!({
1567        "tool": OWNER,
1568        "version": version(),
1569        "features": REGISTRY.iter().map(|d| json!({
1570            "feature": d.feature,
1571            "synopsis": d.synopsis,
1572            "group": d.group,
1573        })).collect::<Vec<_>>(),
1574    })
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579    use super::*;
1580
1581    #[test]
1582    fn exact_feature_name_wins() {
1583        let v = respond(Some("impact"));
1584        assert_eq!(v["feature"], "impact");
1585        assert_eq!(v["owner"], "asd");
1586        assert!(v["help_tokens"].as_u64().unwrap() > 0);
1587    }
1588
1589    #[test]
1590    fn strong_name_match_returns_single_doc() {
1591        let v = respond(Some("prepare change"));
1592        assert_eq!(v["feature"], "prepare_change");
1593    }
1594
1595    #[test]
1596    fn weak_phrase_disambiguates_not_guesses() {
1597        let v = respond(Some("understand the code"));
1598        assert!(
1599            v.get("feature").is_none(),
1600            "should not guess a single doc: {v}"
1601        );
1602        assert!(
1603            v["matches"].is_array() || v["not_found"].is_string(),
1604            "got {v}"
1605        );
1606    }
1607
1608    #[test]
1609    fn empty_topic_returns_catalog() {
1610        let v = respond(None);
1611        assert_eq!(
1612            v["feature_count"].as_u64().unwrap() as usize,
1613            REGISTRY.len()
1614        );
1615        assert!(v["groups"].is_object());
1616    }
1617
1618    #[test]
1619    fn manifest_lists_every_feature() {
1620        let m = manifest();
1621        assert_eq!(m["tool"], "asd");
1622        assert_eq!(m["features"].as_array().unwrap().len(), REGISTRY.len());
1623    }
1624}