From ff51832bd596249e3879780780e12bb37b260a67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Fri, 1 May 2026 15:47:55 -0600 Subject: [PATCH] feat: ship Charters as first-class entity (fw-4.4.0 / cli-3.6.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First step of the post-Sentinel roadmap (Propuesta/devtrail-cli-roadmap.md Phase 1). Crystallizes the Charter pattern from the 6-cycle Sentinel /plan-audit experiment as a first-class entity in DevTrail going forward. Framework (fw-4.4.0): - charter-template.md (EN + ES) ported from Sentinel TEMPLATE.md v3 with the 6 validated format conventions integrated as a single coherent block - charter.schema.v0.json (Draft 2020-12, marked experimental v0) - two anonymized canonical examples in dist/docs/examples/charters/ (CHARTER-01 from PLAN-05, CHARTER-02 from PLAN-06) CLI (cli-3.6.0): - devtrail charter new with 3 origin paths (--from-ailog | --from-spec | neither), mutually exclusive at the clap level - devtrail charter list --status --origin (filters) - devtrail charter status [CHARTER-ID] (numeric / prefix / full match) - devtrail validate --include-charters: schema + originating_ailogs ref check + originating_spec path check, opt-in default false - devtrail explore: synthetic Charters group in TUI nav tree appended only when at least one Charter exists, with CH badge and i18n label The artifact is "Charter" going forward (not "Plan") to avoid colliding with GitHub SpecKit's plan.md. Sentinel's historical files preserve "Plan" deliberately. Justification in Propuesta/que-es-un-charter.md §2. 95 new tests (63 unit + 32 integration), 0 regressions on the existing suite (327 tests total, all green). Schema, template, and tooling marked v0/experimental until validated on a second domain (devtrail-thesis-validation.md §6 N≈2-3 argument). Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 30 + README.md | 9 +- cli/Cargo.lock | 196 +++- cli/Cargo.toml | 3 +- cli/src/charter.rs | 782 +++++++++++++++ cli/src/charter_schema.rs | 454 +++++++++ cli/src/commands/charter/list.rs | 309 ++++++ cli/src/commands/charter/mod.rs | 10 + cli/src/commands/charter/new.rs | 367 +++++++ cli/src/commands/charter/status.rs | 218 +++++ cli/src/commands/mod.rs | 1 + cli/src/commands/validate.rs | 23 +- cli/src/main.rs | 89 +- cli/src/tui/i18n_strings.rs | 4 + cli/src/tui/index.rs | 179 ++++ cli/src/validation.rs | 156 +++ cli/tests/charter_test.rs | 899 ++++++++++++++++++ dist/.devtrail/00-governance/AGENT-RULES.md | 2 +- .../00-governance/C4-DIAGRAM-GUIDE.md | 2 +- .../00-governance/DOCUMENTATION-POLICY.md | 2 +- .../00-governance/QUICK-REFERENCE.md | 2 +- .../00-governance/i18n/es/AGENT-RULES.md | 2 +- .../00-governance/i18n/es/C4-DIAGRAM-GUIDE.md | 2 +- .../i18n/es/DOCUMENTATION-POLICY.md | 2 +- .../00-governance/i18n/es/QUICK-REFERENCE.md | 2 +- .../00-governance/i18n/zh-CN/AGENT-RULES.md | 2 +- .../i18n/zh-CN/C4-DIAGRAM-GUIDE.md | 2 +- .../i18n/zh-CN/DOCUMENTATION-POLICY.md | 2 +- .../i18n/zh-CN/QUICK-REFERENCE.md | 2 +- dist/.devtrail/QUICK-REFERENCE.md | 2 +- dist/.devtrail/schemas/charter.schema.v0.json | 49 + dist/.devtrail/templates/charter-template.md | 191 ++++ .../templates/i18n/es/charter-template.md | 197 ++++ dist/dist-manifest.yml | 2 +- .../charters/CHARTER-01-anomaly-thresholds.md | 183 ++++ .../charters/CHARTER-02-baseline-recompute.md | 150 +++ docs/adopters/ADOPTION-GUIDE.md | 2 +- docs/adopters/CLI-REFERENCE.md | 121 ++- docs/i18n/es/README.md | 9 +- docs/i18n/es/adopters/ADOPTION-GUIDE.md | 2 +- docs/i18n/es/adopters/CLI-REFERENCE.md | 83 +- docs/i18n/zh-CN/README.md | 9 +- docs/i18n/zh-CN/adopters/ADOPTION-GUIDE.md | 2 +- docs/i18n/zh-CN/adopters/CLI-REFERENCE.md | 72 +- 44 files changed, 4762 insertions(+), 65 deletions(-) create mode 100644 cli/src/charter.rs create mode 100644 cli/src/charter_schema.rs create mode 100644 cli/src/commands/charter/list.rs create mode 100644 cli/src/commands/charter/mod.rs create mode 100644 cli/src/commands/charter/new.rs create mode 100644 cli/src/commands/charter/status.rs create mode 100644 cli/tests/charter_test.rs create mode 100644 dist/.devtrail/schemas/charter.schema.v0.json create mode 100644 dist/.devtrail/templates/charter-template.md create mode 100644 dist/.devtrail/templates/i18n/es/charter-template.md create mode 100644 dist/docs/examples/charters/CHARTER-01-anomaly-thresholds.md create mode 100644 dist/docs/examples/charters/CHARTER-02-baseline-recompute.md 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