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