Skip to main content

kranz_cli/
cli.rs

1//! clap derive surface of the `kranz` binary (plan §5 Phase 1-2).
2
3use clap::{Parser, Subcommand};
4use kranz_engine::event_log::LockForce;
5use std::path::PathBuf;
6
7/// Long help for `kranz msg` (plan §4.5): the queue/interrupt semantics must
8/// be documented verbatim in `--help`.
9pub const MSG_LONG_ABOUT: &str = "Queue a message for the mission's orchestrator.\n\n\
10     Messages queue and are processed between worker runs by default; the \
11     orchestrator reads them at its next decision point and records a \
12     decision. Passing --interrupt additionally aborts the current worker \
13     run (recorded as partial) before injecting the message — use it when \
14     the current work is headed the wrong way.";
15
16/// kranz — a local mission-control harness: an orchestrator plans, fresh
17/// headless-agent sessions implement features, validators judge milestones,
18/// and git is the source of truth.
19#[derive(Parser, Debug)]
20#[command(name = "kranz", version, about, author = None)]
21pub struct Cli {
22    /// Target repository root (defaults to the current directory)
23    #[arg(long, global = true, value_name = "PATH")]
24    pub repo: Option<PathBuf>,
25
26    /// Mission id (defaults to the repo's only mission; with several, the
27    /// one whose event log was updated most recently)
28    #[arg(long, global = true, value_name = "ID")]
29    pub mission: Option<String>,
30
31    /// Steal the engine lock unless its holder is provably ALIVE (a lock
32    /// whose holder is provably dead is stolen automatically, without this
33    /// flag; a live holder additionally needs --dangerously-steal-live-lock)
34    #[arg(long, global = true)]
35    pub force_lock: bool,
36
37    /// DANGEROUS: steal the engine lock even from a provably LIVE holder
38    /// (implies --force-lock). Only for a holder you have verified — e.g. via
39    /// `ps -p <pid>` — to be a zombie or foreign process: stealing from a
40    /// running kranz engine lets two engines corrupt one event log.
41    #[arg(long, global = true, hide_short_help = true)]
42    pub dangerously_steal_live_lock: bool,
43
44    /// DANGEROUS: bypass all permission gating for every agent session
45    /// (bypassPermissions). Loud, never the default.
46    #[arg(long, global = true)]
47    pub dangerously_allow_all: bool,
48
49    #[command(subcommand)]
50    pub command: Command,
51}
52
53impl Cli {
54    /// Map the two lock flags onto the engine's [`LockForce`] tier. Passing
55    /// both is fine — the strongest wins (--dangerously-steal-live-lock
56    /// implies --force-lock).
57    pub fn lock_force(&self) -> LockForce {
58        if self.dangerously_steal_live_lock {
59            LockForce::EvenIfLive
60        } else if self.force_lock {
61            LockForce::IfNotLive
62        } else {
63            LockForce::No
64        }
65    }
66}
67
68#[derive(Subcommand, Debug)]
69pub enum Command {
70    /// Print Kranz, Rust dependency and embedded-dashboard license notices
71    Licenses,
72
73    /// Prepare an existing Git worktree for its first Kranz mission.
74    ///
75    /// Scaffolds an additive runtime-ignore block, a tracked merge-gate
76    /// suite, and the tickets directory. Common Rust, Node, and Python gates
77    /// are detected; unfamiliar toolchains must supply --gate. Re-running is
78    /// safe: existing gates are validated and never replaced.
79    Init {
80        /// Unconditional validation command (repeat for multiple gates).
81        /// Overrides toolchain detection when creating a new gate suite.
82        #[arg(long = "gate", value_name = "COMMAND")]
83        gates: Vec<String>,
84
85        /// Register this canonical root in the global multi-repo host catalog.
86        #[arg(long)]
87        register: bool,
88
89        /// Host-catalog repository id (defaults to a slug of the directory).
90        #[arg(long, value_name = "ID", requires = "register")]
91        id: Option<String>,
92
93        /// Friendly name shown in the dashboard project picker.
94        #[arg(long, value_name = "NAME", requires = "register")]
95        display_name: Option<String>,
96    },
97
98    /// Create a mission and shape its plan in an interactive conversation.
99    ///
100    /// With no goal, resumes the most recent mission still in planning
101    /// (e.g. after a Claude usage-limit interruption) — the orchestrator
102    /// session is resumed with its full conversation context.
103    Plan {
104        /// The mission goal, in plain language (omit to resume planning)
105        goal: Option<String>,
106    },
107
108    /// Execute the mission loop (also crash-resumes an interrupted mission)
109    Run,
110
111    /// Show the mission tree, totals and recent decisions (read-only, no lock)
112    Status {
113        /// Dump the full MissionState as JSON instead of the tree
114        #[arg(long)]
115        json: bool,
116    },
117
118    /// Probe the native Windows AppContainer candidate without launching it.
119    ///
120    /// Loads processmodel.dll from System32 only, checks for Microsoft's
121    /// experimental process-sandbox export, and records the Windows build.
122    /// API presence never enables production enforcement by itself.
123    SandboxProbe {
124        /// Print the stable probe report as JSON.
125        #[arg(long)]
126        json: bool,
127    },
128
129    /// Prepare the Windows host for AppContainer enforcement.
130    ///
131    /// Adds only the two persistent, non-inheriting metadata ACEs required by
132    /// Windows tools on each drive root and reapplies the documented null-device
133    /// descriptor that resets at boot. Run from an elevated PowerShell;
134    /// ordinary Kranz launches verify both prerequisites read-only.
135    SandboxPrepare {
136        /// Literal local drive root to prepare (repeat for multiple drives).
137        #[arg(long = "target", value_name = "DRIVE-ROOT", required = true)]
138        targets: Vec<PathBuf>,
139    },
140
141    /// Show the flight-surgeon outcomes fold: autonomy ratio, grant-latency
142    /// distribution, per-task-class rows, context reuse, the rubber-stamp
143    /// flag, and the escalation ledger (read-only, no lock)
144    Outcomes {
145        /// Dump the full Outcomes struct as JSON instead of the text report
146        #[arg(long)]
147        json: bool,
148
149        /// Cost per merged change grouped by repo across the host catalog
150        /// (~/.kranz/config.json), beside the autonomy ratio (KRZ-329)
151        #[arg(long)]
152        all: bool,
153
154        /// Inclusive activity window for outcome reasons, or the merged-change
155        /// denominator with --all (default 30); other existing metrics keep their scope.
156        #[arg(long, default_value_t = kranz_engine::outcomes::DEFAULT_MERGED_CHANGE_WINDOW_DAYS)]
157        window_days: u64,
158    },
159
160    /// Show the flight-surgeon console: autonomy ratio split by outcome,
161    /// the rubber-stamp signal (park→grant p50/p90 + sub-10s count), false
162    /// greens (completed missions with traced defect tickets), and the
163    /// escalation ledger (read-only, no lock)
164    EscalationMetrics {
165        /// Dump the full EscalationMetrics struct as JSON instead of the text
166        /// report
167        #[arg(long)]
168        json: bool,
169    },
170
171    /// Replay why a mission's unit passed from its event log alone: the gate
172    /// ladder in order (verdicts + artefact resolution against the mission
173    /// dir), each session's backend/model and prompt identity, every human
174    /// decision with its event seq, and the terminal outcome (read-only, no
175    /// lock). A cleaned runs/ degrades artefact refs to "unresolved", never
176    /// to an error.
177    Provenance {
178        /// The mission id (defaults to the global --mission / auto-selection)
179        mission_id: Option<String>,
180
181        /// Dump the full ProvenanceChain struct as JSON instead of the text
182        /// report
183        #[arg(long)]
184        json: bool,
185    },
186
187    /// Show the human review packet: approved scope, candidate, check freshness,
188    /// findings, exceptions and pending decisions (read-only, no provider call)
189    ReviewPacket {
190        /// The mission id (defaults to --mission / auto-selection)
191        mission_id: Option<String>,
192        /// Emit the shared dashboard projection as JSON
193        #[arg(long)]
194        json: bool,
195    },
196
197    /// Show the recorded evaluation series for one gate identity across all
198    /// missions: every gate.result with that gate name, in log order —
199    /// verdict, and the gate-supplied score + threshold where the gate
200    /// reported them (read-only, no lock). kranz records what gates report,
201    /// never normalizes it, and never derives the verdict from the score;
202    /// a boolean-only gate's series shows verdicts with no score column
203    /// (absence is the normal case, never a zero).
204    GateScores {
205        /// The gate identity (the `gate` field on gate.result events — a
206        /// defect-class name like `vacuous-filter`, a pack gate name,
207        /// `merge-gate-suite`)
208        gate: String,
209
210        /// Dump the GateScoreSeries struct as JSON instead of the text table
211        #[arg(long)]
212        json: bool,
213    },
214
215    /// Pause the mission (takes effect between worker runs)
216    Pause,
217
218    /// Resume a paused mission (takes effect between worker runs)
219    Resume,
220
221    /// Queue a message for the orchestrator
222    #[command(long_about = MSG_LONG_ABOUT)]
223    Msg {
224        /// The message text
225        text: String,
226
227        /// Abort the current worker run (recorded as partial) before
228        /// injecting the message
229        #[arg(long)]
230        interrupt: bool,
231    },
232
233    /// Request a revised plan for an active mission
234    Revise {
235        /// Mission id to revise
236        id: String,
237
238        /// Operator instructions for the revision
239        #[arg(required = true, num_args = 1.., trailing_var_arg = true)]
240        instructions: Vec<String>,
241    },
242
243    /// Approve or reject a pending plan revision
244    Revision {
245        #[command(subcommand)]
246        command: RevisionCommand,
247    },
248
249    /// Approve or deny a parked capability-grant request
250    Grant {
251        #[command(subcommand)]
252        command: GrantCommand,
253    },
254    /// Inspect or answer an exact live ACP invocation (no mission-wide grant).
255    Permission {
256        #[command(subcommand)]
257        command: PermissionCommand,
258    },
259
260    /// Answer an open structured human question (the pending-decision
261    /// projection the dashboard and Slack also render)
262    Question {
263        #[command(subcommand)]
264        command: QuestionCommand,
265    },
266
267    /// List this repo's missions
268    Missions,
269
270    /// Retire a mission: mark it ABANDONED (a terminal state, not a failure).
271    ///
272    /// Appends `mission.abandoned` to the event log and stops here — git
273    /// branches, tags, and the deliverable are left untouched. A mission that
274    /// is already terminal (Complete/Failed/Abandoned) is rejected. If a live
275    /// engine still holds the mission lock, stop it first; --force-lock
276    /// steals only a lock whose holder is not provably alive, and
277    /// --dangerously-steal-live-lock steals even a live one.
278    Abandon {
279        /// The mission id (defaults to the global --mission / auto-selection)
280        id: Option<String>,
281
282        /// Why the mission is being retired (recorded on the event)
283        #[arg(long, value_name = "TEXT")]
284        reason: Option<String>,
285    },
286
287    /// Remove stale mission directories under .kranz/missions/.
288    ///
289    /// Cleans Failed, Abandoned, and abandoned-in-planning husks (Planning
290    /// with no plan.json) by default; --all additionally removes Complete
291    /// missions. A mission whose lock is held by a live engine is never
292    /// cleaned. Only mission directories are removed — git branches/tags and
293    /// the missions index.md are left intact.
294    Clean {
295        /// Skip the confirmation prompt (assume yes)
296        #[arg(long)]
297        yes: bool,
298
299        /// Also remove Complete missions (kept by default for review)
300        #[arg(long)]
301        all: bool,
302    },
303
304    /// Work with mission tickets (the backlog): list, show, new, queue
305    Ticket {
306        #[command(subcommand)]
307        command: TicketCommand,
308    },
309
310    /// Draft a plan for a ticket non-interactively (orchestrator only).
311    ///
312    /// Seeds the orchestrator with the whole ticket, requests the plan, and
313    /// either parks a committed plan.md for review (default) or, with --yes,
314    /// approves and queues it immediately. If the orchestrator needs more
315    /// context, its questions are appended to the ticket and the ticket is
316    /// flagged NEEDS-CONTEXT. Spend is bounded by the orchestrator budget cap.
317    Draft {
318        /// The ticket slug (file stem under .kranz/tickets/)
319        slug: String,
320
321        /// Approve and enqueue the plan immediately instead of parking it for
322        /// review
323        #[arg(long)]
324        yes: bool,
325
326        /// Seed the ticket's `traced-from-mission` frontmatter with this
327        /// mission id (drafting a defect ticket traced back to the mission
328        /// that shipped the defect — the flight-surgeon false-green join)
329        #[arg(long = "from-mission", value_name = "MISSION_ID")]
330        from_mission: Option<String>,
331    },
332
333    /// Decompose a complex goal into a ticket DAG (blocked-by edges).
334    ///
335    /// One planner turn proposes 1..=8 tickets as JSON; the proposed DAG
336    /// (slugs, titles, priorities, edges) is printed for review. Without
337    /// --yes nothing is written (dry-run preview). With --yes all tickets are
338    /// written at once: slug rules, unknown blockers, a missing root, or a
339    /// blocked-by cycle each refuse the whole write loudly — no partial
340    /// writes. Every emitted ticket is an ordinary ticket: draft it with
341    /// `kranz draft <slug>`, queue it with `kranz ticket queue <slug>`; deps
342    /// gating keeps a node from running before its blockers Complete.
343    Decompose {
344        /// The complex goal, in plain language
345        goal: String,
346
347        /// Write the proposed tickets (without this flag it is a dry-run preview)
348        #[arg(long, short = 'y')]
349        yes: bool,
350    },
351
352    /// Run a mission fully headlessly from a plan file (CI: plan in, exit code out).
353    ///
354    /// The file is a ticket-shaped markdown (`## Goal`, `## Context`, `##
355    /// Scoping answers`, `## Acceptance hints`). exec seeds the orchestrator
356    /// with the whole file, auto-approves the returned plan (no human), and
357    /// runs the mission to a terminal state. Events stream to stderr; the only
358    /// line on stdout is `kranz exec <id> <STATUS> cost=$X.XX branch=<b>`.
359    ///
360    /// Exit codes: 0 complete, 1 failed, 2 blocked, 3 underspecified (the
361    /// orchestrator wanted clarification a headless run cannot provide — make
362    /// the plan file self-sufficient and re-run). stdin is never read.
363    Exec {
364        /// The mission plan file (ticket-shaped markdown)
365        #[arg(short = 'f', long = "file", value_name = "MISSION.md")]
366        file: std::path::PathBuf,
367
368        /// Accepted for symmetry; headless runs always auto-approve (no-op)
369        #[arg(long)]
370        yes: bool,
371
372        /// Override maxFixCyclesPerMilestone for this run (bounds CI spend)
373        #[arg(long, value_name = "N")]
374        max_cycles: Option<u32>,
375
376        /// Create, plan, approve, and enqueue the mission without running it.
377        /// A later `kranz work` drain owns execution. This is the native-queue
378        /// handoff for headless producers such as the Gas City pack.
379        #[arg(long, conflicts_with = "push")]
380        enqueue: bool,
381
382        /// Stable producer name recorded beside an enqueued mission so its
383        /// terminal state can be returned even if another dispatcher drains
384        /// the shared queue. Must be paired with --enqueue-external-ref.
385        #[arg(
386            long,
387            value_name = "PRODUCER",
388            requires_all = ["enqueue", "enqueue_external_ref"]
389        )]
390        enqueue_source: Option<String>,
391
392        /// Producer-owned identifier recorded with --enqueue-source.
393        #[arg(
394            long,
395            value_name = "REF",
396            requires_all = ["enqueue", "enqueue_source"]
397        )]
398        enqueue_external_ref: Option<String>,
399
400        /// After a COMPLETE run, push the mission's `kranz/*` branch to this
401        /// git remote (the cloud-mission handoff: a human reviews the branch
402        /// and opens the PR). Refuses to push anything but a kranz/* ref.
403        #[arg(long, value_name = "REMOTE")]
404        push: Option<String>,
405
406        /// Override the unattended scrutiny floor: without this, exec refuses
407        /// to run a mission whose config has skipScrutiny set, since a headless
408        /// run with the scrutiny validator disabled has no adversarial reader
409        /// and can pass its own tautological acceptance (see docs/gascity.md
410        /// lesson 3). The `KRANZ_ALLOW_UNVALIDATED=1` env var is equivalent.
411        #[arg(long)]
412        allow_unvalidated: bool,
413    },
414
415    /// Show or remove an entry from the per-repo execution queue
416    Queue {
417        /// Remove the queued entry for this mission without running it. The
418        /// mission itself is retained for audit; abandon it separately when
419        /// the producer is cancelling the work rather than re-enqueueing it.
420        #[arg(long, value_name = "MISSION_ID")]
421        remove: Option<String>,
422    },
423
424    /// Report docs/knowledge notes whose verified_against paths drifted.
425    KnowledgeRefresh {
426        /// Print the report as JSON instead of text
427        #[arg(long)]
428        json: bool,
429    },
430
431    /// Scan a git diff for unwaived secret findings.
432    Scan {
433        /// Scan staged changes (`git diff --cached`)
434        #[arg(long)]
435        staged: bool,
436
437        /// Scan a git range such as `main..HEAD`
438        #[arg(long, value_name = "A..B")]
439        range: Option<String>,
440    },
441
442    /// Lint the scoped tree for banned domain vocabulary (the KRZ-314
443    /// clean-room boundary: kranz core stays domain-free, domain knowledge
444    /// ships in private packs). Policy is the committed hashed denylist
445    /// (.kranz/domain-denylist.json) plus reviewed waivers
446    /// (.kranz/domain-allowlist); see docs/domain-lint.md. Exit 0 clean, 1
447    /// on unwaived hits — each named by fingerprint + file:line, never
448    /// quoting the matched term.
449    DomainLint {
450        /// Regenerate the hashed denylist from a plaintext terms file (one
451        /// term per line, `#` comments) instead of linting. The terms file
452        /// IS the protected vocabulary: keep it out of the repo —
453        /// .kranz/domain-terms.local is gitignored for exactly this.
454        #[arg(long, value_name = "TERMS_FILE")]
455        seed_config: Option<PathBuf>,
456
457        /// Print the report as JSON instead of text
458        #[arg(long)]
459        json: bool,
460    },
461
462    /// INTERNAL: the Claude Code lifecycle-hook command the engine installs
463    /// into worker sessions (KRZ-302). Never invoked by operators — the
464    /// session's CLI pipes a PreToolUse hook payload to stdin; the guard
465    /// judges it against the engine-written spec file, records the outcome,
466    /// and exits 0 (allow) / 2 (block, stderr fed to the model) / 1 (guard
467    /// error, failing open — the engine-side sweep remains authoritative).
468    HookGuard {
469        /// The per-session hook-gate spec file the engine wrote
470        #[arg(long, value_name = "PATH")]
471        config: PathBuf,
472    },
473
474    /// INTERNAL: the cursor CLI lifecycle-hook relay the backend installs
475    /// into agent sessions (ticket `agent-hooks-status-signals`). Never
476    /// invoked by operators — the session's CLI pipes a lifecycle hook
477    /// payload to stdin; the relay maps it to a coarse signal and POSTs it
478    /// to the loopback endpoint in the engine-written spec file. Purely
479    /// observational: every failure exits 0.
480    HookStatus {
481        /// The per-session hook-status spec file the engine wrote
482        #[arg(long, value_name = "PATH")]
483        config: PathBuf,
484    },
485
486    /// Score how ready this repo is for autonomous kranz missions.
487    Ready {
488        /// Print the serializable scorecard JSON.
489        #[arg(long)]
490        json: bool,
491        /// Score every repo in the host catalog (~/.kranz/config.json) and
492        /// report the N-of-M-at-L3+ org headline.
493        #[arg(long)]
494        all: bool,
495    },
496
497    /// Drain the execution queue: run queued missions one at a time per repo
498    Work {
499        /// Process exactly one front entry (exit 0 if the repo is busy)
500        /// instead of draining until the queue is empty
501        #[arg(long)]
502        once: bool,
503
504        /// With --once, run only when this exact mission is still at the
505        /// front. A changed front is released without execution.
506        #[arg(long, value_name = "MISSION_ID", requires = "once")]
507        expect: Option<String>,
508    },
509
510    /// Serve the REST/WebSocket API (and the dashboard, if built)
511    Serve {
512        /// TCP port to bind
513        #[arg(long, default_value_t = 4560)]
514        port: u16,
515
516        /// Bind address. Default loopback; set e.g. 0.0.0.0 (LAN) or a
517        /// tailnet IP to reach the API from other devices (glasses app,
518        /// phones). Non-loopback binds require `--insecure-lan` — every
519        /// `/api` GET/POST/WS then requires a token. Reads accept the
520        /// read-only token; POSTs require the mutation token.
521        #[arg(long, default_value = "127.0.0.1")]
522        host: String,
523
524        /// Acknowledge that a non-loopback bind exposes the API on the
525        /// network. Required when `--host` is not a loopback address;
526        /// off-loopback, GETs and WS upgrades require either the read-only or
527        /// mutation token; POSTs require the mutation token. Ignored for
528        /// loopback addresses (127.0.0.0/8, ::1).
529        #[arg(long)]
530        insecure_lan: bool,
531
532        /// Require either the read-only or mutation token on `/api` GETs and
533        /// the WS upgrade on ANY bind class, including loopback. POSTs still
534        /// require the mutation token. Off-loopback binds already gate reads;
535        /// this flag forces that posture on loopback too. Still requires
536        /// `--insecure-lan` for a non-loopback bind (unchanged).
537        #[arg(long)]
538        read_auth: bool,
539
540        /// Open the dashboard in the default browser
541        #[arg(long)]
542        open: bool,
543
544        /// Directory holding the built dashboard (index.html + assets).
545        /// Default search order: `$KRANZ_DASHBOARD_DIST`, `<repo>/apps/dashboard/dist`,
546        /// installed asset dirs, the kranz source checkout used to build the
547        /// binary, then the embedded dashboard bundled into the CLI.
548        #[arg(long, value_name = "DIR")]
549        dashboard: Option<std::path::PathBuf>,
550
551        /// Pin the mutation token instead of generating one (scripting).
552        /// Every POST /api/... must carry it in the x-kranz-token header.
553        /// Falls back to $KRANZ_TOKEN when unset.
554        #[arg(long, value_name = "TOKEN")]
555        token: Option<String>,
556
557        /// Pin the read-only token instead of generating one (falls back to
558        /// $KRANZ_READ_TOKEN). Authenticates /api GETs and the WS upgrade
559        /// only — never mutations — so it is the token safe to hand to
560        /// dashboards and agents. Stored next to serve.token at
561        /// .kranz/serve.read.token (operator catalog:
562        /// `~/.kranz/serve/<endpoint>.read.token`). Must be non-empty visible
563        /// ASCII without whitespace and differ from the mutation token.
564        #[arg(long, value_name = "TOKEN")]
565        read_token: Option<String>,
566
567        /// Also run the Slack bridge (Socket Mode). Requires bot/app tokens +
568        /// channel in ~/.kranz/config.json or KRANZ_SLACK_* env vars; a no-op
569        /// with a log line when unconfigured. See docs/backlog-and-slack.md.
570        #[arg(long)]
571        slack: bool,
572    },
573
574    /// Free the mission's single-writer lock held by a running `kranz serve`.
575    ///
576    /// Resolves the selected root against the running serve's live catalog,
577    /// then POSTs to its repository-scoped release endpoint. The CLI runs in
578    /// a different process and cannot reach serve's in-memory registry
579    /// directly. The mission id comes from the global --mission /
580    /// auto-selection, same as `kranz abandon`.
581    Release {
582        /// Base URL of the running `kranz serve` instance
583        #[arg(long, default_value = "http://127.0.0.1:4560")]
584        url: String,
585
586        /// Mutation token printed by `kranz serve` (falls back to $KRANZ_TOKEN)
587        #[arg(long, value_name = "TOKEN")]
588        token: Option<String>,
589    },
590
591    /// Tail mission event logs and export OpenTelemetry spans over OTLP HTTP.
592    ///
593    /// Entirely read-side: polls each in-scope mission's events.jsonl (like
594    /// `kranz run`'s tail and the Slack bridge), folds spans from the event
595    /// timestamps, and exports one span per closed run/milestone/mission.
596    /// Runs until Ctrl-C. Honors the global --repo/--mission.
597    Otel {
598        /// OTLP HTTP traces endpoint, e.g. http://localhost:4318/v1/traces
599        #[arg(long, value_name = "URL")]
600        endpoint: String,
601
602        /// Replay each mission's full log (spans built from event
603        /// timestamps) before following live. Without this, each mission's
604        /// cursor is seeded at its current head — only spans whose opening
605        /// AND closing events arrive during the tail are exported.
606        #[arg(long)]
607        from_start: bool,
608    },
609
610    /// Export a mission's portable audit bundle (KRZ-326): a self-contained
611    /// directory an auditor can open without repo access — manifest.json
612    /// (every entry with its sha256 + source ref), a human summary.md, the
613    /// provenance chain.json, the escalation ledger and cost fold, the raw
614    /// scrubbed event log, and every resolvable artefact's bytes under
615    /// artefacts/. Missing artefact bytes are listed as unresolved manifest
616    /// entries, never omitted. The same log always yields the same bundle.
617    EvidenceBundle {
618        /// The mission id (defaults to the global --mission / auto-selection)
619        mission_id: Option<String>,
620
621        /// Directory to write the bundle into (created; must be empty).
622        /// Defaults to `./evidence-bundle-<mission-id>`
623        #[arg(long, value_name = "DIR")]
624        out: Option<PathBuf>,
625    },
626
627    /// Export validation-PASSED worker traces as fine-tuning-ready JSONL.
628    ///
629    /// Derived and regenerable: loads and folds the target mission's event
630    /// log on demand (like `status`) and prints one instruction-pair JSON
631    /// object per line to stdout — there is no persisted dataset file, so
632    /// re-running this command over an unchanged event log always yields
633    /// byte-identical output.
634    ExportTraces {
635        /// The mission id (defaults to the global --mission / auto-selection;
636        /// ignored with --all)
637        mission_id: Option<String>,
638
639        /// Aggregate passed traces across every mission under
640        /// .kranz/missions. A mission whose event log is missing or
641        /// unreadable is skipped, not fatal.
642        #[arg(long)]
643        all: bool,
644
645        /// Write the JSONL output to this path instead of stdout.
646        #[arg(long, value_name = "PATH")]
647        out: Option<PathBuf>,
648    },
649
650    /// Export the provenance-tagged training corpus as JSONL (KRZ-332).
651    ///
652    /// One tagged record per line (`source`: worker-trace / divergence /
653    /// escalation): validation-PASSED worker traces, divergence
654    /// comparison+resolution pairs, and escalation-ledger human judgments —
655    /// every record carrying the provenance refs (mission, backend/model,
656    /// run id, gate-chain seqs) that resolve it through `kranz provenance`.
657    /// Derived and regenerable like export-traces (which stays a
658    /// traces-only contract): same logs in, byte-identical JSONL out.
659    ExportCorpus {
660        /// The mission id (defaults to the global --mission / auto-selection;
661        /// ignored with --all)
662        mission_id: Option<String>,
663
664        /// Aggregate the corpus across every mission under .kranz/missions
665        /// (ids sorted). A mission whose event log is missing, unreadable,
666        /// or corrupt is skipped, not fatal.
667        #[arg(long)]
668        all: bool,
669
670        /// Write the JSONL output to this path instead of stdout.
671        #[arg(long, value_name = "PATH")]
672        out: Option<PathBuf>,
673    },
674
675    /// Inspect and edit kranz configuration (files + mid-mission changes).
676    ///
677    /// Config resolves from three layers, later winning: compiled-in defaults
678    /// <- `~/.kranz/config.json` (`--global`) <- `<repo>/.kranz/config.json` (the
679    /// default target). `show` prints the effective merge; `set`/`unset` edit
680    /// one layer file (validated before writing, other keys preserved);
681    /// `role` is the MID-MISSION path — it enqueues a config-change control
682    /// command on a running mission (the CLI twin of Slack's /kranz config),
683    /// while file edits only shape future missions.
684    Config {
685        #[command(subcommand)]
686        command: crate::config_cmd::ConfigCommand,
687    },
688
689    /// Work with kranz packs (the pack contract: deterministic gates, role
690    /// prompts, checklists, artefact stores — docs/pack-contract.md)
691    Pack {
692        #[command(subcommand)]
693        command: PackCommand,
694    },
695
696    /// Work with Flight Rules standards (KRZ-341): the schema-4 pack
697    /// standards corpus — RFCs, rules, the normalized manifest + content
698    /// digest, and the lifecycle transition lint
699    /// (docs/scoping/flight-rules-engineering-standards.md)
700    Standards {
701        #[command(subcommand)]
702        command: StandardsCommand,
703    },
704}
705
706/// Subcommands under `kranz pack` — the pack contract surface (ticket
707/// `.kranz/tickets/pack-contract-gates-prompts.md`).
708#[derive(Subcommand, Debug)]
709pub enum PackCommand {
710    /// Load and validate a pack directory fully locally, printing what it
711    /// registers (gates, prompts, checklists, artefact stores).
712    ///
713    /// A directory without a pack.toml is not a pack — the command says so
714    /// plainly and exits 0. An invalid pack fails closed: nonzero exit
715    /// naming the offending field (unknown field, wrong type, missing
716    /// required key, empty gate command, duplicate name, model-judged gate
717    /// kind, engine-reserved gate name).
718    Lint {
719        /// The pack directory containing pack.toml
720        dir: PathBuf,
721    },
722}
723
724/// Subcommands under `kranz standards` — the Flight Rules surface (ticket
725/// `.kranz/tickets/flight-rules-pack-contract.md`, KRZ-341).
726#[derive(Subcommand, Debug)]
727pub enum StandardsCommand {
728    /// Fold Flight Rules effectiveness across mission event logs and traced
729    /// defect tickets. Raw denominators are always shown; interpretive smells
730    /// remain suppressed below the documented minimum sample count.
731    Metrics {
732        /// Emit the deterministic machine-readable report
733        #[arg(long)]
734        json: bool,
735    },
736
737    /// Load a pack's `[standards]` corpus and print the normalized manifest:
738    /// every RFC and rule with its effective lifecycle status, checker
739    /// binding, and scopes, plus the sha256 content digest and the trust
740    /// posture (an external/untracked pack is advisory-only — enforced rules
741    /// are refused at load naming the remedy).
742    ///
743    /// With `--against <ref>`, the base pack is read from TRACKED BLOBS at
744    /// that git ref (never the worktree) and lifecycle transition violations
745    /// are refused: absent/draft → enforced, a semantic rule change without
746    /// a revision increment, a disappeared known rule ID, tombstone
747    /// reactivation. Exit 0 clean, 1 on load errors or refused transitions.
748    Lint {
749        /// The pack directory containing pack.toml
750        dir: PathBuf,
751
752        /// Base git ref (branch or sha) whose tracked pack bytes define the
753        /// approved lifecycle state for the transition check
754        #[arg(long, value_name = "REF")]
755        against: Option<String>,
756    },
757
758    /// Record an authorized human waiver for ONE standards failure (ticket
759    /// flight-rules-waiver-decisions, KRZ-344; design D-I) — the only
760    /// approval surface. Displays the finding, the pinned rule, the
761    /// affected paths, and the diff digest the waiver binds, then appends
762    /// `standards.waiver.approved` to the mission log. Refuses: a rule with
763    /// `waivable: false`, a rule absent from the approved pin (an expired/
764    /// retired rule or RFC is never pinned), a mismatched revision, an
765    /// absent finding, an already-waived finding, or a past expiry. The
766    /// approver is recorded honestly as `local-operator` plus this surface
767    /// — a model may request a waiver but can never approve one.
768    Waive {
769        /// The pinned rule id to except (e.g. ENG-RUST-014)
770        #[arg(long)]
771        rule: String,
772
773        /// The revision you believe you are waiving (defaults to the pinned
774        /// revision; a mismatch refuses rather than silently rebinding)
775        #[arg(long)]
776        revision: Option<u64>,
777
778        /// Waive only the latest finding with this subject (disambiguates
779        /// when several findings cite the rule)
780        #[arg(long)]
781        finding: Option<String>,
782
783        /// Why the exception is granted (recorded verbatim)
784        #[arg(long)]
785        reason: String,
786
787        /// Expiry instant, RFC 3339 (e.g. 2026-09-01T00:00:00Z) — must be
788        /// in the future; waivers are never permanent
789        #[arg(long, value_name = "RFC3339")]
790        expires: String,
791    },
792
793    /// Record the authorized human verdict for one approval-pinned
794    /// `manual-attestation` rule. The attestation binds to the current
795    /// affected paths and diff digest, so any relevant change invalidates
796    /// it. The approver is always the local operator using this CLI surface.
797    Attest {
798        /// The pinned manual-attestation rule id
799        #[arg(long)]
800        rule: String,
801
802        /// Why the operator judges the current change compliant
803        #[arg(long)]
804        reason: String,
805    },
806}
807
808#[derive(Subcommand, Debug)]
809pub enum RevisionCommand {
810    /// Approve a proposed plan revision
811    Approve {
812        /// Mission id whose pending revision should be approved
813        id: String,
814
815        /// Revision number to approve
816        revision: u32,
817    },
818
819    /// Reject a proposed plan revision
820    Reject {
821        /// Mission id whose pending revision should be rejected
822        id: String,
823
824        /// Revision number to reject
825        revision: u32,
826    },
827}
828
829#[derive(Subcommand, Debug)]
830pub enum GrantCommand {
831    /// Approve the parked grant request (extend command_grants + re-validate)
832    Approve {
833        /// Mission id whose pending grant should be approved
834        id: String,
835
836        /// The exact command to grant, quoted (must match the parked request)
837        command: String,
838    },
839
840    /// Deny the parked grant request (block the milestone, fail closed)
841    Deny {
842        /// Mission id whose pending grant should be denied
843        id: String,
844
845        /// The exact command being denied, quoted (must match the parked request)
846        command: String,
847
848        /// Reason recorded on the denial
849        #[arg(long, default_value = "denied by operator")]
850        reason: String,
851    },
852}
853
854#[derive(Subcommand, Debug)]
855pub enum PermissionCommand {
856    /// Show pending requests, their complete action, binding and deadline.
857    List { id: String },
858    /// Allow exactly the invocation whose binding was inspected.
859    Allow {
860        id: String,
861        request_id: String,
862        #[arg(long)]
863        binding: String,
864    },
865    /// Refuse exactly the invocation whose binding was inspected.
866    Deny {
867        id: String,
868        request_id: String,
869        #[arg(long)]
870        binding: String,
871    },
872}
873
874/// Subcommands under `kranz question` — the structured human-question
875/// pending-decision projection (ticket structured-human-question-events).
876#[derive(Subcommand, Debug)]
877pub enum QuestionCommand {
878    /// List the mission's open questions (id, text, options)
879    List {
880        /// Mission id whose open questions should be listed
881        id: String,
882    },
883
884    /// Answer an open question (lands as question.answered; the answer
885    /// reaches the running mission via the user-message consult)
886    Answer {
887        /// Mission id whose open question should be answered
888        id: String,
889
890        /// The engine-minted question id (`q-<n>`, from `kranz question list`)
891        question_id: String,
892
893        /// The answer: an offered option's text verbatim, or free text
894        answer: String,
895
896        /// 0-based index of the offered option picked (omit for free text)
897        #[arg(long)]
898        option: Option<u32>,
899    },
900}
901
902/// Subcommands under `kranz ticket` — the backlog surface.
903#[derive(Subcommand, Debug)]
904pub enum TicketCommand {
905    /// List tickets with slug, priority, pipeline state, and title
906    List,
907
908    /// List tickets ready to pick up now: actionable states whose
909    /// `defer-until` (if any) has passed — deferred tickets stay hidden until
910    /// their time (D-BW-3; the clock decides at listing time, no scheduler)
911    Ready {
912        /// Also list the not-yet-ready deferred tickets, with their defer
913        /// times (operator visibility; the default listing stays clean)
914        #[arg(long)]
915        include_deferred: bool,
916    },
917
918    /// Show one ticket: parsed fields, its state, and any needs-context block
919    Show {
920        /// The ticket slug (file stem under .kranz/tickets/)
921        slug: String,
922    },
923
924    /// Scaffold a new ticket at `.kranz/tickets/<slug>.md` (refuses to overwrite)
925    New {
926        /// The ticket slug (used as the file stem)
927        slug: String,
928
929        /// The ticket title (frontmatter `title`)
930        #[arg(long)]
931        title: String,
932
933        /// An optional one-paragraph goal to pre-fill the `## Goal` section
934        #[arg(long)]
935        goal: Option<String>,
936    },
937
938    /// Import an OpenSpec change folder (`openspec/changes/<name>/`) as a
939    /// ticket. Carries the proposal and its requirements; deliberately drops
940    /// `tasks.md`, and never passes SHALL scenarios off as acceptance
941    /// criteria. One way only — the approved plan stays authoritative
942    ImportOpenspec {
943        /// Path to the OpenSpec change directory (must hold proposal.md)
944        path: PathBuf,
945
946        /// Ticket slug (defaults to the change directory's name)
947        #[arg(long)]
948        slug: Option<String>,
949    },
950
951    /// Append a note to a ticket's discussion
952    /// (`.kranz/tickets/<slug>.notes.jsonl` — append-only, committed with the
953    /// ticket; D-BW-3). Author is $KRANZ_NOTE_AUTHOR, else "operator"
954    Note {
955        /// The ticket slug
956        slug: String,
957
958        /// The note text (multiple words are joined with spaces)
959        #[arg(required = true)]
960        text: Vec<String>,
961    },
962
963    /// Print a ticket's discussion notes chronologically (append-only — there
964    /// is no edit or delete, mirroring the event log's honesty posture)
965    Notes {
966        /// The ticket slug
967        slug: String,
968    },
969
970    /// Queue a drafted (REVIEW) ticket: enqueue its mission and mark it QUEUED
971    Queue {
972        /// The ticket slug
973        slug: String,
974
975        /// The drafted mission id (auto-detected from the ticket goal if omitted)
976        #[arg(long, value_name = "ID")]
977        mission: Option<String>,
978
979        /// Queue despite unsatisfied `blocked-by` dependencies (a
980        /// blocked-by cycle is never overridable)
981        #[arg(long)]
982        force: bool,
983    },
984
985    /// Deprecated alias for `ticket queue` (kept for one release; prints a
986    /// deprecation note to stderr). Do not confuse with plan approval — see
987    /// docs/scoping/pipeline-view.md decision D-A.
988    Approve {
989        /// The ticket slug
990        slug: String,
991
992        /// The drafted mission id (auto-detected from the ticket goal if omitted)
993        #[arg(long, value_name = "ID")]
994        mission: Option<String>,
995
996        /// Approve despite unsatisfied `blocked-by` dependencies (a
997        /// blocked-by cycle is never overridable)
998        #[arg(long)]
999        force: bool,
1000    },
1001
1002    /// Fold terminal `.status` sidecar states into committed frontmatter
1003    /// `state:` keys — the one-time migration from the ticket-state-
1004    /// frontmatter design, so done verdicts survive a fresh clone. Dry-run
1005    /// by default; tickets with uncommitted .md edits are skipped by name
1006    /// (never rewrite a file an in-flight editor or agent has open)
1007    MigrateState {
1008        /// Apply the fold (without this flag it only reports what it would do)
1009        #[arg(long)]
1010        yes: bool,
1011    },
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017
1018    #[test]
1019    fn serve_parses_read_auth_flag() {
1020        let cli = Cli::try_parse_from(["kranz", "serve", "--read-auth"]).unwrap();
1021        match cli.command {
1022            Command::Serve { read_auth, .. } => assert!(read_auth),
1023            other => panic!("expected Serve, got {other:?}"),
1024        }
1025    }
1026
1027    #[test]
1028    fn serve_parses_read_token_flag() {
1029        let cli = Cli::try_parse_from(["kranz", "serve", "--read-token", "ro-123"]).unwrap();
1030        match cli.command {
1031            Command::Serve { read_token, .. } => {
1032                assert_eq!(read_token.as_deref(), Some("ro-123"))
1033            }
1034            other => panic!("expected Serve, got {other:?}"),
1035        }
1036    }
1037
1038    #[test]
1039    fn pack_contract_pack_lint_parses_dir() {
1040        let cli = Cli::try_parse_from(["kranz", "pack", "lint", "some/dir"]).unwrap();
1041        match cli.command {
1042            Command::Pack { command } => match command {
1043                PackCommand::Lint { dir } => assert_eq!(dir, PathBuf::from("some/dir")),
1044            },
1045            other => panic!("expected Pack, got {other:?}"),
1046        }
1047    }
1048
1049    #[test]
1050    fn flight_rules_contract_standards_lint_parses_dir_and_against() {
1051        let cli = Cli::try_parse_from(["kranz", "standards", "lint", "some/dir"]).unwrap();
1052        match cli.command {
1053            Command::Standards { command } => match command {
1054                StandardsCommand::Lint { dir, against } => {
1055                    assert_eq!(dir, PathBuf::from("some/dir"));
1056                    assert_eq!(against, None);
1057                }
1058                other => panic!("expected Lint, got {other:?}"),
1059            },
1060            other => panic!("expected Standards, got {other:?}"),
1061        }
1062        let cli = Cli::try_parse_from([
1063            "kranz",
1064            "standards",
1065            "lint",
1066            "some/dir",
1067            "--against",
1068            "main",
1069        ])
1070        .unwrap();
1071        match cli.command {
1072            Command::Standards { command } => match command {
1073                StandardsCommand::Lint { dir, against } => {
1074                    assert_eq!(dir, PathBuf::from("some/dir"));
1075                    assert_eq!(against.as_deref(), Some("main"));
1076                }
1077                other => panic!("expected Lint, got {other:?}"),
1078            },
1079            other => panic!("expected Standards, got {other:?}"),
1080        }
1081    }
1082
1083    /// KRZ-344 (D-I): the waiver surface parses its full flag set; the
1084    /// approver is never a flag — the record honestly names
1085    /// `local-operator` plus the `cli` surface.
1086    #[test]
1087    fn flight_rules_waiver_standards_waive_parses_flags() {
1088        let cli = Cli::try_parse_from([
1089            "kranz",
1090            "standards",
1091            "waive",
1092            "--rule",
1093            "ZZ-FAIL-001",
1094            "--reason",
1095            "accepted risk",
1096            "--expires",
1097            "2026-09-01T00:00:00Z",
1098        ])
1099        .unwrap();
1100        match cli.command {
1101            Command::Standards { command } => match command {
1102                StandardsCommand::Waive {
1103                    rule,
1104                    revision,
1105                    finding,
1106                    reason,
1107                    expires,
1108                } => {
1109                    assert_eq!(rule, "ZZ-FAIL-001");
1110                    assert_eq!(revision, None);
1111                    assert_eq!(finding, None);
1112                    assert_eq!(reason, "accepted risk");
1113                    assert_eq!(expires, "2026-09-01T00:00:00Z");
1114                }
1115                other => panic!("expected Waive, got {other:?}"),
1116            },
1117            other => panic!("expected Standards, got {other:?}"),
1118        }
1119        let cli = Cli::try_parse_from([
1120            "kranz",
1121            "standards",
1122            "waive",
1123            "--rule",
1124            "ZZ-FAIL-001",
1125            "--revision",
1126            "2",
1127            "--finding",
1128            "a-1",
1129            "--reason",
1130            "accepted risk",
1131            "--expires",
1132            "2026-09-01T00:00:00Z",
1133        ])
1134        .unwrap();
1135        match cli.command {
1136            Command::Standards { command } => match command {
1137                StandardsCommand::Waive {
1138                    revision, finding, ..
1139                } => {
1140                    assert_eq!(revision, Some(2));
1141                    assert_eq!(finding.as_deref(), Some("a-1"));
1142                }
1143                other => panic!("expected Waive, got {other:?}"),
1144            },
1145            other => panic!("expected Standards, got {other:?}"),
1146        }
1147        // --reason and --expires are required: no silent permanent or
1148        // reason-less waiver exists.
1149        assert!(
1150            Cli::try_parse_from(["kranz", "standards", "waive", "--rule", "ZZ-FAIL-001"]).is_err()
1151        );
1152    }
1153
1154    #[test]
1155    fn flight_rules_enforcement_standards_attest_parses_flags() {
1156        let cli = Cli::try_parse_from([
1157            "kranz",
1158            "standards",
1159            "attest",
1160            "--rule",
1161            "ZZ-MANUAL-001",
1162            "--reason",
1163            "reviewed the deployment evidence",
1164        ])
1165        .unwrap();
1166        match cli.command {
1167            Command::Standards { command } => match command {
1168                StandardsCommand::Attest { rule, reason } => {
1169                    assert_eq!(rule, "ZZ-MANUAL-001");
1170                    assert_eq!(reason, "reviewed the deployment evidence");
1171                }
1172                other => panic!("expected Attest, got {other:?}"),
1173            },
1174            other => panic!("expected Standards, got {other:?}"),
1175        }
1176        assert!(
1177            Cli::try_parse_from(["kranz", "standards", "attest", "--rule", "ZZ-MANUAL-001"])
1178                .is_err()
1179        );
1180    }
1181
1182    #[test]
1183    fn flight_rules_metrics_standards_metrics_parses_json() {
1184        let cli = Cli::try_parse_from(["kranz", "standards", "metrics", "--json"]).unwrap();
1185        match cli.command {
1186            Command::Standards {
1187                command: StandardsCommand::Metrics { json },
1188            } => assert!(json),
1189            other => panic!("expected standards metrics, got {other:?}"),
1190        }
1191    }
1192
1193    #[test]
1194    fn evidence_bundle_parses_mission_and_out() {
1195        let cli =
1196            Cli::try_parse_from(["kranz", "evidence-bundle", "m-1", "--out", "some/dir"]).unwrap();
1197        match cli.command {
1198            Command::EvidenceBundle { mission_id, out } => {
1199                assert_eq!(mission_id.as_deref(), Some("m-1"));
1200                assert_eq!(out.as_deref(), Some(PathBuf::from("some/dir").as_path()));
1201            }
1202            other => panic!("expected EvidenceBundle, got {other:?}"),
1203        }
1204
1205        // Both optional: the mission falls back to auto-selection, the output
1206        // dir to ./evidence-bundle-<mission-id>.
1207        let cli = Cli::try_parse_from(["kranz", "evidence-bundle"]).unwrap();
1208        match cli.command {
1209            Command::EvidenceBundle { mission_id, out } => {
1210                assert_eq!(mission_id, None);
1211                assert_eq!(out, None);
1212            }
1213            other => panic!("expected EvidenceBundle, got {other:?}"),
1214        }
1215    }
1216
1217    #[test]
1218    fn decompose_parses_goal_and_yes_flag() {
1219        let cli = Cli::try_parse_from(["kranz", "decompose", "build the thing", "--yes"]).unwrap();
1220        match cli.command {
1221            Command::Decompose { goal, yes } => {
1222                assert_eq!(goal, "build the thing");
1223                assert!(yes);
1224            }
1225            other => panic!("expected Decompose, got {other:?}"),
1226        }
1227
1228        let cli = Cli::try_parse_from(["kranz", "decompose", "g", "-y"]).unwrap();
1229        match cli.command {
1230            Command::Decompose { yes, .. } => assert!(yes),
1231            other => panic!("expected Decompose, got {other:?}"),
1232        }
1233
1234        // Dry-run is the default: no flag, no write.
1235        let cli = Cli::try_parse_from(["kranz", "decompose", "g"]).unwrap();
1236        match cli.command {
1237            Command::Decompose { yes, .. } => assert!(!yes),
1238            other => panic!("expected Decompose, got {other:?}"),
1239        }
1240    }
1241
1242    #[test]
1243    fn knowledge_refresh_parses_json_flag() {
1244        let cli = Cli::try_parse_from(["kranz", "knowledge-refresh"]).unwrap();
1245        match cli.command {
1246            Command::KnowledgeRefresh { json } => assert!(!json),
1247            other => panic!("expected KnowledgeRefresh, got {other:?}"),
1248        }
1249        let cli = Cli::try_parse_from(["kranz", "knowledge-refresh", "--json"]).unwrap();
1250        match cli.command {
1251            Command::KnowledgeRefresh { json } => assert!(json),
1252            other => panic!("expected KnowledgeRefresh, got {other:?}"),
1253        }
1254    }
1255
1256    #[test]
1257    fn domain_lint_command_parses_seed_config_and_json_flags() {
1258        // Bare form: lint mode, text output.
1259        let cli = Cli::try_parse_from(["kranz", "domain-lint"]).unwrap();
1260        match cli.command {
1261            Command::DomainLint { seed_config, json } => {
1262                assert_eq!(seed_config, None);
1263                assert!(!json);
1264            }
1265            other => panic!("expected DomainLint, got {other:?}"),
1266        }
1267
1268        let cli = Cli::try_parse_from(["kranz", "domain-lint", "--seed-config", "terms", "--json"])
1269            .unwrap();
1270        match cli.command {
1271            Command::DomainLint { seed_config, json } => {
1272                assert_eq!(seed_config, Some(PathBuf::from("terms")));
1273                assert!(json);
1274            }
1275            other => panic!("expected DomainLint, got {other:?}"),
1276        }
1277    }
1278
1279    /// Composition audit (ticket `config-fail-open-audit`): every CLI flag
1280    /// whose name signals a guard-weakening override must either carry the
1281    /// `dangerously-` prefix or be one of the enumerated, justified
1282    /// exceptions. A future flag that short-circuits a guard without the
1283    /// prefix trips this test until its justification is recorded — the
1284    /// naming rule's tripwire. The per-flag rationales live in
1285    /// docs/config-composition.md.
1286    #[test]
1287    fn composition_audit_guard_weakening_flags_are_dangerously_prefixed_or_enumerated() {
1288        use clap::CommandFactory;
1289
1290        fn collect_long_flags(cmd: &clap::Command, out: &mut Vec<String>) {
1291            for arg in cmd.get_arguments() {
1292                if let Some(long) = arg.get_long() {
1293                    out.push(long.to_string());
1294                }
1295            }
1296            for sub in cmd.get_subcommands() {
1297                collect_long_flags(sub, out);
1298            }
1299        }
1300
1301        let mut flags = Vec::new();
1302        collect_long_flags(&Cli::command(), &mut flags);
1303        // The heuristic: names that read like they weaken a guard. Wide on
1304        // purpose — a false positive only costs a recorded justification.
1305        let suspicious = [
1306            "force",
1307            "steal",
1308            "bypass",
1309            "unvalidated",
1310            "insecure",
1311            "skip",
1312            "unsafe",
1313            "dangerous",
1314            "override",
1315        ];
1316        let mut hits: Vec<String> = flags
1317            .into_iter()
1318            .filter(|flag| suspicious.iter().any(|s| flag.contains(s)))
1319            .collect();
1320        hits.sort();
1321        hits.dedup();
1322
1323        // The documented set. `dangerously-*` members are the naming rule's
1324        // escape valve; the rest are the accepted exceptions of
1325        // docs/config-composition.md:
1326        // - force-lock: steals only from a holder PROVABLY dead (the
1327        //   liveness probe is fail-closed); the live-holder bypass is the
1328        //   dangerously-named flag.
1329        // - allow-unvalidated (exec): lifts only the unattended scrutiny
1330        //   FLOOR — a refuse-to-run gate, not a deny list; self-describing.
1331        // - insecure-lan (serve): an acknowledgment that ADDS token
1332        //   requirements on non-loopback binds; it removes nothing.
1333        // - force (ticket queue/approve): skips blocked-by READINESS only;
1334        //   dependency cycles are never overridable.
1335        let expected = [
1336            "allow-unvalidated",
1337            "dangerously-allow-all",
1338            "dangerously-steal-live-lock",
1339            "force",
1340            "force-lock",
1341            "insecure-lan",
1342        ];
1343        assert_eq!(
1344            hits,
1345            expected.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
1346            "a guard-weakening flag changed: every bypass of a deny list must carry \
1347             the dangerously- prefix or a recorded exception (docs/config-composition.md)"
1348        );
1349    }
1350}