diff --git a/CHANGELOG.md b/CHANGELOG.md index 0006d73..6bf6433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,36 @@ and this project uses [independent versioning](README.md#versioning) for Framewo --- +## Framework 4.4.0 / CLI 3.6.0 — Charters as a first-class entity + +The first user-visible step of the post-Sentinel roadmap (`Propuesta/devtrail-cli-roadmap.md` Fase 1). Crystallizes the Charter pattern — bounded, auditable units of work declared ex-ante and validated ex-post — that emerged from the 6-cycle Sentinel `/plan-audit` experiment. The artifact was historically called "Plan" in Sentinel; renamed to **Charter** to disambiguate from GitHub SpecKit's `plan.md`. Sentinel's historical files preserve "Plan"; everything DevTrail ships from this release on uses "Charter". + +### Added (Framework) + +- **Charter template** at `dist/.devtrail/templates/charter-template.md` (EN + ES, ports Sentinel's `TEMPLATE.md v3` with the 6 validated format conventions: Local/Production verification split, time-based effort, structured sub-sections, R emergent risks, Charter Closure section, auto-checklist drift). Localized parallel under `templates/i18n/es/`. +- **Charter JSON Schema** at `dist/.devtrail/schemas/charter.schema.v0.json` (Draft 2020-12, marked `experimental`). Required fields: `charter_id`, `status` (`declared`/`in-progress`/`closed`), `effort_estimate` (`XS`/`S`/`M`/`L`), `trigger`. Mutually-exclusive optional fields: `originating_ailogs` array or `originating_spec` path. The `v0` suffix and additional-properties:true posture leave room for evolution; v1.0 stable requires a second-domain adopter (see `Propuesta/devtrail-thesis-validation.md` §6). +- **Two anonymized canonical examples** at `dist/docs/examples/charters/CHARTER-01-anomaly-thresholds.md` (M-effort feature) and `CHARTER-02-baseline-recompute.md` (XS-effort admin endpoint), derived from Sentinel PLAN-05 / PLAN-06 with identifiers anonymized but structural conventions preserved. + +### Added (CLI) + +- **`devtrail charter new`** scaffolds a Charter from the framework template into `docs/charters/NN-slug.md`. Three origin paths supported, mutually exclusive at the clap level: `--from-ailog AILOG-YYYY-MM-DD-NNN` (post-MVP / maintenance mode — the Sentinel case), `--from-spec specs/.../spec.md` (greenfield mode driven by SpecKit), or neither (Charter scaffolded without an explicit origin, to be filled in manually before status moves to `in-progress`). `--type XS|S|M|L` defaults to `M`. Sequential numbering is project-local; concurrency on parallel branches is documented as a known v0 limitation. +- **`devtrail charter list [--status declared|in-progress|closed|all] [--origin ailog|spec|any]`** enumerates Charters as a tight table (NN, STATUS, EFFORT, ORIGIN, TITLE) with width-adaptive columns. Files that fail to parse are reported as warnings to stderr; the command lists what it can. +- **`devtrail charter status [CHARTER-ID] [--path ]`** with an ID resolves the full charter_id, the `CHARTER-NN` prefix, or just the numeric NN; numeric matching is permissive across zero-padding (`10` matches both `CHARTER-10` and `CHARTER-010`). Without an ID, prints the 5 most recent Charters by NN descending. Status output flags Phase 2 features (`charter close`, `charter drift`) as not yet available. +- **`devtrail validate --include-charters`** validates `docs/charters/*.md` against the Charter schema (shape, enums, mutual exclusion of origin types) plus referential integrity (`originating_ailogs` IDs resolve to AILOG files; `originating_spec` path exists). Default `false` so projects that don't yet use Charters keep working unchanged. Schema-level errors emit hint-rich messages; missing schema emits a single warning rather than failing per-Charter. Currently honored only in the all-mode path; `--staged` integration is queued for cli-3.7.0. +- **`devtrail explore` Charters view (TUI)**: a synthetic "Charters" group is appended to the navigation tree when at least one Charter exists. Charter files render with a `CH` badge, are searchable / sortable like governance docs, and the `charter_id` resolves through `find_by_ref` so a related-link from any document can navigate to a Charter. Group label translates to `Charters` (es loanword) / `章程` (zh-CN). + +### Changed (CLI) + +- **`devtrail validate`** gains the `--include-charters` opt-in flag described above. No change to the existing pipeline when the flag is absent. + +### Notes + +- The Charter pattern is empirically validated in **a single project (Sentinel) on a single domain (Go backend)**. Per principle #12 of `Propuesta/devtrail-design-principles.md`, the schema, template, and tooling ship as `v0` / experimental. Stabilization to `v1.0` requires validation in a second domain (frontend, ML pipeline, infra-as-code) — see `Propuesta/devtrail-thesis-validation.md` §6 for the full N≈2-3 argument. +- Phase 2 of the CLI roadmap (`Propuesta/devtrail-cli-roadmap.md` §4) adds `charter close` (interactive telemetry capture at Charter cierre) and `charter drift` (file-vs-commit drift check, port of Sentinel's `check-plan-drift.sh`). Phase 3 adds `charter audit` (multi-model external audit with inter-family heterogeneity constraint). +- This release ships **no breaking changes**. Existing adopters can update via `devtrail update-cli` and `devtrail update-framework`; their existing flow remains identical until they opt into the Charter commands. + +--- + ## CLI 3.5.3 — `devtrail update` no longer leaks package internals into adopter projects ### Fixed (CLI) diff --git a/README.md b/README.md index 1a15ac4..8cfe381 100644 --- a/README.md +++ b/README.md @@ -206,8 +206,8 @@ DevTrail uses independent version tags for each component: | Component | Tag prefix | Example | Includes | |-----------|-----------|---------|----------| -| Framework | `fw-` | `fw-4.3.0` | Templates (12 types), governance, directives | -| CLI | `cli-` | `cli-3.5.3` | The `devtrail` binary | +| Framework | `fw-` | `fw-4.4.0` | Templates (12 types), governance, directives, Charter template + schema | +| CLI | `cli-` | `cli-3.6.0` | The `devtrail` binary | Check installed versions with `devtrail status` or `devtrail about`. @@ -222,7 +222,8 @@ Check installed versions with `devtrail status` or `devtrail about`. | `devtrail remove [--full]` | Remove DevTrail from project | | `devtrail status [path]` | Show installation health and doc stats | | `devtrail repair [path]` | Restore missing directories and framework files | -| `devtrail validate [path]` | Validate documents for compliance and correctness | +| `devtrail validate [path]` | Validate documents for compliance and correctness (use `--include-charters` to also check `docs/charters/`) | +| `devtrail charter ` | Manage Charters: `new`, `list`, `status` (bounded units of work declared ex-ante, audited ex-post) | | `devtrail compliance [path]` | Check regulatory compliance (EU AI Act, ISO 42001, NIST) | | `devtrail metrics [path]` | Show governance metrics and documentation statistics | | `devtrail analyze [path]` | Analyze code complexity (cognitive + cyclomatic metrics) | @@ -237,7 +238,7 @@ See [CLI Reference](https://github.com/StrangeDaysTech/devtrail/blob/main/docs/a ```bash # Download the latest framework release ZIP from GitHub # Go to https://github.com/StrangeDaysTech/devtrail/releases -# and download the latest fw-* release (e.g., fw-4.3.0) +# and download the latest fw-* release (e.g., fw-4.4.0) # Extract and copy to your project unzip devtrail-fw-*.zip -d your-project/ diff --git a/cli/Cargo.lock b/cli/Cargo.lock index f8910b1..2951a67 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -19,6 +19,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -163,6 +177,21 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bitflags" version = "2.11.0" @@ -195,6 +224,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "byteorder" version = "1.5.0" @@ -537,7 +572,7 @@ dependencies = [ [[package]] name = "devtrail-cli" -version = "3.5.3" +version = "3.6.0" dependencies = [ "anyhow", "arborist-metrics", @@ -549,6 +584,7 @@ dependencies = [ "dialoguer", "flate2", "indicatif", + "jsonschema", "predicates", "pulldown-cmark", "ratatui", @@ -642,6 +678,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -720,6 +767,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1214,6 +1271,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1249,6 +1315,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0f4bea31643be4c6a678e9aa4ae44f0db9e5609d5ca9dc9083d06eb3e9a27a" +dependencies = [ + "ahash", + "anyhow", + "base64", + "bytecount", + "fancy-regex", + "fraction", + "getrandom 0.2.17", + "iso8601", + "itoa", + "memchr", + "num-cmp", + "once_cell", + "parking_lot", + "percent-encoding", + "regex", + "serde", + "serde_json", + "time", + "url", + "uuid", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1399,18 +1493,97 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2346,6 +2519,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2354,6 +2528,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -2725,6 +2909,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 4e62d6b..5025d4d 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devtrail-cli" -version = "3.5.3" +version = "3.6.0" edition = "2021" description = "CLI tool for DevTrail - Documentation Governance for AI-Assisted Development" license = "MIT" @@ -34,6 +34,7 @@ semver = "1" flate2 = "1" tar = "0.4" unicode-width = "0.2" +jsonschema = { version = "0.18", default-features = false } ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] } crossterm = { version = "0.28", optional = true } pulldown-cmark = { version = "0.12", optional = true } diff --git a/cli/src/charter.rs b/cli/src/charter.rs new file mode 100644 index 0000000..faf74e6 --- /dev/null +++ b/cli/src/charter.rs @@ -0,0 +1,782 @@ +//! Charter — DevTrail's bounded, auditable unit of work. +//! +//! A Charter is the artifact that pairs declarative ex-ante scope with ex-post +//! drift detection and external audit. The schema lives in +//! `dist/.devtrail/schemas/charter.schema.v0.json` (experimental v0). This +//! module provides typed access to a Charter document on disk. +//! +//! Conceptually distinct from `DocType` (governance documents): Charters live +//! at `docs/charters/NN-slug.md` (project-root level), use sequential filenames +//! without a date prefix, and their schema is its own contract. See +//! `Propuesta/que-es-un-charter.md` for the conceptual scope. + +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Lifecycle status of a Charter. Source of truth — the prose status mirror in +/// the body is decorative. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum CharterStatus { + Declared, + InProgress, + Closed, +} + +impl CharterStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Declared => "declared", + Self::InProgress => "in-progress", + Self::Closed => "closed", + } + } +} + +/// Time-based effort estimate. Sentinel /plan-audit validated this as predictive +/// in 4/5 cycles; line count is intentionally not tracked. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EffortEstimate { + #[serde(rename = "XS")] + Xs, + #[serde(rename = "S")] + S, + #[serde(rename = "M")] + M, + #[serde(rename = "L")] + L, +} + +impl EffortEstimate { + pub fn as_str(&self) -> &'static str { + match self { + Self::Xs => "XS", + Self::S => "S", + Self::M => "M", + Self::L => "L", + } + } +} + +/// Typed view of a Charter's YAML frontmatter. The JSON Schema in +/// `dist/.devtrail/schemas/charter.schema.v0.json` is the source of truth for +/// shape; this struct is the typed Rust mirror used by CLI logic. Additional +/// fields the schema permits (e.g., `note`, `closed_at`) are not enumerated +/// here — for full schema-aware validation, parse the frontmatter as +/// `serde_yaml::Value` and use `crate::charter_schema::validate_frontmatter`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharterFrontmatter { + pub charter_id: String, + pub status: CharterStatus, + pub effort_estimate: EffortEstimate, + pub trigger: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub originating_ailogs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub originating_spec: Option, +} + +/// A parsed Charter document from disk. +/// +/// For schema-aware access to fields the typed struct does not enumerate +/// (e.g., `note`, `closed_at`), use `read_frontmatter_yaml` instead — it +/// returns the raw `serde_yaml::Value` without typed deserialization. +#[derive(Debug, Clone)] +pub struct Charter { + pub path: PathBuf, + pub frontmatter: CharterFrontmatter, + pub body: String, +} + +/// Parse a Charter document from disk. Returns an error if the file has no +/// YAML frontmatter or required fields are missing/wrong-typed. +pub fn parse_charter(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read Charter file {}", path.display()))?; + parse_charter_str(path, &content) +} + +/// Parse a Charter from raw content (split out for testability). +pub fn parse_charter_str(path: &Path, content: &str) -> Result { + let (frontmatter_str, body) = split_frontmatter(content).ok_or_else(|| { + anyhow!( + "Charter at {} has no YAML frontmatter (expected --- delimiters at top of file)", + path.display() + ) + })?; + let frontmatter: CharterFrontmatter = serde_yaml::from_str(frontmatter_str) + .with_context(|| { + format!( + "Charter frontmatter at {} is missing required fields or has wrong types. \ + Required: charter_id, status (declared|in-progress|closed), effort_estimate (XS|S|M|L), trigger.", + path.display() + ) + })?; + Ok(Charter { + path: path.to_path_buf(), + frontmatter, + body: body.to_string(), + }) +} + +/// Discover all Charter files in a project. Charters live at `docs/charters/*.md` +/// with filenames `NN-slug.md` (sequential prefix). Returns paths sorted by +/// filename so callers get stable ordering. Files that don't match the +/// `NN-slug.md` pattern are skipped (e.g., a stray `README.md`). +pub fn discover_charters(project_root: &Path) -> Vec { + let dir = project_root.join("docs").join("charters"); + if !dir.exists() { + return Vec::new(); + } + let mut paths: Vec = match std::fs::read_dir(&dir) { + Ok(rd) => rd + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| is_charter_filename(p)) + .collect(), + Err(_) => return Vec::new(), + }; + paths.sort(); + paths +} + +/// True if the file matches the Charter filename pattern `NN-*.md` where NN is +/// one or more digits. +fn is_charter_filename(p: &Path) -> bool { + if p.extension().and_then(|e| e.to_str()) != Some("md") { + return false; + } + let name = match p.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => return false, + }; + let leading: String = name.chars().take_while(|c| c.is_ascii_digit()).collect(); + !leading.is_empty() && name[leading.len()..].starts_with('-') +} + +/// Read and parse just the YAML frontmatter from a Charter file, without +/// applying the typed `CharterFrontmatter` deserialization. Used by the +/// schema validator so shape errors (bad enum, missing required field) are +/// reported by the schema itself with rich hints, rather than being masked +/// by typed-parse failures. +pub fn read_frontmatter_yaml(path: &Path) -> Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read {}", path.display()))?; + let (fm_str, _body) = split_frontmatter(&content).ok_or_else(|| { + anyhow!( + "Charter at {} has no YAML frontmatter (expected --- delimiters at top of file)", + path.display() + ) + })?; + serde_yaml::from_str(fm_str) + .with_context(|| format!("Failed to parse Charter frontmatter at {}", path.display())) +} + +/// Discover all Charters in a project and parse each one. Returns the parsed +/// Charters along with a list of `(path, error)` for files that failed to parse. +/// Callers decide whether to fail (status sub-command on a specific ID) or +/// display a degraded view (list sub-command — show the parseable ones, warn +/// about the rest). +pub fn discover_and_parse(project_root: &Path) -> (Vec, Vec<(PathBuf, anyhow::Error)>) { + let mut parsed = Vec::new(); + let mut errors = Vec::new(); + for path in discover_charters(project_root) { + match parse_charter(&path) { + Ok(c) => parsed.push(c), + Err(e) => errors.push((path, e)), + } + } + (parsed, errors) +} + +/// Find a Charter in a slice by user-supplied ID. Accepts three forms: +/// - The full charter_id (`CHARTER-01-per-service-anomaly-thresholds`) +/// - The CHARTER-NN prefix (`CHARTER-01`) +/// - Just the numeric NN (`01` or `1`) +/// +/// Returns the matching Charter or None. When multiple Charters share the +/// same NN (which should not happen in a healthy repo), the first in the +/// slice wins — call sites typically receive Charters sorted by filename so +/// "first" is the one with the lexicographically lowest slug. +pub fn find_by_id<'a>(charters: &'a [Charter], id_input: &str) -> Option<&'a Charter> { + let trimmed = id_input.trim(); + if trimmed.is_empty() { + return None; + } + + // Exact match on charter_id. + if let Some(c) = charters + .iter() + .find(|c| c.frontmatter.charter_id == trimmed) + { + return Some(c); + } + + // CHARTER-NN[-slug] prefix-with-boundary match (CHARTER-01 matches + // CHARTER-01-anything but not CHARTER-010). + if trimmed.starts_with("CHARTER-") { + if let Some(c) = charters.iter().find(|c| { + let cid = &c.frontmatter.charter_id; + cid.starts_with(trimmed) + && (cid.len() == trimmed.len() + || cid.as_bytes().get(trimmed.len()) == Some(&b'-')) + }) { + return Some(c); + } + } + + // Numeric NN match — compares parsed integers, so "10" matches both + // CHARTER-10-x and CHARTER-010-x (same numerical NN, different padding). + // String-prefix match (above) is what disambiguates when both exist. + if let Ok(n) = trimmed.parse::() { + if let Some(c) = charters.iter().find(|c| { + let cid = &c.frontmatter.charter_id; + let after_prefix = cid.strip_prefix("CHARTER-").unwrap_or(cid); + let digits: String = after_prefix + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect(); + digits.parse::().ok() == Some(n) + }) { + return Some(c); + } + } + + None +} + +/// Extract a display title from a Charter's body. Looks for an H1 line of the +/// form `# Charter: ` and returns `<title>`. Falls back to the slug +/// portion of the filename if the H1 is missing or doesn't match. +pub fn display_title(charter: &Charter) -> String { + for line in charter.body.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("# Charter:") { + let t = rest.trim(); + if !t.is_empty() && !t.starts_with('[') { + return t.to_string(); + } + } + } + // Fallback: derive from filename slug. + charter + .path + .file_stem() + .and_then(|s| s.to_str()) + .and_then(|stem| { + // Strip leading digits + dash. + let after_digits = stem.trim_start_matches(|c: char| c.is_ascii_digit()); + after_digits.strip_prefix('-').or(Some(after_digits)) + }) + .map(|s| s.replace('-', " ")) + .unwrap_or_else(|| "(untitled)".to_string()) +} + +/// Categorize a Charter's origin for filtering. Returns one of "ailog", "spec", +/// or "none". +pub fn origin_kind(fm: &CharterFrontmatter) -> &'static str { + if fm.originating_ailogs.is_some() { + "ailog" + } else if fm.originating_spec.is_some() { + "spec" + } else { + "none" + } +} + +/// Render the origin field for display. Returns the AILOG ID(s) joined or the +/// spec path, or "—" when no origin is set. +pub fn display_origin(fm: &CharterFrontmatter) -> String { + if let Some(ailogs) = &fm.originating_ailogs { + ailogs.join(", ") + } else if let Some(spec) = &fm.originating_spec { + spec.clone() + } else { + "—".to_string() + } +} + +/// Determine the next sequential Charter number for a project. Reads +/// `docs/charters/` and returns `max(NN) + 1`, or 1 if no Charters exist yet. +/// +/// Note: this is intentionally racy on parallel branches (R2 in the Phase 1 +/// plan). Two contributors running `charter new` simultaneously can both +/// receive NN=03; the conflict surfaces at merge time. Phase 2+ may add +/// atomic numbering via a lock file; v0 documents the caveat. +pub fn next_charter_number(project_root: &Path) -> u32 { + discover_charters(project_root) + .iter() + .filter_map(charter_number_from_path) + .max() + .map(|n| n + 1) + .unwrap_or(1) +} + +/// Extract the leading digits from a Charter filename (`05-foo.md` → 5). +fn charter_number_from_path(p: &PathBuf) -> Option<u32> { + let name = p.file_name()?.to_str()?; + let prefix: String = name.chars().take_while(|c| c.is_ascii_digit()).collect(); + if prefix.is_empty() { + return None; + } + prefix.parse::<u32>().ok() +} + +/// Split a markdown document into (frontmatter, body) at the first pair of `---` +/// delimiters. Returns `None` if the document has no frontmatter block. +/// Accepts both `\n` and `\r\n` line endings on the closing delimiter. +fn split_frontmatter(content: &str) -> Option<(&str, &str)> { + // Opening delimiter: must be at the very start of the file. + let after_open = content.strip_prefix("---\n").or_else(|| content.strip_prefix("---\r\n"))?; + // Closing delimiter: find the first occurrence of `\n---\n` or `\r\n---\r\n`. + let (end, delim_len) = if let Some(idx) = after_open.find("\n---\n") { + (idx, 5) + } else if let Some(idx) = after_open.find("\r\n---\r\n") { + (idx, 7) + } else if let Some(idx) = after_open.find("\n---\r\n") { + (idx, 6) + } else { + return None; + }; + let frontmatter = &after_open[..end]; + let body = &after_open[end + delim_len..]; + Some((frontmatter, body)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); + } + + const VALID_FRONTMATTER: &str = r#"--- +charter_id: CHARTER-01-test +status: declared +effort_estimate: M +trigger: "test trigger" +--- + +# Charter: Test +Body content. +"#; + + #[test] + fn parse_minimal_valid_charter() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("01-test.md"); + write(&p, VALID_FRONTMATTER); + let charter = parse_charter(&p).unwrap(); + assert_eq!(charter.frontmatter.charter_id, "CHARTER-01-test"); + assert_eq!(charter.frontmatter.status, CharterStatus::Declared); + assert_eq!(charter.frontmatter.effort_estimate, EffortEstimate::M); + assert_eq!(charter.frontmatter.trigger, "test trigger"); + assert!(charter.frontmatter.originating_ailogs.is_none()); + assert!(charter.frontmatter.originating_spec.is_none()); + assert!(charter.body.contains("# Charter: Test")); + } + + #[test] + fn parse_with_ailogs_origin() { + let content = r#"--- +charter_id: CHARTER-02-with-ailog +status: in-progress +effort_estimate: S +trigger: "follow-up triggered" +originating_ailogs: + - AILOG-2026-04-28-021 + - AILOG-2026-04-28-022 +--- + +Body. +"#; + let charter = parse_charter_str(Path::new("02-with-ailog.md"), content).unwrap(); + assert_eq!( + charter.frontmatter.originating_ailogs.as_deref(), + Some(&["AILOG-2026-04-28-021".to_string(), "AILOG-2026-04-28-022".to_string()][..]) + ); + } + + #[test] + fn parse_with_spec_origin() { + let content = r#"--- +charter_id: CHARTER-03-from-spec +status: declared +effort_estimate: L +trigger: "from spec" +originating_spec: specs/001-feature/spec.md +--- + +Body. +"#; + let charter = parse_charter_str(Path::new("03-from-spec.md"), content).unwrap(); + assert_eq!( + charter.frontmatter.originating_spec.as_deref(), + Some("specs/001-feature/spec.md") + ); + } + + #[test] + fn parse_ignores_unknown_fields_silently() { + // Fields the typed struct does not enumerate (e.g., `note`, `closed_at`) + // must not cause a parse error — the schema controls their shape via + // additionalProperties: true. Use read_frontmatter_yaml when you need + // schema-aware access to those fields. + let content = r#"--- +charter_id: CHARTER-04-extras +status: closed +effort_estimate: XS +trigger: "x" +note: "this is an example" +closed_at: "2026-04-30" +--- + +Body. +"#; + let charter = parse_charter_str(Path::new("04-extras.md"), content).unwrap(); + assert_eq!(charter.frontmatter.charter_id, "CHARTER-04-extras"); + assert_eq!(charter.frontmatter.status, CharterStatus::Closed); + } + + #[test] + fn read_frontmatter_yaml_preserves_unknown_fields() { + let tmp = TempDir::new().unwrap(); + let p = tmp.path().join("04-extras.md"); + write( + &p, + "---\ncharter_id: CHARTER-04-extras\nstatus: closed\neffort_estimate: XS\ntrigger: x\nnote: example\nclosed_at: \"2026-04-30\"\n---\n\nBody.\n", + ); + let yaml = read_frontmatter_yaml(&p).unwrap(); + let map = yaml.as_mapping().unwrap(); + assert_eq!( + map.get(serde_yaml::Value::String("note".into())) + .and_then(|v| v.as_str()), + Some("example") + ); + } + + #[test] + fn parse_fails_without_frontmatter() { + let content = "# Just a markdown file\n\nNo frontmatter.\n"; + let err = parse_charter_str(Path::new("bad.md"), content).unwrap_err(); + assert!(err.to_string().contains("no YAML frontmatter")); + } + + #[test] + fn parse_fails_with_missing_required_field() { + let content = r#"--- +charter_id: CHARTER-05 +status: declared +effort_estimate: M +--- + +Missing trigger. +"#; + let err = parse_charter_str(Path::new("missing.md"), content).unwrap_err(); + assert!( + err.to_string().contains("missing") + || err.to_string().contains("trigger") + || err.chain().any(|c| c.to_string().contains("trigger")) + ); + } + + #[test] + fn parse_fails_with_invalid_status_enum() { + let content = r#"--- +charter_id: CHARTER-06 +status: unknown-state +effort_estimate: M +trigger: "x" +--- + +Body. +"#; + let err = parse_charter_str(Path::new("bad-status.md"), content).unwrap_err(); + // serde reports "unknown variant" or similar. + let chain: Vec<String> = err.chain().map(|c| c.to_string()).collect(); + assert!( + chain.iter().any(|s| s.contains("unknown") || s.contains("variant") || s.contains("status")), + "unexpected error chain: {:?}", + chain + ); + } + + #[test] + fn discover_returns_empty_when_dir_missing() { + let tmp = TempDir::new().unwrap(); + assert!(discover_charters(tmp.path()).is_empty()); + } + + #[test] + fn discover_returns_sorted_charter_files_only() { + let tmp = TempDir::new().unwrap(); + let charters_dir = tmp.path().join("docs").join("charters"); + write(&charters_dir.join("03-third.md"), VALID_FRONTMATTER); + write(&charters_dir.join("01-first.md"), VALID_FRONTMATTER); + write(&charters_dir.join("02-second.md"), VALID_FRONTMATTER); + // Non-Charter files that should be ignored. + write(&charters_dir.join("README.md"), "# Charters\n"); + write(&charters_dir.join("notes.txt"), "ignored"); + write(&charters_dir.join("draft.md"), "no leading digits"); + + let found = discover_charters(tmp.path()); + let names: Vec<&str> = found + .iter() + .map(|p| p.file_name().unwrap().to_str().unwrap()) + .collect(); + assert_eq!(names, vec!["01-first.md", "02-second.md", "03-third.md"]); + } + + #[test] + fn next_number_is_one_when_empty() { + let tmp = TempDir::new().unwrap(); + assert_eq!(next_charter_number(tmp.path()), 1); + } + + #[test] + fn next_number_is_max_plus_one() { + let tmp = TempDir::new().unwrap(); + let charters_dir = tmp.path().join("docs").join("charters"); + write(&charters_dir.join("01-a.md"), VALID_FRONTMATTER); + write(&charters_dir.join("05-b.md"), VALID_FRONTMATTER); + write(&charters_dir.join("03-c.md"), VALID_FRONTMATTER); + assert_eq!(next_charter_number(tmp.path()), 6); + } + + #[test] + fn next_number_skips_non_numbered_files() { + let tmp = TempDir::new().unwrap(); + let charters_dir = tmp.path().join("docs").join("charters"); + write(&charters_dir.join("01-a.md"), VALID_FRONTMATTER); + write(&charters_dir.join("README.md"), "# Charters\n"); + write(&charters_dir.join("draft-no-number.md"), VALID_FRONTMATTER); + assert_eq!(next_charter_number(tmp.path()), 2); + } + + #[test] + fn split_frontmatter_handles_unix_line_endings() { + let (fm, body) = split_frontmatter("---\nfoo: bar\n---\nbody\n").unwrap(); + assert_eq!(fm, "foo: bar"); + assert_eq!(body, "body\n"); + } + + #[test] + fn split_frontmatter_returns_none_without_block() { + assert!(split_frontmatter("# no frontmatter\n").is_none()); + assert!(split_frontmatter("---\nfoo: bar\n").is_none()); // no closing + } + + #[test] + fn status_as_str_matches_schema_enum_values() { + assert_eq!(CharterStatus::Declared.as_str(), "declared"); + assert_eq!(CharterStatus::InProgress.as_str(), "in-progress"); + assert_eq!(CharterStatus::Closed.as_str(), "closed"); + } + + #[test] + fn effort_as_str_matches_schema_enum_values() { + assert_eq!(EffortEstimate::Xs.as_str(), "XS"); + assert_eq!(EffortEstimate::S.as_str(), "S"); + assert_eq!(EffortEstimate::M.as_str(), "M"); + assert_eq!(EffortEstimate::L.as_str(), "L"); + } + + fn make_charter(id: &str, status: CharterStatus, origin: Origin, body: &str) -> Charter { + let fm = CharterFrontmatter { + charter_id: id.to_string(), + status, + effort_estimate: EffortEstimate::M, + trigger: "x".to_string(), + originating_ailogs: match &origin { + Origin::Ailog(ids) => Some(ids.clone()), + _ => None, + }, + originating_spec: match &origin { + Origin::Spec(s) => Some(s.clone()), + _ => None, + }, + }; + // Build a realistic filename: NN-slug.md (mirrors what `charter new` produces). + // Strip the CHARTER- prefix from the ID to derive the on-disk filename. + let filename = id + .strip_prefix("CHARTER-") + .map(|rest| format!("{}.md", rest.to_lowercase())) + .unwrap_or_else(|| format!("{}.md", id.to_lowercase())); + Charter { + path: PathBuf::from(format!("docs/charters/{}", filename)), + frontmatter: fm, + body: body.to_string(), + } + } + + enum Origin { + Ailog(Vec<String>), + Spec(String), + None, + } + + #[test] + fn find_by_id_exact_match() { + let charters = vec![ + make_charter("CHARTER-01-foo", CharterStatus::Declared, Origin::None, ""), + make_charter("CHARTER-02-bar", CharterStatus::Closed, Origin::None, ""), + ]; + let found = find_by_id(&charters, "CHARTER-02-bar").unwrap(); + assert_eq!(found.frontmatter.charter_id, "CHARTER-02-bar"); + } + + #[test] + fn find_by_id_charter_nn_prefix() { + let charters = vec![ + make_charter("CHARTER-01-foo", CharterStatus::Declared, Origin::None, ""), + make_charter("CHARTER-02-bar", CharterStatus::Closed, Origin::None, ""), + ]; + let found = find_by_id(&charters, "CHARTER-01").unwrap(); + assert_eq!(found.frontmatter.charter_id, "CHARTER-01-foo"); + } + + #[test] + fn find_by_id_numeric() { + let charters = vec![ + make_charter("CHARTER-01-foo", CharterStatus::Declared, Origin::None, ""), + make_charter("CHARTER-02-bar", CharterStatus::Closed, Origin::None, ""), + ]; + assert_eq!(find_by_id(&charters, "1").unwrap().frontmatter.charter_id, "CHARTER-01-foo"); + assert_eq!(find_by_id(&charters, "01").unwrap().frontmatter.charter_id, "CHARTER-01-foo"); + assert_eq!(find_by_id(&charters, "2").unwrap().frontmatter.charter_id, "CHARTER-02-bar"); + } + + #[test] + fn find_by_id_no_match_returns_none() { + let charters = vec![make_charter( + "CHARTER-01-foo", + CharterStatus::Declared, + Origin::None, + "", + )]; + assert!(find_by_id(&charters, "CHARTER-99").is_none()); + assert!(find_by_id(&charters, "99").is_none()); + assert!(find_by_id(&charters, "PLAN-01").is_none()); + assert!(find_by_id(&charters, "").is_none()); + } + + #[test] + fn find_by_id_prefix_does_not_match_longer_number() { + // CHARTER-01 must NOT match CHARTER-010. + let charters = vec![make_charter( + "CHARTER-010-extended", + CharterStatus::Declared, + Origin::None, + "", + )]; + assert!(find_by_id(&charters, "CHARTER-01").is_none()); + assert!(find_by_id(&charters, "1").is_none()); + assert_eq!( + find_by_id(&charters, "10").unwrap().frontmatter.charter_id, + "CHARTER-010-extended" + ); + } + + #[test] + fn display_title_extracts_h1() { + let c = make_charter( + "CHARTER-01-x", + CharterStatus::Declared, + Origin::None, + "# Charter: My Real Title\n\nbody text\n", + ); + assert_eq!(display_title(&c), "My Real Title"); + } + + #[test] + fn display_title_falls_back_to_filename_when_h1_is_placeholder() { + let c = make_charter( + "CHARTER-01-foo-bar", + CharterStatus::Declared, + Origin::None, + "# Charter: [BRIEF TITLE]\n\nbody\n", + ); + // The body H1 still contains the placeholder, so we fall back. + assert_eq!(display_title(&c), "foo bar"); + } + + #[test] + fn display_title_falls_back_when_body_lacks_h1() { + let c = make_charter( + "CHARTER-01-foo-bar", + CharterStatus::Declared, + Origin::None, + "no h1 in this body\n", + ); + assert_eq!(display_title(&c), "foo bar"); + } + + #[test] + fn origin_kind_categorizes_correctly() { + let with_ailog = make_charter( + "CHARTER-01-x", + CharterStatus::Declared, + Origin::Ailog(vec!["AILOG-2026-04-28-021".into()]), + "", + ); + assert_eq!(origin_kind(&with_ailog.frontmatter), "ailog"); + + let with_spec = make_charter( + "CHARTER-02-x", + CharterStatus::Declared, + Origin::Spec("specs/001/spec.md".into()), + "", + ); + assert_eq!(origin_kind(&with_spec.frontmatter), "spec"); + + let with_none = make_charter("CHARTER-03-x", CharterStatus::Declared, Origin::None, ""); + assert_eq!(origin_kind(&with_none.frontmatter), "none"); + } + + #[test] + fn display_origin_renders_each_kind() { + let with_ailog = make_charter( + "CHARTER-01-x", + CharterStatus::Declared, + Origin::Ailog(vec!["AILOG-2026-04-28-021".into(), "AILOG-2026-04-28-022".into()]), + "", + ); + assert_eq!( + display_origin(&with_ailog.frontmatter), + "AILOG-2026-04-28-021, AILOG-2026-04-28-022" + ); + + let with_spec = make_charter( + "CHARTER-02-x", + CharterStatus::Declared, + Origin::Spec("specs/001/spec.md".into()), + "", + ); + assert_eq!(display_origin(&with_spec.frontmatter), "specs/001/spec.md"); + + let with_none = make_charter("CHARTER-03-x", CharterStatus::Declared, Origin::None, ""); + assert_eq!(display_origin(&with_none.frontmatter), "—"); + } + + #[test] + fn discover_and_parse_separates_good_and_bad() { + let tmp = TempDir::new().unwrap(); + let charters_dir = tmp.path().join("docs").join("charters"); + write(&charters_dir.join("01-good.md"), VALID_FRONTMATTER); + // A file with leading digits + dash but broken frontmatter. + write( + &charters_dir.join("02-bad.md"), + "no frontmatter at all\n", + ); + let (parsed, errors) = discover_and_parse(tmp.path()); + assert_eq!(parsed.len(), 1); + assert_eq!(errors.len(), 1); + assert!(errors[0].0.file_name().unwrap().to_str().unwrap().contains("02-bad")); + } +} diff --git a/cli/src/charter_schema.rs b/cli/src/charter_schema.rs new file mode 100644 index 0000000..57bb040 --- /dev/null +++ b/cli/src/charter_schema.rs @@ -0,0 +1,454 @@ +//! JSON Schema validation for Charter frontmatter. +//! +//! The schema is shipped at `<framework>/.devtrail/schemas/charter.schema.v0.json` +//! (the framework distribution drops it into the project at `devtrail init` time). +//! This module loads and compiles the schema once and validates frontmatter +//! parsed as `serde_yaml::Value`, mapping JSON-Schema errors to the +//! `ValidationIssue` shape used by the rest of the validate pipeline so the +//! existing output formatter handles them uniformly. + +use anyhow::{anyhow, Context, Result}; +use jsonschema::JSONSchema; +use serde_json::Value; +use std::path::{Path, PathBuf}; + +use crate::validation::{Severity, ValidationIssue}; + +/// Path to the Charter schema relative to a project's `.devtrail/` directory. +pub const SCHEMA_RELATIVE_PATH: &str = "schemas/charter.schema.v0.json"; + +/// A loaded and compiled Charter schema, ready to validate frontmatter. +pub struct CharterSchema { + compiled: JSONSchema, +} + +impl CharterSchema { + /// Load and compile the Charter schema from a project's `.devtrail/` + /// directory. Returns an error if the schema file is missing, not valid + /// JSON, or not a valid JSON Schema. + pub fn load(devtrail_dir: &Path) -> Result<Self> { + let path = devtrail_dir.join(SCHEMA_RELATIVE_PATH); + let raw = std::fs::read_to_string(&path).with_context(|| { + format!( + "Failed to read Charter schema at {}. Run `devtrail repair` to restore framework files.", + path.display() + ) + })?; + Self::from_json_str(&raw, path) + } + + /// Compile the schema from a raw JSON string. Split out for testability. + /// `source_path` is used only in error messages; it is not stored. + pub fn from_json_str(raw: &str, source_path: PathBuf) -> Result<Self> { + let schema_json: Value = serde_json::from_str(raw).with_context(|| { + format!("Charter schema at {} is not valid JSON", source_path.display()) + })?; + let compiled = JSONSchema::options() + .compile(&schema_json) + .map_err(|e| anyhow!("Failed to compile Charter schema: {e}"))?; + Ok(Self { compiled }) + } + + /// Validate a Charter frontmatter (parsed YAML) against the schema. Returns + /// a Vec of `ValidationIssue` (empty if the frontmatter is valid). + pub fn validate( + &self, + yaml_value: &serde_yaml::Value, + file_path: &Path, + ) -> Vec<ValidationIssue> { + let json_value = match yaml_to_json(yaml_value) { + Ok(v) => v, + Err(e) => { + return vec![ValidationIssue { + file: file_path.to_path_buf(), + rule: "CHARTER-CONVERT".to_string(), + message: format!( + "Charter frontmatter cannot be converted to JSON for schema validation: {e}" + ), + severity: Severity::Error, + fix_hint: Some( + "Frontmatter values must be JSON-compatible (no YAML-only constructs like timestamps or non-string keys).".to_string(), + ), + }]; + } + }; + // Bind the validate result to a local before consuming so the + // ErrorIterator's borrow of `json_value` is released before the + // function returns. + let issues: Vec<ValidationIssue> = match self.compiled.validate(&json_value) { + Ok(()) => Vec::new(), + Err(errors) => errors + .map(|err| ValidationIssue { + file: file_path.to_path_buf(), + rule: rule_from_error(&err), + message: format_message(&err), + severity: Severity::Error, + fix_hint: hint_for(&err), + }) + .collect(), + }; + issues + } +} + +/// Build a stable rule code from a validation error's schema path. The schema +/// path is a JSON Pointer like `/properties/charter_id/pattern` — we translate +/// it to a readable code like `CHARTER-SCHEMA/charter_id/pattern`. +fn rule_from_error(err: &jsonschema::ValidationError) -> String { + let path = err.schema_path.to_string(); + let trimmed = path.trim_start_matches('/').replace("/properties/", "/"); + if trimmed.is_empty() { + "CHARTER-SCHEMA".to_string() + } else { + format!("CHARTER-SCHEMA/{}", trimmed) + } +} + +/// Format a validation error into a single-line message that includes the +/// instance location (so the user knows which field is at fault). +fn format_message(err: &jsonschema::ValidationError) -> String { + let instance_path = err.instance_path.to_string(); + let location = if instance_path.is_empty() { + "frontmatter".to_string() + } else { + instance_path.trim_start_matches('/').replace('/', ".") + }; + format!("{} (at {})", err, location) +} + +/// Provide friendlier hints for the most common violations. Returns `None` +/// when no specific guidance is warranted; the schema error message itself +/// is generally clear enough as a fallback. +fn hint_for(err: &jsonschema::ValidationError) -> Option<String> { + let path = err.schema_path.to_string(); + if path.contains("/charter_id/pattern") { + Some( + "charter_id must match CHARTER-NN[-slug] (e.g., CHARTER-01-anomaly-thresholds)." + .to_string(), + ) + } else if path.contains("/status/enum") { + Some("status must be one of: declared, in-progress, closed.".to_string()) + } else if path.contains("/effort_estimate/enum") { + Some("effort_estimate must be one of: XS, S, M, L.".to_string()) + } else if path.contains("/required") { + Some("Add the missing required field to the frontmatter (charter_id, status, effort_estimate, trigger).".to_string()) + } else if path.contains("/not") { + Some( + "originating_ailogs and originating_spec are mutually exclusive — set exactly one or neither." + .to_string(), + ) + } else if path.contains("/originating_ailogs/items/pattern") { + Some("Each originating_ailogs entry must match AILOG-YYYY-MM-DD-NNN[-slug].".to_string()) + } else { + None + } +} + +/// Convert a `serde_yaml::Value` to a `serde_json::Value`. YAML is a superset +/// of JSON so the conversion is direct for the constructs the schema expects. +/// YAML-only constructs (non-string mapping keys, tagged values not handled +/// here) cause a typed error. +fn yaml_to_json(v: &serde_yaml::Value) -> Result<Value> { + Ok(match v { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::Bool(*b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } + serde_yaml::Value::String(s) => Value::String(s.clone()), + serde_yaml::Value::Sequence(seq) => { + let mut arr = Vec::with_capacity(seq.len()); + for item in seq { + arr.push(yaml_to_json(item)?); + } + Value::Array(arr) + } + serde_yaml::Value::Mapping(map) => { + let mut obj = serde_json::Map::with_capacity(map.len()); + for (k, val) in map { + let key = match k { + serde_yaml::Value::String(s) => s.clone(), + other => { + return Err(anyhow!( + "YAML mapping key is not a string: {:?} (JSON Schema requires string keys)", + other + )); + } + }; + obj.insert(key, yaml_to_json(val)?); + } + Value::Object(obj) + } + serde_yaml::Value::Tagged(t) => yaml_to_json(&t.value)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Charter schema text. In tests we keep an in-tree copy of the + /// schema's structure (a minimal subset) so this module compiles without + /// depending on the framework distribution being installed at test time. + /// The full schema lives at `dist/.devtrail/schemas/charter.schema.v0.json`. + const TEST_SCHEMA: &str = r##"{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["charter_id", "status", "effort_estimate", "trigger"], + "properties": { + "charter_id": { + "type": "string", + "pattern": "^CHARTER-[0-9]{2,}(-[a-z0-9-]+)?$" + }, + "status": { + "type": "string", + "enum": ["declared", "in-progress", "closed"] + }, + "effort_estimate": { + "type": "string", + "enum": ["XS", "S", "M", "L"] + }, + "trigger": { "type": "string", "minLength": 1 }, + "originating_ailogs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^AILOG-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{3}(-[a-z0-9-]+)?$" + }, + "minItems": 1 + }, + "originating_spec": { "type": "string", "minLength": 1 } + }, + "not": { "required": ["originating_ailogs", "originating_spec"] } + }"##; + + fn schema() -> CharterSchema { + CharterSchema::from_json_str(TEST_SCHEMA, PathBuf::from("test://schema")).unwrap() + } + + fn yaml(s: &str) -> serde_yaml::Value { + serde_yaml::from_str(s).unwrap() + } + + #[test] + fn validates_minimal_valid_frontmatter() { + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-01-test +status: declared +effort_estimate: M +trigger: "x" +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(issues.is_empty(), "unexpected issues: {:?}", issues); + } + + #[test] + fn validates_with_ailogs_origin() { + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-02-with-ailog +status: in-progress +effort_estimate: S +trigger: "x" +originating_ailogs: + - AILOG-2026-04-28-021 +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(issues.is_empty(), "unexpected issues: {:?}", issues); + } + + #[test] + fn validates_with_spec_origin() { + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-03-from-spec +status: declared +effort_estimate: L +trigger: "x" +originating_spec: specs/001-feature/spec.md +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(issues.is_empty(), "unexpected issues: {:?}", issues); + } + + #[test] + fn rejects_missing_required_field() { + let s = schema(); + // Missing `trigger`. + let fm = yaml( + r#" +charter_id: CHARTER-04-missing +status: declared +effort_estimate: M +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(!issues.is_empty(), "expected at least one issue"); + assert!( + issues.iter().any(|i| i.rule.contains("required") || i.message.contains("required") || i.message.contains("trigger")), + "expected required-field error, got: {:?}", + issues + ); + } + + #[test] + fn rejects_invalid_status_enum() { + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-05-bad-status +status: unknown-state +effort_estimate: M +trigger: "x" +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(!issues.is_empty()); + assert!( + issues.iter().any(|i| i.rule.contains("status") || i.message.contains("status") || i.fix_hint.as_deref().map(|h| h.contains("status")).unwrap_or(false)), + "expected status enum error, got: {:?}", + issues + ); + } + + #[test] + fn rejects_invalid_effort_estimate() { + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-06-bad-effort +status: declared +effort_estimate: XXL +trigger: "x" +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(!issues.is_empty()); + } + + #[test] + fn rejects_charter_id_pattern_mismatch() { + let s = schema(); + // Plan-NN format (Sentinel historical) is rejected — DevTrail vocabulary + // requires CHARTER-NN. + let fm = yaml( + r#" +charter_id: PLAN-01 +status: declared +effort_estimate: M +trigger: "x" +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(!issues.is_empty()); + assert!( + issues.iter().any(|i| i.fix_hint.as_deref().map(|h| h.contains("CHARTER-NN")).unwrap_or(false)), + "expected charter_id hint, got: {:?}", + issues + ); + } + + #[test] + fn rejects_both_origins_set() { + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-07-both +status: declared +effort_estimate: M +trigger: "x" +originating_ailogs: [AILOG-2026-04-28-021] +originating_spec: specs/001-feature/spec.md +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(!issues.is_empty()); + assert!( + issues.iter().any(|i| i.fix_hint.as_deref().map(|h| h.contains("mutually exclusive")).unwrap_or(false)), + "expected mutual-exclusion hint, got: {:?}", + issues + ); + } + + #[test] + fn accepts_neither_origin_set() { + // Both absent is valid (Charter scaffolded without explicit origin). + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-08-no-origin +status: declared +effort_estimate: M +trigger: "x" +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(issues.is_empty(), "unexpected issues: {:?}", issues); + } + + #[test] + fn additional_properties_are_permitted() { + // The schema has additionalProperties: true (default), so unknown + // fields like `note` and `closed_at` must not trigger errors. + let s = schema(); + let fm = yaml( + r#" +charter_id: CHARTER-09-extras +status: closed +effort_estimate: XS +trigger: "x" +note: "anonymized example" +closed_at: "2026-04-30" +"#, + ); + let issues = s.validate(&fm, Path::new("test.md")); + assert!(issues.is_empty(), "unexpected issues: {:?}", issues); + } + + #[test] + fn yaml_to_json_handles_basic_types() { + let v = yaml( + r#" +str: hello +int: 42 +float: 3.14 +bool: true +null_val: null +list: [1, 2, 3] +nested: { a: 1, b: 2 } +"#, + ); + let j = yaml_to_json(&v).unwrap(); + let obj = j.as_object().unwrap(); + assert_eq!(obj.get("str").unwrap().as_str(), Some("hello")); + assert_eq!(obj.get("int").unwrap().as_i64(), Some(42)); + assert_eq!(obj.get("bool").unwrap().as_bool(), Some(true)); + assert!(obj.get("null_val").unwrap().is_null()); + } + + #[test] + fn yaml_to_json_rejects_non_string_keys() { + let mut map = serde_yaml::Mapping::new(); + map.insert(serde_yaml::Value::Number(1.into()), serde_yaml::Value::String("v".into())); + let v = serde_yaml::Value::Mapping(map); + let err = yaml_to_json(&v).unwrap_err(); + assert!(err.to_string().contains("not a string")); + } +} diff --git a/cli/src/commands/charter/list.rs b/cli/src/commands/charter/list.rs new file mode 100644 index 0000000..e0ca0a4 --- /dev/null +++ b/cli/src/commands/charter/list.rs @@ -0,0 +1,309 @@ +//! `devtrail charter list` — enumerate Charters with optional status / origin filter. +//! +//! Output is a plain-text table to stdout. Files that fail to parse are +//! reported as a warning to stderr but do not fail the command (Unix-style: +//! list what you can, surface what you can't). + +use anyhow::{anyhow, Result}; +use colored::Colorize; + +use crate::charter::{ + self, display_origin, display_title, origin_kind, Charter, CharterStatus, +}; +use crate::utils; + +pub fn run(path: &str, status_filter: &str, origin_filter: Option<&str>) -> Result<()> { + let resolved = utils::resolve_project_root(path) + .ok_or_else(|| anyhow!("DevTrail not installed. Run 'devtrail init' first."))?; + let project_root = &resolved.path; + + let (charters, errors) = charter::discover_and_parse(project_root); + + // Surface parse errors as warnings (non-fatal). + for (path, err) in &errors { + utils::warn(&format!( + "Skipping {}: {}", + path.strip_prefix(project_root).unwrap_or(path).display(), + err + )); + } + + let filtered = filter(&charters, status_filter, origin_filter); + + if filtered.is_empty() { + if charters.is_empty() && errors.is_empty() { + println!("No Charters in this project. Run `devtrail charter new` to create one."); + } else { + println!("No Charters match the given filter."); + } + return Ok(()); + } + + print_table(&filtered); + Ok(()) +} + +/// Apply the status and origin filters to a slice of Charters. Returns +/// borrowed references in the original order. +fn filter<'a>( + charters: &'a [Charter], + status: &str, + origin: Option<&str>, +) -> Vec<&'a Charter> { + charters + .iter() + .filter(|c| match status { + "all" => true, + "declared" => c.frontmatter.status == CharterStatus::Declared, + "in-progress" => c.frontmatter.status == CharterStatus::InProgress, + "closed" => c.frontmatter.status == CharterStatus::Closed, + _ => true, + }) + .filter(|c| match origin { + None | Some("any") => true, + Some("ailog") => origin_kind(&c.frontmatter) == "ailog", + Some("spec") => origin_kind(&c.frontmatter) == "spec", + Some(_) => true, + }) + .collect() +} + +/// Print the table to stdout. Columns: NN, STATUS, EFFORT, ORIGIN, TITLE. +/// Widths are computed from the data so the layout stays tight on small +/// projects and adapts to longer fields when present. +fn print_table(charters: &[&Charter]) { + // Compute column widths. + let nn_w = charters + .iter() + .map(|c| nn_display(c).len()) + .max() + .unwrap_or(2) + .max(2); + let status_w = charters + .iter() + .map(|c| c.frontmatter.status.as_str().len()) + .max() + .unwrap_or(0) + .max("STATUS".len()); + let effort_w = "EFFORT".len(); + // Origin column is capped to keep the title readable; longer origins are + // truncated with an ellipsis. 32 columns is enough for AILOG IDs and + // typical specs/<n>-feature/spec.md paths. + const ORIGIN_MAX: usize = 32; + let origin_w = charters + .iter() + .map(|c| utils::visual_width(&display_origin(&c.frontmatter)).min(ORIGIN_MAX)) + .max() + .unwrap_or(0) + .max("ORIGIN".len()); + + // Header. + println!( + " {} {} {} {} {}", + utils::pad_right_visual("NN", nn_w).bold(), + utils::pad_right_visual("STATUS", status_w).bold(), + utils::pad_right_visual("EFFORT", effort_w).bold(), + utils::pad_right_visual("ORIGIN", origin_w).bold(), + "TITLE".bold(), + ); + + for c in charters { + let nn = nn_display(c); + let status_text = c.frontmatter.status.as_str(); + let status_colored = colorize_status(c.frontmatter.status, status_text); + let origin = utils::truncate_visual(&display_origin(&c.frontmatter), ORIGIN_MAX); + let title = display_title(c); + + // We pad the raw status string for column alignment, then color it. + // Padding before coloring is critical — color escape codes don't + // contribute to visual width, so padding-after-coloring would + // miscalculate. + let padded_status = utils::pad_right_visual(status_text, status_w); + let _ = status_colored; // keep the helper available; current style + // colors only for closed/in-progress states + // when terminals support it. + + println!( + " {} {} {} {} {}", + utils::pad_right_visual(&nn, nn_w), + colorize_status(c.frontmatter.status, &padded_status), + utils::pad_right_visual(c.frontmatter.effort_estimate.as_str(), effort_w), + utils::pad_right_visual(&origin, origin_w), + title, + ); + } +} + +/// Two-digit (or longer if needed) zero-padded NN extracted from the charter_id. +/// Returns "??" when the charter_id doesn't contain a parseable NN, which +/// indicates a malformed Charter — surface as visual cue. +fn nn_display(c: &Charter) -> String { + let id = &c.frontmatter.charter_id; + let after_prefix = id.strip_prefix("CHARTER-").unwrap_or(id); + let digits: String = after_prefix + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect(); + if digits.is_empty() { + "??".to_string() + } else { + // Preserve at least 2 chars width. + format!("{:0>2}", digits) + } +} + +fn colorize_status(status: CharterStatus, text: &str) -> colored::ColoredString { + match status { + CharterStatus::Declared => text.normal(), + CharterStatus::InProgress => text.yellow(), + CharterStatus::Closed => text.green(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::charter::{CharterFrontmatter, EffortEstimate}; + use std::path::PathBuf; + + fn make(id: &str, status: CharterStatus, ailog: Option<Vec<String>>, spec: Option<String>) -> Charter { + Charter { + path: PathBuf::from(format!("docs/charters/{}.md", id.to_lowercase())), + frontmatter: CharterFrontmatter { + charter_id: id.to_string(), + status, + effort_estimate: EffortEstimate::M, + trigger: "x".to_string(), + originating_ailogs: ailog, + originating_spec: spec, + }, + body: String::new(), + } + } + + #[test] + fn filter_status_all_returns_everything() { + let charters = vec![ + make("CHARTER-01-a", CharterStatus::Declared, None, None), + make("CHARTER-02-b", CharterStatus::InProgress, None, None), + make("CHARTER-03-c", CharterStatus::Closed, None, None), + ]; + let filtered = filter(&charters, "all", None); + assert_eq!(filtered.len(), 3); + } + + #[test] + fn filter_status_declared_only() { + let charters = vec![ + make("CHARTER-01-a", CharterStatus::Declared, None, None), + make("CHARTER-02-b", CharterStatus::InProgress, None, None), + make("CHARTER-03-c", CharterStatus::Declared, None, None), + ]; + let filtered = filter(&charters, "declared", None); + assert_eq!(filtered.len(), 2); + assert!(filtered.iter().all(|c| c.frontmatter.status == CharterStatus::Declared)); + } + + #[test] + fn filter_origin_ailog_only() { + let charters = vec![ + make( + "CHARTER-01-a", + CharterStatus::Declared, + Some(vec!["AILOG-2026-04-28-021".into()]), + None, + ), + make( + "CHARTER-02-b", + CharterStatus::Declared, + None, + Some("specs/001/spec.md".into()), + ), + make("CHARTER-03-c", CharterStatus::Declared, None, None), + ]; + let filtered = filter(&charters, "all", Some("ailog")); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].frontmatter.charter_id, "CHARTER-01-a"); + } + + #[test] + fn filter_origin_spec_only() { + let charters = vec![ + make( + "CHARTER-01-a", + CharterStatus::Declared, + Some(vec!["AILOG-2026-04-28-021".into()]), + None, + ), + make( + "CHARTER-02-b", + CharterStatus::Declared, + None, + Some("specs/001/spec.md".into()), + ), + ]; + let filtered = filter(&charters, "all", Some("spec")); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].frontmatter.charter_id, "CHARTER-02-b"); + } + + #[test] + fn filter_origin_any_matches_no_origin_too() { + let charters = vec![ + make("CHARTER-01-a", CharterStatus::Declared, None, None), + make( + "CHARTER-02-b", + CharterStatus::Declared, + Some(vec!["AILOG-2026-04-28-021".into()]), + None, + ), + ]; + let filtered = filter(&charters, "all", Some("any")); + assert_eq!(filtered.len(), 2); + } + + #[test] + fn filter_combines_status_and_origin() { + let charters = vec![ + make( + "CHARTER-01-a", + CharterStatus::Closed, + Some(vec!["AILOG-2026-04-28-021".into()]), + None, + ), + make( + "CHARTER-02-b", + CharterStatus::Declared, + Some(vec!["AILOG-2026-04-28-022".into()]), + None, + ), + make( + "CHARTER-03-c", + CharterStatus::Closed, + None, + Some("specs/001/spec.md".into()), + ), + ]; + let filtered = filter(&charters, "closed", Some("ailog")); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].frontmatter.charter_id, "CHARTER-01-a"); + } + + #[test] + fn nn_display_zero_pads_single_digits() { + let c = make("CHARTER-01-a", CharterStatus::Declared, None, None); + assert_eq!(nn_display(&c), "01"); + } + + #[test] + fn nn_display_preserves_three_or_more_digits() { + let c = make("CHARTER-100-x", CharterStatus::Declared, None, None); + assert_eq!(nn_display(&c), "100"); + } + + #[test] + fn nn_display_returns_question_marks_when_id_is_malformed() { + let c = make("CHARTER-foo", CharterStatus::Declared, None, None); + assert_eq!(nn_display(&c), "??"); + } +} diff --git a/cli/src/commands/charter/mod.rs b/cli/src/commands/charter/mod.rs new file mode 100644 index 0000000..8d8b6dc --- /dev/null +++ b/cli/src/commands/charter/mod.rs @@ -0,0 +1,10 @@ +//! `devtrail charter` — Charter lifecycle subcommands. +//! +//! Phase 1 ships `new` (scaffold from template), `list` (enumerate with filters), +//! and `status` (detail view). +//! Phase 2 will add `close` (interactive telemetry) and `drift` (file-vs-commit +//! drift check). Phase 3 will add `audit` (multi-model external audit). + +pub mod list; +pub mod new; +pub mod status; diff --git a/cli/src/commands/charter/new.rs b/cli/src/commands/charter/new.rs new file mode 100644 index 0000000..f77d086 --- /dev/null +++ b/cli/src/commands/charter/new.rs @@ -0,0 +1,367 @@ +//! `devtrail charter new` — scaffold a Charter from the framework template. +//! +//! Three origin paths: +//! - `--from-ailog AILOG-ID`: post-MVP / maintenance mode (the Sentinel case). +//! - `--from-spec specs/.../spec.md`: greenfield mode driven by SpecKit. +//! - neither: Charter scaffolded without an explicit origin (must be filled +//! manually before status moves to `in-progress`). +//! +//! Mutual exclusion of `--from-ailog` and `--from-spec` is enforced by clap +//! at parse time. + +use anyhow::{anyhow, bail, Context, Result}; +use colored::Colorize; +use dialoguer::{theme::ColorfulTheme, Input}; +use std::path::Path; + +use crate::charter::next_charter_number; +use crate::config::DevTrailConfig; +use crate::utils; + +/// Default effort when the user does not pass `--type`. M is the median bucket +/// observed across Sentinel PLAN-01..06 and a sensible neutral default. +const DEFAULT_EFFORT: &str = "M"; + +pub fn run( + path: &str, + effort_arg: Option<&str>, + from_ailog: Option<&str>, + from_spec: Option<&str>, + title_arg: Option<&str>, +) -> Result<()> { + // clap enforces mutual exclusion via conflicts_with — keep this assertion + // as a defense against direct programmatic invocation. + if from_ailog.is_some() && from_spec.is_some() { + bail!("--from-ailog and --from-spec are mutually exclusive"); + } + + let resolved = utils::resolve_project_root(path) + .ok_or_else(|| anyhow!("DevTrail not installed. Run 'devtrail init' first."))?; + let project_root = &resolved.path; + let devtrail_dir = project_root.join(".devtrail"); + + let resolved_language = DevTrailConfig::resolve_language(project_root); + let lang = resolved_language.as_str(); + + // Title (interactive fallback matches `devtrail new`'s UX). + let title = match title_arg { + Some(t) => t.to_string(), + None => Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Charter title") + .interact_text()?, + }; + if title.trim().is_empty() { + bail!("Title is required"); + } + + let effort = effort_arg.unwrap_or(DEFAULT_EFFORT); + + // Validate origin inputs (early rejection of obviously malformed values). + if let Some(ailog_id) = from_ailog { + validate_ailog_id(ailog_id)?; + } + if let Some(spec_path) = from_spec { + validate_spec_path(project_root, spec_path)?; + } + + // Resolve template (with i18n) and load. + let templates_dir = devtrail_dir.join("templates"); + let template_path = utils::resolve_localized_path(&templates_dir, "charter-template.md", lang); + let template = std::fs::read_to_string(&template_path).with_context(|| { + format!( + "Charter template not found at {}. Run `devtrail repair` to restore framework files.", + template_path.display() + ) + })?; + + // Build identifiers. + let nn = next_charter_number(project_root); + let slug = slugify(&title); + if slug.is_empty() { + bail!( + "Title '{}' produces an empty slug — titles must contain at least one alphanumeric character", + title + ); + } + let charter_id = format!("CHARTER-{:02}-{}", nn, slug); + let filename = format!("{:02}-{}.md", nn, slug); + + // Substitute placeholders. The template uses unique tokens for each + // substitution so plain `String::replace` is safe and predictable. + let content = apply_substitutions( + &template, + &charter_id, + effort, + &title, + from_ailog, + from_spec, + ); + + // Write to docs/charters/. + let charters_dir = project_root.join("docs").join("charters"); + utils::ensure_dir(&charters_dir)?; + let out_path = charters_dir.join(&filename); + if out_path.exists() { + bail!( + "Charter file already exists: {} (next number computed as {:02} but a file with this slug exists)", + out_path.display(), + nn + ); + } + std::fs::write(&out_path, content)?; + + let rel_path = out_path + .strip_prefix(project_root) + .unwrap_or(&out_path) + .display(); + + println!(); + utils::success(&format!("Created: {}", rel_path)); + println!(); + println!(" {}", "Next steps:".bold()); + println!(" 1. Edit the Charter to fill in Context, Scope, Files to modify, Verification, Risks, Tasks."); + println!(" 2. Set the trigger field in frontmatter to a concrete observable signal."); + if from_ailog.is_none() && from_spec.is_none() { + println!(" 3. Set originating_ailogs or originating_spec in frontmatter (or leave both absent if standalone)."); + } + println!(" 4. When you start executing: change frontmatter status from `declared` to `in-progress`."); + println!(); + + Ok(()) +} + +/// Apply all placeholder substitutions to the template body. Returns the +/// substituted content. Pure function — exposed for unit testing. +fn apply_substitutions( + template: &str, + charter_id: &str, + effort: &str, + title: &str, + from_ailog: Option<&str>, + from_spec: Option<&str>, +) -> String { + let mut content = template.to_string(); + + // charter_id placeholder (frontmatter). + content = content.replace("charter_id: CHARTER-NN", &format!("charter_id: {}", charter_id)); + + // effort_estimate (frontmatter — appears once). + content = content.replace( + "effort_estimate: M", + &format!("effort_estimate: {}", effort), + ); + + // Title placeholders (EN and ES variants of the body H1). + content = content.replace("# Charter: [BRIEF TITLE]", &format!("# Charter: {}", title)); + content = content.replace("# Charter: [TÍTULO BREVE]", &format!("# Charter: {}", title)); + + // Prose effort mirror line (EN: "Effort:" / ES: "Esfuerzo:" with the same + // bracketed enum). The "~[N] min" stays as a placeholder for the user to + // fill in the actual time estimate. + content = content.replace("[XS | S | M | L]", effort); + + // Origin: uncomment the chosen line and (optionally) tighten the prose summary. + if let Some(ailog_id) = from_ailog { + content = content.replace( + "# originating_ailogs: [AILOG-YYYY-MM-DD-NNN]", + &format!("originating_ailogs: [{}]", ailog_id), + ); + // Replace the prose Origin placeholder with a concrete reference (EN). + content = content.replace( + "[human-readable summary; the machine-readable form is `originating_ailogs` or `originating_spec` in frontmatter]", + &format!("Follow-up of {}. [Add 1-line context about why this Charter exists now.]", ailog_id), + ); + // ES variant. + content = content.replace( + "[resumen humano; la forma machine-readable es `originating_ailogs` u `originating_spec` en el frontmatter]", + &format!("Follow-up de {}. [Añadir 1 línea de contexto sobre por qué este Charter existe ahora.]", ailog_id), + ); + } else if let Some(spec_path) = from_spec { + content = content.replace( + "# originating_spec: specs/001-feature/spec.md", + &format!("originating_spec: {}", spec_path), + ); + content = content.replace( + "[human-readable summary; the machine-readable form is `originating_ailogs` or `originating_spec` in frontmatter]", + &format!("Implementation derived from spec at {}. [Add 1-line context.]", spec_path), + ); + content = content.replace( + "[resumen humano; la forma machine-readable es `originating_ailogs` u `originating_spec` en el frontmatter]", + &format!("Implementación derivada del spec en {}. [Añadir 1 línea de contexto.]", spec_path), + ); + } + // If neither: both `# originating_*` lines stay commented, the prose Origin + // placeholder stays as-is for the user to fill in. + + content +} + +/// Cheap syntactic check on the AILOG ID. Catches typos at scaffold time. +/// The schema's regex enforces the same shape on read-back. +fn validate_ailog_id(s: &str) -> Result<()> { + if !s.starts_with("AILOG-") { + bail!( + "--from-ailog: expected an AILOG ID like AILOG-YYYY-MM-DD-NNN, got '{}'", + s + ); + } + Ok(()) +} + +/// Verify the spec path exists relative to the project root. Catches typos and +/// the common confusion of passing a glob or a directory without spec.md. +fn validate_spec_path(project_root: &Path, spec_path: &str) -> Result<()> { + let p = project_root.join(spec_path); + if !p.exists() { + bail!( + "--from-spec: file does not exist at {} (relative to project root). \ + Pass the path to a SpecKit spec.md (e.g., specs/001-feature/spec.md).", + p.display() + ); + } + Ok(()) +} + +/// Slugify a title for use in a Charter filename. Mirrors the implementation +/// in `commands::new::slugify` (kept private there — duplicated here to avoid +/// touching the existing command in this PR; consolidate to `utils` later). +fn slugify(title: &str) -> String { + let lower = title.to_lowercase(); + let parts: Vec<&str> = lower + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .collect(); + let slug = parts.join("-"); + if slug.chars().count() > 50 { + let truncated: String = slug.chars().take(50).collect(); + truncated.trim_end_matches('-').to_string() + } else { + slug + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal template that covers all substitution points the runner touches. + /// Mirrors the structure of dist/.devtrail/templates/charter-template.md + /// without the full body. + const TEMPLATE: &str = r#"--- +charter_id: CHARTER-NN +status: declared +effort_estimate: M +trigger: "[1-line]" +# originating_ailogs: [AILOG-YYYY-MM-DD-NNN] +# originating_spec: specs/001-feature/spec.md +--- + +# Charter: [BRIEF TITLE] + +> **Status (mirrored from frontmatter — source of truth is above):** declared. Effort: [XS | S | M | L] (~[N] min). +> +> **Origin:** [human-readable summary; the machine-readable form is `originating_ailogs` or `originating_spec` in frontmatter]. + +Body content. +"#; + + #[test] + fn applies_all_basic_substitutions() { + let out = apply_substitutions( + TEMPLATE, + "CHARTER-01-test-charter", + "M", + "Test Charter", + None, + None, + ); + assert!(out.contains("charter_id: CHARTER-01-test-charter")); + assert!(out.contains("# Charter: Test Charter")); + assert!(out.contains("Effort: M (~[N] min)")); + // Both origin lines remain commented out. + assert!(out.contains("# originating_ailogs: [AILOG-YYYY-MM-DD-NNN]")); + assert!(out.contains("# originating_spec: specs/001-feature/spec.md")); + } + + #[test] + fn from_ailog_uncomments_originating_ailogs() { + let out = apply_substitutions( + TEMPLATE, + "CHARTER-01-x", + "S", + "X", + Some("AILOG-2026-04-28-021"), + None, + ); + assert!(out.contains("originating_ailogs: [AILOG-2026-04-28-021]")); + // The other origin stays commented as a placeholder. + assert!(out.contains("# originating_spec: specs/001-feature/spec.md")); + // Prose Origin line gets a concrete reference. + assert!(out.contains("Follow-up of AILOG-2026-04-28-021")); + } + + #[test] + fn from_spec_uncomments_originating_spec() { + let out = apply_substitutions( + TEMPLATE, + "CHARTER-02-x", + "L", + "X", + None, + Some("specs/001-test/spec.md"), + ); + assert!(out.contains("originating_spec: specs/001-test/spec.md")); + assert!(out.contains("# originating_ailogs: [AILOG-YYYY-MM-DD-NNN]")); + assert!(out.contains("derived from spec at specs/001-test/spec.md")); + } + + #[test] + fn effort_substitution_handles_all_buckets() { + for e in ["XS", "S", "M", "L"] { + let out = apply_substitutions(TEMPLATE, "CHARTER-01-x", e, "X", None, None); + assert!(out.contains(&format!("effort_estimate: {}", e))); + assert!(out.contains(&format!("Effort: {} (~[N] min)", e))); + } + } + + #[test] + fn validate_ailog_id_rejects_non_ailog_prefix() { + assert!(validate_ailog_id("PLAN-05").is_err()); + assert!(validate_ailog_id("CHARTER-01").is_err()); + assert!(validate_ailog_id("").is_err()); + } + + #[test] + fn validate_ailog_id_accepts_ailog_prefix() { + assert!(validate_ailog_id("AILOG-2026-04-28-021").is_ok()); + // The CLI's syntactic check is intentionally loose — the schema + // enforces the full pattern at validate time. + assert!(validate_ailog_id("AILOG-anything").is_ok()); + } + + #[test] + fn validate_spec_path_requires_existing_file() { + let tmp = tempfile::TempDir::new().unwrap(); + let result = validate_spec_path(tmp.path(), "specs/001-missing/spec.md"); + assert!(result.is_err()); + + let spec_dir = tmp.path().join("specs").join("001-test"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::write(spec_dir.join("spec.md"), "# Spec").unwrap(); + assert!(validate_spec_path(tmp.path(), "specs/001-test/spec.md").is_ok()); + } + + #[test] + fn slugify_matches_devtrail_new_pattern() { + assert_eq!(slugify("Hello World"), "hello-world"); + assert_eq!(slugify("Per-service anomaly thresholds"), "per-service-anomaly-thresholds"); + assert_eq!(slugify("UPPER_case mixed!"), "upper-case-mixed"); + } + + #[test] + fn slugify_truncates_long_titles_to_50_chars() { + let long = "a".repeat(100); + let s = slugify(&long); + assert!(s.len() <= 50); + } +} diff --git a/cli/src/commands/charter/status.rs b/cli/src/commands/charter/status.rs new file mode 100644 index 0000000..d2f04ce --- /dev/null +++ b/cli/src/commands/charter/status.rs @@ -0,0 +1,218 @@ +//! `devtrail charter status [CHARTER-ID]` — show detail for a Charter. +//! +//! With an ID: prints frontmatter, file location, body section list, and +//! placeholders for telemetry / drift-check (Phase 2 features). +//! Without an ID: prints the last 5 Charters by NN descending. + +use anyhow::{anyhow, Result}; +use colored::Colorize; + +use crate::charter::{ + self, display_origin, display_title, Charter, CharterStatus, +}; +use crate::utils; + +/// Number of Charters shown when `charter status` is called without an ID. +const RECENT_LIMIT: usize = 5; + +pub fn run(path: &str, charter_id: Option<&str>) -> Result<()> { + let resolved = utils::resolve_project_root(path) + .ok_or_else(|| anyhow!("DevTrail not installed. Run 'devtrail init' first."))?; + let project_root = &resolved.path; + + let (charters, errors) = charter::discover_and_parse(project_root); + + for (path, err) in &errors { + utils::warn(&format!( + "Skipping {}: {}", + path.strip_prefix(project_root).unwrap_or(path).display(), + err + )); + } + + match charter_id { + Some(id) => { + let c = charter::find_by_id(&charters, id).ok_or_else(|| { + anyhow!( + "Charter not found: '{}'. Run `devtrail charter list` to see available Charters.", + id + ) + })?; + print_detail(c, project_root); + } + None => { + if charters.is_empty() && errors.is_empty() { + println!("No Charters in this project. Run `devtrail charter new` to create one."); + return Ok(()); + } + print_recent(&charters); + } + } + + Ok(()) +} + +fn print_detail(c: &Charter, project_root: &std::path::Path) { + let rel_path = c + .path + .strip_prefix(project_root) + .unwrap_or(&c.path) + .display(); + + println!(); + println!("{} {}", "Charter:".bold(), c.frontmatter.charter_id); + println!(); + println!( + " {} {}", + "Status:".bold(), + colorize_status(c.frontmatter.status, c.frontmatter.status.as_str()), + ); + println!( + " {} {}", + "Effort:".bold(), + c.frontmatter.effort_estimate.as_str(), + ); + println!(" {} {}", "Trigger:".bold(), c.frontmatter.trigger); + println!(" {} {}", "Origin:".bold(), display_origin(&c.frontmatter)); + println!(" {} {}", "Title:".bold(), display_title(c)); + println!(" {} {}", "File:".bold(), rel_path); + + let sections = body_section_headings(&c.body); + if sections.is_empty() { + println!(" {} (no `## ` headings detected)", "Sections:".bold()); + } else { + println!(" {} {}", "Sections:".bold(), sections.join(", ")); + } + + println!(); + println!(" {}", "Phase 2 features (not yet available):".dimmed()); + println!(" {}", "telemetry — devtrail charter close (planned cli-3.7.0)".dimmed()); + println!(" {}", "drift-check — devtrail charter drift (planned cli-3.7.0)".dimmed()); + println!(); +} + +fn print_recent(charters: &[Charter]) { + // Sort by NN descending (most recent first). + let mut indexed: Vec<&Charter> = charters.iter().collect(); + indexed.sort_by(|a, b| { + nn_of(b) + .cmp(&nn_of(a)) + .then_with(|| a.frontmatter.charter_id.cmp(&b.frontmatter.charter_id)) + }); + let shown = indexed.iter().take(RECENT_LIMIT).copied().collect::<Vec<_>>(); + + println!(); + println!( + "{} ({})", + "Most recent Charters".bold(), + if charters.len() <= RECENT_LIMIT { + format!("{}", charters.len()) + } else { + format!("last {} of {}", shown.len(), charters.len()) + } + ); + println!(); + + for c in &shown { + let nn_str = format!("{:0>2}", nn_of(c)); + let status_text = c.frontmatter.status.as_str(); + let status_padded = utils::pad_right_visual(status_text, "in-progress".len()); + println!( + " {} {} {} {}", + nn_str, + colorize_status(c.frontmatter.status, &status_padded), + utils::pad_right_visual(c.frontmatter.effort_estimate.as_str(), 2), + display_title(c), + ); + } + + println!(); + println!( + " Run {} for detail, or {} to see all.", + "devtrail charter status CHARTER-NN".bold(), + "devtrail charter list".bold() + ); + println!(); +} + +fn body_section_headings(body: &str) -> Vec<String> { + body.lines() + .filter_map(|line| { + let trimmed = line.trim(); + // Only top-level section headings (## ...). Skip ### and below + // (those are sub-sections of Verification etc., noise here). + if let Some(rest) = trimmed.strip_prefix("## ") { + Some(rest.trim().to_string()) + } else { + None + } + }) + .collect() +} + +/// Numeric NN extracted from a Charter's ID. Returns 0 for malformed IDs so +/// they sort before everything else (visually flagging the issue). +fn nn_of(c: &Charter) -> u32 { + let id = &c.frontmatter.charter_id; + let after_prefix = id.strip_prefix("CHARTER-").unwrap_or(id); + let digits: String = after_prefix + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect(); + digits.parse::<u32>().unwrap_or(0) +} + +fn colorize_status(status: CharterStatus, text: &str) -> colored::ColoredString { + match status { + CharterStatus::Declared => text.normal(), + CharterStatus::InProgress => text.yellow(), + CharterStatus::Closed => text.green(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::charter::{CharterFrontmatter, EffortEstimate}; + use std::path::PathBuf; + + fn make(id: &str, body: &str) -> Charter { + Charter { + path: PathBuf::from(format!("docs/charters/{}.md", id.to_lowercase())), + frontmatter: CharterFrontmatter { + charter_id: id.to_string(), + status: CharterStatus::Declared, + effort_estimate: EffortEstimate::M, + trigger: "x".to_string(), + originating_ailogs: None, + originating_spec: None, + }, + body: body.to_string(), + } + } + + #[test] + fn body_section_headings_extracts_top_level_only() { + let body = "# Charter: Title\n\n## Context\n\nfoo\n\n### Local checks\n\n## Scope\n\nbar\n"; + let sections = body_section_headings(body); + assert_eq!(sections, vec!["Context", "Scope"]); + } + + #[test] + fn body_section_headings_empty_body() { + assert!(body_section_headings("").is_empty()); + assert!(body_section_headings("just text\n").is_empty()); + } + + #[test] + fn nn_of_extracts_correctly() { + let c1 = make("CHARTER-01-foo", ""); + assert_eq!(nn_of(&c1), 1); + let c10 = make("CHARTER-10-bar", ""); + assert_eq!(nn_of(&c10), 10); + let c100 = make("CHARTER-100-baz", ""); + assert_eq!(nn_of(&c100), 100); + let bad = make("CHARTER-abc", ""); + assert_eq!(nn_of(&bad), 0); + } +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 8ef453a..cf131a5 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod about; #[cfg(feature = "analyze")] pub mod analyze; pub mod audit; +pub mod charter; pub mod compliance; #[cfg(feature = "tui")] pub mod explore; diff --git a/cli/src/commands/validate.rs b/cli/src/commands/validate.rs index 66cfe80..ef9a593 100644 --- a/cli/src/commands/validate.rs +++ b/cli/src/commands/validate.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use crate::utils; use crate::validation::{self, Severity, ValidationIssue}; -pub fn run(path: &str, fix: bool, staged: bool) -> Result<()> { +pub fn run(path: &str, fix: bool, staged: bool, include_charters: bool) -> Result<()> { let resolved = match utils::resolve_project_root(path) { Some(r) => r, None => { @@ -32,7 +32,9 @@ pub fn run(path: &str, fix: bool, staged: bool) -> Result<()> { let target = resolved.path; let devtrail_dir = target.join(".devtrail"); - // --staged mode: validate only git-staged .devtrail/ documents + // --staged mode: validate only git-staged .devtrail/ documents. + // Charter validation in --staged mode is a Phase 2 enhancement; in v0 + // the flag is honored only in the all-mode path below. if staged { return run_staged(&target, &devtrail_dir); } @@ -44,7 +46,14 @@ pub fn run(path: &str, fix: bool, staged: bool) -> Result<()> { println!(); // Run validation - let (result, doc_count) = validation::validate_all(&devtrail_dir); + let (mut result, mut doc_count) = validation::validate_all(&devtrail_dir); + + if include_charters { + let (charter_result, charter_count) = + validation::validate_charters(&target, &devtrail_dir); + result.merge(charter_result); + doc_count += charter_count; + } if doc_count == 0 { utils::info("No documents found to validate."); @@ -62,7 +71,13 @@ pub fn run(path: &str, fix: bool, staged: bool) -> Result<()> { if fix { apply_fixes(&devtrail_dir); // Re-validate after fixes - let (result, doc_count) = validation::validate_all(&devtrail_dir); + let (mut result, mut doc_count) = validation::validate_all(&devtrail_dir); + if include_charters { + let (charter_result, charter_count) = + validation::validate_charters(&target, &devtrail_dir); + result.merge(charter_result); + doc_count += charter_count; + } print_results(&result, doc_count); return exit_with_code(&result); } diff --git a/cli/src/main.rs b/cli/src/main.rs index c471278..4450f1d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,6 +4,8 @@ use colored::Colorize; #[cfg(feature = "analyze")] mod analysis_engine; mod audit_engine; +mod charter; +mod charter_schema; mod commands; mod compliance; mod config; @@ -79,6 +81,12 @@ enum Commands { /// Validate only git-staged files (for pre-commit hooks) #[arg(long)] staged: bool, + /// Also validate Charters in docs/charters/ against the Charter schema + /// and referential integrity (originating_ailogs IDs exist; + /// originating_spec path exists). Default: false, to avoid breaking + /// projects that don't yet use the Charter pattern. + #[arg(long)] + include_charters: bool, }, /// Check regulatory compliance (EU, NIST, ISO; China standards opt-in via regional_scope) Compliance { @@ -173,6 +181,57 @@ enum Commands { #[arg(long)] lang: Option<String>, }, + /// Manage Charters: bounded units of work declared ex-ante and audited ex-post + Charter { + #[command(subcommand)] + command: CharterCommands, + }, +} + +#[derive(Subcommand)] +enum CharterCommands { + /// Scaffold a new Charter from the framework template + New { + /// Effort estimate (defaults to M if absent) + #[arg(long = "type", short = 't', value_parser = ["XS", "S", "M", "L"])] + effort: Option<String>, + /// Originating AILOG ID (e.g., AILOG-2026-04-28-021). + /// Mutually exclusive with --from-spec. + #[arg(long, conflicts_with = "from_spec")] + from_ailog: Option<String>, + /// Originating SpecKit spec path (e.g., specs/001-feature/spec.md). + /// Mutually exclusive with --from-ailog. + #[arg(long, conflicts_with = "from_ailog")] + from_spec: Option<String>, + /// Charter title (used to build the slug and filename) + #[arg(long)] + title: Option<String>, + /// Project directory (default: current directory) + #[arg(default_value = ".")] + path: String, + }, + /// List Charters with optional status / origin filter + List { + /// Filter by lifecycle status + #[arg(long, default_value = "all", value_parser = ["declared", "in-progress", "closed", "all"])] + status: String, + /// Filter by origin type + #[arg(long, value_parser = ["ailog", "spec", "any"])] + origin: Option<String>, + /// Project directory (default: current directory) + #[arg(default_value = ".")] + path: String, + }, + /// Show Charter detail (or last 5 Charters if no ID is given) + Status { + /// Charter identifier (CHARTER-NN, CHARTER-NN-slug, or just NN) + charter_id: Option<String>, + /// Project directory (default: current directory). + /// Use a flag (rather than positional) so it cannot be confused + /// with the optional charter_id positional. + #[arg(long = "path", default_value = ".")] + path: String, + }, } fn main() { @@ -187,7 +246,12 @@ fn main() { Commands::UpdateFramework => commands::update_framework::run(), Commands::UpdateCli { method } => commands::update_cli::run(&method), Commands::Remove { full } => commands::remove::run(full), - Commands::Validate { path, fix, staged } => commands::validate::run(&path, fix, staged), + Commands::Validate { + path, + fix, + staged, + include_charters, + } => commands::validate::run(&path, fix, staged, include_charters), Commands::Audit { path, from, @@ -224,6 +288,29 @@ fn main() { } => commands::analyze::run(&path, threshold, &output, top), #[cfg(feature = "tui")] Commands::Explore { path, lang } => commands::explore::run(&path, lang.as_deref()), + Commands::Charter { command } => match command { + CharterCommands::New { + effort, + from_ailog, + from_spec, + title, + path, + } => commands::charter::new::run( + &path, + effort.as_deref(), + from_ailog.as_deref(), + from_spec.as_deref(), + title.as_deref(), + ), + CharterCommands::List { + status, + origin, + path, + } => commands::charter::list::run(&path, &status, origin.as_deref()), + CharterCommands::Status { charter_id, path } => { + commands::charter::status::run(&path, charter_id.as_deref()) + } + }, }; if let Err(e) = result { diff --git a/cli/src/tui/i18n_strings.rs b/cli/src/tui/i18n_strings.rs index d74e063..4c4b428 100644 --- a/cli/src/tui/i18n_strings.rs +++ b/cli/src/tui/i18n_strings.rs @@ -41,6 +41,10 @@ pub fn t<'a>(en: &'a str, lang: &str) -> &'a str { ("Security", "zh-CN") => "安全", ("AI Models", "es") => "Modelos IA", ("AI Models", "zh-CN") => "AI 模型", + // Charter-related labels: "Charters" stays as a loanword in ES to match + // the rest of the bilingual technical vocabulary (Plan→Charter rename). + ("Charters", "es") => "Charters", + ("Charters", "zh-CN") => "章程", // ── Subgroup labels ─────────────────────────────────────────── ("Exceptions", "es") => "Excepciones", diff --git a/cli/src/tui/index.rs b/cli/src/tui/index.rs index ce4653c..0468809 100644 --- a/cli/src/tui/index.rs +++ b/cli/src/tui/index.rs @@ -206,6 +206,27 @@ impl DocIndex { }); } + // Synthetic "Charters" group at the end. Charters live at + // <project_root>/docs/charters/, NOT under .devtrail/. We append them + // as a 10th-style pseudo-group so the existing NavSelection tree + // model handles them without modification. The group is added only + // when at least one Charter exists — adopters who don't use the + // pattern see no empty stub. + let project_root = devtrail_dir.parent().unwrap_or(devtrail_dir); + let charter_files = scan_charters(project_root, &mut relations); + if !charter_files.is_empty() { + total_docs += charter_files.len(); + groups.push(DocGroup { + // Sentinel name with underscore prefix so it cannot collide + // with a real GROUP_DEFS entry that always uses NN-name. + name: "_charters".to_string(), + label: t("Charters", language).to_string(), + path: project_root.join("docs").join("charters"), + subgroups: Vec::new(), + files: charter_files, + }); + } + Self { groups, relations, @@ -496,6 +517,66 @@ fn fallback_meta(path: &Path, content: Option<&str>) -> ScannedMeta { } } +/// Build TUI-compatible `DocEntry` records for all Charters in a project. +/// Reuses `crate::charter` for discovery/parsing so the schema and the TUI +/// stay aligned on what "a Charter" means. Each entry carries the +/// `charter_id` as its `id` so hyperlinks via `find_by_ref` resolve, and a +/// "CH" badge so the nav tree distinguishes Charters from governance docs. +fn scan_charters(project_root: &Path, relations: &mut RelationIndex) -> Vec<DocEntry> { + let paths = crate::charter::discover_charters(project_root); + let mut entries = Vec::with_capacity(paths.len()); + for path in paths { + let filename = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + + let entry = match crate::charter::parse_charter(&path) { + Ok(charter) => { + let title = crate::charter::display_title(&charter); + let id = charter.frontmatter.charter_id.clone(); + if !id.is_empty() { + relations + .id_to_path + .insert(id.clone(), path.to_path_buf()); + } + DocEntry { + filename, + path: path.clone(), + title, + id, + doc_type: "CH".to_string(), + tags: Vec::new(), + created: String::new(), + has_frontmatter: true, + } + } + Err(_) => { + // Charter has malformed frontmatter — show it anyway as a + // degraded entry so the user can find and fix it from the TUI. + let stem = path + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + DocEntry { + filename, + path: path.clone(), + title: humanize_filename(&stem), + id: String::new(), + doc_type: "CH".to_string(), + tags: Vec::new(), + created: String::new(), + has_frontmatter: false, + } + } + }; + entries.push(entry); + } + entries +} + fn quick_scan_frontmatter(path: &Path, relations: &mut RelationIndex) -> ScannedMeta { let content = match std::fs::read_to_string(path) { Ok(c) => c, @@ -667,6 +748,104 @@ mod tests { ); } + #[test] + fn build_appends_charters_synthetic_group_when_present() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_root = tmp.path(); + let devtrail_dir = project_root.join(".devtrail"); + std::fs::create_dir_all(&devtrail_dir).unwrap(); + let charters_dir = project_root.join("docs").join("charters"); + std::fs::create_dir_all(&charters_dir).unwrap(); + std::fs::write( + charters_dir.join("01-real.md"), + "---\n\ + charter_id: CHARTER-01-real\n\ + status: declared\n\ + effort_estimate: M\n\ + trigger: \"x\"\n\ + ---\n\n\ + # Charter: Real Title\n\nbody\n", + ) + .unwrap(); + std::fs::write( + charters_dir.join("02-broken.md"), + "no frontmatter at all\n", + ) + .unwrap(); + + let index = DocIndex::build(&devtrail_dir, "en"); + // The synthetic group is appended after the GROUP_DEFS entries. + let charters_group = index + .groups + .iter() + .find(|g| g.name == "_charters") + .expect("synthetic charters group present"); + assert_eq!(charters_group.label, "Charters"); + assert_eq!(charters_group.files.len(), 2); + + let real = charters_group + .files + .iter() + .find(|e| e.filename == "01-real.md") + .unwrap(); + assert_eq!(real.id, "CHARTER-01-real"); + assert_eq!(real.title, "Real Title"); + assert_eq!(real.doc_type, "CH"); + + // Charters with broken frontmatter still appear (degraded entry) so + // the user can find and fix them from the TUI. + let broken = charters_group + .files + .iter() + .find(|e| e.filename == "02-broken.md") + .unwrap(); + assert_eq!(broken.doc_type, "CH"); + assert!(!broken.has_frontmatter); + + // Charter id is indexed in relations so find_by_ref resolves it. + assert_eq!( + index.relations.id_to_path.get("CHARTER-01-real"), + Some(&charters_dir.join("01-real.md")) + ); + } + + #[test] + fn build_skips_charters_group_when_no_charters_exist() { + let tmp = tempfile::TempDir::new().unwrap(); + let devtrail_dir = tmp.path().join(".devtrail"); + std::fs::create_dir_all(&devtrail_dir).unwrap(); + // No docs/charters/ directory. + + let index = DocIndex::build(&devtrail_dir, "en"); + assert!( + index.groups.iter().all(|g| g.name != "_charters"), + "no synthetic Charters group should be present when none exist" + ); + } + + #[test] + fn build_charters_label_localizes_to_zh_cn() { + let tmp = tempfile::TempDir::new().unwrap(); + let project_root = tmp.path(); + let devtrail_dir = project_root.join(".devtrail"); + std::fs::create_dir_all(&devtrail_dir).unwrap(); + let charters_dir = project_root.join("docs").join("charters"); + std::fs::create_dir_all(&charters_dir).unwrap(); + std::fs::write( + charters_dir.join("01-x.md"), + "---\ncharter_id: CHARTER-01-x\nstatus: declared\neffort_estimate: M\ntrigger: x\n---\n\n# Charter: X\n", + ) + .unwrap(); + + let index = DocIndex::build(&devtrail_dir, "zh-CN"); + let charters_group = index + .groups + .iter() + .find(|g| g.name == "_charters") + .expect("synthetic charters group present"); + assert_eq!(charters_group.label, "章程"); + } + #[test] fn build_does_not_localize_user_subgroups() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/cli/src/validation.rs b/cli/src/validation.rs index 48a7a4e..7e0ae59 100644 --- a/cli/src/validation.rs +++ b/cli/src/validation.rs @@ -72,6 +72,162 @@ fn china_in_scope(devtrail_dir: &Path) -> bool { config.has_region("china") } +/// Validate all Charters in a project against the Charter JSON Schema and +/// referential integrity rules: +/// - Schema (shape, enums, required fields, mutual exclusion of origin types). +/// - `originating_ailogs` IDs resolve to real AILOG files under +/// `.devtrail/07-ai-audit/agent-logs/`. +/// - `originating_spec` path exists relative to the project root. +/// +/// Returns the result + number of Charters considered (parsed + parse-failed). +/// If the schema file itself cannot be loaded, emits a single warning and +/// skips schema-level checks; referential integrity is still attempted. +pub fn validate_charters(project_root: &Path, devtrail_dir: &Path) -> (ValidationResult, usize) { + let mut result = ValidationResult::default(); + + // Try to load the schema. Missing schema is a warning, not a hard failure + // (the project may have been initialized before the schema shipped, or + // the file may have been removed). + let schema = match crate::charter_schema::CharterSchema::load(devtrail_dir) { + Ok(s) => Some(s), + Err(e) => { + result.warnings.push(ValidationIssue { + file: devtrail_dir.join(crate::charter_schema::SCHEMA_RELATIVE_PATH), + rule: "CHARTER-SCHEMA-MISSING".to_string(), + message: format!("Charter schema not loadable: {e}"), + severity: Severity::Warning, + fix_hint: Some( + "Run `devtrail repair` to restore framework files.".to_string(), + ), + }); + None + } + }; + + let paths = crate::charter::discover_charters(project_root); + let charter_count = paths.len(); + + for path in &paths { + // Step 1: read raw YAML frontmatter (without typed deserialization). + // This preserves schema-level errors (bad enum, missing required) so + // the schema validator sees them and emits rich hints, rather than + // letting a typed-parse failure mask the actual cause. + let raw_yaml = match crate::charter::read_frontmatter_yaml(path) { + Ok(y) => y, + Err(e) => { + result.errors.push(ValidationIssue { + file: path.clone(), + rule: "CHARTER-PARSE".to_string(), + message: format!("Failed to read Charter: {e}"), + severity: Severity::Error, + fix_hint: Some( + "Check that the file has valid YAML frontmatter between --- delimiters." + .to_string(), + ), + }); + continue; + } + }; + + // Step 2: schema validation. Catches shape errors (enum mismatch, + // missing required, mutual exclusion of origin types) with friendly + // hints from `crate::charter_schema::hint_for`. + if let Some(schema) = &schema { + for issue in schema.validate(&raw_yaml, path) { + result.errors.push(issue); + } + } + + // Step 3: typed parse for referential-integrity checks. If schema + // validation already caught problems, the typed parse may also fail — + // in that case we skip ref checks (cannot trust the structure) but + // we don't double-report (errors already in result via schema). + let typed: Option<crate::charter::CharterFrontmatter> = + serde_yaml::from_value(raw_yaml).ok(); + let typed = match typed { + Some(t) => t, + None => continue, + }; + + // CHARTER-AILOG-REF: every originating AILOG ID must resolve to a file. + if let Some(ailogs) = &typed.originating_ailogs { + for ailog_id in ailogs { + if !ailog_exists(devtrail_dir, ailog_id) { + result.errors.push(ValidationIssue { + file: path.clone(), + rule: "CHARTER-AILOG-REF".to_string(), + message: format!( + "originating_ailogs references missing AILOG: {}", + ailog_id + ), + severity: Severity::Error, + fix_hint: Some(format!( + "Either create the AILOG (e.g., `devtrail new --doc-type ailog`) or \ + remove '{}' from originating_ailogs if it was a typo.", + ailog_id + )), + }); + } + } + } + + // CHARTER-SPEC-REF: the originating_spec path must exist. + if let Some(spec_path) = &typed.originating_spec { + let abs = project_root.join(spec_path); + if !abs.exists() { + result.errors.push(ValidationIssue { + file: path.clone(), + rule: "CHARTER-SPEC-REF".to_string(), + message: format!( + "originating_spec references missing file: {}", + spec_path + ), + severity: Severity::Error, + fix_hint: Some( + "Pass a path that exists under the project root (e.g., \ + specs/001-feature/spec.md), or remove originating_spec if it was a typo." + .to_string(), + ), + }); + } + } + } + + (result, charter_count) +} + +/// True if an AILOG file matching the given ID exists under +/// `.devtrail/07-ai-audit/agent-logs/`. The match is by filename prefix: +/// `AILOG-2026-04-28-021` matches `AILOG-2026-04-28-021-anything.md` but not +/// `AILOG-2026-04-28-0210-something.md` (boundary: next char must be `-` or +/// `.md` extension). +fn ailog_exists(devtrail_dir: &Path, ailog_id: &str) -> bool { + let agent_logs = devtrail_dir.join("07-ai-audit").join("agent-logs"); + if !agent_logs.exists() { + return false; + } + let id = ailog_id.trim_end_matches(".md"); + let entries = match std::fs::read_dir(&agent_logs) { + Ok(e) => e, + Err(_) => return false, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = match name.to_str() { + Some(s) => s, + None => continue, + }; + if let Some(rest) = name.strip_prefix(id) { + // Boundary: either the file extension follows immediately or a + // dash separator before the slug. + if rest == ".md" || rest.starts_with('-') { + return true; + } + } + } + false +} + /// Validate all documents found under a .devtrail/ directory pub fn validate_all(devtrail_dir: &Path) -> (ValidationResult, usize) { let paths = document::discover_documents(devtrail_dir); diff --git a/cli/tests/charter_test.rs b/cli/tests/charter_test.rs new file mode 100644 index 0000000..5df116a --- /dev/null +++ b/cli/tests/charter_test.rs @@ -0,0 +1,899 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use tempfile::TempDir; + +/// Set up a minimal DevTrail installation with the Charter template. Mirrors +/// what `devtrail init` would produce for the Charter feature, sufficient for +/// `devtrail charter new` to operate. +fn setup_devtrail_with_charter_template(dir: &std::path::Path) { + let devtrail = dir.join(".devtrail"); + std::fs::create_dir_all(devtrail.join("templates")).unwrap(); + std::fs::write(devtrail.join("config.yml"), "language: en\n").unwrap(); + + // Bundled template. We ship the actual template from + // dist/.devtrail/templates/charter-template.md in the framework; this test + // helper inlines a structurally-equivalent copy so tests don't depend on + // the dist/ path being available at test runtime. + let template = r#"--- +charter_id: CHARTER-NN +status: declared +effort_estimate: M +trigger: "[1-line: what observable signal justifies executing this Charter now]" +# originating_ailogs: [AILOG-YYYY-MM-DD-NNN] +# originating_spec: specs/001-feature/spec.md +--- + +# Charter: [BRIEF TITLE] + +> **Status (mirrored from frontmatter — source of truth is above):** declared. Effort: [XS | S | M | L] (~[N] min). +> +> **Origin:** [human-readable summary; the machine-readable form is `originating_ailogs` or `originating_spec` in frontmatter]. + +## Context + +[1-2 paragraphs.] + +## Scope + +**In scope:** + +1. [Item 1] + +**Out of scope:** + +- [Item 1] + +## Files to modify + +| File | Change | +|---|---| + +## Verification + +### Local checks + +```bash +<build-command> +``` + +### Production smoke (after deploy) + +```bash +TOKEN="$(<auth-cli> print-identity-token)" +``` + +## Risks + +- **R1 — [risk]**: mitigation. + +## Tasks + +1. Sync main. + +## Charter Closure + +When closing this Charter (post-merge): + +1. Drift check. +"#; + std::fs::write(devtrail.join("templates").join("charter-template.md"), template).unwrap(); +} + +#[test] +fn charter_new_requires_devtrail_installed() { + let dir = TempDir::new().unwrap(); + + let mut cmd = Command::cargo_bin("devtrail").unwrap(); + cmd.arg("charter") + .arg("new") + .arg("--title") + .arg("Test") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stderr(predicate::str::contains("not installed")); +} + +#[test] +fn charter_new_no_origin_creates_file_with_defaults() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + + let mut cmd = Command::cargo_bin("devtrail").unwrap(); + cmd.arg("charter") + .arg("new") + .arg("--title") + .arg("Test Charter") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("Created:")); + + let charters_dir = dir.path().join("docs").join("charters"); + assert!(charters_dir.exists(), "docs/charters/ should exist"); + let entries: Vec<_> = std::fs::read_dir(&charters_dir).unwrap().flatten().collect(); + assert_eq!(entries.len(), 1, "should have exactly one Charter file"); + + let path = entries[0].path(); + let filename = path.file_name().unwrap().to_str().unwrap(); + assert_eq!(filename, "01-test-charter.md"); + + let content = std::fs::read_to_string(&path).unwrap(); + // charter_id placeholder substituted. + assert!(content.contains("charter_id: CHARTER-01-test-charter"), "{}", content); + // Default effort is M. + assert!(content.contains("effort_estimate: M"), "{}", content); + // Body title substituted. + assert!(content.contains("# Charter: Test Charter"), "{}", content); + // Both origin lines remain commented (no flag was passed). + assert!(content.contains("# originating_ailogs: [AILOG-YYYY-MM-DD-NNN]"), "{}", content); + assert!(content.contains("# originating_spec: specs/001-feature/spec.md"), "{}", content); +} + +#[test] +fn charter_new_explicit_effort_is_substituted() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--type") + .arg("L") + .arg("--title") + .arg("Big Work") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + + let path = dir.path().join("docs/charters/01-big-work.md"); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("effort_estimate: L"), "{}", content); + // Prose mirror line also reflects the chosen effort. + assert!(content.contains("Effort: L (~[N] min)"), "{}", content); +} + +#[test] +fn charter_new_with_from_ailog_uncomments_origin() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("From AILOG") + .arg("--from-ailog") + .arg("AILOG-2026-04-28-021") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + + let path = dir.path().join("docs/charters/01-from-ailog.md"); + let content = std::fs::read_to_string(&path).unwrap(); + // Frontmatter origin uncommented and populated. + assert!( + content.contains("originating_ailogs: [AILOG-2026-04-28-021]"), + "{}", + content + ); + // The other origin stays commented. + assert!(content.contains("# originating_spec: specs/001-feature/spec.md")); + // Prose Origin updated with concrete reference. + assert!(content.contains("Follow-up of AILOG-2026-04-28-021"), "{}", content); +} + +#[test] +fn charter_new_with_from_spec_uncomments_origin() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + // Create a stub SpecKit spec file so --from-spec validation passes. + let spec_dir = dir.path().join("specs").join("001-test"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::write(spec_dir.join("spec.md"), "# Test Spec\n").unwrap(); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("From Spec") + .arg("--from-spec") + .arg("specs/001-test/spec.md") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + + let path = dir.path().join("docs/charters/01-from-spec.md"); + let content = std::fs::read_to_string(&path).unwrap(); + assert!( + content.contains("originating_spec: specs/001-test/spec.md"), + "{}", + content + ); + assert!(content.contains("# originating_ailogs: [AILOG-YYYY-MM-DD-NNN]")); + assert!( + content.contains("derived from spec at specs/001-test/spec.md"), + "{}", + content + ); +} + +#[test] +fn charter_new_rejects_both_origins_at_clap_level() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("Both Origins") + .arg("--from-ailog") + .arg("AILOG-2026-04-28-021") + .arg("--from-spec") + .arg("specs/001-test/spec.md") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used")); +} + +#[test] +fn charter_new_from_spec_rejects_missing_file() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("Bad Spec") + .arg("--from-spec") + .arg("specs/missing/spec.md") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stderr(predicate::str::contains("does not exist")); +} + +#[test] +fn charter_new_increments_sequence_number() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + + for (n, title) in [(1, "First"), (2, "Second"), (3, "Third")] { + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg(title) + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + + let expected_filename = format!("{:02}-{}.md", n, title.to_lowercase()); + let path = dir.path().join("docs/charters").join(&expected_filename); + assert!(path.exists(), "expected {} to exist", path.display()); + } + + let entries: Vec<_> = std::fs::read_dir(dir.path().join("docs/charters")) + .unwrap() + .flatten() + .collect(); + assert_eq!(entries.len(), 3); +} + +#[test] +fn charter_new_rejects_invalid_effort_at_clap_level() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--type") + .arg("XXL") + .arg("--title") + .arg("Bad Effort") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stderr(predicate::str::contains("invalid value")); +} + +#[test] +fn charter_new_uses_es_template_when_config_says_es() { + let dir = TempDir::new().unwrap(); + let devtrail = dir.path().join(".devtrail"); + std::fs::create_dir_all(devtrail.join("templates").join("i18n").join("es")).unwrap(); + std::fs::write(devtrail.join("config.yml"), "language: es\n").unwrap(); + + // EN template (fallback). + std::fs::write( + devtrail.join("templates").join("charter-template.md"), + "---\ncharter_id: CHARTER-NN\nstatus: declared\neffort_estimate: M\ntrigger: \"[x]\"\n---\n\n# Charter: [BRIEF TITLE]\n\nEN body.\n", + ).unwrap(); + // ES translation. + std::fs::write( + devtrail.join("templates").join("i18n").join("es").join("charter-template.md"), + "---\ncharter_id: CHARTER-NN\nstatus: declared\neffort_estimate: M\ntrigger: \"[x]\"\n---\n\n# Charter: [TÍTULO BREVE]\n\nES body.\n", + ).unwrap(); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("Hola Mundo") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + + let content = + std::fs::read_to_string(dir.path().join("docs/charters/01-hola-mundo.md")).unwrap(); + assert!(content.contains("ES body"), "expected ES template selected, got: {}", content); + assert!(content.contains("# Charter: Hola Mundo")); +} + +// ---------------------------------------------------------------------------- +// `devtrail charter list` +// ---------------------------------------------------------------------------- + +#[test] +fn charter_list_requires_devtrail_installed() { + let dir = TempDir::new().unwrap(); + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("list") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stderr(predicate::str::contains("not installed")); +} + +#[test] +fn charter_list_empty_when_no_charters() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("list") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("No Charters")); +} + +#[test] +fn charter_list_shows_all_charters_by_default() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("list") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("first")) + .stdout(predicate::str::contains("second")) + .stdout(predicate::str::contains("third")); +} + +#[test] +fn charter_list_filter_status_declared() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + // Mark the first one as closed. + let p = dir.path().join("docs/charters/01-first.md"); + let content = std::fs::read_to_string(&p).unwrap(); + std::fs::write(&p, content.replace("status: declared", "status: closed")).unwrap(); + + let out = Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("list") + .arg("--status") + .arg("declared") + .arg(dir.path().to_str().unwrap()) + .output() + .unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(out.status.success()); + assert!(stdout.contains("second"), "stdout: {}", stdout); + assert!(stdout.contains("third"), "stdout: {}", stdout); + assert!(!stdout.contains(" first"), "stdout should hide closed first: {}", stdout); +} + +#[test] +fn charter_list_filter_origin_ailog() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + let charters_dir = dir.path().join("docs/charters"); + std::fs::create_dir_all(&charters_dir).unwrap(); + + // One Charter with --from-ailog (tested via the actual command). + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("with ailog") + .arg("--from-ailog") + .arg("AILOG-2026-04-28-021") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + + // One without origin. + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("standalone") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + + let out = Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("list") + .arg("--origin") + .arg("ailog") + .arg(dir.path().to_str().unwrap()) + .output() + .unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(out.status.success()); + assert!(stdout.contains("with ailog"), "stdout: {}", stdout); + assert!(!stdout.contains("standalone"), "stdout should hide no-origin: {}", stdout); +} + +#[test] +fn charter_list_no_match_shows_friendly_message() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + + // All three are declared by default, so --status closed matches none. + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("list") + .arg("--status") + .arg("closed") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("No Charters match")); +} + +// ---------------------------------------------------------------------------- +// `devtrail charter status` +// ---------------------------------------------------------------------------- + +#[test] +fn charter_status_requires_devtrail_installed() { + let dir = TempDir::new().unwrap(); + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("status") + .arg("--path") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stderr(predicate::str::contains("not installed")); +} + +#[test] +fn charter_status_empty_when_no_charters() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("status") + .arg("--path") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("No Charters")); +} + +#[test] +fn charter_status_without_id_shows_recent() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("status") + .arg("--path") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("Most recent")) + .stdout(predicate::str::contains("first")) + .stdout(predicate::str::contains("second")) + .stdout(predicate::str::contains("third")); +} + +#[test] +fn charter_status_with_full_id_shows_detail() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("status") + .arg("CHARTER-02-second") + .arg("--path") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("CHARTER-02-second")) + .stdout(predicate::str::contains("Status:")) + .stdout(predicate::str::contains("Effort:")) + .stdout(predicate::str::contains("File:")); +} + +#[test] +fn charter_status_with_charter_nn_prefix_shows_detail() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("status") + .arg("CHARTER-02") + .arg("--path") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("CHARTER-02-second")); +} + +#[test] +fn charter_status_with_numeric_id_shows_detail() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("status") + .arg("2") + .arg("--path") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("CHARTER-02-second")); +} + +#[test] +fn charter_status_with_unknown_id_fails() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + create_three_charters(dir.path()); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("status") + .arg("CHARTER-99") + .arg("--path") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stderr(predicate::str::contains("not found")); +} + +// ---------------------------------------------------------------------------- +// `devtrail validate --include-charters` +// ---------------------------------------------------------------------------- + +#[test] +fn validate_without_flag_skips_charter_checks() { + // Verifies the opt-in: a project with a broken Charter (missing required + // field) still passes `devtrail validate` when --include-charters is absent. + let dir = TempDir::new().unwrap(); + setup_devtrail_full(dir.path()); + let charters_dir = dir.path().join("docs/charters"); + std::fs::create_dir_all(&charters_dir).unwrap(); + // Write a Charter missing the required `trigger` field. Without + // --include-charters the validator should not even look at it. + std::fs::write( + charters_dir.join("01-broken.md"), + "---\ncharter_id: CHARTER-01-broken\nstatus: declared\neffort_estimate: M\n---\n\n# Charter: Broken\n", + ) + .unwrap(); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); +} + +#[test] +fn validate_with_flag_passes_for_valid_charter() { + let dir = TempDir::new().unwrap(); + setup_devtrail_full(dir.path()); + create_charter_via_cli(dir.path(), "valid charter", &[]); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg("--include-charters") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("passed validation")); +} + +#[test] +fn validate_with_flag_fails_on_missing_required_field() { + let dir = TempDir::new().unwrap(); + setup_devtrail_full(dir.path()); + let charters_dir = dir.path().join("docs/charters"); + std::fs::create_dir_all(&charters_dir).unwrap(); + // Frontmatter is missing `trigger` — schema should reject. + std::fs::write( + charters_dir.join("01-no-trigger.md"), + "---\ncharter_id: CHARTER-01-no-trigger\nstatus: declared\neffort_estimate: M\n---\n\n# Charter: No trigger\n", + ) + .unwrap(); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg("--include-charters") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stdout(predicate::str::contains("CHARTER-SCHEMA")); +} + +#[test] +fn validate_with_flag_fails_on_invalid_status_enum() { + let dir = TempDir::new().unwrap(); + setup_devtrail_full(dir.path()); + create_charter_via_cli(dir.path(), "bad status", &[]); + let p = dir.path().join("docs/charters/01-bad-status.md"); + let content = std::fs::read_to_string(&p).unwrap(); + std::fs::write(&p, content.replace("status: declared", "status: unknown-state")).unwrap(); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg("--include-charters") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stdout(predicate::str::contains("declared, in-progress, closed")); +} + +#[test] +fn validate_fails_when_originating_ailog_does_not_exist() { + let dir = TempDir::new().unwrap(); + setup_devtrail_full(dir.path()); + create_charter_via_cli( + dir.path(), + "missing ailog", + &["--from-ailog", "AILOG-2026-04-28-099"], + ); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg("--include-charters") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stdout(predicate::str::contains("CHARTER-AILOG-REF")) + .stdout(predicate::str::contains("AILOG-2026-04-28-099")); +} + +#[test] +fn validate_passes_when_originating_ailog_exists() { + let dir = TempDir::new().unwrap(); + setup_devtrail_full(dir.path()); + // Create a real AILOG file so the reference resolves. The frontmatter + // includes all META-001 required fields so the existing AILOG validator + // (independent of Charter checks) does not flag this stub. + let agent_logs = dir.path().join(".devtrail/07-ai-audit/agent-logs"); + std::fs::write( + agent_logs.join("AILOG-2026-04-28-021-real.md"), + "---\n\ + id: AILOG-2026-04-28-021\n\ + title: Real AILOG stub for testing\n\ + status: accepted\n\ + created: 2026-04-28\n\ + agent: test-agent-v1.0\n\ + confidence: high\n\ + review_required: false\n\ + risk_level: low\n\ + ---\n\n\ + Body.\n", + ) + .unwrap(); + create_charter_via_cli( + dir.path(), + "real ailog", + &["--from-ailog", "AILOG-2026-04-28-021"], + ); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg("--include-charters") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); +} + +#[test] +fn validate_fails_when_originating_spec_path_missing() { + let dir = TempDir::new().unwrap(); + setup_devtrail_full(dir.path()); + // Create a Charter with a spec path that exists at scaffold time, then + // delete the spec to simulate a broken reference (e.g., spec was renamed). + let spec_dir = dir.path().join("specs/001-test"); + std::fs::create_dir_all(&spec_dir).unwrap(); + std::fs::write(spec_dir.join("spec.md"), "# Spec\n").unwrap(); + create_charter_via_cli( + dir.path(), + "from spec", + &["--from-spec", "specs/001-test/spec.md"], + ); + std::fs::remove_file(spec_dir.join("spec.md")).unwrap(); + + Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg("--include-charters") + .arg(dir.path().to_str().unwrap()) + .assert() + .failure() + .stdout(predicate::str::contains("CHARTER-SPEC-REF")) + .stdout(predicate::str::contains("specs/001-test/spec.md")); +} + +#[test] +fn validate_warns_when_charter_schema_missing() { + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); // no schema written + let charters_dir = dir.path().join("docs/charters"); + std::fs::create_dir_all(&charters_dir).unwrap(); + std::fs::write( + charters_dir.join("01-x.md"), + "---\ncharter_id: CHARTER-01-x\nstatus: declared\neffort_estimate: M\ntrigger: \"x\"\n---\n\n# Charter: X\n", + ) + .unwrap(); + + let out = Command::cargo_bin("devtrail") + .unwrap() + .arg("validate") + .arg("--include-charters") + .arg(dir.path().to_str().unwrap()) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("CHARTER-SCHEMA-MISSING") || stdout.contains("schema not loadable"), + "expected schema-missing warning. stdout: {}", + stdout + ); +} + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +/// Set up a DevTrail installation with both the template AND the real Charter +/// schema (copied from dist/). Used by validate --include-charters tests. +fn setup_devtrail_full(dir: &std::path::Path) { + setup_devtrail_with_charter_template(dir); + // Copy the real schema from the framework distribution. + let schemas_dir = dir.join(".devtrail/schemas"); + std::fs::create_dir_all(&schemas_dir).unwrap(); + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let real_schema = manifest_dir + .join("..") + .join("dist/.devtrail/schemas/charter.schema.v0.json"); + let schema_content = std::fs::read_to_string(&real_schema).unwrap_or_else(|e| { + panic!( + "test setup needs the real schema at {}: {}", + real_schema.display(), + e + ) + }); + std::fs::write(schemas_dir.join("charter.schema.v0.json"), schema_content).unwrap(); + // Also create the agent-logs directory so AILOG-ref tests have somewhere to look. + std::fs::create_dir_all(dir.join(".devtrail/07-ai-audit/agent-logs")).unwrap(); +} + +/// Run `devtrail charter new` with the given title and extra flags. Asserts +/// success. Used by validate tests to produce real on-disk Charters. +fn create_charter_via_cli(dir: &std::path::Path, title: &str, extra_args: &[&str]) { + let mut cmd = Command::cargo_bin("devtrail").unwrap(); + cmd.arg("charter").arg("new").arg("--title").arg(title); + for arg in extra_args { + cmd.arg(arg); + } + cmd.arg(dir.to_str().unwrap()).assert().success(); +} + +/// Create three Charters via the actual `devtrail charter new` command, so +/// list/status tests exercise real on-disk shapes (not synthetic stubs). +fn create_three_charters(dir: &std::path::Path) { + for title in ["first", "second", "third"] { + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg(title) + .arg(dir.to_str().unwrap()) + .assert() + .success(); + } +} + +#[test] +fn charter_new_does_not_overwrite_existing_file() { + // Edge case: if the user manually created docs/charters/01-foo.md and then + // tries `charter new --title "foo"`, we should refuse rather than clobber. + let dir = TempDir::new().unwrap(); + setup_devtrail_with_charter_template(dir.path()); + let charters_dir = dir.path().join("docs").join("charters"); + std::fs::create_dir_all(&charters_dir).unwrap(); + std::fs::write(charters_dir.join("01-foo.md"), "preexisting; not a Charter\n").unwrap(); + // The pre-existing file does not match the Charter naming pattern (still + // matches `NN-*.md`) so next_charter_number() will compute 02. + // To force the clash we insert a Charter-shaped placeholder and verify + // next_charter_number lands on 02; then the new file at 02-foo.md is fine. + // We instead test the explicit overwrite-refusal branch by pre-creating + // the exact filename the next run would produce. + let _ = std::fs::remove_file(charters_dir.join("01-foo.md")); + std::fs::write(charters_dir.join("01-foo.md"), "real Charter content\n").unwrap(); + + // next_charter_number reads existing files; with 01-foo.md present it + // returns 2. So `charter new --title foo` produces 02-foo.md, not a clash. + // We can only force a clash with concurrent invocations, which we don't + // simulate here. The overwrite guard is defensive — verify it compiles + // and the happy path produces a distinct filename. + Command::cargo_bin("devtrail") + .unwrap() + .arg("charter") + .arg("new") + .arg("--title") + .arg("foo") + .arg(dir.path().to_str().unwrap()) + .assert() + .success(); + assert!(charters_dir.join("02-foo.md").exists()); +} diff --git a/dist/.devtrail/00-governance/AGENT-RULES.md b/dist/.devtrail/00-governance/AGENT-RULES.md index 0924521..3a5344d 100644 --- a/dist/.devtrail/00-governance/AGENT-RULES.md +++ b/dist/.devtrail/00-governance/AGENT-RULES.md @@ -270,4 +270,4 @@ When a change modifies API endpoints: --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/C4-DIAGRAM-GUIDE.md b/dist/.devtrail/00-governance/C4-DIAGRAM-GUIDE.md index 6509145..7c52168 100644 --- a/dist/.devtrail/00-governance/C4-DIAGRAM-GUIDE.md +++ b/dist/.devtrail/00-governance/C4-DIAGRAM-GUIDE.md @@ -234,4 +234,4 @@ Use a Level 1 (Context) diagram to illustrate: --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/DOCUMENTATION-POLICY.md b/dist/.devtrail/00-governance/DOCUMENTATION-POLICY.md index 51cc8a9..6eebdda 100644 --- a/dist/.devtrail/00-governance/DOCUMENTATION-POLICY.md +++ b/dist/.devtrail/00-governance/DOCUMENTATION-POLICY.md @@ -257,4 +257,4 @@ See also [ADR-2025-01-20-001] for architectural context. --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/QUICK-REFERENCE.md b/dist/.devtrail/00-governance/QUICK-REFERENCE.md index 85ff38f..5a5d613 100644 --- a/dist/.devtrail/00-governance/QUICK-REFERENCE.md +++ b/dist/.devtrail/00-governance/QUICK-REFERENCE.md @@ -213,4 +213,4 @@ Mark `review_required: true` when: --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/es/AGENT-RULES.md b/dist/.devtrail/00-governance/i18n/es/AGENT-RULES.md index fcfd7c7..8715bf2 100644 --- a/dist/.devtrail/00-governance/i18n/es/AGENT-RULES.md +++ b/dist/.devtrail/00-governance/i18n/es/AGENT-RULES.md @@ -270,4 +270,4 @@ Cuando un cambio modifica endpoints de API: --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md b/dist/.devtrail/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md index fd9c5e8..63a143c 100644 --- a/dist/.devtrail/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md +++ b/dist/.devtrail/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md @@ -234,4 +234,4 @@ Usar un diagrama de Nivel 1 (Contexto) para ilustrar: --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/es/DOCUMENTATION-POLICY.md b/dist/.devtrail/00-governance/i18n/es/DOCUMENTATION-POLICY.md index 7d539a8..ce4b766 100644 --- a/dist/.devtrail/00-governance/i18n/es/DOCUMENTATION-POLICY.md +++ b/dist/.devtrail/00-governance/i18n/es/DOCUMENTATION-POLICY.md @@ -249,4 +249,4 @@ Ver también [ADR-2025-01-20-001] para contexto arquitectónico. --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/es/QUICK-REFERENCE.md b/dist/.devtrail/00-governance/i18n/es/QUICK-REFERENCE.md index 88af424..2d7cd9c 100644 --- a/dist/.devtrail/00-governance/i18n/es/QUICK-REFERENCE.md +++ b/dist/.devtrail/00-governance/i18n/es/QUICK-REFERENCE.md @@ -188,4 +188,4 @@ Marcar `review_required: true` cuando: --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/zh-CN/AGENT-RULES.md b/dist/.devtrail/00-governance/i18n/zh-CN/AGENT-RULES.md index 690dfa6..025faaa 100644 --- a/dist/.devtrail/00-governance/i18n/zh-CN/AGENT-RULES.md +++ b/dist/.devtrail/00-governance/i18n/zh-CN/AGENT-RULES.md @@ -270,4 +270,4 @@ confidence: high | medium | low --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md b/dist/.devtrail/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md index eb28c32..4b7858b 100644 --- a/dist/.devtrail/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md +++ b/dist/.devtrail/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md @@ -234,4 +234,4 @@ Rel(api, db, "Reads/Writes", "SQL") --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md b/dist/.devtrail/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md index 6e07a42..3f9f7e1 100644 --- a/dist/.devtrail/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md +++ b/dist/.devtrail/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md @@ -249,4 +249,4 @@ draft ──────► accepted ──────► deprecated --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/00-governance/i18n/zh-CN/QUICK-REFERENCE.md b/dist/.devtrail/00-governance/i18n/zh-CN/QUICK-REFERENCE.md index c403033..78d1e40 100644 --- a/dist/.devtrail/00-governance/i18n/zh-CN/QUICK-REFERENCE.md +++ b/dist/.devtrail/00-governance/i18n/zh-CN/QUICK-REFERENCE.md @@ -188,4 +188,4 @@ risk_level: low | medium | high | critical --- -*DevTrail v4.3.0 | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/QUICK-REFERENCE.md b/dist/.devtrail/QUICK-REFERENCE.md index 9a21ec7..1ceda1b 100644 --- a/dist/.devtrail/QUICK-REFERENCE.md +++ b/dist/.devtrail/QUICK-REFERENCE.md @@ -168,4 +168,4 @@ Mark `review_required: true` when: --- -*DevTrail v4.3.0 | [GitHub](https://github.com/StrangeDaysTech/devtrail) | [Strange Days Tech](https://strangedays.tech)* +*DevTrail v4.4.0 | [GitHub](https://github.com/StrangeDaysTech/devtrail) | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.devtrail/schemas/charter.schema.v0.json b/dist/.devtrail/schemas/charter.schema.v0.json new file mode 100644 index 0000000..a56f8f4 --- /dev/null +++ b/dist/.devtrail/schemas/charter.schema.v0.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://devtrail.dev/schemas/charter.schema.v0.json", + "title": "DevTrail Charter (experimental v0)", + "description": "Frontmatter schema for a DevTrail Charter — a bounded, auditable unit of work declared ex-ante and verified ex-post. See devtrail-cli-roadmap.md §3.1 and que-es-un-charter.md for the conceptual scope.", + "$comment": "EXPERIMENTAL v0. Crystallized from Sentinel /plan-audit (6 cycles, format v1 → v2 → v3). Will not stabilize to v1.0 until validated in a second domain (frontend, ML pipeline, or infra-as-code). See devtrail-thesis-validation.md §6 for the N≈2-3 argument. Adopters: avoid persisting tooling that depends on this exact shape; expect breaking changes between v0.x revisions.", + "type": "object", + "required": ["charter_id", "status", "effort_estimate", "trigger"], + "additionalProperties": true, + "properties": { + "charter_id": { + "type": "string", + "pattern": "^CHARTER-[0-9]{2,}(-[a-z0-9-]+)?$", + "description": "Unique Charter identifier. Format: CHARTER-NN where NN is project-local sequential, zero-padded to at least 2 digits. Optionally followed by a slug (e.g., CHARTER-05-per-service-thresholds)." + }, + "status": { + "type": "string", + "enum": ["declared", "in-progress", "closed"], + "description": "Lifecycle status of the Charter. Source of truth — the prose status mirror in the body is decorative." + }, + "effort_estimate": { + "type": "string", + "enum": ["XS", "S", "M", "L"], + "description": "Time-based effort estimate. Sentinel validated this as predictive (1.0x in 4/5 cycles); line-count is not predictive and is intentionally absent." + }, + "trigger": { + "type": "string", + "minLength": 1, + "description": "Free-text observable signal that justifies executing this Charter now (e.g., 'first false-positive ticket in svc_xxx', 'when admin UI lands'). Per Sentinel telemetry, declared triggers tend to be prescriptive, not predictive — track effective trigger separately at closure." + }, + "originating_ailogs": { + "type": "array", + "items": { + "type": "string", + "pattern": "^AILOG-[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{3}(-[a-z0-9-]+)?$" + }, + "minItems": 1, + "description": "AILOG IDs that motivated this Charter. Used in maintenance / post-MVP mode (the Sentinel case). Mutually exclusive with originating_spec; both may be absent if the Charter was scaffolded without an explicit origin." + }, + "originating_spec": { + "type": "string", + "minLength": 1, + "description": "Path (relative to project root) to a SpecKit spec.md or equivalent (e.g., 'specs/001-feature/spec.md'). Used in greenfield mode where SpecKit drives planning. Free-form to avoid coupling the schema to a specific SpecKit version. Mutually exclusive with originating_ailogs." + } + }, + "not": { + "required": ["originating_ailogs", "originating_spec"] + } +} diff --git a/dist/.devtrail/templates/charter-template.md b/dist/.devtrail/templates/charter-template.md new file mode 100644 index 0000000..36daf5d --- /dev/null +++ b/dist/.devtrail/templates/charter-template.md @@ -0,0 +1,191 @@ +--- +charter_id: CHARTER-NN +status: declared +effort_estimate: M +trigger: "[1-line: what observable signal justifies executing this Charter now]" +# Exactly one of the following two should be set when the Charter has a known origin. +# Both absent is valid for a Charter scaffolded without an explicit origin (must be +# filled before status moves to in-progress). +# originating_ailogs: [AILOG-YYYY-MM-DD-NNN] +# originating_spec: specs/001-feature/spec.md +--- + +# Charter: [BRIEF TITLE] + +> **Status (mirrored from frontmatter — source of truth is above):** declared. Effort: [XS | S | M | L] (~[N] min). +> +> **Origin:** [human-readable summary; the machine-readable form is `originating_ailogs` or `originating_spec` in frontmatter]. + +<!-- Charter template — 6 format conventions distilled from the Sentinel /plan-audit + experiment (6 cycles, 2026-04-28). See the comment block at the end of this file + for each convention with its empirical justification, and devtrail-cli-roadmap.md §3 + plus devtrail-thesis-validation.md §3-§5 for the source evidence. --> + +## Context + +[1-2 paragraphs. What problem this Charter solves, what operational or regulatory +motivation makes it urgent, what has been attempted before (if anything). Cite the +originating AILOGs here too if it helps the reader understand why the work was deferred.] + +## Scope + +**In scope:** + +[Numbered list of concrete changes to apply. Each item must be verifiable: "X file +gains Y method", "Z test covers W case". Avoid vague items like "improve performance" +— those are objectives, not scope.] + +1. [Item 1] +2. [Item 2] +3. [...] + +**Out of scope:** + +[List of things explicitly NOT covered by this Charter. Important so external auditors +do not classify them as gaps. Ideally cite the Charter or initiative where they belong.] + +- [Item 1] — deferred to [Charter/initiative]. +- [Item 2] — out of scope because [reason]. + +## Files to modify + +| File | Change | +|---|---| +| `path/to/file.ext` | [Concrete description of the change] | +| `...` | `...` | +| `.devtrail/07-ai-audit/agent-logs/AILOG-...md` | New, `risk_level: [low|medium|high]` | + +## Verification + +### Local checks + +Commands executable literal in a clean shell — include explicit setup of dependencies. +Any failure of these commands indicates real debt. + +```bash +# Build & test (adapt to your stack) +<build-command> +<test-command> + +# Security/vulnerability scanners with explicit setup +# (Pattern validated in Sentinel PLAN-01..05: implicit PATH lookups generated +# false-positive 'real_debt' classifications from external auditors.) +<install-and-run-security-scanner> +<install-and-run-vulnerability-scanner> + +# Other local commands here. If they require integration infra, document explicitly: +<integration-test-command> +``` + +### Production smoke (after deploy) + +Commands that **only apply after deploy to a real environment**. NOT executable in a +clean shell without infrastructure. External auditors should skip this section — +failures here are NOT `real_debt`. + +```bash +# Example: verify a new endpoint is live in production. +TOKEN="$(<auth-cli> print-identity-token)" +curl -X PUT "https://${SERVICE_HOST}/api/v1/.../..." \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"...": "..."}' + +# Example: SQL query in production DB to verify event persistence. +<production-db-cli> connect <service-db> -- \ + -c "SELECT context FROM audit_records WHERE action='...' \ + ORDER BY timestamp DESC LIMIT 1" +``` + +## Risks + +[List of risks R1, R2, ... that the implementation commits to mitigate. Each with its +mitigation documented. Convention: if a NEW risk emerges during execution that was not +anticipated, document it in the AILOG under `## Risk` as `R<N+1> (new, not in Charter)` +— Gemini and other external auditors validate these cross-document.] + +- **R1 — [risk description]**: [probability/severity]. + Mitigation: [concrete action taken in implementation]. +- **R2 — ...**: ... +- [...] + +## Tasks + +1. Sync main, branch `<branch-prefix>/[slug]`. +2. [Implementation task 1]. +3. [Implementation task 2]. +4. [...] +5. AILOG (`risk_level: [low|medium|high]`, `review_required: [true|false]`). +6. Local verification passes clean. +7. **Auto-checklist drift** (when Phase 2 of the CLI roadmap ships): + `devtrail charter drift CHARTER-NN <range>` to detect drifts between declared + and modified files **before** commit. If it reports omissions, complete the work + or document in the AILOG under `## Risk` as `R<N+1> (new, not in Charter)`. If it + reports scope expansion, document in the AILOG the reason (mock updates, generated + files, drift fix pre-existing, etc.). Until Phase 2 ships, run Sentinel's + `check-plan-drift.sh` manually for the same effect. +8. Commit + push + open PR. + +## Charter Closure + +When closing this Charter (post-merge): + +1. **Charter drift check** (automated when Phase 2 ships + manual review): + - Run `devtrail charter drift CHARTER-NN origin/main..HEAD` (Phase 2) or the + equivalent Sentinel script, and validate the output is clean or that all + drifts are documented in the AILOG. + - Additionally, review the AILOG generated by the implementation. If it + declares divergences from this Charter (location of changes, scope expansion, + new R<N> risks not anticipated, etc.) update this Charter doc to reflect the + actual execution. Pattern validated in 5/5 cycles of Sentinel `/plan-audit`: + AILOGs document divergences but Charters stay stale unless explicitly updated. + +2. **Move the row** in `docs/charters/README.md` to `## Closed` and reference the PR. + +3. **Status frontmatter** moves from `in-progress` to `closed` (and optionally + `closed_at: YYYY-MM-DD` is added — the schema allows arbitrary additional fields). + +4. **Do not delete** this file — the planning history matters as much as the AILOG + of execution. + +--- + +<!-- +Format conventions — 6 patterns embedded in this template, distilled from the +6-cycle Sentinel /plan-audit experiment (2026-04-28). The provenance is part of the +historical record (in DevTrail terms these are simply "the conventions", not "v2 + +v3 addition" — the partition was Sentinel's iteration log, not structural). + +1. Verification splits into `### Local checks` (executable literal in clean shell) + and `### Production smoke (after deploy)` (not executable without infrastructure). + Reason: external auditors classified prod-only command failures as `real_debt` — + avoidable noise. Validated 5/5 cycles after the convention was named. + +2. Effort is measured in TIME (XS/S/M/L), not in `~N lines`. Reason: time met the + estimate (1.0x) in 4/5 cycles; line count drifted 1.0x → 3.1x → 8.1x due to + AILOG/tests/mocks. Lines are not predictive of cognitive effort. + +3. Modifiers like `(optional)` or `(after deploy)` live as structured sub-sections, + never as inline parenthetical comments. Reason: the Gemini auditor consistently + ignored parenthetical modifiers and classified marked-optional commands as + `real_debt`. Validated 2/2 cycles where the pattern applied. + +4. R<N> risks are enumerated in the Charter; new risks emergent during execution are + documented in the AILOG as `R<N+1> (new, not in Charter)`. Reason: cross-validable + signal by external auditors — they triangulate Charter declarations against AILOG + emergence. Validated 4/4 cycles where new risks emerged. + +5. The `## Charter Closure` section explicitly reminds to update the Charter doc + post-merge if the AILOG documented divergences. Reason: 5/5 cycles showed drift + between declared Charter and actual execution; without an explicit trigger, the + Charter stays stale and future readers misinterpret divergences as failures. + +6. Auto-checklist drift (`devtrail charter drift`, Phase 2 of the CLI roadmap; + Sentinel had `scripts/check-plan-drift.sh`) runs in pre-commit (Tasks #7) and at + Charter closure. Detects OMISSION drifts (file declared, not touched) and SCOPE + EXPANSION drifts (file touched, not declared). Reason: external auditors caught + implementation-gap and hallucination drifts that the implementer did not document + in their AILOG. The script catches the same drifts BEFORE commit, separating + "known and documented" from "forgotten". Zero false positives on 2/2 empirical + tests against the canonical Sentinel Plans. +--> diff --git a/dist/.devtrail/templates/i18n/es/charter-template.md b/dist/.devtrail/templates/i18n/es/charter-template.md new file mode 100644 index 0000000..2449733 --- /dev/null +++ b/dist/.devtrail/templates/i18n/es/charter-template.md @@ -0,0 +1,197 @@ +--- +charter_id: CHARTER-NN +status: declared +effort_estimate: M +trigger: "[1 línea: qué señal observable justifica ejecutar este Charter ahora]" +# Establece exactamente uno de los siguientes dos cuando el Charter tenga un origen conocido. +# Ambos ausentes es válido para un Charter creado sin origen explícito (debe llenarse antes +# de que el status pase a in-progress). +# originating_ailogs: [AILOG-YYYY-MM-DD-NNN] +# originating_spec: specs/001-feature/spec.md +--- + +# Charter: [TÍTULO BREVE] + +> **Status (espejado del frontmatter — la fuente de verdad está arriba):** declared. Esfuerzo: [XS | S | M | L] (~[N] min). +> +> **Origen:** [resumen humano; la forma machine-readable es `originating_ailogs` u `originating_spec` en el frontmatter]. + +<!-- Charter template — 6 convenciones de formato destiladas del experimento + Sentinel /plan-audit (6 ciclos, 2026-04-28). Ver el bloque de comentario al final + de este archivo para cada convención con su justificación empírica, y + devtrail-cli-roadmap.md §3 + devtrail-thesis-validation.md §3-§5 para la + evidencia de origen. --> + +## Context + +[1-2 párrafos. Qué problema resuelve este Charter, qué motivación operacional o +regulatoria lo hace urgente, qué se ha intentado antes (si algo). Cita los AILOGs +origen aquí también si ayuda al lector a entender por qué la deuda quedó abierta.] + +## Scope + +**In scope:** + +[Lista numerada de los cambios concretos a aplicar. Cada item debe ser verificable: +"X archivo gana Y método", "Z test cubre W caso". Evitar items vagos como "mejorar +performance" — esos son objetivos, no scope.] + +1. [Item 1] +2. [Item 2] +3. [...] + +**Out of scope:** + +[Lista de cosas explícitamente NO cubiertas por este Charter. Importante para que +auditores no las clasifiquen como gaps. Idealmente cita el Charter o iniciativa +donde sí van.] + +- [Item 1] — diferido a [Charter/iniciativa]. +- [Item 2] — fuera del alcance porque [razón]. + +## Archivos a modificar + +| Archivo | Cambio | +|---|---| +| `path/al/archivo.ext` | [Descripción concreta del cambio] | +| `...` | `...` | +| `.devtrail/07-ai-audit/agent-logs/AILOG-...md` | Nuevo, `risk_level: [low|medium|high]` | + +## Verification + +### Local checks + +Comandos ejecutables literal en clean shell — incluyen setup explícito de dependencias. +Cualquier fallo de estos comandos indica deuda real. + +```bash +# Build & test (adapta a tu stack) +<comando-build> +<comando-test> + +# Setup explícito de scanners de seguridad/vulnerabilidades +# (Patrón validado en Sentinel PLAN-01..05: lookups implícitos en PATH generaban +# clasificaciones falso-positivas como 'real_debt' por auditores externos.) +<install-and-run-security-scanner> +<install-and-run-vulnerability-scanner> + +# Otros comandos locales aquí. Si requieren infra de integración, indicar: +<comando-integration-test> +``` + +### Production smoke (after deploy) + +Comandos que **solo aplican después de deploy a un ambiente real**. NO ejecutables +en clean shell sin infraestructura. Auditores externos deben saltar esta sección — +fallos aquí no son `real_debt`. + +```bash +# Ejemplo: verificar que un endpoint nuevo está vivo en producción. +TOKEN="$(<auth-cli> print-identity-token)" +curl -X PUT "https://${SERVICE_HOST}/api/v1/.../..." \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"...": "..."}' + +# Ejemplo: query SQL en DB productiva para verificar evento persistido. +<production-db-cli> connect <service-db> -- \ + -c "SELECT context FROM audit_records WHERE action='...' \ + ORDER BY timestamp DESC LIMIT 1" +``` + +## Riesgos + +[Lista de riesgos R1, R2, ... que la implementación se compromete a mitigar. Cada uno +con su mitigación documentada. Convención: si durante la ejecución emerge un riesgo +NUEVO no anticipado, documentarlo en el AILOG bajo `## Risk` como +`R<N+1> (nuevo, no en Charter)` — Gemini y otros auditores externos validan estos +cross-document.] + +- **R1 — [descripción del riesgo]**: [probabilidad/severidad]. + Mitigación: [acción concreta tomada en la implementación]. +- **R2 — ...**: ... +- [...] + +## Tasks + +1. Sync main, branch `<prefijo>/[slug]`. +2. [Tarea de implementación 1]. +3. [Tarea de implementación 2]. +4. [...] +5. AILOG (`risk_level: [low|medium|high]`, `review_required: [true|false]`). +6. Verification local pasa limpio. +7. **Auto-checklist drift** (cuando entregue Fase 2 del CLI roadmap): + `devtrail charter drift CHARTER-NN <range>` para detectar drifts entre lo declarado + y lo modificado **antes** del commit. Si reporta omisiones, completar el trabajo + o documentar en AILOG bajo `## Risk` como `R<N+1> (nuevo, no en Charter)`. Si + reporta scope expansion, documentar en AILOG el motivo (mock updates, generated + files, drift fix pre-existente, etc.). Hasta que Fase 2 entregue, correr el + `check-plan-drift.sh` de Sentinel manualmente para el mismo efecto. +8. Commit + push + abrir PR. + +## Cierre del Charter + +Al cerrar este Charter (post-merge): + +1. **Charter drift check** (automatizado cuando entregue Fase 2 + revisión manual): + - Correr `devtrail charter drift CHARTER-NN origin/main..HEAD` (Fase 2) o el + script de Sentinel equivalente, y validar que el output esté limpio o que + todos los drifts estén documentados en el AILOG. + - Adicionalmente, revisar el AILOG generado por la implementación. Si declara + divergencias respecto a este Charter (ubicación de cambios, scope expansion, + riesgos `R<N>` nuevos no anticipados, etc.) actualizar este Charter doc para + reflejar la ejecución real. Patrón validado en 5/5 ciclos del experimento + `/plan-audit` de Sentinel: los AILOGs documentan divergencias pero los + Charters quedan stale si nadie los actualiza explícitamente. + +2. **Mover la fila** en `docs/charters/README.md` a `## Cerrados` y referenciar el PR. + +3. **Status del frontmatter** pasa de `in-progress` a `closed` (y opcionalmente + se añade `closed_at: YYYY-MM-DD` — el schema permite campos adicionales arbitrarios). + +4. **No borrar** este archivo — el historial de planning importa tanto como el AILOG + de ejecución. + +--- + +<!-- +Convenciones de formato — 6 patrones embebidos en este template, destilados del +experimento Sentinel /plan-audit de 6 ciclos (2026-04-28). La provenance forma parte +del registro histórico (en términos de DevTrail estas son simplemente "las +convenciones", no "v2 + adición v3" — la partición era el log de iteración de +Sentinel, no estructural). + +1. Verification se divide en `### Local checks` (ejecutables literal en clean shell) + y `### Production smoke (after deploy)` (no ejecutables sin infraestructura). + Razón: los auditores externos clasificaban como `real_debt` los fallos de comandos + prod-only — ruido evitable. Validado 5/5 ciclos tras nombrar la convención. + +2. Esfuerzo se mide en TIEMPO (XS/S/M/L), no en `~N líneas`. Razón: el tiempo cumplió + la estimación (1.0x) en 4/5 ciclos; las líneas se inflaron 1.0x → 3.1x → 8.1x por + AILOG/tests/mocks. Las líneas no son predictivas del esfuerzo cognitivo. + +3. Modificadores como `(opcional)` o `(después de deploy)` viven como sub-secciones + estructuradas, nunca como comentarios in-line entre paréntesis. Razón: el auditor + Gemini ignoró consistentemente los modificadores parentizados y clasificó comandos + marcados como opcionales como `real_debt`. Validado 2/2 ciclos donde aplicaba. + +4. Riesgos R<N> se enumeran en el Charter; los nuevos riesgos que emergen durante + ejecución se documentan en el AILOG como `R<N+1> (nuevo, no en Charter)`. Razón: + señal cross-validable por auditores externos — triangulan declaraciones del + Charter contra emergencia en el AILOG. Validado 4/4 ciclos donde emergieron + riesgos nuevos. + +5. La sección `## Cierre del Charter` recuerda explícitamente actualizar el Charter + doc post-merge si el AILOG documentó divergencias. Razón: 5/5 ciclos mostraron + drift entre Charter declarado y ejecución real; sin un trigger explícito, el + Charter queda stale y los lectores futuros interpretan divergencias como fallos. + +6. Auto-checklist drift (`devtrail charter drift`, Fase 2 del CLI roadmap; Sentinel + tenía `scripts/check-plan-drift.sh`) corre en pre-commit (Tasks #7) y al cierre + del Charter. Detecta drifts de OMISIÓN (archivo declarado, no tocado) y de SCOPE + EXPANSION (archivo tocado, no declarado). Razón: los auditores externos capturaron + drifts de implementation-gap y hallucination que el implementador no documentó + en su AILOG. El script atrapa los mismos drifts ANTES del commit, separando + "conocidos y documentados" de "olvidados". Cero falsos positivos en 2/2 tests + empíricos contra los Plans canónicos de Sentinel. +--> diff --git a/dist/dist-manifest.yml b/dist/dist-manifest.yml index 74bf5cc..f0cd015 100644 --- a/dist/dist-manifest.yml +++ b/dist/dist-manifest.yml @@ -1,4 +1,4 @@ -version: "4.3.0" +version: "4.4.0" description: "DevTrail distribution manifest" repository: "https://github.com/StrangeDaysTech/devtrail" diff --git a/dist/docs/examples/charters/CHARTER-01-anomaly-thresholds.md b/dist/docs/examples/charters/CHARTER-01-anomaly-thresholds.md new file mode 100644 index 0000000..0cc5d8b --- /dev/null +++ b/dist/docs/examples/charters/CHARTER-01-anomaly-thresholds.md @@ -0,0 +1,183 @@ +--- +charter_id: CHARTER-01-anomaly-thresholds +status: closed +effort_estimate: M +trigger: "First false-positive anomaly ticket from a service with irregular traffic" +originating_ailogs: [AILOG-2026-01-15-001] +note: "Anonymized example derived from Sentinel PLAN-05 (per-service-anomaly-thresholds). See devtrail-cli-roadmap.md §3.1 for the porting context." +closed_at: "2026-01-28" +--- + +# Charter: Per-service anomaly threshold overrides via PolicyEngine + +> **Status (mirrored from frontmatter — source of truth is above):** closed. Effort: M (~1.5h). +> +> **Origin:** follow-up of AILOG-2026-01-15-001 ([upstream issue] anomaly detector). Forked from a "large features" backlog as Feature 1. + +<!-- Anonymized example derived from Sentinel PLAN-05. + Format conventions match charter-template.md (6 conventions distilled from the + Sentinel /plan-audit experiment). Structural conventions preserved verbatim; + identifiers anonymized. See devtrail-cli-roadmap.md §3.1. --> + +## Context + +Today `AnomalyDetectorConfig` (DeviationFactor 3σ, CriticalFactor 5σ, MinSamples 7, ZeroActivityMinAvg 1.0) is **global** — passed to the detector via DI wiring at boot, immutable at runtime. The originating issue documented that services with irregular traffic (batch jobs, cron-driven workloads) generate false positives: a job that runs 1× every 4 hours has very high `StdDevRPM` in its activity bucket, and `DeviationFactor=3.0` flags it as anomalous when it is expected behavior. + +The solution is per-service overrides stored in `policies.data.anomaly_thresholds`. The `policies` table already supports service-specific policies (the `system` row is default; rows with `policy_id = "service:<id>"` override). The detector already has a parallel pattern we copy: `s.policy.GetHealthProfile(ctx, hb.ServiceID)` is called per-heartbeat for layering of `HealthProfile`. This Charter adds `GetAnomalyThresholds` symmetrically. + +Central trade-off: the detector acquires a dependency on `PolicyQuerier` (today it has none — `cfg` is baked in at construction). Mitigation: if `PolicyQuerier.GetAnomalyThresholds` fails, the detector uses the global default without aborting — **fail-open toward pre-Charter behavior**. + +## Scope + +**In scope:** + +1. New type `interfaces.AnomalyThresholds` in `src/core/interfaces/policy.<ext>` with the 4 overrideable fields (`DeviationFactor`, `CriticalFactor`, `MinSamples`, `ZeroActivityMinAvg`). `Enabled` is not exposed — that flag stays global (operational kill-switch, not per-service). +2. Extend `policy.PolicyData` with `AnomalyThresholds *interfaces.AnomalyThresholds` (`omitempty`). +3. Extend the `interfaces.PolicyQuerier` interface with `GetAnomalyThresholds(ctx, serviceID) (*AnomalyThresholds, error)`. Implementation in `PolicyEvaluator` (mimics `ResolveHealthProfile`). +4. Extend `monitor.StubPolicyQuerier.GetAnomalyThresholds` returning `nil, nil` (no override) — existing tests are not affected. +5. `AnomalyDetector` gains a new field `policy interfaces.PolicyQuerier` (may be nil → pre-Charter behavior). In `Evaluate`, before `classifyAnomaly`, resolve effective config: start with `d.cfg`, layer overrides from `s.policy.GetAnomalyThresholds(ctx, hb.ServiceID)` when not nil. If lookup fails, log warn and use global cfg. +6. `policy` Service: new method `SetAnomalyThresholds(ctx, caller, serviceID, thresholds *AnomalyThresholds)`. SUPER_ADMIN guard. Passing `nil` un-sets the override and reverts to global. Repository upserts a service-specific policy row. +7. `policy` Handler: `PUT /api/v1/services/{service_id}/anomaly-thresholds` with body `{deviation_factor?, critical_factor?, min_samples?, zero_activity_min_avg?}`. Empty body `{}` un-sets override. Validation: positive floats, `critical_factor >= deviation_factor`, `min_samples >= 1`. Audit event `policy.anomaly_thresholds.changed` with `{service_id, previous, current, changed_by}`. +8. AuditTrail wiring: add topic to `consumer.auditTopics` + classification rule `WARNING`. +9. Tests: + - Unit handler: SUPER_ADMIN guard, body validation (negative floats rejected, critical < deviation rejected), happy path set + unset. + - Unit service: `SetAnomalyThresholds` validates + persists + publishes event; previous/current correct. + - Unit anomaly_detector: with `PolicyQuerier` returning override, the detector uses overridden thresholds; with `nil` policy or policy returning `nil, nil`, uses global cfg; with error in GetAnomalyThresholds, fail-open to global cfg. + - Integration: round-trip (request → DB → audit_records with WARNING) + verifies that an override changes the detector behavior end-to-end. + +**Out of scope:** + +- Per-tenant (cross-service) overrides — only per-service. +- Override of the `Enabled` flag per-service — stays global as operational kill-switch. +- Client UI/CLI — pure REST, curl works. +- DEVOPS role-relaxation for the endpoint — deferred to a follow-up Charter if real tickets request the access. +- Automatic discovery of "override candidate" services based on historical metrics — deferred. +- Formal schema validation (JSON Schema) of `policy_data.anomaly_thresholds` — deferred to a follow-up. + +## Files to modify + +| File | Change | +|---|---| +| `src/core/interfaces/policy.<ext>` | New `AnomalyThresholds` type + new method on `PolicyQuerier`. | +| `src/services/policy/models.<ext>` | `PolicyData.AnomalyThresholds` + `AnomalyThresholdsChangedData` payload. | +| `src/services/policy/evaluator.<ext>` | `ResolveAnomalyThresholds` method (mimics `ResolveHealthProfile`). | +| `src/services/policy/service.<ext>` | Implement `GetAnomalyThresholds` + new `SetAnomalyThresholds` method with SUPER_ADMIN guard + publish event + cache invalidate. Persists via `GetServicePolicy + CreatePolicyVersion` (read-modify-write); does not require new repository method. | +| `src/services/policy/handler_privacy.<ext>` | New handler `setAnomalyThresholds` + structs + route `PUT /api/v1/services/{service_id}/anomaly-thresholds`. | +| `src/services/policy/handler_test.<ext>` | Handler tests (4): success set, success unset, unauthenticated 401, invalid body 422. | +| `src/services/policy/service_test.<ext>` | Service tests (5): SUPER_ADMIN guard rejects Admin/Devops, valid set, valid unset, validation rejects critical<deviation, event published with previous/current. | +| `src/services/policy/evaluator_test.<ext>` | Resolver tests (3): system policy → nil thresholds; service policy with thresholds → returns those; service policy without AnomalyThresholds → fallback to nil. | +| `src/services/monitor/anomaly_detector.<ext>` | `AnomalyDetector` gains `policy interfaces.PolicyQuerier`. `Evaluate` resolves effective config layering global cfg + overrides. Fail-open on error. | +| `src/services/monitor/anomaly_detector_test.<ext>` | Tests (4): nil policy → uses global; policy with override → uses override; policy with nil thresholds → uses global; policy returns error → log + global. | +| `src/services/monitor/stub_policy.<ext>` | `GetAnomalyThresholds` added (returns `nil, nil` by default). | +| `src/main.<ext>` (DI wiring) | Regenerate or update DI graph. The injection of `policy` to `NewAnomalyDetector` happens here; the monitor service file does not need changes. | +| `src/services/audit/consumer.<ext>` | Add `"policy.anomaly_thresholds.changed"` to `auditTopics`. | +| `src/services/audit/classifier.<ext>` | WARNING rule for the new topic. | +| `src/integration/integration_test.<ext>` | End-to-end integration test: PUT request → policy row updated → heartbeat with irregular traffic → with override does not flag anomaly, without override does. | +| `specs/contracts/events.md` | New event entry `policy.anomaly_thresholds.changed` + subscription matrix. | +| `.devtrail/07-ai-audit/agent-logs/AILOG-...md` | New, `risk_level: medium` (admin endpoint + changes detector inner loop). | + +## Verification + +### Local checks + +Commands executable literal in clean shell — include explicit setup of dependencies. + +```bash +<build-command> # e.g. cargo build, go build, npm run build +<test-command-scoped> src/services/policy/ src/services/monitor/ src/services/audit/ + +# Explicit setup for security/vulnerability scanners +# (Pattern: implicit PATH lookups generate false-positive 'real_debt' from external auditors.) +<install-and-run-security-scanner> +<install-and-run-vulnerability-scanner> + +# Integration with testcontainers (~3 min): +<integration-test-runner> -run 'TestIntegration_AnomalyThresholds' src/integration/ +``` + +### Production smoke (after deploy) + +Commands that only apply after deploy to a real environment. External auditors skip this section. + +```bash +# Set override for a service with known irregular traffic. +TOKEN="$(<auth-cli> print-identity-token)" +curl -X PUT "https://${SERVICE_HOST}/api/v1/services/${SVC_ID}/anomaly-thresholds" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"deviation_factor": 5.0, "critical_factor": 8.0, "min_samples": 14}' + +# Verify audit_records persisted under WARNING. +<production-db-cli> connect <service-db> -- \ + -c "SELECT context FROM audit_records \ + WHERE action='policy.anomaly_thresholds.changed' \ + ORDER BY timestamp DESC LIMIT 1" + +# Verify that anomaly_evaluations_total{outcome="critical"} for that +# service falls to 0 in the next 24h with the override applied. +``` + +## Risks + +- **R1 — `PolicyQuerier.GetAnomalyThresholds` fails at runtime and blocks heartbeats**: today `s.anomaly.Evaluate` is best-effort (does not block ProcessHeartbeat). The new lookup runs inside Evaluate, so a PolicyQuerier failure must not propagate. Mitigation: `Evaluate` wraps the lookup in a wrapper returning `(nil, error)`; on error, log warn + use `d.cfg` (fail-open). + +- **R2 — Stale PolicyEngine cache serves old thresholds for 30s post-update**: `ConfigCache` has TTL 30s. After `PUT /anomaly-thresholds`, the AnomalyDetector may keep using prior thresholds for up to 30s. Mitigation: invalidate cache explicitly in `SetAnomalyThresholds` (`s.cache.Invalidate(serviceID)` already exists). Integration test verifies new threshold applies immediately. + +- **R3 — Cross-validation `critical_factor >= deviation_factor` not applied on read**: if an operator writes directly to the DB with an invalid value (bypassing the endpoint), the detector would use bad thresholds. Mitigation: not defensive — direct DB writes are operator responsibility. Document in struct comment. Validation happens at API boundary only. + +- **R4 — `AnomalyDetector.Evaluate` loses performance with one extra round-trip to PolicyEngine per heartbeat**: each heartbeat today does 1 baseline query; this Charter adds 1 thresholds query. Mitigation: `ConfigCache` has TTL 30s — most heartbeats hit cache. Verify with benchmark that p95 of `health_evaluations_duration_ms` does not exceed 10ms (still dominated by baseline lookup, not threshold lookup). + +- **R5 — DI graph circularity after adding `policy` to AnomalyDetector**: today the monitor service receives `policy` in `NewService`; AnomalyDetector lives within the monitor. Passing `policy` from `NewService` to `NewAnomalyDetector` is direct, introduces no cycle. Verify after DI regeneration. + +## Tasks + +1. Sync main, branch `post-mvp/per-service-anomaly-thresholds`. +2. `interfaces/policy.<ext>`: add `AnomalyThresholds` struct + extend `PolicyQuerier` interface. +3. `policy/models.<ext>`: extend `PolicyData` with `AnomalyThresholds` + `AnomalyThresholdsChangedData` payload. +4. `policy/evaluator.<ext>`: `ResolveAnomalyThresholds`. +5. `policy/service.<ext>`: `GetAnomalyThresholds` + `SetAnomalyThresholds` with guard + publish + cache invalidate (read-modify-write via `GetServicePolicy + CreatePolicyVersion`). +6. `policy/handler_privacy.<ext>`: new handler + route + structs. +7. `audit`: topic + WARNING rule. +8. `monitor/stub_policy.<ext>`: stub method (returns nil, nil). +9. `monitor/anomaly_detector.<ext>`: new field `policy` + resolve effective config in `Evaluate`. Fail-open on error. +10. `src/main.<ext>` (DI wiring): regenerate / update so DI passes `policy` to the AnomalyDetector constructor directly. +11. Unit tests (handler + service + evaluator + anomaly_detector + audit). +12. Integration test (round-trip + behavioral verification). +13. `events.md` bump + subscription matrix. +14. AILOG (`risk_level: medium`, `review_required: true` because it is an admin endpoint). +15. Local verification passes clean. +16. Commit + push + open PR. + +## Charter Closure + +Closed post-merge. The implementation introduced 3 emergent risks (R6, R7, R8) documented in the AILOG, plus two drifts caught only by external multi-model audit (F4: forgotten evaluator tests; F5: hallucinated injection point). The retroactive update of this Charter file is itself the canonical example of why the format includes a Charter Closure section. The drift-check tooling (Phase 2 of the CLI roadmap) is designed to catch the F4/F5-class drifts before commit, not after audit. + +--- + +<!-- +Architectural decisions made during planning: + +- Thresholds live in `policies.data` (JSONB), NOT in `service_configs.data`. + Reason: policies already host HealthProfile/Quotas/RateLimits with the same + override shape; service_configs is webhook/branding. + +- Endpoint lives in the policy module, NOT in identity. + Reason: gcp-resource is in identity because the `services` table is identity's. + Anomaly thresholds modify PolicyData. Follows the privacy profiles pattern. + +- AnomalyDetector keeps `cfg AnomalyDetectorConfig` as GLOBAL DEFAULTS. + Reason: backwards-compat with existing tests; override is optional; + old tests keep passing with `nil` policy. + +- Fail-open on PolicyQuerier lookup error. + Reason: anomaly evaluation is best-effort today; a downed PolicyEngine + must not degrade worse than pre-Charter. + +- SUPER_ADMIN-only in v1. + Reason: consistent with privacy profiles + safer default. DEVOPS expansion + as explicit follow-up if tickets request it. + +- Explicit cache invalidation in SetAnomalyThresholds. + Reason: ConfigCache TTL of 30s would delay override application up to 30s. + Immediate invalidation closes the gap. +--> diff --git a/dist/docs/examples/charters/CHARTER-02-baseline-recompute.md b/dist/docs/examples/charters/CHARTER-02-baseline-recompute.md new file mode 100644 index 0000000..36019d7 --- /dev/null +++ b/dist/docs/examples/charters/CHARTER-02-baseline-recompute.md @@ -0,0 +1,150 @@ +--- +charter_id: CHARTER-02-baseline-recompute +status: closed +effort_estimate: XS +trigger: "Operator post-onboarding fast-track: anomaly dashboard shows correlated spikes after batch service onboarding" +originating_ailogs: [AILOG-2026-01-20-002] +note: "Anonymized example derived from Sentinel PLAN-06 (baseline-recompute-job). See devtrail-cli-roadmap.md §3.1 for the porting context." +closed_at: "2026-01-30" +--- + +# Charter: Baseline re-compute manual via admin endpoint + +> **Status (mirrored from frontmatter — source of truth is above):** closed. Effort: XS (~30 min). +> +> **Origin:** follow-up of [upstream issue F7] (deferred from AILOG-2026-01-20-002 [upstream issue]). + +<!-- Anonymized example derived from Sentinel PLAN-06. + Format conventions match charter-template.md (6 conventions distilled from the + Sentinel /plan-audit experiment). Structural conventions preserved verbatim; + identifiers anonymized. See devtrail-cli-roadmap.md §3.1. --> + +## Context + +`StartActivityBaselineJob` runs `RefreshActivityBaselines` every 24 hours and naturally absorbs source changes (event-rate fallback → request-count cache authoritative). The daily refresh re-absorbs baseline bias in ~7 days (the SQL query window). + +The operational gap: when a batch of services is onboarded to the MetricsPoller simultaneously — or when the dashboard `anomaly_evaluations_total{outcome="critical"}` shows spikes correlated with recent mapping changes — the operator has no way to force a re-compute and must wait for the next natural tick. + +A prior issue left `RefreshActivityBaselines` exported in the Repository interface and the SQL idempotent via UPSERT (ON CONFLICT DO UPDATE). The only thing missing is a manual trigger via admin endpoint. Effort XS, high operational value for fast-track post-onboarding. + +## Scope + +**In scope:** + +1. New method `Service.RefreshActivityBaselines(ctx, caller) (*BaselineRefreshResult, error)` that calls the existing Repository method, measures duration, publishes an audit event and returns metadata to the caller. +2. SUPER_ADMIN guard at the handler edge (same pattern as the policy-handler `requireSuperAdmin`). No DEVOPS-relax — consistent with the per-service-thresholds Charter. +3. New route `POST /api/v1/admin/baselines/refresh` (no path params; refresh is global by design, not per-service). +4. Audit event `monitor.baselines.refreshed` with payload `{caller, duration_ms, completed_at}` and classification `WARNING` (admin operation that affects the AnomalyDetector). +5. Unit tests handler (3): auth gate, happy path, transient repo error mapping. +6. Unit tests service (2): repo error propagation, event published with duration and caller. + +**Out of scope:** + +- Per-service refresh (filter `WHERE service_id=$1` in a new SQL query): the current use-case is post-onboarding batch, not point-fix per-service. If a concrete ticket emerges, a follow-up Charter is opened. +- Asynchrony (background job with status polling): typical refresh duration with real MVP data is <1s; a sync endpoint with a 5min timeout covers 99% of cases. +- Endpoint in the policy or identity module — the job lives in the monitor module (which owns the AnomalyDetector + `RefreshActivityBaselines`), so the handler also lives there. +- Dedicated OTel metric (`baseline_manual_refresh_duration_ms`): the audit event with `duration_ms` already covers operational observability. If an SLO need emerges, it can be added later. + +## Files to modify + +| File | Change | +|---|---| +| `src/services/monitor/service.<ext>` | Add method `RefreshActivityBaselines(ctx, caller) (*BaselineRefreshResult, error)` to the `Service` interface + impl in `serviceImpl`. New type `BaselineRefreshResult` with `DurationMS`, `StartedAt`, `CompletedAt`. | +| `src/services/monitor/handler.<ext>` | Private helper `requireSuperAdmin` (clone of the policy module pattern). New route `POST /api/v1/admin/baselines/refresh` registered in `RegisterRoutes` with handler `refreshBaselines`. | +| `src/services/monitor/handler_test.<ext>` | 3 tests: auth required (401), forbidden (403 without SUPER_ADMIN), happy path (200 + event published). | +| `src/services/monitor/service_test.<ext>` | 2 tests: repo error propagated without event published, happy path with event + correct payload. | +| `src/services/monitor/models.<ext>` | New types `BaselineRefreshedData` (event payload) + `BaselineRefreshResult` (Service response shape). | +| `src/services/audit/consumer.<ext>` | Add `"monitor.baselines.refreshed"` to `auditTopics`. | +| `src/services/audit/classifier.<ext>` | WARNING rule for the new topic. | +| `specs/contracts/events.md` | New event entry `monitor.baselines.refreshed` + subscription matrix. | +| `.devtrail/07-ai-audit/agent-logs/AILOG-...md` | New, `risk_level: low` (idempotent operation, RBAC SUPER_ADMIN, no schema changes). | + +## Verification + +### Local checks + +Commands executable literal in clean shell — include explicit setup of dependencies. + +```bash +<build-command> +<test-command-scoped> src/services/monitor/ src/services/audit/ + +# Explicit setup for security/vulnerability scanners +<install-and-run-security-scanner> src/services/monitor/ src/services/audit/ +<install-and-run-vulnerability-scanner> src/services/monitor/ src/services/audit/ +``` + +### Production smoke (after deploy) + +Commands that only apply after deploy to a real environment. External auditors skip this section. + +```bash +# Force refresh after onboarding a batch of services to the MetricsPoller. +TOKEN="$(<auth-cli> print-identity-token)" +curl -X POST "https://${SERVICE_HOST}/api/v1/admin/baselines/refresh" \ + -H "Authorization: Bearer $TOKEN" + +# Verify audit_records persisted under WARNING. +<production-db-cli> connect <service-db> -- \ + -c "SELECT context FROM audit_records \ + WHERE action='monitor.baselines.refreshed' \ + ORDER BY timestamp DESC LIMIT 1" + +# Verify activity_baselines.updated_at reflects the manual refresh. +<production-db-cli> connect <service-db> -- \ + -c "SELECT MAX(updated_at) FROM activity_baselines" +``` + +## Risks + +- **R1 — Long refresh blocks the handler until timeout**: the global refresh scans heartbeats from the last 7 days. With 100+ services and current-month partitioning this may approach 30s. Mitigation: 5-minute timeout in the handler (matching the perTickTimeout used by `StartActivityBaselineJob`). If that timeout consistently approaches, GIN-index optimization or asynchrony emerge as follow-ups. + +- **R2 — Concurrency with the daily job**: if the operator calls the endpoint while the daily ticker is mid-tick, both run `RefreshActivityBaselines` in parallel. Mitigation: the SQL is UPSERT-idempotent — the second one "wins" and leaves data consistent; PostgreSQL serializes UPSERTs by (service_id, day_of_week, hour_of_day). No data race. Double computation is the acceptable cost of a rare operation. + +- **R3 — Privilege escalation**: any role other than SUPER_ADMIN might want this button. Mitigation: explicit SUPER_ADMIN gate in the handler (pattern from the prior Charter). If DEVOPS needs it in production, open a follow-up Charter with operational justification. + +- **R4 — Noisy audit event**: if an automated script calls the endpoint every minute, `audit_records` inflate. Mitigation: cooldown is managed operationally (this is not an endpoint a script should hit regularly); the WARNING classification helps make abuse visible. If real abuse emerges, a later Charter adds caller-level cooldown. + +## Tasks + +1. Sync main, branch `post-mvp/baseline-recompute-job`. +2. `monitor/models.<ext>`: types `BaselineRefreshResult` (response) + `BaselineRefreshedData` (event payload). +3. `monitor/service.<ext>`: extend `Service` interface + impl `RefreshActivityBaselines`. +4. `monitor/handler.<ext>`: helper `requireSuperAdmin` + route `POST /api/v1/admin/baselines/refresh` + handler `refreshBaselines`. +5. `audit`: topic + WARNING rule. +6. `events.md` bump + subscription matrix. +7. Unit tests (handler 3 + service 2). +8. AILOG (`risk_level: low`, `review_required: false` — idempotent operation, no schema changes). +9. Local verification passes clean. +10. **Auto-checklist drift** (Phase 2 of the CLI roadmap): `devtrail charter drift CHARTER-02 <range>`. If it reports omissions, complete; if scope expansion, document in AILOG. Until Phase 2 ships, run Sentinel's `check-plan-drift.sh` manually. +11. Commit + push + open PR. + +## Charter Closure + +Closed post-merge. The implementation matched the Charter declaration with no major drift; the `effort_estimate: XS` was met within margin (~40 min real vs ~30 min estimate, 1.33x — within the noise band of XS work). Drift script reported 0 omissions and 0 scope expansions. + +This Charter is a useful counter-example to CHARTER-01: small, well-bounded work where the format conventions add minimal overhead. The same template that supports an M-sized Charter with 5+ risks scales cleanly down to XS. + +--- + +<!-- +Architectural decisions made during planning: + +- Global endpoint (not per-service). Reason: the originating issue documents the + real use-case (batch onboarding fast-track), not point-fix per-service. Global + refresh reuses existing SQL without new indexes or queries. + +- Endpoint in the monitor module (not policy or identity). + Reason: the job and Repository.RefreshActivityBaselines live here; the handler + must live where the logic is. + +- SUPER_ADMIN-only in v1. Reason: consistent with the per-service-thresholds + Charter. DEVOPS expansion as explicit follow-up if real tickets request it. + +- Sync (not async). Reason: typical refresh <1s, 5-min timeout covers worst case. + Asynchrony adds complexity (status polling, job queue) without immediate + operational value. + +- Audit event with `WARNING` classification. Reason: parity with other admin- + operation events — all admin operational changes the operator wants visible. +--> diff --git a/docs/adopters/ADOPTION-GUIDE.md b/docs/adopters/ADOPTION-GUIDE.md index 5b0d4d8..76ce634 100644 --- a/docs/adopters/ADOPTION-GUIDE.md +++ b/docs/adopters/ADOPTION-GUIDE.md @@ -226,7 +226,7 @@ The CLI automatically: 1. **Download the latest release** - Go to [GitHub Releases](https://github.com/StrangeDaysTech/devtrail/releases) and download the latest `fw-*` release ZIP (e.g., `fw-4.3.0`). + Go to [GitHub Releases](https://github.com/StrangeDaysTech/devtrail/releases) and download the latest `fw-*` release ZIP (e.g., `fw-4.4.0`). 2. **Extract to your project** ```bash diff --git a/docs/adopters/CLI-REFERENCE.md b/docs/adopters/CLI-REFERENCE.md index 9f355cb..f5aedee 100644 --- a/docs/adopters/CLI-REFERENCE.md +++ b/docs/adopters/CLI-REFERENCE.md @@ -12,7 +12,7 @@ 1. [Installation](#installation) 2. [Versioning](#versioning) -3. [Commands](#commands) — init, update, remove, status, repair, validate, new, compliance, metrics, analyze, audit, explore, about +3. [Commands](#commands) — init, update, remove, status, repair, validate, new, charter, compliance, metrics, analyze, audit, explore, about 4. [Environment Variables](#environment-variables) 5. [Exit Codes](#exit-codes) @@ -48,8 +48,8 @@ DevTrail uses **independent version tags** for each component: | Component | Tag prefix | Example | What it includes | |-----------|-----------|---------|------------------| -| Framework | `fw-` | `fw-4.3.0` | Templates (12 types), governance docs, directives | -| CLI | `cli-` | `cli-3.5.3` | The `devtrail` binary | +| Framework | `fw-` | `fw-4.4.0` | Templates (12 types), governance docs, directives, Charter template + schema | +| CLI | `cli-` | `cli-3.6.0` | The `devtrail` binary | Framework and CLI are released independently. A framework update does not require a CLI update, and vice versa. @@ -86,7 +86,7 @@ Initialize DevTrail in a project directory. ```bash $ devtrail init . -✔ Downloaded DevTrail fw-4.3.0 +✔ Downloaded DevTrail fw-4.4.0 ✔ Created .devtrail/ directory structure ✔ Created DEVTRAIL.md ✔ Configured AI agent directives @@ -108,7 +108,7 @@ If `.devtrail/` does not exist in the current directory, the framework update is ```bash $ devtrail update Updating framework... -✔ Framework updated to fw-4.3.0 +✔ Framework updated to fw-4.4.0 Updating CLI... ✔ CLI updated to cli-3.5.2 ``` @@ -125,7 +125,7 @@ Update only the framework files. Looks for the latest `fw-*` release on GitHub. ```bash $ devtrail update-framework -✔ Framework updated to fw-4.3.0 +✔ Framework updated to fw-4.4.0 ``` --- @@ -209,7 +209,7 @@ $ devtrail status Project ┌───────────┬──────────────────────────┐ │ Path │ /home/user/my-project │ - │ Framework │ fw-4.3.0 │ + │ Framework │ fw-4.4.0 │ │ CLI │ cli-3.5.2 │ │ Language │ en │ └───────────┴──────────────────────────┘ @@ -266,7 +266,7 @@ Repairing DevTrail in /home/user/my-project → Restoring 1 missing directory... ✓ Restored .devtrail/templates/ → Downloading framework to restore missing files... - Using version: fw-4.3.0 + Using version: fw-4.4.0 ✓ Restored 16 file(s) from framework → Updating checksums... @@ -275,7 +275,7 @@ Repairing DevTrail in /home/user/my-project --- -### `devtrail validate [path] [--fix] [--staged]` +### `devtrail validate [path] [--fix] [--staged] [--include-charters]` Validate DevTrail documents for compliance and correctness. @@ -286,6 +286,7 @@ Validate DevTrail documents for compliance and correctness. | `path` | `.` (current directory) | Target project directory | | `--fix` | — | Automatically fix simple issues (e.g., missing `review_required: true` for high-risk docs) | | `--staged` | — | Validate only staged (git-added) files. Ideal for pre-commit hooks. | +| `--include-charters` | — | Also validate Charters in `docs/charters/` against the Charter JSON Schema and referential integrity (originating AILOG IDs resolve, originating spec paths exist). Opt-in so projects that don't yet use the Charter pattern are unaffected. Currently honored only without `--staged` — Charter validation in staged mode lands in cli-3.7.0. | **What it checks:** @@ -355,6 +356,106 @@ $ devtrail new -t ailog --title "Implement JWT authentication" --- +### `devtrail charter <subcommand>` + +Manage **Charters**: bounded, auditable units of work declared ex-ante and validated ex-post. A Charter pairs declarative scope (files to touch, risks, executable verification) with ex-post audit anchoring (drift detection, multi-model audit). Charters live at `docs/charters/NN-slug.md` (project-root level, **not** under `.devtrail/`). + +> **Naming history.** In the Sentinel `/plan-audit` experiment that crystallized this pattern (2026-04, 6 cycles), Charters were called *Plans*. The DevTrail CLI uses **Charter** going forward to disambiguate from GitHub SpecKit's `plan.md`. Sentinel's historical files preserve "Plan" deliberately. The full conceptual scope and the rename rationale live in `Propuesta/que-es-un-charter.md`. + +**Subcommands:** + +- `devtrail charter new` — scaffold a new Charter from the framework template +- `devtrail charter list` — enumerate Charters with optional filters +- `devtrail charter status` — show Charter detail, or the most recent 5 Charters + +Phase 2 of the CLI roadmap will add `charter close` (interactive telemetry) and `charter drift` (file-vs-commit drift check). Phase 3 adds `charter audit` (multi-model external audit). + +#### `devtrail charter new [-t XS|S|M|L] [--from-ailog <id> | --from-spec <path>] [--title <title>] [path]` + +Scaffold a Charter from the framework template into `docs/charters/NN-slug.md`. Prompts for the title interactively if not passed. The two origin flags are mutually exclusive at the clap level. + +| Argument/Flag | Default | Description | +|---------------|---------|-------------| +| `path` | `.` (current directory) | Target project directory | +| `--type`, `-t` | `M` | Effort estimate. One of `XS`, `S`, `M`, `L`. | +| `--title` | — | Charter title. Used to build the slug and filename. Prompts if absent. | +| `--from-ailog` | — | Originating AILOG ID (e.g., `AILOG-2026-04-28-021`). Pre-populates `originating_ailogs` in frontmatter. **Mutually exclusive with `--from-spec`.** | +| `--from-spec` | — | Path to a SpecKit spec.md (e.g., `specs/001-feature/spec.md`). Pre-populates `originating_spec` in frontmatter. The path is verified at scaffold time. **Mutually exclusive with `--from-ailog`.** | + +When neither origin flag is given, both `originating_ailogs` and `originating_spec` stay commented out in the generated frontmatter — the Charter is scaffolded "without explicit origin" and the user fills it in before status moves to `in-progress`. + +**Examples:** + +```bash +# Standalone (no origin) — interactive title prompt +$ devtrail charter new --type M + +# Maintenance / post-MVP mode — Charter rooted in an existing AILOG +$ devtrail charter new -t S --from-ailog AILOG-2026-04-28-021 --title "per-service thresholds" + +# Greenfield mode — Charter implementing a SpecKit spec +$ devtrail charter new -t L --from-spec specs/001-payments/spec.md --title "wire payment provider" +``` + +**Example output:** + +``` +$ devtrail charter new -t M --title "test charter" + + ✔ Created: docs/charters/01-test-charter.md + + Next steps: + 1. Edit the Charter to fill in Context, Scope, Files to modify, Verification, Risks, Tasks. + 2. Set the trigger field in frontmatter to a concrete observable signal. + 3. Set originating_ailogs or originating_spec in frontmatter (or leave both absent if standalone). + 4. When you start executing: change frontmatter status from `declared` to `in-progress`. +``` + +#### `devtrail charter list [--status declared|in-progress|closed|all] [--origin ailog|spec|any] [path]` + +Enumerate Charters as a table. + +| Argument/Flag | Default | Description | +|---------------|---------|-------------| +| `path` | `.` | Target project directory | +| `--status` | `all` | Filter by lifecycle status | +| `--origin` | `any` (no filter) | Filter by origin type: `ailog`, `spec`, or `any` | + +Files that fail to parse are reported as warnings to stderr without failing the command — the table lists what it can. + +**Example:** + +```bash +$ devtrail charter list + NN STATUS EFFORT ORIGIN TITLE + 01 declared M AILOG-2026-04-28-021 Per-service anomaly thresholds + 02 in-progress XS — Baseline recompute + 03 closed L specs/001/spec.md Wire payment provider +``` + +#### `devtrail charter status [CHARTER-ID] [--path <dir>]` + +With an ID: print the full Charter detail (frontmatter, file location, body section list, Phase 2 placeholders). Without an ID: print the 5 most recent Charters by NN descending. + +| Argument/Flag | Default | Description | +|---------------|---------|-------------| +| `CHARTER-ID` | — | Charter identifier. Accepts the full `charter_id` (`CHARTER-01-test`), the `CHARTER-NN` prefix (`CHARTER-01`), or just the numeric NN (`01` or `1`). Numeric matching is permissive across zero-padding. | +| `--path` | `.` | Target project directory. Use a flag (rather than positional) so it cannot be confused with the optional `CHARTER-ID` positional. | + +**Examples:** + +```bash +# Most recent 5 +$ devtrail charter status + +# Detail for a specific Charter (any of these resolves to CHARTER-02-baseline-recompute) +$ devtrail charter status CHARTER-02-baseline-recompute +$ devtrail charter status CHARTER-02 +$ devtrail charter status 2 +``` + +--- + ### `devtrail compliance [path] [--standard <name>] [--region <name>] [--all] [--output <format>]` Check regulatory compliance. By default, evaluates the standards whose region is in `regional_scope` from `.devtrail/config.yml` (default `[global, eu]`). Six Chinese frameworks are available opt-in when `china` is added to `regional_scope`. @@ -696,7 +797,7 @@ Show version, authorship, and license information. $ devtrail about DevTrail CLI CLI version: cli-3.5.2 - Framework version: fw-4.3.0 + Framework version: fw-4.4.0 Author: Strange Days Tech, S.A.S. License: MIT Repository: https://github.com/StrangeDaysTech/devtrail diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index 8c2ed07..ef285df 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -149,8 +149,8 @@ DevTrail usa tags de versión independientes para cada componente: | Componente | Prefijo de tag | Ejemplo | Incluye | |------------|---------------|---------|---------| -| Framework | `fw-` | `fw-4.3.0` | Plantillas (12 tipos), gobernanza, directivas | -| CLI | `cli-` | `cli-3.5.3` | El binario `devtrail` | +| Framework | `fw-` | `fw-4.4.0` | Plantillas (12 tipos), gobernanza, directivas | +| CLI | `cli-` | `cli-3.6.0` | El binario `devtrail` | Verifica las versiones instaladas con `devtrail status` o `devtrail about`. @@ -165,7 +165,8 @@ Verifica las versiones instaladas con `devtrail status` o `devtrail about`. | `devtrail remove [--full]` | Eliminar DevTrail del proyecto | | `devtrail status [path]` | Mostrar estado de la instalación y estadísticas | | `devtrail repair [path]` | Restaurar directorios y archivos del framework faltantes | -| `devtrail validate [path]` | Validar documentos por cumplimiento y corrección | +| `devtrail validate [path]` | Validar documentos por cumplimiento y corrección (use `--include-charters` para validar también `docs/charters/`) | +| `devtrail charter <subcomando>` | Gestionar Charters: `new`, `list`, `status` (unidades acotadas de trabajo declaradas ex-ante, auditadas ex-post) | | `devtrail compliance [path]` | Verificar cumplimiento regulatorio (EU AI Act, ISO 42001, NIST) | | `devtrail metrics [path]` | Mostrar métricas de gobernanza y estadísticas | | `devtrail analyze [path]` | Analizar complejidad de código (métricas cognitiva + ciclomática) | @@ -180,7 +181,7 @@ Ver [Referencia CLI](adopters/CLI-REFERENCE.md) para uso detallado. ```bash # Descargar el último release ZIP del framework desde GitHub # Ve a https://github.com/StrangeDaysTech/devtrail/releases -# y descarga el último release fw-* (ej. fw-4.3.0) +# y descarga el último release fw-* (ej. fw-4.4.0) # Extraer y copiar a tu proyecto unzip devtrail-fw-*.zip -d tu-proyecto/ diff --git a/docs/i18n/es/adopters/ADOPTION-GUIDE.md b/docs/i18n/es/adopters/ADOPTION-GUIDE.md index 74ca117..aea5786 100644 --- a/docs/i18n/es/adopters/ADOPTION-GUIDE.md +++ b/docs/i18n/es/adopters/ADOPTION-GUIDE.md @@ -217,7 +217,7 @@ El CLI automáticamente: 1. **Descargar el último release** - Ve a [GitHub Releases](https://github.com/StrangeDaysTech/devtrail/releases) y descarga el último release `fw-*` (ej. `fw-4.3.0`). + Ve a [GitHub Releases](https://github.com/StrangeDaysTech/devtrail/releases) y descarga el último release `fw-*` (ej. `fw-4.4.0`). 2. **Extraer en tu proyecto** ```bash diff --git a/docs/i18n/es/adopters/CLI-REFERENCE.md b/docs/i18n/es/adopters/CLI-REFERENCE.md index 8cab770..5710b2c 100644 --- a/docs/i18n/es/adopters/CLI-REFERENCE.md +++ b/docs/i18n/es/adopters/CLI-REFERENCE.md @@ -12,7 +12,7 @@ 1. [Instalación](#instalación) 2. [Versionado](#versionado) -3. [Comandos](#comandos) — init, update, remove, status, repair, validate, new, compliance, metrics, analyze, audit, explore, about +3. [Comandos](#comandos) — init, update, remove, status, repair, validate, new, charter, compliance, metrics, analyze, audit, explore, about 4. [Variables de Entorno](#variables-de-entorno) 5. [Códigos de Salida](#códigos-de-salida) @@ -48,8 +48,8 @@ DevTrail usa **tags de versión independientes** para cada componente: | Componente | Prefijo de tag | Ejemplo | Qué incluye | |------------|---------------|---------|-------------| -| Framework | `fw-` | `fw-4.3.0` | Plantillas (12 tipos), docs de gobernanza, directivas | -| CLI | `cli-` | `cli-3.5.3` | El binario `devtrail` | +| Framework | `fw-` | `fw-4.4.0` | Plantillas (12 tipos), docs de gobernanza, directivas | +| CLI | `cli-` | `cli-3.6.0` | El binario `devtrail` | Framework y CLI se publican de forma independiente. Una actualización del framework no requiere actualización del CLI, y viceversa. @@ -86,7 +86,7 @@ Inicializa DevTrail en un directorio de proyecto. ```bash $ devtrail init . -✔ Downloaded DevTrail fw-4.3.0 +✔ Downloaded DevTrail fw-4.4.0 ✔ Created .devtrail/ directory structure ✔ Created DEVTRAIL.md ✔ Configured AI agent directives @@ -107,7 +107,7 @@ Si `.devtrail/` no existe en el directorio actual, la actualización del framewo ```bash $ devtrail update Updating framework... -✔ Framework updated to fw-4.3.0 +✔ Framework updated to fw-4.4.0 Updating CLI... ✔ CLI updated to cli-3.5.2 ``` @@ -124,7 +124,7 @@ Actualiza solo los archivos del framework. Busca el último release `fw-*` en Gi ```bash $ devtrail update-framework -✔ Framework updated to fw-4.3.0 +✔ Framework updated to fw-4.4.0 ``` --- @@ -203,7 +203,7 @@ $ devtrail status DevTrail Status ─────────────── Path: /home/user/my-project -Framework version: fw-4.3.0 +Framework version: fw-4.4.0 CLI version: cli-3.5.2 Language: en Structure: ✔ Complete @@ -256,7 +256,7 @@ Repairing DevTrail in /home/user/mi-proyecto --- -### `devtrail validate [path] [--fix] [--staged]` +### `devtrail validate [path] [--fix] [--staged] [--include-charters]` Valida documentos DevTrail verificando cumplimiento y corrección. @@ -267,6 +267,7 @@ Valida documentos DevTrail verificando cumplimiento y corrección. | `path` | `.` (directorio actual) | Directorio del proyecto | | `--fix` | — | Corregir automáticamente problemas simples | | `--staged` | — | Validar solo archivos staged en Git (ideal para hooks pre-commit) | +| `--include-charters` | — | Validar también los Charters en `docs/charters/` contra el JSON Schema y la integridad referencial (los IDs en `originating_ailogs` resuelven; el path en `originating_spec` existe). Opt-in, default `false` para no afectar a proyectos que no usan el patrón. Por ahora solo se honra fuera de `--staged`; la validación de Charters en modo staged llega en cli-3.7.0. | **Reglas de validación:** @@ -325,6 +326,70 @@ $ devtrail new -t ailog --title "Refactorizar módulo de pagos" --- +### `devtrail charter <subcomando>` + +Gestiona **Charters**: unidades acotadas y auditables de trabajo, declaradas ex-ante y validadas ex-post. Un Charter empareja scope declarativo (archivos a tocar, riesgos, comandos de verificación ejecutables) con el ancla de auditoría ex-post (drift detection, auditoría multi-modelo). Los Charters viven en `docs/charters/NN-slug.md` (a nivel del project root, **no** bajo `.devtrail/`). + +> **Nota histórica.** En el experimento Sentinel `/plan-audit` que cristalizó este patrón (abril 2026, 6 ciclos), los Charters se llamaban *Plans*. El CLI DevTrail usa **Charter** going-forward para evitar la colisión nominal con el `plan.md` de GitHub SpecKit. Los archivos históricos de Sentinel preservan "Plan" deliberadamente. El alcance conceptual completo y la justificación del rename viven en `Propuesta/que-es-un-charter.md`. + +**Subcomandos:** + +- `devtrail charter new` — crea un nuevo Charter desde el template del framework +- `devtrail charter list` — enumera Charters con filtros opcionales +- `devtrail charter status` — muestra detalle de un Charter, o los 5 más recientes + +La Fase 2 del CLI roadmap añadirá `charter close` (telemetría interactiva) y `charter drift` (chequeo de drift archivo-vs-commit). La Fase 3 añadirá `charter audit` (auditoría externa multi-modelo). + +#### `devtrail charter new [-t XS|S|M|L] [--from-ailog <id> | --from-spec <path>] [--title <titulo>] [path]` + +Crea un Charter desde el template del framework en `docs/charters/NN-slug.md`. Si no se pasa `--title`, se solicita interactivamente. Los dos flags de origen son mutuamente excluyentes a nivel de clap. + +| Argumento/Flag | Default | Descripción | +|----------------|---------|-------------| +| `path` | `.` (directorio actual) | Directorio del proyecto | +| `--type`, `-t` | `M` | Estimación de esfuerzo. Uno de `XS`, `S`, `M`, `L`. | +| `--title` | — | Título del Charter. Se usa para construir el slug y el nombre de archivo. Solicita prompt si está ausente. | +| `--from-ailog` | — | ID del AILOG origen (p.ej. `AILOG-2026-04-28-021`). Pre-popula `originating_ailogs` en el frontmatter. **Mutuamente excluyente con `--from-spec`.** | +| `--from-spec` | — | Path a un spec.md de SpecKit (p.ej. `specs/001-feature/spec.md`). Pre-popula `originating_spec` en el frontmatter. El path se verifica al crear. **Mutuamente excluyente con `--from-ailog`.** | + +Cuando ningún flag de origen se pasa, ambos `originating_ailogs` y `originating_spec` quedan comentados en el frontmatter generado — el Charter se crea "sin origen explícito" y el usuario lo llena antes de mover el status a `in-progress`. + +**Ejemplos:** + +```bash +# Standalone (sin origen) — prompt interactivo de título +$ devtrail charter new --type M + +# Modo mantenimiento / post-MVP — Charter rooteado en un AILOG existente +$ devtrail charter new -t S --from-ailog AILOG-2026-04-28-021 --title "thresholds por servicio" + +# Modo greenfield — Charter implementando un spec de SpecKit +$ devtrail charter new -t L --from-spec specs/001-pagos/spec.md --title "integrar provider de pagos" +``` + +#### `devtrail charter list [--status declared|in-progress|closed|all] [--origin ailog|spec|any] [path]` + +Enumera Charters como tabla. + +| Argumento/Flag | Default | Descripción | +|----------------|---------|-------------| +| `path` | `.` | Directorio del proyecto | +| `--status` | `all` | Filtra por status del ciclo de vida | +| `--origin` | `any` (sin filtro) | Filtra por tipo de origen: `ailog`, `spec`, o `any` | + +Los archivos que no parsean se reportan como warnings en stderr sin abortar el comando — la tabla muestra lo que puede. + +#### `devtrail charter status [CHARTER-ID] [--path <dir>]` + +Con un ID: imprime el detalle completo del Charter (frontmatter, ubicación del archivo, lista de secciones del cuerpo, placeholders de Fase 2). Sin ID: imprime los 5 Charters más recientes por NN descendente. + +| Argumento/Flag | Default | Descripción | +|----------------|---------|-------------| +| `CHARTER-ID` | — | Identificador del Charter. Acepta el `charter_id` completo (`CHARTER-01-test`), el prefijo `CHARTER-NN` (`CHARTER-01`), o solo el NN numérico (`01` o `1`). El match numérico es permisivo respecto al zero-padding. | +| `--path` | `.` | Directorio del proyecto. Es flag (no positional) para evitar colisión con el positional opcional `CHARTER-ID`. | + +--- + ### `devtrail compliance [path] [--standard <nombre>] [--region <nombre>] [--all] [--output <formato>]` Verifica cumplimiento regulatorio. Por defecto evalúa los estándares cuya región esté incluida en `regional_scope` de `.devtrail/config.yml` (default `[global, eu]`). Seis frameworks chinos disponibles opt-in cuando `china` se añade a `regional_scope`. @@ -568,7 +633,7 @@ Muestra información de versión, autoría y licencia. $ devtrail about DevTrail CLI CLI version: cli-3.5.2 - Framework version: fw-4.3.0 + Framework version: fw-4.4.0 Author: Strange Days Tech, S.A.S. License: MIT Repository: https://github.com/StrangeDaysTech/devtrail diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index c2a4a24..14caf85 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -149,8 +149,8 @@ DevTrail 为每个组件使用独立的版本标签: | 组件 | 标签前缀 | 示例 | 包含内容 | |------|----------|------|----------| -| Framework | `fw-` | `fw-4.3.0` | 模板(12 种类型)、治理文档、指令 | -| CLI | `cli-` | `cli-3.5.3` | `devtrail` 二进制文件 | +| Framework | `fw-` | `fw-4.4.0` | 模板(12 种类型)、治理文档、指令 | +| CLI | `cli-` | `cli-3.6.0` | `devtrail` 二进制文件 | 使用 `devtrail status` 或 `devtrail about` 查看已安装的版本。 @@ -165,7 +165,8 @@ DevTrail 为每个组件使用独立的版本标签: | `devtrail remove [--full]` | 从项目中移除 DevTrail | | `devtrail status [path]` | 显示安装状态和文档统计 | | `devtrail repair [path]` | 恢复缺失的目录和框架文件 | -| `devtrail validate [path]` | 验证文档的合规性和正确性 | +| `devtrail validate [path]` | 验证文档的合规性和正确性(使用 `--include-charters` 同时验证 `docs/charters/`) | +| `devtrail charter <子命令>` | 管理章程:`new`、`list`、`status`(事前声明、事后审计的有界工作单元) | | `devtrail compliance [path]` | 检查法规合规(EU AI Act、ISO 42001、NIST) | | `devtrail metrics [path]` | 显示治理指标和文档统计 | | `devtrail analyze [path]` | 分析代码复杂度(认知复杂度 + 圈复杂度指标) | @@ -180,7 +181,7 @@ DevTrail 为每个组件使用独立的版本标签: ```bash # 从 GitHub 下载最新的框架发布 ZIP # 前往 https://github.com/StrangeDaysTech/devtrail/releases -# 下载最新的 fw-* 发布(例如 fw-4.3.0) +# 下载最新的 fw-* 发布(例如 fw-4.4.0) # 解压并复制到你的项目 unzip devtrail-fw-*.zip -d your-project/ diff --git a/docs/i18n/zh-CN/adopters/ADOPTION-GUIDE.md b/docs/i18n/zh-CN/adopters/ADOPTION-GUIDE.md index 3ecc35a..3386f7d 100644 --- a/docs/i18n/zh-CN/adopters/ADOPTION-GUIDE.md +++ b/docs/i18n/zh-CN/adopters/ADOPTION-GUIDE.md @@ -226,7 +226,7 @@ CLI 自动完成: 1. **下载最新版本** - 前往 [GitHub Releases](https://github.com/StrangeDaysTech/devtrail/releases),下载最新的 `fw-*` 版本 ZIP(例如 `fw-4.3.0`)。 + 前往 [GitHub Releases](https://github.com/StrangeDaysTech/devtrail/releases),下载最新的 `fw-*` 版本 ZIP(例如 `fw-4.4.0`)。 2. **解压到你的项目** ```bash diff --git a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md index e6c1acc..ff3236d 100644 --- a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md +++ b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md @@ -12,7 +12,7 @@ 1. [安装](#安装) 2. [版本管理](#版本管理) -3. [命令](#命令) — init, update, remove, status, repair, validate, new, compliance, metrics, analyze, audit, explore, about +3. [命令](#命令) — init, update, remove, status, repair, validate, new, charter, compliance, metrics, analyze, audit, explore, about 4. [环境变量](#环境变量) 5. [退出码](#退出码) @@ -48,8 +48,8 @@ DevTrail 为每个组件使用**独立的版本标签**: | 组件 | 标签前缀 | 示例 | 包含内容 | |------|----------|------|----------| -| Framework | `fw-` | `fw-4.3.0` | 模板(12 种类型)、治理文档、指令 | -| CLI | `cli-` | `cli-3.5.3` | `devtrail` 二进制文件 | +| Framework | `fw-` | `fw-4.4.0` | 模板(12 种类型)、治理文档、指令 | +| CLI | `cli-` | `cli-3.6.0` | `devtrail` 二进制文件 | Framework 和 CLI 独立发布。Framework 更新不需要 CLI 更新,反之亦然。 @@ -86,7 +86,7 @@ devtrail status # 显示完整的安装状态,包括版本 ```bash $ devtrail init . -✔ Downloaded DevTrail fw-4.3.0 +✔ Downloaded DevTrail fw-4.4.0 ✔ Created .devtrail/ directory structure ✔ Created DEVTRAIL.md ✔ Configured AI agent directives @@ -108,7 +108,7 @@ Next: git add .devtrail/ DEVTRAIL.md && git commit -m "chore: adopt DevTrail" ```bash $ devtrail update Updating framework... -✔ Framework updated to fw-4.3.0 +✔ Framework updated to fw-4.4.0 Updating CLI... ✔ CLI updated to cli-3.5.2 ``` @@ -125,7 +125,7 @@ Updating CLI... ```bash $ devtrail update-framework -✔ Framework updated to fw-4.3.0 +✔ Framework updated to fw-4.4.0 ``` --- @@ -209,7 +209,7 @@ $ devtrail status Project ┌───────────┬──────────────────────────┐ │ Path │ /home/user/my-project │ - │ Framework │ fw-4.3.0 │ + │ Framework │ fw-4.4.0 │ │ CLI │ cli-3.5.2 │ │ Language │ en │ └───────────┴──────────────────────────┘ @@ -266,7 +266,7 @@ Repairing DevTrail in /home/user/my-project → Restoring 1 missing directory... ✓ Restored .devtrail/templates/ → Downloading framework to restore missing files... - Using version: fw-4.3.0 + Using version: fw-4.4.0 ✓ Restored 16 file(s) from framework → Updating checksums... @@ -275,7 +275,7 @@ Repairing DevTrail in /home/user/my-project --- -### `devtrail validate [path] [--fix] [--staged]` +### `devtrail validate [path] [--fix] [--staged] [--include-charters]` 验证 DevTrail 文档的合规性和正确性。 @@ -286,6 +286,7 @@ Repairing DevTrail in /home/user/my-project | `path` | `.`(当前目录) | 目标项目目录 | | `--fix` | — | 自动修复简单问题(例如为高风险文档添加缺失的 `review_required: true`) | | `--staged` | — | 仅验证已暂存(git add)的文件。适合 pre-commit 钩子。 | +| `--include-charters` | — | 同时根据章程 JSON Schema 和引用完整性(`originating_ailogs` 中的 ID 解析;`originating_spec` 路径存在)验证 `docs/charters/` 中的章程。Opt-in,默认 `false`,确保未使用章程模式的项目不受影响。目前仅在非 `--staged` 模式下生效;staged 模式的章程验证将在 cli-3.7.0 中加入。 | **检查项目:** @@ -355,6 +356,57 @@ $ devtrail new -t ailog --title "Implement JWT authentication" --- +### `devtrail charter <子命令>` + +管理**章程(Charter)**:事前声明、事后审计的有界工作单元。一个章程将声明性范围(要修改的文件、风险、可执行的验证命令)与事后审计锚点(漂移检测、多模型审计)配对。章程位于 `docs/charters/NN-slug.md`(项目根目录级别,**不在** `.devtrail/` 之下)。 + +> **命名历史。**在使该模式定型的 Sentinel `/plan-audit` 实验中(2026 年 4 月,6 个周期),章程被称为 *Plans*。DevTrail CLI 从此版本开始使用 **Charter** 以避免与 GitHub SpecKit 的 `plan.md` 命名冲突。Sentinel 的历史文件刻意保留 "Plan" 命名。完整的概念范围与重命名理由见 `Propuesta/que-es-un-charter.md`。 + +**子命令:** + +- `devtrail charter new` — 从框架模板创建新的章程 +- `devtrail charter list` — 用可选过滤器枚举章程 +- `devtrail charter status` — 显示章程详情,或最近的 5 个章程 + +CLI 路线图的第 2 阶段将增加 `charter close`(交互式遥测)和 `charter drift`(文件与提交的漂移检查)。第 3 阶段将增加 `charter audit`(多模型外部审计)。 + +#### `devtrail charter new [-t XS|S|M|L] [--from-ailog <id> | --from-spec <path>] [--title <title>] [path]` + +从框架模板将章程创建到 `docs/charters/NN-slug.md`。如果未传入 `--title`,会以交互方式提示。两个来源标志在 clap 级别互斥。 + +| 参数/标志 | 默认值 | 描述 | +|-----------|--------|------| +| `path` | `.`(当前目录) | 目标项目目录 | +| `--type`, `-t` | `M` | 工作量估计。`XS`、`S`、`M`、`L` 之一。 | +| `--title` | — | 章程标题。用于构造 slug 和文件名。缺失时提示。 | +| `--from-ailog` | — | 来源 AILOG ID(如 `AILOG-2026-04-28-021`)。在前置元数据中预填充 `originating_ailogs`。**与 `--from-spec` 互斥。** | +| `--from-spec` | — | SpecKit 规范文件路径(如 `specs/001-feature/spec.md`)。在前置元数据中预填充 `originating_spec`。创建时会校验路径存在性。**与 `--from-ailog` 互斥。** | + +未传入任何来源标志时,`originating_ailogs` 和 `originating_spec` 在生成的前置元数据中均保持注释状态——章程"无显式来源"地创建,由用户在状态变为 `in-progress` 之前手动填写。 + +#### `devtrail charter list [--status declared|in-progress|closed|all] [--origin ailog|spec|any] [path]` + +以表格形式枚举章程。 + +| 参数/标志 | 默认值 | 描述 | +|-----------|--------|------| +| `path` | `.` | 目标项目目录 | +| `--status` | `all` | 按生命周期状态过滤 | +| `--origin` | `any`(无过滤) | 按来源类型过滤:`ailog`、`spec` 或 `any` | + +无法解析的文件作为警告输出到 stderr,不会中断命令——表格显示能列出的内容。 + +#### `devtrail charter status [CHARTER-ID] [--path <dir>]` + +带 ID:打印完整的章程详情(前置元数据、文件位置、正文章节列表、第 2 阶段功能占位符)。无 ID:按 NN 降序打印最近的 5 个章程。 + +| 参数/标志 | 默认值 | 描述 | +|-----------|--------|------| +| `CHARTER-ID` | — | 章程标识符。接受完整的 `charter_id`(`CHARTER-01-test`)、`CHARTER-NN` 前缀(`CHARTER-01`),或仅数字 NN(`01` 或 `1`)。数字匹配对零填充宽容。 | +| `--path` | `.` | 目标项目目录。使用标志(而非位置参数)以避免与可选位置参数 `CHARTER-ID` 混淆。 | + +--- + ### `devtrail compliance [path] [--standard <name>] [--region <name>] [--all] [--output <format>]` 检查法规合规状态。默认评估 `.devtrail/config.yml` 中 `regional_scope` 所列区域的标准(默认 `[global, eu]`)。在 `regional_scope` 中加入 `china` 后,六个中国法规框架可用。 @@ -689,7 +741,7 @@ $ devtrail explore --lang es # 会话内切换到西班牙语 $ devtrail about DevTrail CLI CLI version: cli-3.5.2 - Framework version: fw-4.3.0 + Framework version: fw-4.4.0 Author: Strange Days Tech, S.A.S. License: MIT Repository: https://github.com/StrangeDaysTech/devtrail