1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
use clap::{Parser, Subcommand};
use colored::Colorize;
#[cfg(feature = "analyze")]
mod analysis_engine;
mod ailog;
mod audit_engine;
mod audit_schema;
mod charter;
mod charter_schema;
mod commands;
mod compliance;
mod config;
mod document;
mod download;
mod inject;
mod manifest;
mod metrics_engine;
mod platform;
mod prompts;
mod self_update;
mod telemetry_schema;
#[cfg(feature = "tui")]
mod tui;
mod utils;
mod validation;
/// StrayMark CLI - Documentation Governance for AI-Assisted Development
#[derive(Parser)]
#[command(name = "straymark", version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize StrayMark in a project directory
Init {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
/// After init, install the framework's pre-PR hook (runs
/// `straymark charter drift` automatically before `git push`).
/// Opt-in per principle #6 — friction with consent. Requires the
/// project to be a git repository.
#[arg(long)]
hooks: bool,
},
/// Update both framework and CLI to the latest version
Update {
/// Update method for the CLI binary: auto, github, or cargo
#[arg(long, default_value = "auto", value_parser = ["auto", "github", "cargo"])]
method: String,
},
/// Update the StrayMark framework to the latest version
UpdateFramework,
/// Update the CLI binary to the latest version
UpdateCli {
/// Update method: auto (detect), github (prebuilt binary), or cargo (compile from source)
#[arg(long, default_value = "auto", value_parser = ["auto", "github", "cargo"])]
method: String,
},
/// Remove StrayMark from the project
Remove {
/// Remove everything including user-generated documents (requires confirmation)
#[arg(long)]
full: bool,
},
/// Show StrayMark installation status and documentation statistics
Status {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
},
/// Repair StrayMark structure by restoring missing directories and files
Repair {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
},
/// Validate StrayMark documents for compliance and correctness
Validate {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
/// Automatically fix simple issues
#[arg(long)]
fix: bool,
/// Validate only git-staged files (for pre-commit hooks)
#[arg(long)]
staged: bool,
/// Also validate Charters in .straymark/charters/ against the Charter schema
/// and referential integrity (originating_ailogs IDs exist;
/// originating_spec path exists). Default: false, to avoid breaking
/// projects that don't yet use the Charter pattern.
#[arg(long)]
include_charters: bool,
/// Surface documents with `review_required: true` and no
/// `review_outcome` that are older than --max-pending-days. Warn-only
/// (never fails the validate exit code); useful for CI dashboards of
/// the approval backlog. See DOCUMENTATION-POLICY.md §3.5.
#[arg(long)]
check_pending_reviews: bool,
/// Threshold for --check-pending-reviews (default: 14 days).
#[arg(long, default_value_t = 14)]
max_pending_days: i64,
},
/// Check regulatory compliance (EU, NIST, ISO; China standards opt-in via regional_scope)
Compliance {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
/// Check a specific standard
#[arg(long, value_parser = [
"eu-ai-act", "iso-42001", "nist-ai-rmf",
"china-tc260", "china-pipl", "china-gb45438",
"china-cac", "china-gb45652", "china-csl",
])]
standard: Option<String>,
/// Run all standards in a region: global, eu, china, or all
#[arg(long, value_parser = ["global", "eu", "china", "all"])]
region: Option<String>,
/// Check all standards (equivalent to --region all, regardless of regional_scope)
#[arg(long)]
all: bool,
/// Output format
#[arg(long, default_value = "text", value_parser = ["text", "markdown", "json"])]
output: String,
},
/// Generate audit trail reports with timeline and traceability
Audit {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
/// Start date for audit period (YYYY-MM-DD)
#[arg(long)]
from: Option<String>,
/// End date for audit period (YYYY-MM-DD)
#[arg(long)]
to: Option<String>,
/// Filter by system/component name
#[arg(long)]
system: Option<String>,
/// Output format
#[arg(long, default_value = "text", value_parser = ["text", "markdown", "json", "html"])]
output: String,
},
/// Show governance metrics and documentation statistics
Metrics {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
/// Time period for metrics
#[arg(long, default_value = "last-30-days", value_parser = ["last-7-days", "last-30-days", "last-90-days", "all"])]
period: String,
/// Output format
#[arg(long, default_value = "text", value_parser = ["text", "markdown", "json"])]
output: String,
},
/// Create a new StrayMark document from a template
New {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
/// Document type (e.g., ailog, adr, sec)
#[arg(long, short = 't')]
doc_type: Option<String>,
/// Document title
#[arg(long)]
title: Option<String>,
},
/// Show version, author, and license information
About,
/// Record a formal human approval on a `review_required: true` document.
/// Writes the reviewed_by/reviewed_at/review_outcome frontmatter fields
/// and appends the canonical `## Approval` body section in one edit.
Approve {
/// Document ID (e.g., AIDEC-2026-05-02-001 or with slug)
doc_id: String,
/// Outcome of the review
#[arg(long, value_parser = ["approved", "revisions_requested", "rejected"])]
outcome: Option<String>,
/// Reviewer identity: email | github-handle | DID
#[arg(long)]
reviewer: Option<String>,
/// Approval date (default: today, format YYYY-MM-DD)
#[arg(long)]
at: Option<String>,
/// Optional reviewer notes (appended in the body section)
#[arg(long)]
notes: Option<String>,
/// Re-apply approval on a document that already has one. Required for
/// the revisions_requested → approved iteration cycle and for
/// multi-reviewer hand-offs. Without this flag, re-running approve
/// on an already-approved document is a no-op (cli-3.7.1+).
#[arg(long)]
force: bool,
/// Suppress the per-document success and idempotent-skip messages.
/// Useful for bulk approve runs (the operator just wants the
/// exit code). High-risk warnings (`risk_level: high|critical`)
/// are NOT silenceable by this flag — bulk-approving high-risk
/// docs without seeing the warning is exactly the failure mode
/// the warning exists to prevent (cli-3.8.0+, F5 of issue #81).
#[arg(long)]
quiet: bool,
/// Project directory (default: current directory)
#[arg(long = "path", default_value = ".")]
path: String,
},
/// Analyze code complexity using cognitive and cyclomatic metrics
#[cfg(feature = "analyze")]
Analyze {
/// Target directory or file (default: current directory)
#[arg(default_value = ".")]
path: String,
/// Cognitive complexity threshold (default: from config or 8)
#[arg(long)]
threshold: Option<u32>,
/// Output format
#[arg(long, default_value = "text", value_parser = ["text", "json", "markdown"])]
output: String,
/// Show only top N most complex functions
#[arg(long)]
top: Option<usize>,
},
/// Explore StrayMark documentation interactively
#[cfg(feature = "tui")]
Explore {
/// Target directory (default: current directory)
#[arg(default_value = ".")]
path: String,
/// Display language override (e.g., en, es, zh-CN). Defaults to
/// `language` from .straymark/config.yml.
#[arg(long)]
lang: Option<String>,
},
/// Manage Charters: bounded units of work declared ex-ante and audited ex-post
Charter {
#[command(subcommand)]
command: CharterCommands,
},
}
#[derive(Subcommand)]
enum CharterCommands {
/// Scaffold a new Charter from the framework template
New {
/// Effort estimate (defaults to M if absent)
#[arg(long = "type", short = 't', value_parser = ["XS", "S", "M", "L"])]
effort: Option<String>,
/// Originating AILOG ID (e.g., AILOG-2026-04-28-021).
/// Mutually exclusive with --from-spec.
#[arg(long, conflicts_with = "from_spec")]
from_ailog: Option<String>,
/// Originating SpecKit spec path (e.g., specs/001-feature/spec.md).
/// Mutually exclusive with --from-ailog.
#[arg(long, conflicts_with = "from_ailog")]
from_spec: Option<String>,
/// Charter title (used to build the slug and filename)
#[arg(long)]
title: Option<String>,
/// Explicit slug override (cli-3.7.2+). Use when the title-derived
/// slug would lose meaningful context (e.g., a long title with a
/// trailing reference like `… Plan 04 F3` whose suffix would be
/// truncated). Input is normalized through the slugifier.
#[arg(long)]
slug: Option<String>,
/// Project directory (default: current directory)
#[arg(default_value = ".")]
path: String,
},
/// List Charters with optional status / origin filter
List {
/// Filter by lifecycle status
#[arg(long, default_value = "all", value_parser = ["declared", "in-progress", "closed", "all"])]
status: String,
/// Filter by origin type
#[arg(long, value_parser = ["ailog", "spec", "any"])]
origin: Option<String>,
/// Project directory (default: current directory)
#[arg(default_value = ".")]
path: String,
},
/// Show Charter detail (or last 5 Charters if no ID is given)
Status {
/// Charter identifier (CHARTER-NN, CHARTER-NN-slug, or just NN)
charter_id: Option<String>,
/// Project directory (default: current directory).
/// Use a flag (rather than positional) so it cannot be confused
/// with the optional charter_id positional.
#[arg(long = "path", default_value = ".")]
path: String,
},
/// Record post-execution telemetry and bump status to `closed`
Close {
/// Charter identifier (CHARTER-NN, CHARTER-NN-slug, or just NN)
charter_id: String,
/// Copy the telemetry template next to the Charter for manual editing
/// instead of running the interactive flow. Combine with
/// --non-interactive for CI / scripted use.
#[arg(long)]
from_template: bool,
/// Skip prompts. Requires --from-template (the schema cannot be
/// validated until the user has filled in the YAML).
#[arg(long, requires = "from_template")]
non_interactive: bool,
/// Project directory (default: current directory)
#[arg(long = "path", default_value = ".")]
path: String,
},
/// Mark a Charter batch as complete in the AILOG `## Batch Ledger`.
/// For multi-batch Charters (3+ batches or >1 day): substitutes the
/// `(pending)` placeholder under `### Batch <N>` with batch notes
/// captured interactively (default) or via --note (one-shot / scripted).
/// The `straymark charter drift` close-time gate rejects any batch left
/// as `(pending)`.
BatchComplete {
/// Charter identifier (CHARTER-NN, CHARTER-NN-slug, or just NN)
charter_id: String,
/// Batch number (1-based) — matches the `### Batch <N>` heading in the
/// AILOG `## Batch Ledger` section.
batch_number: u32,
/// Pre-filled batch note body. With this flag, the command writes the
/// note non-interactively and skips prompts. Use for scripts / agents.
#[arg(long)]
note: Option<String>,
/// Skip prompts. Requires --note (no batch content can be inferred).
#[arg(long, requires = "note")]
non_interactive: bool,
/// Project directory (default: current directory)
#[arg(long = "path", default_value = ".")]
path: String,
},
/// Orchestrate a multi-model external audit cycle (3-step: prepare,
/// calibrate, finalize). Phase 3 v0 is orchestration-only — the CLI
/// resolves prompts, validates auditor outputs, and prints findings
/// for telemetry. It does NOT invoke LLM APIs; the operator runs the
/// prompts in their auditor of choice (Copilot, Gemini, Claude, etc.)
/// and saves responses to canonical paths.
Audit {
/// Charter identifier (CHARTER-NN, CHARTER-NN-slug, or just NN)
charter_id: String,
/// Git revision range (default: origin/main..HEAD with fallback to
/// origin/master..HEAD; falls back to HEAD~1..HEAD with warning when
/// no upstream is reachable). Override with explicit value as needed.
#[arg(long)]
range: Option<String>,
/// Generate the unified audit prompt and write it to
/// .straymark/audits/CHARTER-NN/audit-prompt.md. Default action when
/// no other flag is passed. Equivalent to the v0 PREPARE step.
#[arg(long, conflicts_with_all = ["merge_reports", "calibrate", "finalize"])]
prepare: bool,
/// Read all report-*.md files in .straymark/audits/CHARTER-NN/,
/// validate them against audit-output.schema.v0.json, and emit the
/// external_audit YAML block. Combine with --merge-into to append
/// the block directly into the Charter's telemetry YAML.
#[arg(long, conflicts_with_all = ["prepare", "calibrate", "finalize"])]
merge_reports: bool,
/// Deprecated v0 flag. The v1 flow does not have a separate calibrate
/// step — the calibrator role is handled by the main agent via the
/// /straymark-audit-review skill. Emits a warning and exits.
#[arg(long, hide = true, conflicts_with_all = ["prepare", "merge_reports", "finalize"])]
calibrate: bool,
/// Deprecated v0 flag. Use --merge-reports instead. Emits a
/// deprecation warning and routes through the new path.
#[arg(long, hide = true, conflicts_with_all = ["prepare", "merge_reports", "calibrate"])]
finalize: bool,
/// With --merge-reports (or deprecated --finalize): append the
/// external_audit array directly into the Charter's telemetry YAML
/// at the given path instead of printing it to stdout. Re-audit
/// (file already has external_audit) is rejected with a clear error.
#[arg(long)]
merge_into: Option<String>,
/// Project directory (default: current directory)
#[arg(long = "path", default_value = ".")]
path: String,
},
/// Detect file-vs-commit drift at Charter close (declared-but-not-modified
/// files; scope expansion). Suppresses alerts on paths already documented
/// as risks in the Charter's originating AILOGs.
Drift {
/// Charter identifier (CHARTER-NN, CHARTER-NN-slug, or just NN)
charter_id: String,
/// Git revision range (default: HEAD~1..HEAD)
#[arg(long)]
range: Option<String>,
/// Disable AILOG-aware suppression (show every declared-omitted path
/// even if documented in an AILOG).
#[arg(long)]
no_ailog_suppress: bool,
/// Disable the Batch Ledger gate (do not fail on `### Batch N (pending)`
/// entries in AILOGs referenced by this Charter). Use only when the
/// adopter has explicitly opted out of the ledger pattern for this
/// Charter (e.g., consolidating post-close).
#[arg(long)]
no_batch_ledger_check: bool,
/// Project directory (default: current directory)
#[arg(long = "path", default_value = ".")]
path: String,
},
}
fn main() {
// Clean up leftover binary from previous update
self_update::cleanup_old_binary();
let cli = Cli::parse();
let result = match cli.command {
Commands::Init { path, hooks } => commands::init::run(&path, hooks),
Commands::Update { method } => commands::update::run(&method),
Commands::UpdateFramework => commands::update_framework::run(),
Commands::UpdateCli { method } => commands::update_cli::run(&method),
Commands::Remove { full } => commands::remove::run(full),
Commands::Validate {
path,
fix,
staged,
include_charters,
check_pending_reviews,
max_pending_days,
} => commands::validate::run(
&path,
fix,
staged,
include_charters,
check_pending_reviews,
max_pending_days,
),
Commands::Audit {
path,
from,
to,
system,
output,
} => commands::audit::run(&path, from.as_deref(), to.as_deref(), system.as_deref(), &output),
Commands::Compliance {
path,
standard,
region,
all,
output,
} => commands::compliance::run(&path, standard.as_deref(), region.as_deref(), all, &output),
Commands::Metrics {
path,
period,
output,
} => commands::metrics::run(&path, &period, &output),
Commands::New {
path,
doc_type,
title,
} => commands::new::run(&path, doc_type.as_deref(), title.as_deref()),
Commands::Status { path } => commands::status::run(&path),
Commands::Repair { path } => commands::repair::run(&path),
Commands::About => commands::about::run(),
Commands::Approve {
doc_id,
outcome,
reviewer,
at,
notes,
force,
quiet,
path,
} => commands::approve::run(
&path,
&doc_id,
outcome.as_deref(),
reviewer.as_deref(),
at.as_deref(),
notes.as_deref(),
force,
quiet,
),
#[cfg(feature = "analyze")]
Commands::Analyze {
path,
threshold,
output,
top,
} => commands::analyze::run(&path, threshold, &output, top),
#[cfg(feature = "tui")]
Commands::Explore { path, lang } => commands::explore::run(&path, lang.as_deref()),
Commands::Charter { command } => match command {
CharterCommands::New {
effort,
from_ailog,
from_spec,
title,
slug,
path,
} => commands::charter::new::run(
&path,
effort.as_deref(),
from_ailog.as_deref(),
from_spec.as_deref(),
title.as_deref(),
slug.as_deref(),
),
CharterCommands::List {
status,
origin,
path,
} => commands::charter::list::run(&path, &status, origin.as_deref()),
CharterCommands::Status { charter_id, path } => {
commands::charter::status::run(&path, charter_id.as_deref())
}
CharterCommands::Close {
charter_id,
from_template,
non_interactive,
path,
} => commands::charter::close::run(&path, &charter_id, from_template, non_interactive),
CharterCommands::Drift {
charter_id,
range,
no_ailog_suppress,
no_batch_ledger_check,
path,
} => commands::charter::drift::run(
&path,
&charter_id,
range.as_deref(),
no_ailog_suppress,
no_batch_ledger_check,
),
CharterCommands::BatchComplete {
charter_id,
batch_number,
note,
non_interactive,
path,
} => commands::charter::batch_complete::run(
&path,
&charter_id,
batch_number,
note.as_deref(),
non_interactive,
),
CharterCommands::Audit {
charter_id,
range,
prepare,
merge_reports,
calibrate,
finalize,
merge_into,
path,
} => commands::charter::audit::run(
&path,
&charter_id,
range.as_deref(),
prepare,
merge_reports,
calibrate,
finalize,
merge_into.as_deref(),
),
},
};
if let Err(e) = result {
eprintln!("{} {}", "error:".red().bold(), e);
std::process::exit(1);
}
}