mod adr;
mod backlog;
mod backlog_order;
mod boot;
mod catalog;
mod clock;
mod commands;
mod concept_map;
mod conduct;
mod contentset;
mod corpus;
mod coverage;
mod coverage_scan;
mod coverage_store;
mod coverage_verify;
mod coverage_view;
mod dep_seq;
mod dispatch;
mod dispatch_config;
mod dtoml;
mod entity;
mod estimate;
mod facet_write;
mod fsutil;
mod git;
mod governance;
mod input;
mod install;
mod integrity;
mod kinds;
mod knowledge;
mod lazyspec;
mod ledger;
mod lexical;
mod lifecycle;
pub(crate) mod links;
mod listing;
mod map_server;
mod mcp_server;
mod memory;
mod meta;
mod plan;
mod policy;
mod priority;
mod projection;
mod rec;
mod reconcile;
mod registry;
mod relation;
mod relation_graph;
mod requirement;
mod retrieve;
mod review;
mod revision;
mod root;
mod skills;
mod slice;
mod spec;
mod standard;
mod state;
mod status;
mod supersede;
mod tag;
mod tomlfmt;
mod tty;
mod value;
mod verify;
mod worktree;
use std::path::PathBuf;
use std::str::FromStr;
use clap::{Args, Parser, Subcommand};
use crate::commands::map::MapServeArgs;
use crate::listing::{Format, ListArgs};
fn parse_expand_depth(s: &str) -> Result<usize, String> {
let depth = s
.parse::<usize>()
.map_err(|_err| "expand depth must be a number")?;
if depth == 0 {
return Err("expand depth must be >= 1".to_string());
}
Ok(depth)
}
#[derive(Parser)]
#[command(name = "doctrine", about = "doctrine CLI")]
struct Cli {
#[arg(long, default_value = "auto", global = true)]
color: clap::ColorChoice,
#[command(subcommand)]
command: Command,
}
#[derive(Args, Debug)]
pub(crate) struct CommonListArgs {
#[arg(long, short = 'f')]
pub(crate) filter: Option<String>,
#[arg(long, short = 'r')]
pub(crate) regexp: Option<String>,
#[arg(long, short = 'i')]
pub(crate) case_insensitive: bool,
#[arg(long, short = 's', value_delimiter = ',')]
pub(crate) status: Vec<String>,
#[arg(long, short = 't')]
pub(crate) tag: Vec<String>,
#[arg(long, short = 'a')]
pub(crate) all: bool,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
pub(crate) format: Format,
#[arg(long)]
pub(crate) json: bool,
#[arg(long, value_delimiter = ',')]
pub(crate) columns: Option<Vec<String>>,
}
impl CommonListArgs {
pub(crate) fn into_list_args(self, color: bool) -> ListArgs {
ListArgs {
substr: self.filter,
regexp: self.regexp,
case_insensitive: self.case_insensitive,
status: self.status,
tags: self.tag,
all: self.all,
format: self.format,
json: self.json,
columns: self.columns,
render: crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
}
}
}
#[derive(Args, Debug)]
pub(crate) struct FindRetrieveArgs {
#[arg(long = "path-scope")]
pub(crate) path_scope: Vec<String>,
#[arg(long = "glob")]
pub(crate) glob: Vec<String>,
#[arg(long = "command")]
pub(crate) command: Vec<String>,
#[arg(long = "tag")]
pub(crate) tag: Vec<String>,
#[arg(long = "query")]
pub(crate) flag_query: Option<String>,
#[arg(long = "type", value_parser = memory::MemoryType::parse)]
pub(crate) memory_type: Option<memory::MemoryType>,
#[arg(long, value_parser = memory::Status::parse)]
pub(crate) status: Option<memory::Status>,
#[arg(long, value_parser = memory::Lifespan::from_str)]
pub(crate) lifespan: Option<memory::Lifespan>,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
pub(crate) format: Format,
#[arg(long)]
pub(crate) json: bool,
#[arg(long = "include-draft")]
pub(crate) include_draft: bool,
#[arg(long, default_value_t = 0)]
pub(crate) offset: usize,
#[arg(long, conflicts_with = "offset")]
pub(crate) page: Option<usize>,
#[arg(long)]
pub(crate) limit: Option<usize>,
#[arg(short = 'p', long)]
pub(crate) path: Option<PathBuf>,
#[arg(long, value_parser = parse_expand_depth)]
pub(crate) expand: Option<usize>,
}
#[derive(clap::Args)]
struct EstimateSetArgs {
id: String,
lower: Option<f64>,
#[arg(allow_hyphen_values = true)]
upper: Option<f64>,
#[arg(long = "exact", short = 'x', conflicts_with_all = ["lower", "upper"])]
exact: Option<f64>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
}
#[derive(clap::Args)]
struct EstimateClearArgs {
id: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
}
#[derive(clap::Args)]
struct ValueSetArgs {
id: String,
#[arg(allow_hyphen_values = true)]
magnitude: f64,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
}
#[derive(clap::Args)]
struct ValueClearArgs {
id: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
}
#[derive(clap::Subcommand)]
enum EstimateAction {
Set(EstimateSetArgs),
Clear(EstimateClearArgs),
}
#[derive(clap::Subcommand)]
enum ValueAction {
Set(ValueSetArgs),
Clear(ValueClearArgs),
}
#[derive(Subcommand)]
enum Command {
Install {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
#[arg(short = 'a', long)]
agent: Vec<String>,
#[arg(short = 's', long)]
skill: Vec<String>,
#[arg(short = 'd', long)]
domain: Vec<String>,
#[arg(long, conflicts_with_all = ["skill", "domain"])]
only_memory: bool,
#[arg(short = 'g', long)]
global: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
},
Catalog {
#[command(subcommand)]
command: CatalogCommand,
},
#[command(hide = true)]
Skills {
#[command(subcommand)]
command: SkillsCommand,
},
Map {
#[command(subcommand)]
command: MapCommand,
},
ConceptMap {
#[command(subcommand)]
command: ConceptMapCommand,
},
Slice {
#[command(subcommand)]
command: SliceCommand,
},
Memory {
#[command(subcommand)]
command: MemoryCommand,
},
Review {
#[command(subcommand)]
command: ReviewCommand,
},
Rec {
#[command(subcommand)]
command: RecCommand,
},
Revision {
#[command(subcommand)]
command: RevisionCommand,
},
Reconcile {
req: String,
#[arg(long)]
slice: String,
#[arg(long = "move", value_parser = rec::RecMove::parse)]
r#move: rec::RecMove,
#[arg(long, value_enum)]
to: Option<requirement::ReqStatus>,
#[arg(long)]
note: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Coverage {
#[command(subcommand)]
command: CoverageCommand,
},
Inspect {
id: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Survey {
#[arg(long)]
all: bool,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Next {
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Blockers {
id: String,
#[arg(long)]
transitive: bool,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Explain {
id: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Adr {
#[command(subcommand)]
command: AdrCommand,
},
Policy {
#[command(subcommand)]
command: PolicyCommand,
},
Standard {
#[command(subcommand)]
command: StandardCommand,
},
Spec {
#[command(subcommand)]
command: SpecCommand,
},
Export {
#[command(subcommand)]
command: ExportCommand,
},
Backlog {
#[command(subcommand)]
command: BacklogCommand,
},
Knowledge {
#[command(subcommand)]
command: KnowledgeCommand,
},
Serve {
#[command(flatten)]
args: commands::serve::ServeArgs,
},
Boot {
#[command(subcommand)]
command: Option<BootCommand>,
#[arg(long)]
check: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Worktree {
#[command(subcommand)]
command: WorktreeCommand,
},
Dispatch {
#[command(subcommand)]
command: DispatchCommand,
},
Validate {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Reseat {
reference: String,
#[arg(long)]
to: Option<u32>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Link {
source: String,
label: String,
target: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Unlink {
source: String,
label: String,
target: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Needs {
source: String,
target: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
After {
source: String,
#[arg(required_unless_present = "prune")]
target: Option<String>,
#[arg(long, default_value_t = 0)]
rank: i32,
#[arg(long, conflicts_with = "prune")]
remove: bool,
#[arg(long, conflicts_with = "remove")]
prune: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Supersede {
new: String,
old: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Estimate {
#[command(subcommand)]
action: EstimateAction,
},
Value {
#[command(subcommand)]
action: ValueAction,
},
}
#[derive(Subcommand)]
enum CatalogCommand {
Scan {
#[arg(long)]
root: Option<PathBuf>,
},
Graph {
#[arg(long)]
root: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum WorktreeCommand {
Provision {
fork: PathBuf,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
CheckAllowlist {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
BranchPointCheck {
#[arg(long)]
base: String,
#[arg(long)]
head: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Fork {
#[arg(long)]
base: String,
#[arg(long)]
branch: String,
#[arg(long)]
dir: PathBuf,
#[arg(long)]
worker: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Coordinate {
#[arg(long)]
slice: u32,
#[arg(long)]
dir: PathBuf,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Import {
#[arg(long)]
base: String,
#[arg(long)]
fork: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Land {
#[arg(long)]
fork: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Gc {
#[arg(long)]
fork: String,
#[arg(long)]
superseded_head: Option<String>,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long)]
assert: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
VerifyWorker {
#[arg(long)]
base: String,
#[arg(long)]
dir: PathBuf,
#[arg(long)]
branch: Option<String>,
},
Marker {
#[arg(long)]
clear: bool,
#[arg(long)]
operator: bool,
#[arg(long)]
stamp_subagent: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum DispatchCommand {
Sync {
#[arg(long)]
slice: u32,
#[arg(long, group = "stage", required = true)]
prepare_review: bool,
#[arg(long, group = "stage", required = true)]
integrate: bool,
#[arg(long, group = "stage", required = true, requires = "trunk")]
show_journal_trunk_oid: bool,
#[arg(long, conflicts_with = "prepare_review")]
trunk: Option<String>,
#[arg(long, requires = "integrate")]
edge: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
RecordBoundary {
#[arg(long)]
slice: u32,
#[arg(long)]
phase: String,
#[arg(long)]
code_start: String,
#[arg(long)]
code_end: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Setup {
#[arg(long)]
slice: u32,
#[arg(long)]
dir: PathBuf,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Candidate {
#[command(subcommand)]
command: CandidateCommand,
},
PlanNext {
#[arg(long)]
slice: u32,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long)]
slice: u32,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum CandidateCommand {
Create {
#[arg(long)]
slice: u32,
#[arg(long, visible_alias = "target")]
label: String,
#[arg(long, default_value = "audit")]
kind: String,
#[arg(long)]
role: String,
#[arg(long)]
payload: String,
#[arg(long)]
base: String,
#[arg(long)]
source: Option<String>,
#[arg(long)]
supersedes: Option<String>,
#[arg(long)]
worktree: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long)]
slice: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Admit {
#[arg(long)]
slice: u32,
#[arg(long)]
role: String,
#[arg(long)]
candidate: String,
#[arg(long)]
review: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum BootCommand {
Install {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
#[arg(long = "agent")]
agent: Vec<String>,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
enum AdrCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
id: u32,
#[arg(long)]
status: adr::AdrStatus,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum PolicyCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
id: u32,
#[arg(long)]
status: policy::PolicyStatus,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum StandardCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
id: u32,
#[arg(long)]
status: standard::StandardStatus,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum SpecCommand {
New {
subtype: spec::SpecSubtype,
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
spec_ref: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Validate {
spec_ref: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Req {
#[command(subcommand)]
command: SpecReqCommand,
},
}
#[derive(Subcommand)]
enum SpecReqCommand {
Add {
spec_ref: String,
title: Option<String>,
#[arg(long)]
kind: requirement::ReqKind,
#[arg(long)]
label: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
req_ref: String,
#[arg(long)]
to: requirement::ReqStatus,
#[arg(long)]
note: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
spec_ref: String,
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum BacklogCommand {
New {
kind: backlog::ItemKind,
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[arg(long)]
kind: Option<backlog::ItemKind>,
#[arg(long = "by", value_enum, default_value_t = backlog::OrderBy::Sequence)]
by: backlog::OrderBy,
#[command(flatten)]
list: CommonListArgs,
substr: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
id: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Edit {
id: String,
#[arg(long)]
status: backlog::Status,
#[arg(long)]
resolution: Option<backlog::Resolution>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Needs {
id: String,
#[arg(required = true)]
prereqs: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
After {
id: String,
#[arg(required_unless_present = "prune")]
to: Option<String>,
#[arg(long, default_value_t = 0)]
rank: i32,
#[arg(long, conflicts_with = "prune")]
remove: bool,
#[arg(long, conflicts_with = "remove")]
prune: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Tag {
id: String,
tags: Vec<String>,
#[arg(long = "remove", short = 'd')]
remove: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum KnowledgeCommand {
New {
kind: knowledge::RecordKind,
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
id: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
id: String,
state: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum CoverageCommand {
Show {
reference: String,
#[arg(long, value_delimiter = ',')]
columns: Option<Vec<String>>,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Record {
#[arg(long)]
slice: String,
#[arg(long)]
requirement: String,
#[arg(long)]
change: String,
#[arg(long)]
mode: String,
#[arg(long, value_parser = coverage_store::parse_status)]
status: Option<requirement::CoverageStatus>,
#[arg(long)]
alias: Option<String>,
#[arg(long = "command")]
command: Vec<String>,
#[arg(long = "extra-args")]
extra_args: Vec<String>,
#[arg(long = "matcher-source")]
matcher_source: Option<String>,
#[arg(long = "matcher-pattern")]
matcher_pattern: Option<String>,
#[arg(long)]
regex: bool,
#[arg(long = "attested-date")]
attested_date: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Verify {
slice: Option<String>,
#[arg(long)]
all: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Forget {
#[arg(long)]
slice: String,
#[arg(long)]
requirement: String,
#[arg(long)]
change: String,
#[arg(long)]
mode: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum MemoryCommand {
#[command(visible_alias = "new")]
Record {
title: String,
#[arg(long = "type", value_parser = memory::MemoryType::parse)]
memory_type: memory::MemoryType,
#[arg(long)]
key: Option<String>,
#[arg(long, value_parser = memory::Lifespan::from_str)]
lifespan: Option<memory::Lifespan>,
#[arg(long, default_value = "active", value_parser = memory::Status::parse)]
status: memory::Status,
#[arg(long)]
summary: Option<String>,
#[arg(long)]
review_by: Option<String>,
#[arg(long = "provenance-source", value_parser = memory::Provenance::parse_flag)]
provenance_source: Vec<memory::Provenance>,
#[arg(long = "trust")]
trust: Option<String>,
#[arg(long = "severity")]
severity: Option<String>,
#[arg(long = "tag")]
tag: Vec<String>,
#[arg(long = "path-scope")]
path_scope: Vec<String>,
#[arg(long = "glob")]
glob: Vec<String>,
#[arg(long = "command")]
command: Vec<String>,
#[arg(long = "repo")]
repo: Option<String>,
#[arg(long = "global")]
global: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Verify {
reference: String,
#[arg(long)]
allow_dirty: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Validate {
reference: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[arg(long = "type", value_parser = memory::MemoryType::parse)]
memory_type: Option<memory::MemoryType>,
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Find {
query: Option<String>,
#[command(flatten)]
args: FindRetrieveArgs,
},
Retrieve {
#[command(flatten)]
args: FindRetrieveArgs,
#[arg(long = "min-trust", value_parser = retrieve::parse_min_trust)]
min_trust: Option<String>,
},
ResolveLinks {
reference: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Backlinks {
reference: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Tag {
reference: String,
tags: Vec<String>,
#[arg(long = "remove", short = 'd')]
remove: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
reference: String,
state: String,
#[arg(long)]
by: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Edit {
reference: String,
#[arg(long)]
title: Option<String>,
#[arg(long)]
summary: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
lifespan: Option<String>,
#[arg(long)]
review_by: Option<String>,
#[arg(long)]
trust: Option<String>,
#[arg(long)]
severity: Option<String>,
#[arg(long)]
key: Option<String>,
#[arg(long = "path-scope")]
path_scope: Vec<String>,
#[arg(long = "glob")]
glob: Vec<String>,
#[arg(long = "command")]
command: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Sync {
#[command(subcommand)]
command: Option<SyncCommand>,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum SyncCommand {
Install {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
enum SliceCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Design {
id: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Plan {
id: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Phases {
id: u32,
#[arg(long)]
prune: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Notes {
id: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Phase {
id: u32,
phase_id: String,
#[arg(long)]
status: state::PhaseStatus,
#[arg(long)]
note: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
id: u32,
state: slice::SliceStatus,
#[arg(long)]
note: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum ConceptMapCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
edges: bool,
#[arg(long)]
nodes: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Check {
id: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Add {
id: String,
source: String,
rel: String,
target: String,
#[arg(long)]
force: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Remove {
id: String,
source: String,
rel: String,
target: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
RenameNode {
id: String,
old: String,
new: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
case_sensitive: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Export {
id: String,
#[arg(long, value_enum)]
format: concept_map::ExportFormat,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum ReviewCommand {
New {
#[arg(long, value_parser = review::Facet::parse)]
facet: review::Facet,
#[arg(long)]
target: String,
#[arg(long)]
phase: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
raiser: Option<String>,
#[arg(long)]
responder: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Raise {
reference: String,
#[arg(long, value_parser = review::Severity::parse)]
severity: review::Severity,
#[arg(long)]
title: String,
#[arg(long)]
detail: String,
#[arg(long = "as")]
role: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Dispose {
reference: String,
#[arg(long)]
finding: String,
#[arg(long)]
disposition: String,
#[arg(long)]
response: String,
#[arg(long = "as")]
role: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Verify {
reference: String,
#[arg(long)]
finding: String,
#[arg(long)]
note: Option<String>,
#[arg(long = "as")]
role: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Contest {
reference: String,
#[arg(long)]
finding: String,
#[arg(long)]
note: Option<String>,
#[arg(long = "as")]
role: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Withdraw {
reference: String,
#[arg(long)]
finding: String,
#[arg(long = "as")]
role: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
reference: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Prime {
reference: String,
#[arg(long)]
seed: bool,
#[arg(long)]
from: Option<PathBuf>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Unlock {
reference: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum RecCommand {
New {
#[arg(long = "move", value_parser = rec::RecMove::parse)]
r#move: rec::RecMove,
#[arg(long)]
owning_slice: Option<String>,
#[arg(long = "decision")]
decision_ref: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum RevisionCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
reference: String,
state: revision::RevStatus,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Change {
#[command(subcommand)]
command: RevisionChangeCommand,
},
Approve {
reference: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Apply {
reference: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum RevisionChangeCommand {
Add {
reference: String,
#[arg(long)]
action: revision::ChangeAction,
#[arg(long)]
target: Option<String>,
#[arg(long = "to-status")]
to_status: Option<String>,
#[arg(long = "new-label")]
new_label: Option<String>,
#[arg(long = "member-of")]
member_of: Option<String>,
#[arg(long = "new-statement")]
new_statement: Option<String>,
#[arg(long)]
primary: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum MapCommand {
Serve(MapServeArgs),
}
#[derive(Subcommand)]
enum ExportCommand {
Lazyspec {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum SkillsCommand {
List {
#[arg(short = 'a', long)]
agent: Option<String>,
#[arg(long)]
installed: bool,
},
}
enum WriteClass {
Read,
Write(&'static str),
Orchestrator(&'static str),
MarkerClear,
Hookmint(&'static str),
}
#[expect(
clippy::match_same_arms,
reason = "consecutive Read arms across different nested-match shapes; merging would degrade readability"
)]
fn write_class(cmd: &Command) -> WriteClass {
use WriteClass::{Hookmint, MarkerClear, Orchestrator, Read, Write};
match cmd {
Command::Install { .. } => Write("install"),
Command::Skills { command } => match command {
SkillsCommand::List { .. } => Read,
},
Command::Map { .. } => Write("map"),
Command::ConceptMap { command } => match command {
ConceptMapCommand::New { .. } => Write("concept-map new"),
ConceptMapCommand::Add { .. } => Write("concept-map add"),
ConceptMapCommand::Remove { .. } => Write("concept-map remove"),
ConceptMapCommand::RenameNode { .. } => Write("concept-map rename-node"),
ConceptMapCommand::List { .. }
| ConceptMapCommand::Show { .. }
| ConceptMapCommand::Check { .. }
| ConceptMapCommand::Export { .. } => Read,
},
Command::Slice { command } => match command {
SliceCommand::New { .. } => Write("slice new"),
SliceCommand::Design { .. } => Write("slice design"),
SliceCommand::Plan { .. } => Write("slice plan"),
SliceCommand::Phases { .. } => Write("slice phases"),
SliceCommand::Notes { .. } => Write("slice notes"),
SliceCommand::Phase { .. } => Write("slice phase"),
SliceCommand::Status { .. } => Write("slice status"),
SliceCommand::List { .. } | SliceCommand::Show { .. } => Read,
},
Command::Memory { command } => match command {
MemoryCommand::Record { .. } => Write("memory record"),
MemoryCommand::Verify { .. } => Write("memory verify"),
MemoryCommand::Sync { command, .. } => match command {
None => Write("memory sync"),
Some(SyncCommand::Install { .. }) => Write("memory sync install"),
},
MemoryCommand::Tag { .. } => Write("memory tag"),
MemoryCommand::Status { .. } => Write("memory status"),
MemoryCommand::Edit { .. } => Write("memory edit"),
MemoryCommand::Validate { .. }
| MemoryCommand::Show { .. }
| MemoryCommand::List { .. }
| MemoryCommand::Find { .. }
| MemoryCommand::Retrieve { .. }
| MemoryCommand::ResolveLinks { .. }
| MemoryCommand::Backlinks { .. } => Read,
},
Command::Review { command } => match command {
ReviewCommand::New { .. } => Write("review new"),
ReviewCommand::Raise { .. } => Write("review raise"),
ReviewCommand::Dispose { .. } => Write("review dispose"),
ReviewCommand::Verify { .. } => Write("review verify"),
ReviewCommand::Contest { .. } => Write("review contest"),
ReviewCommand::Withdraw { .. } => Write("review withdraw"),
ReviewCommand::Unlock { .. } => Write("review unlock"),
ReviewCommand::List { .. }
| ReviewCommand::Show { .. }
| ReviewCommand::Status { .. }
| ReviewCommand::Prime { .. } => Read,
},
Command::Rec { command } => match command {
RecCommand::New { .. } => Write("rec new"),
RecCommand::List { .. } | RecCommand::Show { .. } => Read,
},
Command::Revision { command } => match command {
RevisionCommand::New { .. } => Write("revision new"),
RevisionCommand::Status { .. } => Write("revision status"),
RevisionCommand::Show { .. } => Read,
RevisionCommand::Change { command } => match command {
RevisionChangeCommand::Add { .. } => Write("revision change add"),
},
RevisionCommand::Approve { .. } => Write("revision approve"),
RevisionCommand::Apply { .. } => Write("revision apply"),
},
Command::Reconcile { .. } => Write("reconcile"),
Command::Adr { command } => match command {
AdrCommand::New { .. } => Write("adr new"),
AdrCommand::Status { .. } => Write("adr status"),
AdrCommand::List { .. } | AdrCommand::Show { .. } => Read,
},
Command::Policy { command } => match command {
PolicyCommand::New { .. } => Write("policy new"),
PolicyCommand::Status { .. } => Write("policy status"),
PolicyCommand::List { .. } | PolicyCommand::Show { .. } => Read,
},
Command::Standard { command } => match command {
StandardCommand::New { .. } => Write("standard new"),
StandardCommand::Status { .. } => Write("standard status"),
StandardCommand::List { .. } | StandardCommand::Show { .. } => Read,
},
Command::Spec { command } => match command {
SpecCommand::New { .. } => Write("spec new"),
SpecCommand::Req { command } => match command {
SpecReqCommand::Add { .. } => Write("spec req add"),
SpecReqCommand::Status { .. } => Write("spec req status"),
SpecReqCommand::List { .. } => Read,
},
SpecCommand::List { .. } | SpecCommand::Show { .. } | SpecCommand::Validate { .. } => {
Read
}
},
Command::Export { command } => match command {
ExportCommand::Lazyspec { .. } => Read,
},
Command::Backlog { command } => match command {
BacklogCommand::New { .. } => Write("backlog new"),
BacklogCommand::Edit { .. } => Write("backlog edit"),
BacklogCommand::Needs { .. } => Write("backlog needs"),
BacklogCommand::After { .. } => Write("backlog after"),
BacklogCommand::Tag { .. } => Write("backlog tag"),
BacklogCommand::List { .. } | BacklogCommand::Show { .. } => Read,
},
Command::Knowledge { command } => match command {
KnowledgeCommand::New { .. } => Write("knowledge new"),
KnowledgeCommand::Status { .. } => Write("knowledge status"),
KnowledgeCommand::List { .. } | KnowledgeCommand::Show { .. } => Read,
},
Command::Serve { .. } => Read,
Command::Boot { command, .. } => match command {
None => Write("boot"),
Some(BootCommand::Install { .. }) => Write("boot install"),
},
Command::Worktree { command } => match command {
WorktreeCommand::Provision { .. }
| WorktreeCommand::CheckAllowlist { .. }
| WorktreeCommand::BranchPointCheck { .. }
| WorktreeCommand::VerifyWorker { .. }
| WorktreeCommand::Status { .. } => Read,
WorktreeCommand::Fork { .. } => Orchestrator("fork"),
WorktreeCommand::Coordinate { .. } => Orchestrator("coordinate"),
WorktreeCommand::Import { .. } => Orchestrator("import"),
WorktreeCommand::Land { .. } => Orchestrator("land"),
WorktreeCommand::Gc { .. } => Orchestrator("gc"),
WorktreeCommand::Marker {
stamp_subagent: true,
..
} => Hookmint("marker --stamp-subagent"),
WorktreeCommand::Marker { .. } => MarkerClear,
},
Command::Dispatch { command } => match command {
DispatchCommand::Sync { .. } => Orchestrator("dispatch-sync"),
DispatchCommand::RecordBoundary { .. } => Orchestrator("dispatch-record-boundary"),
DispatchCommand::Setup { .. } => Orchestrator("dispatch-setup"),
DispatchCommand::Candidate { command } => match command {
CandidateCommand::Create { .. } => Orchestrator("dispatch-candidate-create"),
CandidateCommand::Status { .. } => Read,
CandidateCommand::Admit { .. } => Orchestrator("dispatch-candidate-admit"),
},
DispatchCommand::PlanNext { .. } | DispatchCommand::Status { .. } => Read,
},
Command::Coverage { command } => match command {
CoverageCommand::Show { .. } => Read,
CoverageCommand::Record { .. } => Write("coverage record"),
CoverageCommand::Verify { .. } => Write("coverage verify"),
CoverageCommand::Forget { .. } => Write("coverage forget"),
},
Command::Catalog { .. }
| Command::Validate { .. }
| Command::Inspect { .. }
| Command::Survey { .. }
| Command::Next { .. }
| Command::Blockers { .. }
| Command::Explain { .. }
| Command::Status { .. } => Read,
Command::Reseat { .. } => Write("reseat"),
Command::Link { .. } => Write("link"),
Command::Unlink { .. } => Write("unlink"),
Command::Needs { .. } => Write("needs"),
Command::After { .. } => Write("after"),
Command::Supersede { .. } => Write("supersede"),
Command::Estimate { .. } => Write("estimate"),
Command::Value { .. } => Write("value"),
}
}
fn run_catalog_scan(root_arg: Option<PathBuf>) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(root_arg, &crate::root::default_markers())?;
if !root.join(".doctrine").is_dir() {
anyhow::bail!("no .doctrine directory found at '{}'", root.display());
}
let catalog = crate::catalog::hydrate::scan_catalog(&root)?;
let json = serde_json::to_string_pretty(&catalog)
.map_err(|e| anyhow::anyhow!("failed to serialize catalog: {e}"))?;
write!(std::io::stdout(), "{json}")?;
Ok(())
}
fn run_catalog_graph(root_arg: Option<PathBuf>) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(root_arg, &crate::root::default_markers())?;
if !root.join(".doctrine").is_dir() {
anyhow::bail!("no .doctrine directory found at '{}'", root.display());
}
let catalog = crate::catalog::hydrate::scan_catalog(&root)?;
let graph = crate::catalog::graph::CatalogGraph::from_catalog(&catalog);
let json = serde_json::to_string_pretty(&graph)
.map_err(|e| anyhow::anyhow!("failed to serialize graph: {e}"))?;
write!(std::io::stdout(), "{json}")?;
Ok(())
}
fn run_inspect(path: Option<PathBuf>, id: &str, format: Format, json: bool) -> anyhow::Result<()> {
use std::io::{self, Write};
let root = crate::root::find(path, &crate::root::default_markers())?;
let resolved = if json { Format::Json } else { format };
if let Ok(
crate::memory::MemoryRef::Uid(_)
| crate::memory::MemoryRef::UidPrefix(_)
| crate::memory::MemoryRef::Key(_),
) = crate::memory::MemoryRef::parse(id)
{
let uid = crate::memory::resolve_inspect_uid(&root, id)?;
let out = crate::memory::memory_inspect_view(&root, &uid, resolved)?;
write!(std::io::stdout(), "{out}")?;
return Ok(());
}
let mut diagnostics = Vec::new();
let scanned = relation_graph::scan_entities(&root, &mut diagnostics)?;
for diag in &diagnostics {
writeln!(io::stderr(), "{}: {}", diag.file.display(), diag.message)?;
}
let out = match resolved {
Format::Table => {
let relation = relation_graph::render_from(&scanned, &root, id, Format::Table)?;
let block = priority::surface::actionability_block_from(&scanned, &root, id)?;
let block = priority::render::actionability_block_human(&block);
format!("{relation}{block}")
}
Format::Json => {
let view = relation_graph::inspect_from(&scanned, &root, id)?;
let block = priority::surface::actionability_block_from(&scanned, &root, id)?;
let mut value = relation_graph::inspect_value(&view);
if let Some(obj) = value.as_object_mut() {
obj.insert(
"actionability".to_string(),
priority::render::actionability_block_value(&block),
);
}
serde_json::to_string_pretty(&value)
.map_err(|e| anyhow::anyhow!("failed to serialize inspect JSON: {e}"))?
}
};
write!(std::io::stdout(), "{out}")?;
Ok(())
}
fn worker_guard(cmd: &Command) -> anyhow::Result<()> {
let verb = match write_class(cmd) {
WriteClass::Write(verb) | WriteClass::Orchestrator(verb) | WriteClass::Hookmint(verb) => {
verb
}
WriteClass::Read | WriteClass::MarkerClear => return Ok(()),
};
let Ok(root) = root::find(None, &root::default_markers()) else {
if worktree::env_worker_set() {
anyhow::bail!("{}: refusing authored write `{verb}`", worktree::DUAL_CAUSE);
}
return Ok(());
};
let mode = worktree::resolve_mode(&root);
if !mode.refused {
return Ok(());
}
if mode.is_env_on_nonlinked() {
anyhow::bail!("{}: refusing authored write `{verb}`", worktree::DUAL_CAUSE);
}
anyhow::bail!(
"worker fork (signal: {}): refusing authored write `{verb}` — workers return a source delta; doctrine-mediated writes funnel through the orchestrator.",
mode.cause_token()
);
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let color = crate::tty::resolve_color(cli.color);
worker_guard(&cli.command)?;
match cli.command {
Command::Install {
path,
agent,
skill,
domain,
only_memory,
global,
dry_run,
yes,
} => install::run(
path,
&install::InstallArgs {
agents: &agent,
skills: &skill,
domains: &domain,
only_memory,
global,
dry_run,
yes,
},
),
Command::Skills { command } => match command {
SkillsCommand::List { agent, installed } => {
skills::run_list(agent.as_deref(), installed)
}
},
Command::ConceptMap { command } => match command {
ConceptMapCommand::New { title, slug, path } => concept_map::run_new(path, title, slug),
ConceptMapCommand::List { list, path } => {
concept_map::run_list(path, list.into_list_args(color))
}
ConceptMapCommand::Show {
reference,
format,
edges,
nodes,
path,
} => concept_map::run_show(path, &reference, format, edges, nodes),
ConceptMapCommand::Check { id, path } => concept_map::run_check(path, &id),
ConceptMapCommand::Add {
id,
source,
rel,
target,
force,
path,
} => concept_map::run_add(path, &id, &source, &rel, &target, force),
ConceptMapCommand::Remove {
id,
source,
rel,
target,
path,
} => concept_map::run_remove(path, &id, &source, &rel, &target),
ConceptMapCommand::RenameNode {
id,
old,
new,
dry_run,
case_sensitive,
path,
} => concept_map::run_rename_node(path, &id, &old, &new, dry_run, case_sensitive),
ConceptMapCommand::Export { id, format, path } => {
concept_map::run_export(path, &id, &format)
}
},
Command::Slice { command } => match command {
SliceCommand::New { title, slug, path } => slice::run_new(path, title, slug),
SliceCommand::Design { id, path } => slice::run_design(path, id),
SliceCommand::Plan { id, path } => slice::run_plan(path, id),
SliceCommand::Phases { id, prune, path } => slice::run_phases(path, id, prune),
SliceCommand::Notes { id, path } => slice::run_notes(path, id),
SliceCommand::Phase {
id,
phase_id,
status,
note,
path,
} => slice::run_phase(path, id, &phase_id, status, note.as_deref()),
SliceCommand::Status {
id,
state,
note,
path,
} => slice::run_status(path, id, state, note.as_deref()),
SliceCommand::List { list, path } => slice::run_list(path, list.into_list_args(color)),
SliceCommand::Show {
reference,
format,
json,
path,
} => slice::run_show(path, &reference, if json { Format::Json } else { format }),
},
Command::Memory { command } => match command {
MemoryCommand::Record {
title,
memory_type,
key,
lifespan,
status,
summary,
review_by,
provenance_source,
trust,
severity,
tag,
path_scope,
glob,
command,
repo,
global,
path,
} => memory::run_record(
path,
&memory::RecordArgs {
title: &title,
memory_type,
key: key.as_deref(),
lifespan,
status,
summary: summary.as_deref(),
review_by: review_by.as_deref(),
sources: &provenance_source,
trust_level: trust.as_deref(),
severity: severity.as_deref(),
tags: &tag,
paths: &path_scope,
globs: &glob,
commands: &command,
repo: repo.as_deref(),
global,
},
),
MemoryCommand::Show {
reference,
format,
json,
path,
} => memory::run_show(path, &reference, if json { Format::Json } else { format }),
MemoryCommand::Verify {
reference,
allow_dirty,
path,
} => memory::run_verify(path, &reference, allow_dirty),
MemoryCommand::Validate { reference, path } => {
match memory::run_validate(path, reference.as_deref()) {
Ok(()) => Ok(()),
Err(e) if e.to_string().contains("validation warnings found") => {
#[expect(
clippy::disallowed_methods,
reason = "CLI tool needs to exit with code 1 for validation warnings"
)]
{
std::process::exit(1);
}
}
Err(e) => Err(e),
}
}
MemoryCommand::List {
memory_type,
list,
path,
} => memory::run_list(path, memory_type, list.into_list_args(color)),
MemoryCommand::Find { query, args } => {
let free_query = match (query, args.flag_query) {
(Some(_), Some(_)) => {
anyhow::bail!("cannot specify both a positional query and --query")
}
(q, None) | (None, q) => q,
};
if args.limit == Some(0) {
anyhow::bail!("--limit must be >= 1");
}
let page_size = args.limit.unwrap_or(retrieve::RETRIEVE_LIMIT_DEFAULT);
let offset = match args.page {
Some(0) => anyhow::bail!("--page must be >= 1"),
Some(p) => (p - 1) * page_size,
None => args.offset,
};
let resolved_format = if args.json { Format::Json } else { args.format };
retrieve::run_find(
args.path,
args.path_scope,
args.glob,
args.command,
args.tag,
args.lifespan,
free_query,
args.memory_type,
args.status,
args.include_draft,
resolved_format,
offset,
args.limit,
)
}
MemoryCommand::Retrieve { args, min_trust } => {
if args.limit == Some(0) {
anyhow::bail!("--limit must be >= 1");
}
let retrieve_limit = args
.limit
.unwrap_or(retrieve::RETRIEVE_LIMIT_DEFAULT)
.min(retrieve::RETRIEVE_LIMIT_MAX);
let page_size = args.limit.unwrap_or(retrieve::RETRIEVE_LIMIT_DEFAULT);
let offset = match args.page {
Some(0) => anyhow::bail!("--page must be >= 1"),
Some(p) => (p - 1) * page_size,
None => args.offset,
};
let resolved_format = if args.json { Format::Json } else { args.format };
retrieve::run_retrieve(
args.path,
args.path_scope,
args.glob,
args.command,
args.tag,
args.lifespan,
args.flag_query,
args.memory_type,
args.status,
args.include_draft,
retrieve_limit,
min_trust.as_deref(),
offset,
resolved_format,
args.expand,
)
}
MemoryCommand::ResolveLinks { reference, path } => {
memory::run_resolve_links(path, reference.as_deref())
}
MemoryCommand::Backlinks { reference, path } => memory::run_backlinks(path, &reference),
MemoryCommand::Tag {
reference,
tags,
remove,
path,
} => memory::run_tag(path, &reference, &tags, &remove),
MemoryCommand::Status {
reference,
state,
by,
path,
} => memory::run_status(path, &reference, &state, by.as_deref(), color),
MemoryCommand::Edit {
reference,
title,
summary,
status,
lifespan,
review_by,
trust,
severity,
key,
path_scope,
glob,
command,
path,
} => {
let fields = memory::EditFields {
title,
summary,
status,
lifespan,
review_by,
trust,
severity,
key,
path_scope: if path_scope.is_empty() {
None
} else {
Some(path_scope)
},
glob: if glob.is_empty() { None } else { Some(glob) },
command: if command.is_empty() {
None
} else {
Some(command)
},
};
memory::run_edit(path, &reference, &fields)
}
MemoryCommand::Sync {
command,
dry_run: sync_dry_run,
yes: sync_yes,
path: sync_path,
} => match command {
None => corpus::run_sync(sync_path, sync_dry_run, sync_yes),
Some(SyncCommand::Install { path, dry_run, yes }) => {
corpus::run_sync_install(path, dry_run, yes)
}
},
},
Command::Review { command } => match command {
ReviewCommand::New {
facet,
target,
phase,
title,
raiser,
responder,
path,
} => {
use std::io::Write;
let out = review::run_new(
path,
&review::NewArgs {
facet,
target,
phase,
title,
raiser,
responder,
},
)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::List { list, path } => {
use std::io::Write;
let out = review::run_list(path, list.into_list_args(color))?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Show {
reference,
format,
json,
path,
} => {
use std::io::Write;
let out =
review::run_show(path, &reference, if json { Format::Json } else { format })?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Raise {
reference,
severity,
title,
detail,
role,
path,
} => {
use std::io::Write;
let role = review::parse_role(role.as_deref(), review::Role::Raiser)?;
let out = review::run_raise(
path,
&review::RaiseArgs {
reference,
severity,
title,
detail,
},
role,
)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Dispose {
reference,
finding,
disposition,
response,
role,
path,
} => {
use std::io::Write;
let role = review::parse_role(role.as_deref(), review::Role::Responder)?;
let out = review::run_dispose(
path,
&review::DisposeArgs {
reference,
finding,
disposition,
response,
},
role,
)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Verify {
reference,
finding,
note,
role,
path,
} => {
use std::io::Write;
let role = review::parse_role(role.as_deref(), review::Role::Raiser)?;
let out = review::run_verify(path, &reference, &finding, note.as_deref(), role)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Contest {
reference,
finding,
note,
role,
path,
} => {
use std::io::Write;
let role = review::parse_role(role.as_deref(), review::Role::Raiser)?;
let out = review::run_contest(path, &reference, &finding, note.as_deref(), role)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Withdraw {
reference,
finding,
role,
path,
} => {
use std::io::Write;
let role = review::parse_role(role.as_deref(), review::Role::Raiser)?;
let out = review::run_withdraw(path, &reference, &finding, role)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Status { reference, path } => {
use std::io::Write;
let out = review::run_status(path, &reference)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Prime {
reference,
seed,
from,
path,
} => {
use std::io::Write;
let out = review::run_prime(
path,
&review::PrimeArgs {
reference,
seed,
from,
},
)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
ReviewCommand::Unlock { reference, path } => {
use std::io::Write;
let out = review::run_unlock(path, &reference)?;
let rendered = review::print_review(&out);
write!(std::io::stdout(), "{rendered}")?;
Ok(())
}
},
Command::Rec { command } => match command {
RecCommand::New {
r#move,
owning_slice,
decision_ref,
title,
path,
} => rec::run_new(
path,
&rec::NewArgs {
r#move,
owning_slice,
decision_ref,
title,
},
),
RecCommand::List { list, path } => rec::run_list(path, list.into_list_args(color)),
RecCommand::Show {
reference,
format,
json,
path,
} => rec::run_show(path, &reference, if json { Format::Json } else { format }),
},
Command::Revision { command } => match command {
RevisionCommand::New { title, slug, path } => revision::run_new(path, title, slug),
RevisionCommand::Show {
reference,
format,
json,
path,
} => revision::run_show(path, &reference, if json { Format::Json } else { format }),
RevisionCommand::Status {
reference,
state,
path,
} => revision::run_status(path, &reference, state, color),
RevisionCommand::Change { command } => match command {
RevisionChangeCommand::Add {
reference,
action,
target,
to_status,
new_label,
member_of,
new_statement,
primary,
path,
} => revision::run_change_add(
path,
&reference,
&revision::ChangeAddArgs {
action,
target,
to_status,
new_label,
member_of,
new_statement,
primary,
},
),
},
RevisionCommand::Approve { reference, path } => revision::run_approve(path, &reference),
RevisionCommand::Apply { reference, path } => revision::run_apply(path, &reference),
},
Command::Reconcile {
req,
slice,
r#move,
to,
note,
path,
} => reconcile::run(
path,
&reconcile::ReconcileArgs {
req,
slice,
r#move,
to,
note,
},
),
Command::Coverage { command } => match command {
CoverageCommand::Show {
reference,
columns,
format,
json,
path,
} => coverage_view::run(path, &reference, columns.as_deref(), format, json, color),
CoverageCommand::Record {
slice,
requirement,
change,
mode,
status,
alias,
command,
extra_args,
matcher_source,
matcher_pattern,
regex,
attested_date,
path,
} => coverage_store::run_record(
path,
&coverage_store::CoverageRecordArgs {
slice: &slice,
requirement: &requirement,
change: &change,
mode: &mode,
status,
alias: alias.as_deref(),
command: &command,
extra_args: &extra_args,
matcher_source: matcher_source.as_deref(),
matcher_pattern: matcher_pattern.as_deref(),
regex,
attested_date: attested_date.as_deref(),
},
),
CoverageCommand::Verify { slice, all, path } => {
coverage_verify::run_cli(path, slice.as_deref(), all)
}
CoverageCommand::Forget {
slice,
requirement,
change,
mode,
path,
} => coverage_store::run_forget(path, &slice, &requirement, &change, &mode),
},
Command::Inspect {
id,
format,
json,
path,
} => run_inspect(path, &id, format, json),
Command::Survey {
all,
format,
json,
path,
} => priority::run_survey(
path,
all,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Next { format, json, path } => priority::run_next(
path,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Blockers {
id,
transitive,
format,
json,
path,
} => priority::run_blockers(
path,
&id,
transitive,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Explain {
id,
format,
json,
path,
} => priority::run_explain(
path,
&id,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Adr { command } => match command {
AdrCommand::New { title, slug, path } => adr::run_new(path, title, slug),
AdrCommand::List { list, path } => adr::run_list(path, list.into_list_args(color)),
AdrCommand::Show {
reference,
format,
json,
path,
} => adr::run_show(path, &reference, if json { Format::Json } else { format }),
AdrCommand::Status { id, status, path } => adr::run_status(path, id, status, color),
},
Command::Policy { command } => match command {
PolicyCommand::New { title, slug, path } => policy::run_new(path, title, slug),
PolicyCommand::List { list, path } => {
policy::run_list(path, list.into_list_args(color))
}
PolicyCommand::Show {
reference,
format,
json,
path,
} => policy::run_show(path, &reference, if json { Format::Json } else { format }),
PolicyCommand::Status { id, status, path } => {
policy::run_status(path, id, status, color)
}
},
Command::Standard { command } => match command {
StandardCommand::New { title, slug, path } => standard::run_new(path, title, slug),
StandardCommand::List { list, path } => {
standard::run_list(path, list.into_list_args(color))
}
StandardCommand::Show {
reference,
format,
json,
path,
} => standard::run_show(path, &reference, if json { Format::Json } else { format }),
StandardCommand::Status { id, status, path } => {
standard::run_status(path, id, status, color)
}
},
Command::Spec { command } => match command {
SpecCommand::New {
subtype,
title,
slug,
path,
} => spec::run_new(path, subtype, title, slug),
SpecCommand::List { list, path } => spec::run_list(path, list.into_list_args(color)),
SpecCommand::Show {
spec_ref,
format,
json,
path,
} => spec::run_show(path, &spec_ref, if json { Format::Json } else { format }),
SpecCommand::Validate { spec_ref, path } => {
spec::run_validate(path, spec_ref.as_deref())
}
SpecCommand::Req { command } => match command {
SpecReqCommand::Add {
spec_ref,
title,
kind,
label,
slug,
path,
} => spec::run_req_add(path, &spec_ref, title, kind, label, slug),
SpecReqCommand::Status {
req_ref,
to,
note,
path,
} => spec::run_req_status(path, &req_ref, to, note),
SpecReqCommand::List {
spec_ref,
list,
path,
} => spec::run_req_list(path, &spec_ref, list.into_list_args(color)),
},
},
Command::Export { command } => match command {
ExportCommand::Lazyspec { path } => {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let now = crate::clock::now_timestamp()?;
let version = env!("CARGO_PKG_VERSION");
let json = lazyspec::run_export_lazyspec(&root, &now, version)?;
writeln!(std::io::stdout(), "{json}")?;
Ok(())
}
},
Command::Backlog { command } => match command {
BacklogCommand::New {
kind,
title,
slug,
path,
} => backlog::run_new(path, kind, title, slug),
BacklogCommand::List {
kind,
by,
mut list,
substr,
path,
} => {
if list.filter.is_none() {
list.filter = substr;
}
backlog::run_list(path, kind, by, list.into_list_args(color))
}
BacklogCommand::Show {
id,
format,
json,
path,
} => backlog::run_show(path, &id, if json { Format::Json } else { format }),
BacklogCommand::Edit {
id,
status,
resolution,
path,
} => backlog::run_edit(path, &id, status, resolution),
BacklogCommand::Needs { id, prereqs, path } => backlog::run_needs(path, &id, &prereqs),
BacklogCommand::After {
id,
to,
rank,
remove,
prune,
path,
} => backlog::run_after(path, &id, to.as_deref(), rank, remove, prune),
BacklogCommand::Tag {
id,
tags,
remove,
path,
} => backlog::run_tag(path, &id, &tags, &remove),
},
Command::Knowledge { command } => match command {
KnowledgeCommand::New {
kind,
title,
slug,
path,
} => knowledge::run_new(path, kind, title, slug),
KnowledgeCommand::List { list, path } => {
knowledge::run_list(path, list.into_list_args(color))
}
KnowledgeCommand::Show {
id,
format,
json,
path,
} => knowledge::run_show(path, &id, if json { Format::Json } else { format }),
KnowledgeCommand::Status { id, state, path } => {
knowledge::run_status(path, &id, &state, color)
}
},
Command::Serve { args } => commands::serve::run_serve(args),
Command::Boot {
command,
check,
path: boot_path,
} => match command {
None if check => boot::run_check(boot_path),
None => boot::run(boot_path),
Some(BootCommand::Install {
path,
agent,
dry_run,
yes,
}) => boot::run_install(path, &agent, dry_run, yes),
},
Command::Catalog { command } => match command {
CatalogCommand::Scan { root } => run_catalog_scan(root),
CatalogCommand::Graph { root } => run_catalog_graph(root),
},
Command::Worktree { command } => match command {
WorktreeCommand::Provision { fork, path } => worktree::run_provision(path, &fork),
WorktreeCommand::CheckAllowlist { path } => worktree::run_check_allowlist(path),
WorktreeCommand::BranchPointCheck { base, head, path } => {
worktree::run_branch_point_check(path, &base, head)
}
WorktreeCommand::Fork {
base,
branch,
dir,
worker,
path,
} => worktree::run_fork(path, &base, &branch, &dir, worker),
WorktreeCommand::Coordinate { slice, dir, path } => {
worktree::run_coordinate(path, slice, &dir)
}
WorktreeCommand::Import { base, fork, path } => {
worktree::run_import(path, &base, &fork)
}
WorktreeCommand::Land { fork, path } => worktree::run_land(path, &fork),
WorktreeCommand::Gc {
fork,
superseded_head,
force,
dry_run,
path,
} => worktree::run_gc(path, &fork, superseded_head.as_deref(), force, dry_run),
WorktreeCommand::Status { assert, path } => worktree::run_status(path, assert),
WorktreeCommand::VerifyWorker { base, dir, branch } => {
worktree::run_verify_worker(&base, &dir, branch.as_deref())
}
WorktreeCommand::Marker {
clear,
operator,
stamp_subagent,
path,
} => {
if stamp_subagent {
worktree::run_stamp_subagent(path)
} else if clear {
worktree::run_marker_clear(path, operator)
} else {
anyhow::bail!("`worktree marker` requires `--clear` or `--stamp-subagent`")
}
}
},
Command::Dispatch { command } => match command {
DispatchCommand::Sync {
slice,
integrate,
show_journal_trunk_oid,
trunk,
edge,
path,
..
} => {
if show_journal_trunk_oid {
let trunk = trunk.as_deref().ok_or_else(|| {
anyhow::anyhow!("--show-journal-trunk-oid requires --trunk")
})?;
dispatch::run_show_journal_trunk_oid(path, slice, trunk)
} else if integrate {
dispatch::run_integrate(path, slice, trunk.as_deref(), edge.as_deref())
} else {
dispatch::run_prepare_review(path, slice)
}
}
DispatchCommand::RecordBoundary {
slice,
phase,
code_start,
code_end,
path,
} => dispatch::run_record_boundary(path, slice, &phase, &code_start, &code_end),
DispatchCommand::Setup { slice, dir, path } => {
let claude_harness =
std::env::vars_os().any(|(k, _v)| k.to_string_lossy().starts_with("CLAUDE"));
dispatch::run_setup(path, slice, &dir, claude_harness)
}
DispatchCommand::Candidate { command } => match command {
CandidateCommand::Create {
slice,
label,
kind,
role,
payload,
base,
source,
supersedes,
worktree,
path,
} => {
let req = dispatch::CreateRequest {
slice,
label,
kind: dispatch::parse_kind(&kind)?,
role: dispatch::parse_role(&role)?,
payload: dispatch::parse_payload(&payload)?,
base,
source,
supersedes,
worktree,
created_at: clock::today(),
};
dispatch::run_candidate_create(path, &req)
}
CandidateCommand::Status { slice, path } => {
dispatch::run_candidate_status(path, slice)
}
CandidateCommand::Admit {
slice,
role,
candidate,
review,
path,
} => {
let req = dispatch::AdmitRequest {
slice,
role: dispatch::parse_role(&role)?,
candidate,
review,
admitted_at: clock::today(),
};
dispatch::run_candidate_admit(path, &req)
}
},
DispatchCommand::PlanNext { slice, json, path } => {
dispatch::run_plan_next(path, slice, json)
}
DispatchCommand::Status { slice, json, path } => {
dispatch::run_status(path, slice, json)
}
},
Command::Validate { path } => run_validate(path),
Command::Reseat {
reference,
to,
path,
} => integrity::run_reseat(path, &reference, to),
Command::Link {
source,
label,
target,
path,
} => run_link(path, &source, &label, &target),
Command::Unlink {
source,
label,
target,
path,
} => run_unlink(path, &source, &label, &target),
Command::Needs {
source,
target,
path,
} => run_needs_edge(path, &source, &target),
Command::After {
source,
target,
rank,
remove,
prune,
path,
} => {
if prune {
run_after_prune(path, &source)
} else if remove {
run_after_remove(path, &source, target.as_deref().unwrap_or(""), rank)
} else {
run_after_edge(path, &source, target.as_deref().unwrap_or(""), rank)
}
}
Command::Status { format, json, path } => status::run(path, format, json),
Command::Estimate { action } => match action {
EstimateAction::Set(args) => run_estimate_set(&args),
EstimateAction::Clear(args) => run_estimate_clear(&args),
},
Command::Value { action } => match action {
ValueAction::Set(args) => run_value_set(&args),
ValueAction::Clear(args) => run_value_clear(&args),
},
Command::Supersede { new, old, path } => run_supersede(path, &new, &old),
Command::Map { command } => match command {
MapCommand::Serve(args) => commands::map::run_serve(None, args),
},
}
}
fn resolve_link_path(
root: &std::path::Path,
source: &str,
label: &str,
) -> anyhow::Result<(PathBuf, &'static relation::RelationRule)> {
let (kref, id) = integrity::parse_canonical_ref(source)?;
let rule = relation::validate_link(kref.kind, label)?;
let name = format!("{id:03}");
let toml_path = root
.join(kref.kind.dir)
.join(&name)
.join(format!("{}-{name}.toml", kref.stem));
Ok((toml_path, rule))
}
fn resolve_entity_path_and_canonical(
root: &std::path::Path,
raw: &str,
) -> anyhow::Result<(PathBuf, String)> {
let (kref, id) = integrity::parse_canonical_ref(raw)?;
let name = format!("{id:03}");
let path = root
.join(kref.kind.dir)
.join(&name)
.join(format!("{}-{name}.toml", kref.stem));
if !path.exists() {
anyhow::bail!("entity not found: {raw}");
}
let canonical = listing::canonical_id(kref.kind.prefix, id);
Ok((path, canonical))
}
fn run_estimate_set(args: &EstimateSetArgs) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let (path, canonical) = resolve_entity_path_and_canonical(&root, &args.id)?;
let (lower, upper) = match args.exact {
Some(n) => (n, n),
None => match (args.lower, args.upper) {
(Some(l), Some(u)) => (l, u),
(None | Some(_), None | Some(_)) => {
anyhow::bail!("estimate set: must supply both lower and upper, or -x/--exact");
}
},
};
let facet = estimate::EstimateFacet { lower, upper };
estimate::validate(&facet)?;
let fields: &[(&str, f64)] = &[("lower", lower), ("upper", upper)];
let changed = facet_write::apply_set(&path, "estimate", fields)?;
if changed {
writeln!(
std::io::stdout(),
"estimate set: {canonical} lower={lower} upper={upper}"
)?;
} else {
writeln!(
std::io::stdout(),
"estimate unchanged: {canonical} lower={lower} upper={upper}"
)?;
}
Ok(())
}
fn run_estimate_clear(args: &EstimateClearArgs) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let (path, canonical) = resolve_entity_path_and_canonical(&root, &args.id)?;
let cleared = facet_write::apply_clear(&path, "estimate")?;
if cleared {
writeln!(std::io::stdout(), "estimate cleared: {canonical}")?;
} else {
writeln!(std::io::stdout(), "no estimate to clear: {canonical}")?;
}
Ok(())
}
fn run_value_set(args: &ValueSetArgs) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let (path, canonical) = resolve_entity_path_and_canonical(&root, &args.id)?;
let facet = value::ValueFacet {
value: args.magnitude,
};
value::validate(&facet)?;
let fields: &[(&str, f64)] = &[("value", args.magnitude)];
let changed = facet_write::apply_set(&path, "value", fields)?;
if changed {
writeln!(
std::io::stdout(),
"value set: {canonical} value={}",
args.magnitude
)?;
} else {
writeln!(
std::io::stdout(),
"value unchanged: {canonical} value={}",
args.magnitude
)?;
}
Ok(())
}
fn run_value_clear(args: &ValueClearArgs) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(args.path.clone(), &crate::root::default_markers())?;
let (path, canonical) = resolve_entity_path_and_canonical(&root, &args.id)?;
let cleared = facet_write::apply_clear(&path, "value")?;
if cleared {
writeln!(std::io::stdout(), "value cleared: {canonical}")?;
} else {
writeln!(std::io::stdout(), "no value to clear: {canonical}")?;
}
Ok(())
}
fn run_link(path: Option<PathBuf>, source: &str, label: &str, target: &str) -> anyhow::Result<()> {
use anyhow::Context;
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
if let Ok(mref) = memory::MemoryRef::parse(source) {
let toml_path = memory::resolve_memory_toml_path(&root, &mref)?;
if integrity::parse_canonical_ref(target).is_ok() {
integrity::ensure_ref_resolves(&root, target).with_context(|| {
format!("target `{target}` does not resolve to an existing entity")
})?;
}
let outcome = memory::append_memory_relation(&toml_path, label, target)?;
match outcome {
relation::AppendOutcome::Wrote => {
writeln!(std::io::stdout(), "linked: {source} {label} {target}")?;
}
relation::AppendOutcome::Noop => {
writeln!(
std::io::stdout(),
"already linked: {source} {label} {target}"
)?;
}
}
return Ok(());
}
let (toml_path, rule) = resolve_link_path(&root, source, label)?;
if !matches!(rule.target, relation::TargetSpec::Unvalidated) {
integrity::ensure_ref_resolves(&root, target)?;
let (tkref, _tid) = integrity::parse_canonical_ref(target)?;
let (skref, _sid) = integrity::parse_canonical_ref(source)?;
relation::check_target_kind(rule, skref.kind, tkref.kind.prefix)?;
}
let outcome = relation::append_edge(&toml_path, rule.label, target)?;
match outcome {
relation::AppendOutcome::Wrote => {
writeln!(std::io::stdout(), "linked: {source} {label} {target}")?;
}
relation::AppendOutcome::Noop => {
writeln!(
std::io::stdout(),
"already linked: {source} {label} {target}"
)?;
}
}
Ok(())
}
fn run_unlink(
path: Option<PathBuf>,
source: &str,
label: &str,
target: &str,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
if let Ok(mref) = memory::MemoryRef::parse(source) {
let toml_path = memory::resolve_memory_toml_path(&root, &mref)?;
let outcome = memory::remove_memory_relation(&toml_path, label, target)?;
match outcome {
relation::RemoveOutcome::Removed => {
writeln!(std::io::stdout(), "unlinked: {source} {label} {target}")?;
}
relation::RemoveOutcome::Absent => {
writeln!(std::io::stdout(), "not linked: {source} {label} {target}")?;
}
}
return Ok(());
}
let (toml_path, rule) = resolve_link_path(&root, source, label)?;
let outcome = relation::remove_edge(&toml_path, rule.label, target)?;
match outcome {
relation::RemoveOutcome::Removed => {
writeln!(std::io::stdout(), "unlinked: {source} {label} {target}")?;
}
relation::RemoveOutcome::Absent => {
writeln!(std::io::stdout(), "not linked: {source} {label} {target}")?;
}
}
Ok(())
}
fn is_work_like(kind: &'static entity::Kind) -> bool {
matches!(
kind.prefix,
"SL" | "ISS" | "IMP" | "CHR" | "RSK" | "IDE" | "REV"
)
}
fn resolve_dep_seq_src_path(root: &std::path::Path, source: &str) -> anyhow::Result<PathBuf> {
let (skref, sid) = integrity::parse_canonical_ref(source)?;
anyhow::ensure!(
is_work_like(skref.kind),
"`{source}` is a {} entity, which cannot author needs/after — only a slice or a backlog item (issue/improvement/chore/risk/idea) carries dep/seq",
skref.kind.prefix
);
let name = format!("{sid:03}");
Ok(root
.join(skref.kind.dir)
.join(&name)
.join(format!("{}-{name}.toml", skref.stem)))
}
fn resolve_dep_seq_src(
root: &std::path::Path,
source: &str,
target: &str,
) -> anyhow::Result<PathBuf> {
let toml_path = resolve_dep_seq_src_path(root, source)?;
let (skref, sid) = integrity::parse_canonical_ref(source)?;
let (tkref, tid) = integrity::parse_canonical_ref(target)?;
integrity::ensure_ref_resolves(root, target)?;
anyhow::ensure!(
is_work_like(tkref.kind),
"`{target}` is a {} entity — needs/after may only target work (a slice or a backlog item); cross-tier dep/seq is not yet allowed",
tkref.kind.prefix
);
anyhow::ensure!(
!(skref.kind.prefix == tkref.kind.prefix && sid == tid),
"a {source} edge to itself is not a dependency — self-edges are refused"
);
Ok(toml_path)
}
fn run_needs_edge(path: Option<PathBuf>, source: &str, target: &str) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src(&root, source, target)?;
dep_seq::append(&toml_path, &dep_seq::RelEdit::Needs(&[target.to_string()]))?;
writeln!(std::io::stdout(), "{source} needs {target}")?;
Ok(())
}
fn run_after_edge(
path: Option<PathBuf>,
source: &str,
target: &str,
rank: i32,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src(&root, source, target)?;
dep_seq::append(&toml_path, &dep_seq::RelEdit::After { to: target, rank })?;
let suffix = if rank == 0 {
String::new()
} else {
format!(" (rank {rank})")
};
writeln!(std::io::stdout(), "{source} after {target}{suffix}")?;
Ok(())
}
fn run_after_remove(
path: Option<PathBuf>,
source: &str,
target: &str,
rank: i32,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src(&root, source, target)?;
let ceiling = if rank == 0 { None } else { Some(rank) };
let removed = dep_seq::remove(&toml_path, target, ceiling)?;
if removed == 0 {
anyhow::bail!("{source} has no after edge to {target}");
}
writeln!(
std::io::stdout(),
"{source} after {target} removed ({} edge{})",
removed,
if removed == 1 { "" } else { "s" }
)?;
Ok(())
}
fn run_after_prune(path: Option<PathBuf>, source: &str) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let toml_path = resolve_dep_seq_src_path(&root, source)?;
let ds = dep_seq::read(&toml_path)?;
let mut dropped: Vec<(String, i32, String)> = Vec::new();
let mut to_drop: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for edge in &ds.after {
let is_dangling = match integrity::parse_canonical_ref(&edge.to) {
Ok((kref, tid)) => {
let target_path = root
.join(kref.kind.dir)
.join(format!("{tid:03}"))
.join(format!("{}-{tid:03}.toml", kref.stem));
if target_path.exists() {
let body = std::fs::read_to_string(&target_path).unwrap_or_default();
let val: toml::Value = match toml::from_str(&body) {
Ok(v) => v,
Err(_) => toml::Value::Table(toml::Table::new()),
};
let status = val.get("status").and_then(|s| s.as_str()).unwrap_or("");
status == "resolved" || status == "closed"
} else {
true
}
}
Err(_) => true,
};
if is_dangling {
let reason = match integrity::parse_canonical_ref(&edge.to) {
Ok((kref2, tid2)) => {
let target_path = root
.join(kref2.kind.dir)
.join(format!("{tid2:03}"))
.join(format!("{}-{tid2:03}.toml", kref2.stem));
if target_path.exists() {
let body = std::fs::read_to_string(&target_path).unwrap_or_default();
let val: toml::Value = match toml::from_str(&body) {
Ok(v) => v,
Err(_) => toml::Value::Table(toml::Table::new()),
};
let status = val.get("status").and_then(|s| s.as_str()).unwrap_or("");
let resolution =
val.get("resolution").and_then(|s| s.as_str()).unwrap_or("");
if resolution.is_empty() {
status.to_string()
} else {
format!("{status}/{resolution}")
}
} else {
"absent".to_string()
}
}
Err(_) => "absent (unparseable ref)".to_string(),
};
dropped.push((edge.to.clone(), edge.rank, reason));
to_drop.insert(edge.to.clone());
}
}
if dropped.is_empty() {
writeln!(std::io::stdout(), "{source}: nothing to prune")?;
return Ok(());
}
for target in &to_drop {
let _ = dep_seq::remove(&toml_path, target, None)?;
}
for (target, rank, reason) in &dropped {
writeln!(
std::io::stdout(),
"{source} after {target} (rank {rank}) dropped (dangling: {reason})"
)?;
}
Ok(())
}
fn resolve_supersede_path(
root: &std::path::Path,
kref: &integrity::KindRef,
id: u32,
) -> (PathBuf, String) {
let name = format!("{id:03}");
let toml_path = root
.join(kref.kind.dir)
.join(&name)
.join(format!("{}-{name}.toml", kref.stem));
(toml_path, listing::canonical_id(kref.kind.prefix, id))
}
fn run_supersede(path: Option<PathBuf>, new: &str, old: &str) -> anyhow::Result<()> {
use anyhow::Context;
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let (new_kref, new_id) = integrity::parse_canonical_ref(new)?;
let (old_kref, old_id) = integrity::parse_canonical_ref(old)?;
anyhow::ensure!(
!(new_kref.kind.prefix == old_kref.kind.prefix && new_id == old_id),
"`{new}` cannot supersede itself — a self-supersession is not a decision change"
);
let new_is_adr = new_kref.kind.prefix == "ADR";
let old_is_adr = old_kref.kind.prefix == "ADR";
let new_is_record = crate::knowledge::RecordKind::from_prefix(new_kref.kind.prefix).is_some();
let old_is_record = crate::knowledge::RecordKind::from_prefix(old_kref.kind.prefix).is_some();
let same_family = if new_is_adr && old_is_adr {
true } else if new_is_record && old_is_record {
let Some(new_record_kind) = crate::knowledge::RecordKind::from_prefix(new_kref.kind.prefix)
else {
anyhow::bail!("NEW kind not a valid record kind")
};
let Some(old_record_kind) = crate::knowledge::RecordKind::from_prefix(old_kref.kind.prefix)
else {
anyhow::bail!("OLD kind not a valid record kind")
};
anyhow::ensure!(
crate::supersede::validate_matrix(new_record_kind, old_record_kind),
"cross-kind supersession refused: the §6 matrix disallows {} → {}",
new_kref.kind.prefix,
old_kref.kind.prefix
);
true } else if new_kref.kind.prefix == old_kref.kind.prefix {
true } else {
false };
anyhow::ensure!(
same_family,
"cross-family supersession refused: `{new}` is a {} but `{old}` is a {}",
new_kref.kind.prefix,
old_kref.kind.prefix
);
let policy = crate::supersede::supersede_policy(new_kref.kind).with_context(|| {
format!(
"supersession not yet supported for {} (follow-up F2)",
new_kref.kind.prefix
)
})?;
let old_policy = if !new_is_adr && !old_is_adr && new_kref.kind.prefix != old_kref.kind.prefix {
crate::supersede::supersede_policy(old_kref.kind).with_context(|| {
format!(
"supersession not yet supported for OLD {} (follow-up F2)",
old_kref.kind.prefix
)
})?
} else {
policy
};
let (new_path, new_ref) = resolve_supersede_path(&root, new_kref, new_id);
let (old_path, old_ref) = resolve_supersede_path(&root, old_kref, old_id);
let new_text = std::fs::read_to_string(&new_path)
.with_context(|| format!("supersede: {new} not found at {}", new_path.display()))?;
let mut new_doc = new_text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", new_path.display()))?;
let old_text = std::fs::read_to_string(&old_path)
.with_context(|| format!("supersede: {old} not found at {}", old_path.display()))?;
let mut old_doc = old_text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", old_path.display()))?;
let old_carveout = rel_array(&old_doc, policy.carveout_field);
anyhow::ensure!(
old_carveout.is_some(),
"malformed `{old}` at {}: missing seeded `[relationships].{}` array — restore the seeded `[relationships]` arrays before superseding; the file is left untouched",
old_path.display(),
policy.carveout_field
);
anyhow::ensure!(
old_doc
.get("status")
.and_then(toml_edit::Item::as_str)
.is_some(),
"malformed `{old}` at {}: missing seeded top-level `status` — restore the seeded keys before superseding; the file is left untouched",
old_path.display()
);
anyhow::ensure!(
old_doc
.get("updated")
.and_then(toml_edit::Item::as_str)
.is_some(),
"malformed `{old}` at {}: missing seeded top-level `updated` — restore the seeded keys before superseding; the file is left untouched",
old_path.display()
);
let old_status = old_doc
.get("status")
.and_then(toml_edit::Item::as_str)
.unwrap_or_default()
.to_string();
match policy.storage {
crate::supersede::StorageTarget::RelationRow => {
use crate::relation::{self, RelationLabel};
let relation_doc = relation::RelationDoc::parse(&new_text)
.with_context(|| {
format!(
"malformed `{new}` at {}: missing seeded `[[relation]]` table — restore the seeded template; the file is left untouched",
new_path.display()
)
})?;
let (edges, _illegal) = relation::read_block(new_kref.kind, &relation_doc);
let existing_supersedes: Vec<_> = edges
.iter()
.filter(|e| e.label == RelationLabel::Supersedes)
.collect();
if old_status == policy.superseded_status {
let carveout = old_carveout.unwrap_or_default();
let new_lists_old = existing_supersedes.iter().any(|e| e.target == old_ref);
let single_self = carveout.len() == 1 && carveout.first() == Some(&new_ref);
if single_self && new_lists_old {
writeln!(
std::io::stdout(),
"already recorded: {new} supersedes {old}"
)?;
return Ok(());
}
if let Some(other) = carveout.iter().find(|x| **x != new_ref) {
anyhow::bail!("{old} already superseded by {other}; reopening is deferred");
}
anyhow::bail!(
"{old} status is superseded but its superseded_by carve-out is empty/inconsistent — run `doctrine validate`"
);
}
if let Some(edge) = existing_supersedes.first() {
anyhow::bail!("{new} already supersedes {}", edge.target);
}
let outcome = relation::append_edge(&new_path, RelationLabel::Supersedes, &old_ref)?;
if matches!(outcome, relation::AppendOutcome::Noop) {
writeln!(
std::io::stdout(),
"already recorded: {new} supersedes {old}"
)?;
} else {
writeln!(std::io::stdout(), "{new} supersedes {old}")?;
}
let today = clock::today();
let status_hint = format!(
"malformed `{old}`: missing seeded top-level `status`/`updated` — restore the seeded keys; the file is left untouched"
);
dep_seq::apply_string_append(&mut old_doc, policy.carveout_field, &new_ref)?;
dep_seq::apply_status(
&mut old_doc,
&[("status", policy.superseded_status), ("updated", &today)],
&status_hint,
)?;
std::fs::write(&old_path, old_doc.to_string())
.with_context(|| format!("Failed to write {}", old_path.display()))?;
}
crate::supersede::StorageTarget::TypedArray { field } => {
let new_sup = rel_array(&new_doc, field);
anyhow::ensure!(
new_sup.is_some(),
"malformed `{new}` at {}: missing seeded `[relationships].{}` array — restore the seeded `[relationships]` arrays before superseding; the file is left untouched",
new_path.display(),
field
);
if old_status == old_policy.superseded_status {
let carveout = old_carveout.unwrap_or_default();
let new_lists_old = new_sup.unwrap_or_default().contains(&old_ref);
let single_self = carveout.len() == 1 && carveout.first() == Some(&new_ref);
if single_self && new_lists_old {
writeln!(
std::io::stdout(),
"already recorded: {new} supersedes {old}"
)?;
return Ok(());
}
if let Some(other) = carveout.iter().find(|x| **x != new_ref) {
anyhow::bail!("{old} already superseded by {other}; reopening is deferred");
}
anyhow::bail!(
"{old} status is superseded but its superseded_by carve-out is empty/inconsistent — run `doctrine validate`"
);
}
let today = clock::today();
let status_hint = format!(
"malformed `{old}`: missing seeded top-level `status`/`updated` — restore the seeded keys; the file is left untouched"
);
dep_seq::apply_string_append(&mut new_doc, field, &old_ref)?;
dep_seq::apply_string_append(&mut old_doc, policy.carveout_field, &new_ref)?;
let old_record_kind = crate::knowledge::RecordKind::from_prefix(old_kref.kind.prefix)
.context("OLD kind not a valid record kind")?;
if old_record_kind.is_terminal(&old_status) {
old_doc
.as_table_mut()
.insert("updated", toml_edit::value(today.as_str()));
} else {
dep_seq::apply_status(
&mut old_doc,
&[
("status", old_policy.superseded_status),
("updated", &today),
],
&status_hint,
)?;
}
std::fs::write(&new_path, new_doc.to_string())
.with_context(|| format!("Failed to write {}", new_path.display()))?;
std::fs::write(&old_path, old_doc.to_string())
.with_context(|| format!("Failed to write {}", old_path.display()))?;
writeln!(std::io::stdout(), "{new} supersedes {old}")?;
}
}
Ok(())
}
fn rel_array(doc: &toml_edit::DocumentMut, field: &str) -> Option<Vec<String>> {
doc.get("relationships")
.and_then(toml_edit::Item::as_table)
.and_then(|t| t.get(field))
.and_then(toml_edit::Item::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
}
fn run_validate(path: Option<PathBuf>) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let mut findings = integrity::id_integrity_findings(&root)?;
findings.extend(relation_graph::validate_relations(&root)?);
writeln!(
std::io::stdout(),
"validate: scanned {}",
integrity::scanned_kinds()
)?;
if findings.is_empty() {
writeln!(std::io::stdout(), "validate: corpus clean")?;
return Ok(());
}
for f in &findings {
writeln!(std::io::stdout(), " {f}")?;
}
anyhow::bail!("validate: {} finding(s)", findings.len())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_work_like_is_exactly_slice_plus_backlog_plus_revision() {
assert!(is_work_like(&slice::SLICE_KIND));
for k in integrity::KINDS
.iter()
.filter(|k| matches!(k.kind.prefix, "ISS" | "IMP" | "CHR" | "RSK" | "IDE" | "REV"))
{
assert!(is_work_like(k.kind), "{} is work-like", k.kind.prefix);
}
for k in integrity::KINDS.iter().filter(|k| {
!matches!(
k.kind.prefix,
"SL" | "ISS" | "IMP" | "CHR" | "RSK" | "IDE" | "REV"
)
}) {
assert!(
!is_work_like(k.kind),
"{} must NOT be work-like (off the allowlist)",
k.kind.prefix
);
}
}
#[test]
fn only_memory_conflicts_with_skill() {
let r = Cli::try_parse_from([
"doctrine",
"skills",
"install",
"--only-memory",
"--skill",
"code-review",
]);
assert!(r.is_err());
}
#[test]
fn only_memory_conflicts_with_domain() {
let r = Cli::try_parse_from([
"doctrine",
"skills",
"install",
"--only-memory",
"--domain",
"doctrine",
]);
assert!(r.is_err());
}
#[test]
fn only_memory_alone_parses() {
let r = Cli::try_parse_from(["doctrine", "install", "--only-memory"]);
assert!(r.is_ok());
}
#[test]
fn skills_install_is_gone() {
let r = Cli::try_parse_from(["doctrine", "skills", "install"]);
assert!(r.is_err());
}
#[test]
fn supersede_recovery_from_torn_new_only_state() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = [\"ADR-002\"]\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/001/adr-001.md", "body\n");
catalog::test_helpers::write(
root,
".doctrine/adr/002/adr-002.toml",
"id = 2\nslug = \"a2\"\ntitle = \"A2\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/002/adr-002.md", "body\n");
run_supersede(Some(root.to_path_buf()), "ADR-001", "ADR-002")
.expect("recovery supersede should succeed");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/adr/002/adr-002.toml")).unwrap();
assert!(
old_toml.contains("status = \"superseded\""),
"OLD.status should be superseded, got: {old_toml}"
);
assert!(
old_toml.contains("superseded_by = [\"ADR-001\"]"),
"OLD.superseded_by should contain ADR-001, got: {old_toml}"
);
let new_toml =
std::fs::read_to_string(root.join(".doctrine/adr/001/adr-001.toml")).unwrap();
assert!(
new_toml.contains("[[relation]]")
&& new_toml.contains("label = \"supersedes\"")
&& new_toml.contains("target = \"ADR-002\""),
"NEW should have [[relation]] supersedes → ADR-002: {new_toml}"
);
}
#[test]
fn supersede_same_kind_record_allowed() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.toml",
"id = 2\nslug = \"a2\"\ntitle = \"A2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "ASM-001", "ASM-002")
.expect("same-kind record supersession should succeed");
let old_toml = std::fs::read_to_string(
root.join(".doctrine/knowledge/assumption/002/record-002.toml"),
)
.unwrap();
assert!(
old_toml.contains("status = \"obsolete\""),
"OLD.status should be obsolete, got: {old_toml}"
);
}
#[test]
fn supersede_cross_kind_allowed_matrix() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.toml",
"id = 1\nslug = \"d1\"\ntitle = \"D1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.toml",
"id = 2\nslug = \"a2\"\ntitle = \"A2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "DEC-001", "ASM-002")
.expect("cross-kind supersession DEC → ASM should succeed");
let old_toml = std::fs::read_to_string(
root.join(".doctrine/knowledge/assumption/002/record-002.toml"),
)
.unwrap();
assert!(
old_toml.contains("status = \"obsolete\""),
"OLD.status should be obsolete, got: {old_toml}"
);
}
#[test]
fn supersede_cross_kind_refused_matrix() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
let result = run_supersede(Some(root.to_path_buf()), "ASM-001", "DEC-002");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("§6 matrix disallows ASM → DEC")
);
}
#[test]
fn supersede_question_reopening_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.toml",
"id = 1\nslug = \"q1\"\ntitle = \"Q1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.toml",
"id = 2\nslug = \"q2\"\ntitle = \"Q2\"\nstatus = \"answered\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "QUE-001", "QUE-002")
.expect("supersession should proceed but not flip terminal status");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/question/002/record-002.toml"))
.unwrap();
assert!(
old_toml.contains("status = \"answered\""),
"OLD.status should remain answered (terminal), got: {old_toml}"
);
assert!(
!old_toml.contains("updated = \"2026-01-01\""),
"OLD.updated should be refreshed, got: {old_toml}"
);
}
#[test]
fn supersede_cross_family_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/adr/001/adr-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n",
);
catalog::test_helpers::write(root, ".doctrine/adr/001/adr-001.md", "body\n");
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.toml",
"id = 2\nslug = \"a2\"\ntitle = \"A2\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/002/record-002.md",
"body\n",
);
let result = run_supersede(Some(root.to_path_buf()), "ADR-001", "ASM-002");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("cross-family supersession refused")
);
}
#[test]
fn supersede_self_supersession_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.toml",
"id = 1\nslug = \"a1\"\ntitle = \"A1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/assumption/001/record-001.md",
"body\n",
);
let result = run_supersede(Some(root.to_path_buf()), "ASM-001", "ASM-001");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("cannot supersede itself")
);
}
#[test]
fn supersede_already_terminal_no_flip() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.toml",
"id = 1\nslug = \"d1\"\ntitle = \"D1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"accepted\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "DEC-001", "DEC-002")
.expect("supersession should succeed but not flip terminal status");
let old_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/decision/002/record-002.toml"))
.unwrap();
assert!(
old_toml.contains("status = \"accepted\""),
"OLD.status should remain accepted (terminal), got: {old_toml}"
);
assert!(
!old_toml.contains("updated = \"2026-01-01\""),
"OLD.updated should be refreshed even for terminal, got: {old_toml}"
);
}
#[test]
fn supersede_idempotent_cross_kind() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/constraint/001/record-001.toml",
"id = 1\nslug = \"c1\"\ntitle = \"C1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = [\"QUE-002\"]\nsuperseded_by = []\n[facet]\nkind = \"implementation\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/constraint/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.toml",
"id = 2\nslug = \"q2\"\ntitle = \"Q2\"\nstatus = \"obsolete\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = [\"CON-001\"]\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "CON-001", "QUE-002")
.expect("idempotent cross-kind supersession should succeed");
let new_toml = std::fs::read_to_string(
root.join(".doctrine/knowledge/constraint/001/record-001.toml"),
)
.unwrap();
let old_toml =
std::fs::read_to_string(root.join(".doctrine/knowledge/question/002/record-002.toml"))
.unwrap();
assert!(new_toml.contains("supersedes = [\"QUE-002\"]"));
assert!(old_toml.contains("superseded_by = [\"CON-001\"]"));
assert!(old_toml.contains("status = \"obsolete\""));
}
#[test]
fn supersede_decision_to_question_reopening_refused() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.toml",
"id = 1\nslug = \"q1\"\ntitle = \"Q1\"\nstatus = \"open\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"yes_no\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/question/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
let result = run_supersede(Some(root.to_path_buf()), "QUE-001", "DEC-002");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("§6 matrix disallows QUE → DEC")
);
}
#[test]
fn supersede_torn_recovery() {
let tmp = catalog::test_helpers::tmp();
let root = tmp.path();
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.toml",
"id = 1\nslug = \"d1\"\ntitle = \"D1\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/001/record-001.md",
"body\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.toml",
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"proposed\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
);
catalog::test_helpers::write(
root,
".doctrine/knowledge/decision/002/record-002.md",
"body\n",
);
run_supersede(Some(root.to_path_buf()), "DEC-001", "DEC-002")
.expect("initial supersession should succeed");
std::fs::write(
root.join(".doctrine/knowledge/decision/002/record-002.toml"),
"id = 2\nslug = \"d2\"\ntitle = \"D2\"\nstatus = \"superseded\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
[relationships]\nsupersedes = []\nsuperseded_by = []\n[facet]\nkind = \"action_item\"\n",
)
.unwrap();
let result = run_supersede(Some(root.to_path_buf()), "DEC-001", "DEC-002");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("superseded_by carve-out is empty")
);
}
#[test]
fn link_supersedes_on_record_is_lifecycle_only() {
use crate::relation::{LinkPolicy, RelationLabel, lookup};
let rule = lookup(&crate::knowledge::DECISION_KIND, RelationLabel::Supersedes)
.expect("DECISION_KIND should have a Supersedes rule row");
assert!(
matches!(rule.link, LinkPolicy::LifecycleOnly),
"record Supersedes must be LifecycleOnly"
);
let adr_rule = lookup(&crate::adr::ADR_KIND.kind, RelationLabel::Supersedes)
.expect("ADR_KIND should have a Supersedes rule row");
assert!(
matches!(adr_rule.link, LinkPolicy::LifecycleOnly),
"ADR Supersedes must be LifecycleOnly"
);
}
const MEM_TEST_UID: &str = "mem_018f3a1b2c3d4e5f60718293a4b5c6d7";
fn seed_sl_toml(root: &std::path::Path, id: u32) {
let padded = format!("{id:03}");
let dir = root.join(".doctrine/slice").join(&padded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("slice-{padded}.toml")),
format!(
"id = {id}\nslug = \"t{padded}\"\ntitle = \"Test SL-{padded}\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
status = \"accepted\"\n"
),
)
.unwrap();
std::fs::write(dir.join(format!("slice-{padded}.md")), "body\n").unwrap();
}
fn seed_adr_toml(root: &std::path::Path, id: u32) {
let padded = format!("{id:03}");
let dir = root.join(".doctrine/adr").join(&padded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("adr-{padded}.toml")),
format!(
"id = {id}\nslug = \"a{padded}\"\ntitle = \"ADR {padded}\"\n\
created = \"2026-01-01\"\nupdated = \"2026-01-01\"\n\
status = \"accepted\"\n"
),
)
.unwrap();
std::fs::write(dir.join(format!("adr-{padded}.md")), "body\n").unwrap();
}
fn seed_memory_toml(root: &std::path::Path, uid: &str, content: &str) {
let mem_dir = root.join(".doctrine/memory/items").join(uid);
std::fs::create_dir_all(&mem_dir).unwrap();
std::fs::write(mem_dir.join("memory.toml"), content).unwrap();
}
#[test]
fn link_memory_uid_appends_relation_row() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_memory_toml(root, MEM_TEST_UID, "");
run_link(Some(root.to_path_buf()), MEM_TEST_UID, "related", "SL-001").unwrap();
let content = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
assert!(content.contains("[[relation]]"), "relation row written");
assert!(content.contains("label = \"related\""), "label present");
assert!(content.contains("target = \"SL-001\""), "target present");
}
#[test]
fn link_memory_uid_repeat_is_noop() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
let seed = "[[relation]]\nlabel = \"related\"\ntarget = \"SL-001\"\n";
seed_memory_toml(root, MEM_TEST_UID, seed);
let before = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
run_link(Some(root.to_path_buf()), MEM_TEST_UID, "related", "SL-001").unwrap();
let after = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
assert_eq!(before, after, "file unchanged on re-link");
}
#[test]
fn unlink_memory_uid_then_repeat() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let seed = "[[relation]]\nlabel = \"related\"\ntarget = \"SL-001\"\n";
seed_memory_toml(root, MEM_TEST_UID, seed);
run_unlink(Some(root.to_path_buf()), MEM_TEST_UID, "related", "SL-001").unwrap();
let after_first = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
assert!(
!after_first.contains("[[relation]]"),
"relation row removed after unlink"
);
run_unlink(Some(root.to_path_buf()), MEM_TEST_UID, "related", "SL-001").unwrap();
}
#[test]
fn link_memory_uid_bad_target_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_memory_toml(root, MEM_TEST_UID, "");
let err =
run_link(Some(root.to_path_buf()), MEM_TEST_UID, "related", "SL-999").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("does not resolve"),
"error should mention 'does not resolve', got: {msg}"
);
}
#[test]
fn link_memory_uid_free_text_target_ok() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_memory_toml(root, MEM_TEST_UID, "");
run_link(
Some(root.to_path_buf()),
MEM_TEST_UID,
"drift",
"some free text",
)
.unwrap();
let content = std::fs::read_to_string(
root.join(format!(".doctrine/memory/items/{MEM_TEST_UID}/memory.toml")),
)
.unwrap();
assert!(
content.contains("target = \"some free text\""),
"free text stored"
);
}
#[test]
fn link_numbered_entity_still_works() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 48);
seed_adr_toml(root, 10);
run_link(Some(root.to_path_buf()), "SL-048", "governed_by", "ADR-010").unwrap();
let toml_path = root.join(".doctrine/slice/048/slice-048.toml");
let content = std::fs::read_to_string(&toml_path).unwrap();
assert!(content.contains("[[relation]]"));
assert!(content.contains("governed_by"));
assert!(content.contains("ADR-010"));
}
#[test]
fn link_memory_key_appends_relation_row() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_sl_toml(root, 1);
seed_memory_toml(root, "mem.fact.cli.skinny", "");
run_link(
Some(root.to_path_buf()),
"mem.fact.cli.skinny",
"related",
"SL-001",
)
.unwrap();
let content = std::fs::read_to_string(
root.join(".doctrine/memory/items/mem.fact.cli.skinny/memory.toml"),
)
.unwrap();
assert!(content.contains("[[relation]]"), "relation row written");
assert!(content.contains("target = \"SL-001\""), "target present");
}
}
#[cfg(test)]
mod estimate_value_tests {
use super::*;
fn seed_entity(root: &std::path::Path, prefix: &str, id: u32) -> (std::path::PathBuf, String) {
let padded = format!("{id:03}");
let kref = integrity::kind_by_prefix(prefix).expect("valid prefix");
let dir = root.join(kref.kind.dir).join(&padded);
std::fs::create_dir_all(&dir).unwrap();
let toml_path = dir.join(format!("{}-{padded}.toml", kref.stem));
std::fs::write(
&toml_path,
format!(
"id = {id}\nslug = \"t{padded}\"\ntitle = \"Test {prefix}-{padded}\"\nstatus = \"accepted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n"
),
)
.unwrap();
let canonical = listing::canonical_id(prefix, id);
(toml_path, canonical)
}
fn mk_project_root() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".project"), "").unwrap();
std::fs::create_dir_all(tmp.path().join(".doctrine")).unwrap();
std::fs::write(tmp.path().join("doctrine.toml"), "").unwrap();
let root = tmp.path().to_path_buf();
(tmp, root)
}
#[test]
fn vt8_neither_mode_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let args = EstimateSetArgs {
id: "SL-118".into(),
lower: None,
upper: None,
exact: None,
path: Some(root),
};
let err = run_estimate_set(&args).unwrap_err().to_string();
assert!(
err.contains("must supply both lower and upper"),
"got: {err}"
);
}
#[test]
fn vt8_one_lone_positional_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let args = EstimateSetArgs {
id: "SL-118".into(),
lower: Some(1.0),
upper: None,
exact: None,
path: Some(root),
};
let err = run_estimate_set(&args).unwrap_err().to_string();
assert!(
err.contains("must supply both lower and upper"),
"got: {err}"
);
}
#[test]
fn vt8_negative_lower_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let args = EstimateSetArgs {
id: "SL-118".into(),
lower: Some(-1.0),
upper: Some(5.0),
exact: None,
path: Some(root),
};
let err = run_estimate_set(&args).unwrap_err().to_string();
assert!(err.contains("lower must be >= 0"), "got: {err}");
}
#[test]
fn vt8_upper_lt_lower_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let args = EstimateSetArgs {
id: "SL-118".into(),
lower: Some(5.0),
upper: Some(2.0),
exact: None,
path: Some(root),
};
let err = run_estimate_set(&args).unwrap_err().to_string();
assert!(err.contains("upper must be >= lower"), "got: {err}");
}
#[test]
fn vt8_inf_lower_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let args = EstimateSetArgs {
id: "SL-118".into(),
lower: Some(f64::INFINITY),
upper: Some(5.0),
exact: None,
path: Some(root),
};
let err = run_estimate_set(&args).unwrap_err().to_string();
assert!(err.contains("finite"), "got: {err}");
}
#[test]
fn vt8_nan_lower_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let args = EstimateSetArgs {
id: "SL-118".into(),
lower: Some(f64::NAN),
upper: Some(5.0),
exact: None,
path: Some(root),
};
let err = run_estimate_set(&args).unwrap_err().to_string();
assert!(err.contains("finite"), "got: {err}");
}
#[test]
fn vt8_entity_not_found_rejected() {
let (_tmp, root) = mk_project_root();
let err = resolve_entity_path_and_canonical(&root, "SL-999")
.unwrap_err()
.to_string();
assert!(err.contains("entity not found"), "got: {err}");
}
#[test]
fn vt9_exact_sets_point_estimate() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let args = EstimateSetArgs {
id: "SL-118".into(),
lower: None,
upper: None,
exact: Some(3.0),
path: Some(root.clone()),
};
run_estimate_set(&args).unwrap();
let (path, _) = resolve_entity_path_and_canonical(&root, "SL-118").unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("lower = 3.0"), "missing lower:\n{body}");
assert!(body.contains("upper = 3.0"), "missing upper:\n{body}");
}
#[test]
fn vt10_value_set_then_clear() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
run_value_set(&ValueSetArgs {
id: "SL-118".into(),
magnitude: 42.0,
path: Some(root.clone()),
})
.unwrap();
let (path, _) = resolve_entity_path_and_canonical(&root, "SL-118").unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("value = 42.0"), "missing value:\n{body}");
run_value_clear(&ValueClearArgs {
id: "SL-118".into(),
path: Some(root),
})
.unwrap();
let body2 = std::fs::read_to_string(&path).unwrap();
assert!(
!body2.contains("[value]"),
"[value] should be gone:\n{body2}"
);
}
#[test]
fn vt10_value_set_negative() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
run_value_set(&ValueSetArgs {
id: "SL-118".into(),
magnitude: -5.0,
path: Some(root.clone()),
})
.unwrap();
let (path, _) = resolve_entity_path_and_canonical(&root, "SL-118").unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("value = -5.0"), "missing value:\n{body}");
}
#[test]
fn vt10_value_set_inf_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let err = run_value_set(&ValueSetArgs {
id: "SL-118".into(),
magnitude: f64::INFINITY,
path: Some(root),
})
.unwrap_err()
.to_string();
assert!(err.contains("finite"), "got: {err}");
}
#[test]
fn vt10_value_set_nan_rejected() {
let (_tmp, root) = mk_project_root();
seed_entity(&root, "SL", 118);
let err = run_value_set(&ValueSetArgs {
id: "SL-118".into(),
magnitude: f64::NAN,
path: Some(root),
})
.unwrap_err()
.to_string();
assert!(err.contains("finite"), "got: {err}");
}
#[test]
fn vt11_catalog_scan_estimate_readback() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".doctrine")).unwrap();
std::fs::write(root.join("doctrine.toml"), "").unwrap();
let padded = "118";
let dir = root.join(".doctrine/slice").join(padded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("slice-{padded}.toml")),
"id = 118\nslug = \"t118\"\ntitle = \"Test\"\nstatus = \"accepted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n[estimate]\nlower = 2.0\nupper = 8.0\n",
)
.unwrap();
std::fs::write(dir.join(format!("slice-{padded}.md")), "# Test body\n").unwrap();
let catalog = crate::catalog::hydrate::scan_catalog(root).unwrap();
let entity = catalog
.entities
.iter()
.find(|e| e.kind_label == "SL" && matches!(&e.key, crate::catalog::hydrate::CatalogKey::Numbered(k) if k.id == 118))
.expect("SL-118 should be in the catalog");
let est = entity
.estimate
.as_ref()
.expect("estimate should be present");
assert_eq!(est.lower, 2.0);
assert_eq!(est.upper, 8.0);
}
#[test]
fn vt11_catalog_scan_estimate_clear_readback() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::create_dir_all(root.join(".doctrine")).unwrap();
std::fs::write(root.join("doctrine.toml"), "").unwrap();
let padded = "118";
let dir = root.join(".doctrine/slice").join(padded);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("slice-{padded}.toml")),
"id = 118\nslug = \"t118\"\ntitle = \"Test\"\nstatus = \"accepted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n",
)
.unwrap();
std::fs::write(dir.join(format!("slice-{padded}.md")), "# Test body\n").unwrap();
let catalog = crate::catalog::hydrate::scan_catalog(root).unwrap();
let entity = catalog
.entities
.iter()
.find(|e| e.kind_label == "SL" && matches!(&e.key, crate::catalog::hydrate::CatalogKey::Numbered(k) if k.id == 118))
.expect("SL-118 should be in the catalog");
assert!(
entity.estimate.is_none(),
"estimate should be None after clear, got: {:?}",
entity.estimate
);
}
#[test]
fn vt12_slice_typed_reader_roundtrip() {
let toml_body = "id = 118\nslug = \"t118\"\ntitle = \"Test\"\nstatus = \"accepted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n[estimate]\nlower = 3.0\nupper = 7.0\n";
let val: toml::Table = toml_body.parse().unwrap();
let parsed =
crate::estimate::parse_optional(val.get("estimate").and_then(|v| v.as_table()))
.unwrap()
.expect("estimate should be present");
assert_eq!(parsed.lower, 3.0);
assert_eq!(parsed.upper, 7.0);
}
#[test]
fn vt12_value_typed_reader_roundtrip() {
let toml_body = "id = 118\nslug = \"t118\"\ntitle = \"Test\"\nstatus = \"accepted\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n[value]\nvalue = 99.0\n";
let val: toml::Table = toml_body.parse().unwrap();
let parsed = crate::value::parse_optional(val.get("value").and_then(|v| v.as_table()))
.unwrap()
.expect("value should be present");
assert_eq!(parsed.value, 99.0);
}
}
#[cfg(test)]
mod write_class_tests {
use super::*;
fn cls(cmd: Command) -> Option<&'static str> {
match write_class(&cmd) {
WriteClass::Read => None,
WriteClass::Write(v) | WriteClass::Orchestrator(v) | WriteClass::Hookmint(v) => Some(v),
WriteClass::MarkerClear => None,
}
}
fn clist() -> CommonListArgs {
CommonListArgs {
filter: None,
regexp: None,
case_insensitive: false,
status: Vec::new(),
tag: Vec::new(),
all: false,
format: Format::Table,
json: false,
columns: None,
}
}
#[test]
fn install_is_write() {
assert_eq!(
cls(Command::Install {
path: None,
agent: Vec::new(),
skill: Vec::new(),
domain: Vec::new(),
only_memory: false,
global: false,
dry_run: false,
yes: false
}),
Some("install")
);
}
#[test]
fn skills_list_is_read() {
assert_eq!(
cls(Command::Skills {
command: SkillsCommand::List {
agent: None,
installed: false
}
}),
None
);
}
#[test]
fn slice_split() {
let w = |c| cls(Command::Slice { command: c });
assert_eq!(
w(SliceCommand::New {
title: None,
slug: None,
path: None
}),
Some("slice new")
);
assert_eq!(
w(SliceCommand::Design { id: 0, path: None }),
Some("slice design")
);
assert_eq!(
w(SliceCommand::Plan { id: 0, path: None }),
Some("slice plan")
);
assert_eq!(
w(SliceCommand::Phases {
id: 0,
prune: false,
path: None
}),
Some("slice phases")
);
assert_eq!(
w(SliceCommand::Notes { id: 0, path: None }),
Some("slice notes")
);
assert_eq!(
w(SliceCommand::Phase {
id: 0,
phase_id: String::new(),
status: state::PhaseStatus::Planned,
note: None,
path: None,
}),
Some("slice phase")
);
assert_eq!(
w(SliceCommand::Status {
id: 0,
state: slice::SliceStatus::Proposed,
note: None,
path: None,
}),
Some("slice status")
);
assert_eq!(
w(SliceCommand::List {
list: clist(),
path: None
}),
None
);
assert_eq!(
w(SliceCommand::Show {
reference: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
}
#[test]
fn memory_split() {
let w = |c| cls(Command::Memory { command: c });
assert_eq!(
w(MemoryCommand::Record {
title: String::new(),
memory_type: memory::MemoryType::Concept,
key: None,
lifespan: None,
status: memory::Status::Active,
summary: None,
review_by: None,
provenance_source: Vec::new(),
trust: None,
severity: None,
tag: Vec::new(),
path_scope: Vec::new(),
glob: Vec::new(),
command: Vec::new(),
repo: None,
global: false,
path: None,
}),
Some("memory record")
);
assert_eq!(
w(MemoryCommand::Verify {
reference: String::new(),
allow_dirty: false,
path: None
}),
Some("memory verify")
);
assert_eq!(
w(MemoryCommand::Show {
reference: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
assert_eq!(
w(MemoryCommand::List {
memory_type: None,
list: clist(),
path: None,
}),
None
);
assert_eq!(
w(MemoryCommand::Find {
query: None,
args: FindRetrieveArgs {
path_scope: Vec::new(),
glob: Vec::new(),
command: Vec::new(),
tag: Vec::new(),
flag_query: None,
memory_type: None,
status: None,
lifespan: None,
include_draft: false,
format: Format::Table,
json: false,
offset: 0,
page: None,
limit: None,
path: None,
expand: None,
},
}),
None
);
assert_eq!(
w(MemoryCommand::Retrieve {
args: FindRetrieveArgs {
path_scope: Vec::new(),
glob: Vec::new(),
command: Vec::new(),
tag: Vec::new(),
flag_query: None,
memory_type: None,
status: None,
lifespan: None,
include_draft: false,
format: Format::Table,
json: false,
offset: 0,
page: None,
limit: None,
path: None,
expand: None,
},
min_trust: None,
}),
None
);
assert_eq!(
w(MemoryCommand::ResolveLinks {
reference: None,
path: None,
}),
None
);
assert_eq!(
w(MemoryCommand::Backlinks {
reference: String::new(),
path: None,
}),
None
);
assert_eq!(
w(MemoryCommand::Sync {
command: None,
dry_run: false,
yes: false,
path: None,
}),
Some("memory sync")
);
assert_eq!(
w(MemoryCommand::Sync {
command: Some(SyncCommand::Install {
path: None,
dry_run: false,
yes: false,
}),
dry_run: false,
yes: false,
path: None,
}),
Some("memory sync install")
);
assert_eq!(
w(MemoryCommand::Status {
reference: String::new(),
state: String::new(),
by: None,
path: None,
}),
Some("memory status")
);
assert_eq!(
w(MemoryCommand::Edit {
reference: String::new(),
title: None,
summary: None,
status: None,
lifespan: None,
review_by: None,
trust: None,
severity: None,
key: None,
path_scope: vec![],
glob: vec![],
command: vec![],
path: None,
}),
Some("memory edit")
);
}
#[test]
fn memory_record_new_flags_parse_and_reach_the_variant() {
let cli = Cli::try_parse_from([
"doctrine",
"memory",
"record",
"T",
"--type",
"fact",
"--lifespan",
"semantic",
"--review-by",
"2026-08-01",
"--provenance-source",
"code:src/main.rs:42",
"--trust",
"low",
"--severity",
"critical",
])
.unwrap();
let Command::Memory {
command:
MemoryCommand::Record {
lifespan,
review_by,
provenance_source,
trust,
severity,
..
},
} = cli.command
else {
panic!("expected memory record");
};
assert_eq!(lifespan, Some(memory::Lifespan::Semantic));
assert_eq!(review_by.as_deref(), Some("2026-08-01"));
assert_eq!(provenance_source.len(), 1);
assert_eq!(provenance_source[0].kind, "code");
assert_eq!(provenance_source[0].ref_, "src/main.rs:42");
assert_eq!(trust.as_deref(), Some("low"));
assert_eq!(severity.as_deref(), Some("critical"));
}
#[test]
fn memory_record_invalid_lifespan_is_rejected() {
let cli = Cli::try_parse_from([
"doctrine",
"memory",
"record",
"T",
"--type",
"fact",
"--lifespan",
"bogus",
]);
assert!(cli.is_err());
}
#[test]
fn memory_find_retrieve_lifespan_flag_parses_on_the_shared_args() {
let find =
Cli::try_parse_from(["doctrine", "memory", "find", "--lifespan", "semantic"]).unwrap();
let Command::Memory {
command: MemoryCommand::Find { args, .. },
} = find.command
else {
panic!("expected memory find");
};
assert_eq!(args.lifespan, Some(memory::Lifespan::Semantic));
let retrieve =
Cli::try_parse_from(["doctrine", "memory", "retrieve", "--lifespan", "working"])
.unwrap();
let Command::Memory {
command: MemoryCommand::Retrieve { args, .. },
} = retrieve.command
else {
panic!("expected memory retrieve");
};
assert_eq!(args.lifespan, Some(memory::Lifespan::Working));
}
#[test]
fn memory_find_invalid_lifespan_is_rejected() {
let cli = Cli::try_parse_from(["doctrine", "memory", "find", "--lifespan", "garbage"]);
assert!(cli.is_err());
}
#[test]
fn adr_split() {
let w = |c| cls(Command::Adr { command: c });
assert_eq!(
w(AdrCommand::New {
title: None,
slug: None,
path: None
}),
Some("adr new")
);
assert_eq!(
w(AdrCommand::Status {
id: 0,
status: adr::AdrStatus::Proposed,
path: None,
}),
Some("adr status")
);
assert_eq!(
w(AdrCommand::List {
list: clist(),
path: None
}),
None
);
assert_eq!(
w(AdrCommand::Show {
reference: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
}
#[test]
fn policy_split() {
let w = |c| cls(Command::Policy { command: c });
assert_eq!(
w(PolicyCommand::New {
title: None,
slug: None,
path: None
}),
Some("policy new")
);
assert_eq!(
w(PolicyCommand::Status {
id: 0,
status: policy::PolicyStatus::Draft,
path: None,
}),
Some("policy status")
);
assert_eq!(
w(PolicyCommand::List {
list: clist(),
path: None
}),
None
);
assert_eq!(
w(PolicyCommand::Show {
reference: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
}
#[test]
fn standard_split() {
let w = |c| cls(Command::Standard { command: c });
assert_eq!(
w(StandardCommand::New {
title: None,
slug: None,
path: None
}),
Some("standard new")
);
assert_eq!(
w(StandardCommand::Status {
id: 0,
status: standard::StandardStatus::Draft,
path: None,
}),
Some("standard status")
);
assert_eq!(
w(StandardCommand::List {
list: clist(),
path: None
}),
None
);
assert_eq!(
w(StandardCommand::Show {
reference: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
}
#[test]
fn spec_split() {
let w = |c| cls(Command::Spec { command: c });
assert_eq!(
w(SpecCommand::New {
subtype: spec::SpecSubtype::Product,
title: None,
slug: None,
path: None,
}),
Some("spec new")
);
assert_eq!(
w(SpecCommand::Req {
command: SpecReqCommand::Add {
spec_ref: String::new(),
title: None,
kind: requirement::ReqKind::Functional,
label: None,
slug: None,
path: None,
}
}),
Some("spec req add")
);
assert_eq!(
w(SpecCommand::Req {
command: SpecReqCommand::Status {
req_ref: String::new(),
to: requirement::ReqStatus::Active,
note: None,
path: None,
}
}),
Some("spec req status")
);
assert_eq!(
w(SpecCommand::List {
list: clist(),
path: None
}),
None
);
assert_eq!(
w(SpecCommand::Show {
spec_ref: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
assert_eq!(
w(SpecCommand::Validate {
spec_ref: None,
path: None
}),
None
);
}
#[test]
fn backlog_split() {
let w = |c| cls(Command::Backlog { command: c });
assert_eq!(
w(BacklogCommand::New {
kind: backlog::ItemKind::Issue,
title: None,
slug: None,
path: None,
}),
Some("backlog new")
);
assert_eq!(
w(BacklogCommand::Edit {
id: String::new(),
status: backlog::Status::Open,
resolution: None,
path: None,
}),
Some("backlog edit")
);
assert_eq!(
w(BacklogCommand::List {
kind: None,
by: backlog::OrderBy::Sequence,
list: clist(),
substr: None,
path: None,
}),
None
);
assert_eq!(
w(BacklogCommand::Show {
id: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
}
#[test]
fn knowledge_split() {
let w = |c| cls(Command::Knowledge { command: c });
assert_eq!(
w(KnowledgeCommand::New {
kind: knowledge::RecordKind::Assumption,
title: None,
slug: None,
path: None,
}),
Some("knowledge new")
);
assert_eq!(
w(KnowledgeCommand::Status {
id: String::new(),
state: String::new(),
path: None,
}),
Some("knowledge status")
);
assert_eq!(
w(KnowledgeCommand::List {
list: clist(),
path: None,
}),
None
);
assert_eq!(
w(KnowledgeCommand::Show {
id: String::new(),
format: Format::Table,
json: false,
path: None,
}),
None
);
}
#[test]
fn boot_split() {
assert_eq!(
cls(Command::Boot {
command: None,
check: false,
path: None
}),
Some("boot")
);
assert_eq!(
cls(Command::Boot {
command: None,
check: true,
path: None
}),
Some("boot")
);
assert_eq!(
cls(Command::Boot {
command: Some(BootCommand::Install {
path: None,
agent: Vec::new(),
dry_run: false,
yes: false,
}),
check: false,
path: None,
}),
Some("boot install")
);
}
#[test]
fn worktree_is_read() {
assert_eq!(
cls(Command::Worktree {
command: WorktreeCommand::Provision {
fork: PathBuf::from("x"),
path: None,
}
}),
None
);
assert_eq!(
cls(Command::Worktree {
command: WorktreeCommand::CheckAllowlist { path: None }
}),
None
);
assert_eq!(
cls(Command::Worktree {
command: WorktreeCommand::Status {
assert: false,
path: None,
}
}),
None
);
}
#[test]
fn worktree_marker_is_bespoke_class() {
let c = Command::Worktree {
command: WorktreeCommand::Marker {
clear: true,
operator: false,
stamp_subagent: false,
path: None,
},
};
assert!(
matches!(write_class(&c), WriteClass::MarkerClear),
"marker --clear must be the bespoke MarkerClear class"
);
assert_eq!(cls(c), None);
}
#[test]
fn worktree_marker_stamp_subagent_is_hookmint() {
let c = Command::Worktree {
command: WorktreeCommand::Marker {
clear: false,
operator: false,
stamp_subagent: true,
path: None,
},
};
assert!(
matches!(
write_class(&c),
WriteClass::Hookmint("marker --stamp-subagent")
),
"marker --stamp-subagent must be the Hookmint class"
);
assert_eq!(cls(c), Some("marker --stamp-subagent"));
}
#[test]
fn worktree_fork_is_orchestrator() {
let c = Command::Worktree {
command: WorktreeCommand::Fork {
base: "B".to_string(),
branch: "wkr".to_string(),
dir: PathBuf::from("x"),
worker: false,
path: None,
},
};
assert!(
matches!(write_class(&c), WriteClass::Orchestrator("fork")),
"fork must be Orchestrator(\"fork\")"
);
assert_eq!(cls(c), Some("fork"));
}
#[test]
fn dispatch_sync_is_orchestrator() {
let c = Command::Dispatch {
command: DispatchCommand::Sync {
slice: 64,
prepare_review: true,
integrate: false,
show_journal_trunk_oid: false,
trunk: None,
edge: None,
path: None,
},
};
assert!(
matches!(write_class(&c), WriteClass::Orchestrator("dispatch-sync")),
"dispatch sync must be Orchestrator(\"dispatch-sync\")"
);
assert_eq!(cls(c), Some("dispatch-sync"));
}
#[test]
fn dispatch_sync_integrate_is_orchestrator() {
let c = Command::Dispatch {
command: DispatchCommand::Sync {
slice: 64,
prepare_review: false,
integrate: true,
show_journal_trunk_oid: false,
trunk: None,
edge: None,
path: None,
},
};
assert!(
matches!(write_class(&c), WriteClass::Orchestrator("dispatch-sync")),
"dispatch sync --integrate must be Orchestrator(\"dispatch-sync\")"
);
assert_eq!(cls(c), Some("dispatch-sync"));
}
#[test]
fn inspect_is_read() {
assert_eq!(
cls(Command::Inspect {
id: "SL-046".to_string(),
format: Format::Table,
json: false,
path: None,
}),
None
);
}
#[test]
fn validate_is_read_reseat_is_write() {
assert_eq!(cls(Command::Validate { path: None }), None);
assert_eq!(
cls(Command::Reseat {
reference: "SL-001".to_string(),
to: None,
path: None,
}),
Some("reseat")
);
}
fn estimate_cmd() -> Command {
Command::Estimate {
action: EstimateAction::Set(EstimateSetArgs {
id: "SL-001".into(),
lower: Some(1.0),
upper: Some(3.0),
exact: None,
path: None,
}),
}
}
#[test]
fn estimate_is_write() {
assert_eq!(cls(estimate_cmd()), Some("estimate"));
}
#[test]
fn value_is_write() {
let c = Command::Value {
action: ValueAction::Set(ValueSetArgs {
id: "SL-001".into(),
magnitude: 42.0,
path: None,
}),
};
assert_eq!(cls(c), Some("value"));
}
}