diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-13-001-baton-task-inheritance.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-13-001-baton-task-inheritance.md new file mode 100644 index 00000000..e205b732 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-13-001-baton-task-inheritance.md @@ -0,0 +1,61 @@ +--- +id: AILOG-2026-09-13-001 +title: Baton task inheritance through an unambiguous Charter origin +status: review +created: 2026-09-13 +agent: codex-cli-v0.154.0 +confidence: high +review_required: true +risk_level: medium +work_verb: implement +design_provenance: new +eu_ai_act_risk: not_applicable +nist_genai_risks: [human_ai_config, information_integrity] +iso_42001_clause: [] +observability_scope: none +tags: [baton, adopter-feedback, inheritance] +related: [07-ai-audit/decisions/AIDEC-2026-09-13-001-baton-task-parent.md] +--- + +# AILOG: Baton task inheritance + +## Context and actions + +Estoa's authorized adopter contribution (Discussion #426, issue #427) found the +ratified task inheritance absent on e83cc064. A synthetic test failed before the +change (3 failed, 5 passed). The fix reads the parent declaration using the existing +originating_spec relation, then feeds the unchanged classifier and router. + +Scope: experiment-baton/src/units.rs, tests/task_inheritance.rs, the prototype status +note in the ratification and the current adopter kit. Batch placement documentation +now follows the ratified inline slot (#428); batch harvesting remains pending. +No versions, framework installation, execution policy or model dispatch changed. + +## Decision and verification + +The linked AIDEC records why multiple parents remain undeclared. The nine new tests +exercise inventory through routing and CLI, all verbs, provenance, filtered output, +missing and ambiguous parents, invalid declarations, out-of-project symlinks, +read-only behavior and mandatory --dry-run. One early test incorrectly expected +an auditor's default tier to be frontier; inspection showed economic and the test +was corrected, without changing routing policy. Final checks: `cargo test -p straymark-baton --locked --offline` passed 93 tests +(84 existing + 9 new), with no failed or ignored tests; zero-test binary/doc harnesses +are not counted. `cargo clippy -p straymark-baton --all-targets --locked --offline +-- -D warnings`, rustfmt on the new test file and `straymark validate` passed. +The latter reports existing warnings; full Charter schema validation in this clean +clone is unavailable because regenerable framework files are intentionally absent. +No second framework installation was created. + +## Limits and pending review + +This is repeatability of a deterministic implementation, not human calibration. +Risk escalation remains implementer-only and derives from follow-up severity; +Charter risk_level and Bridge findings do not feed that mechanism. The same-author +check here is not an independent audit. No human documentary approval is recorded. + +## Follow-ups + +Batch inventory/inheritance and placement implementation remain tracked in #428. +Multi-Charter task ownership and direct spec declaration require a defined mapping; +this patch conservatively leaves such tasks undeclared. Track C needs prospective +units and retrospective labels over 2–4 weeks; no concordance or real savings claimed. diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-001-baton-task-inheritance-maintainer-review-amendment.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-001-baton-task-inheritance-maintainer-review-amendment.md new file mode 100644 index 00000000..af8db8b9 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-001-baton-task-inheritance-maintainer-review-amendment.md @@ -0,0 +1,85 @@ +--- +id: AILOG-2026-09-17-001 +title: Baton task inheritance — maintainer review amendments (#430) +status: accepted +created: 2026-09-17 +agent: claude-opus-5-1m +confidence: high +review_required: false +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [human_ai_config, information_integrity] +iso_42001_clause: [] +files_modified: + - experiment-baton/src/units.rs + - experiment-baton/src/main.rs + - experiment-baton/tests/task_inheritance.rs + - experiment-baton/06-work-verb-schema-ratification.md + - experiment-baton/07-track-c-adopter-kit.md + - .straymark/07-ai-audit/decisions/AIDEC-2026-09-13-001-baton-task-parent.md +observability_scope: none +tags: [baton, adopter-feedback, inheritance, review] +related: + - 07-ai-audit/agent-logs/AILOG-2026-09-13-001-baton-task-inheritance.md + - 07-ai-audit/decisions/AIDEC-2026-09-13-001-baton-task-parent.md +--- + +# AILOG: Baton task inheritance — maintainer review amendments (#430) + +## Summary + +Maintainer review of #430 (Estoa's fix for #427) found the implementation correct but two +behaviours worth changing before merge. Both are amended on the PR branch, on top of the +adopter's commit, so its authorship and its own AILOG stay intact. + +## Context + +Measured read-only against the three local adopter corpora (git status unchanged in each): +the original rule classified 86 Sentinel tasks (all of `007-usageguard`), 39 in Estoa and +none in LNXDrive. Sentinel shows the review concern directly: its specs 002–005 carry 2–8 +Charters each (the charter-chain pattern), so under "exactly one parent" a spec loses its +task classification the moment it gains a second Charter — retroactively, for tasks already +done. Uniqueness never proved task ownership either: one Charter may cover part of `tasks.md`. + +## Actions Performed + +1. **Agreeing parents inherit.** Every Charter whose `originating_spec` resolves to the + task's sibling `spec.md` is a candidate parent; the task inherits when all candidates + declare the same `(work_verb, design_provenance)`. Any disagreement, or a candidate that + declares nothing, keeps the task `undeclared`. Agreement is on the *declaration*, not on + the class the current classifier derives from it. +2. **Parents read from raw frontmatter.** Candidate parents now come from + `read_frontmatter_yaml`, not the typed parser: a Charter with an out-of-schema field + (e.g. `effort_estimate: XXL`) still counts as a parent — neither hidden nor disabling + inheritance project-wide. Only unreadable YAML (or a non-string `originating_spec`) + leaves the parent inventory incomplete and disables inheritance, as before. +3. **Say why.** `classify` and `route` print a `note:` on stderr naming the Charters that + disabled inheritance (new `units::task_inheritance_blockers`); JSON on stdout is unchanged. +4. Test harness `Drop` no longer unwraps (a panic while unwinding aborts the test binary). +5. AIDEC, ratification §3 note and Track C kit updated to the amended rule. + +## Decisions Made + +Recorded as an amendment in `AIDEC-2026-09-13-001` (option 2 refined to "all explicit parents +agree"), chosen by the maintainer in this session. + +## Impact + +- **Functionality**: more tasks inherit when a spec's Charters agree; none inherit through + disagreement. On today's corpora the counts are unchanged (Sentinel's multi-Charter specs + declare no verb; Estoa's specs have one Charter each). +- **Performance**: one raw-frontmatter read per Charter per inventory. N/A in practice. +- **Security / Privacy / Environmental**: N/A. Still read-only; `route` still requires `--dry-run`. + +## Verification + +- [x] `cargo test -p straymark-baton --locked`: 97 passed (13 in `task_inheritance.rs`, + replacing the "even when they agree" case and adding typed-invalid and CLI-note cases) +- [x] `cargo clippy -p straymark-baton --all-targets --locked -- -D warnings` +- [x] Read-only re-measurement on Sentinel / Estoa / LNXDrive, git status unchanged +- [x] Manual review performed + +## Additional Notes + +Estoa's local corpus is inflated by a separate, pre-existing inventory bug (Baton walks nested +git worktrees under `.worktrees/`), handled in its own PR, not here. diff --git a/.straymark/07-ai-audit/decisions/AIDEC-2026-09-13-001-baton-task-parent.md b/.straymark/07-ai-audit/decisions/AIDEC-2026-09-13-001-baton-task-parent.md new file mode 100644 index 00000000..4a11d3e5 --- /dev/null +++ b/.straymark/07-ai-audit/decisions/AIDEC-2026-09-13-001-baton-task-parent.md @@ -0,0 +1,65 @@ +--- +id: AIDEC-2026-09-13-001 +title: Conservative explicit parent resolution for Baton tasks +status: accepted +created: 2026-09-13 +agent: codex-cli-v0.154.0 +confidence: high +review_required: false +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [human_ai_config] +iso_42001_clause: [] +tags: [baton, inheritance] +related: + - 07-ai-audit/agent-logs/AILOG-2026-09-13-001-baton-task-inheritance.md + - 07-ai-audit/agent-logs/AILOG-2026-09-17-001-baton-task-inheritance-maintainer-review-amendment.md +--- + +# AIDEC: Explicit task parent + +## Context + +Issue #427 requests the inheritance ratified in #332 without adding task declaration +slots. One spec may have multiple Charters; filenames and titles cannot assign a task. + +## Alternatives + +1. Pick the first/newest/active Charter or infer from titles: more coverage, but + ambiguous ownership could silently recommend a cheaper model. +2. Require exactly one originating_spec relation to the existing sibling spec.md + inside the project: bounded implementation with explicit evidence, but leaves + multi-Charter specs and incomplete inventories undeclared. +3. Design a new task-to-Charter mapping: fuller solution, requires a separate schema + decision and exceeds this first adopter contribution. +4. (Added in maintainer review.) Every Charter whose originating_spec resolves to the + sibling spec.md is a candidate parent; inherit when all candidates declare the same + work_verb and design_provenance. Same evidence as option 2 — neither proves which + tasks a Charter owns — but stable under the charter-chain pattern. + +## Decision + +Proposed in the PR: option 2. **Amended in maintainer review (2026-09-17): option 4**, +chosen by the maintainer. Option 2 is not monotonic: Sentinel's specs 002–005 carry +2–8 Charters each, so a spec that gains an agreeing Charter (e.g. a polish Charter) +would retroactively unclassify tasks already done. Uniqueness never established +ownership either, so it bought no safety that agreement does not. Agreement is on the +declaration itself, not on the class the current classifier derives: `operate` and +`operate` + `design_provenance: new` do not agree. A candidate declaring nothing means +disagreement. + +Candidate parents are read from the raw frontmatter, so a Charter the typed parser +rejects (an out-of-schema field) still counts as a parent. Only an unreadable +frontmatter makes the parent inventory incomplete; inheritance is then disabled for +the project and the CLI names the blocking Charters on stderr. Only work_verb and +design_provenance flow to the child; parent effort_estimate is not a measurement of +task size. Missing or invalid verbs remain unclassifiable in the existing classifier; +no title fallback or task frontmatter. + +## Consequences and review + +Preserves deterministic and read-only recommendations. Known false negatives remain +visible as undeclared, and a disagreeing Charter still unclassifies a spec's tasks — +correctly, since ownership is then ambiguous. Extending mappings or risk policy is +separate work. Accepted by the maintainer as the implementation rule for #332's task +inheritance; it is not a schema ratification and adds no declaration slot. diff --git a/experiment-baton/06-work-verb-schema-ratification.md b/experiment-baton/06-work-verb-schema-ratification.md index 56c5c3db..db736bf6 100644 --- a/experiment-baton/06-work-verb-schema-ratification.md +++ b/experiment-baton/06-work-verb-schema-ratification.md @@ -101,11 +101,24 @@ frontmatter nuevo en unidades que no lo tienen. declara el verbo al grano más fino que sí tenga slot (por batch, vía la línea del ledger). No se fragmenta artificialmente una unidad homogénea. -**Nota de honestidad (estado del prototipo).** Hoy el prototipo **no** implementa la herencia: cosecha -el verbo del charter frontmatter y de las líneas de follow-up, y deja batch/task como `undeclared` -([`src/units.rs`](src/units.rs)). La herencia es una **regla ratificada aquí**, pendiente de -implementar en la graduación al framework (o en un follow-on de Baton). Documentarla ahora evita que -la graduación invente un mecanismo de declaración por-task que esta ratificación descarta. +**Nota de honestidad (estado del prototipo).** La herencia de Task está implementada +por vínculo explícito: son padres los Charters cuyo `originating_spec` resuelve al +`spec.md` hermano de `tasks.md`, dentro del proyecto, y la tarea hereda cuando **todos** +declaran lo mismo (verbo y procedencia). Así, una cadena de Charters sobre la misma spec +que coincide no desclasifica tareas ya hechas. Hereda verbo y procedencia, no el +esfuerzo del Charter. No se añade frontmatter a `tasks.md`. +Si falta el spec o el padre, si los Charters candidatos discrepan (o alguno no declara) +o si el inventario contiene un Charter con frontmatter ilegible, las tareas permanecen +`undeclared`; en el último caso el CLI nombra el Charter en stderr. Un Charter que solo +incumple el esquema tipado sigue contando como padre. No se deduce pertenencia desde +títulos ni desde el orden de archivos. Un verbo inválido conserva el comportamiento +conservador del clasificador. El vínculo `context_spec` no establece parentesco. + +Batch, overrides por ledger, vínculos de follow-ups y declaraciones directas en +spec continúan pendientes. El kit Track C y el placement de batches tienen una +discrepancia documentada en #428; esta corrección de tareas no redefine ese contrato. +La herencia es una regla ratificada; estos límites describen su implementación +parcial en [`src/units.rs`](src/units.rs), no una validación humana de las declaraciones. ## 4. Undeclared = estado honesto diff --git a/experiment-baton/07-track-c-adopter-kit.md b/experiment-baton/07-track-c-adopter-kit.md index 1e351ce6..d88df6d9 100644 --- a/experiment-baton/07-track-c-adopter-kit.md +++ b/experiment-baton/07-track-c-adopter-kit.md @@ -60,9 +60,9 @@ descomentar y llenar: | Unidad | Slot | |---|---| | Charter | Frontmatter: `work_verb:` / `design_provenance:` | -| AILOG (batch ledger) | Frontmatter del AILOG | +| Batch (AILOG ledger) | Línea `- **Work verb**:` en la entrada del batch; placement ratificado, aún pendiente en el inventario (#428) | | Follow-up | Líneas `- **Work verb**:` / `- **Design provenance**:` | -| Task (SpecKit) | Sin slot propio — hereda del charter/spec padre | +| Task (SpecKit) | Sin slot propio — hereda de los Charters cuyo `originating_spec` resuelve al `spec.md` hermano, si todos declaran lo mismo; vínculo ausente o discrepante → `undeclared` | ### Vocabulario y reglas de decisión (resumen de la ratificación) diff --git a/experiment-baton/src/main.rs b/experiment-baton/src/main.rs index 7157bbef..1a6049e5 100644 --- a/experiment-baton/src/main.rs +++ b/experiment-baton/src/main.rs @@ -4,7 +4,7 @@ //! Coherence Bridge can see. The reconciling `coherence` command (finding //! classes C1–C4) lands in batch B3. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::{Parser, Subcommand, ValueEnum}; use colored::Colorize; @@ -17,7 +17,7 @@ use straymark_baton::signals::signals_for; use straymark_baton::speckit; use straymark_baton::telemetry::{build_report, EconomicTelemetry, UnitRouting}; use straymark_baton::tiers::Policy; -use straymark_baton::units::{inventory, Granularity}; +use straymark_baton::units::{inventory, task_inheritance_blockers, Granularity}; #[derive(Parser)] #[command( @@ -174,9 +174,26 @@ fn parse_granularity(s: &str) -> anyhow::Result> { }) } +/// Say why tasks stay undeclared when an unreadable Charter disables task +/// inheritance (#427) — otherwise the only hint is "declare the verb". +fn note_inheritance_blockers(root: &Path, only: Option) { + if only.is_some_and(|g| g != Granularity::Task) { + return; + } + let blockers = task_inheritance_blockers(root); + if !blockers.is_empty() { + eprintln!( + "{} task inheritance disabled — unreadable Charter frontmatter could hide a competing parent: {}", + "note:".yellow(), + blockers.join(", ") + ); + } +} + fn classify_cmd(root: PathBuf, out: OutFmt, granularity: &str) -> anyhow::Result<()> { let only = parse_granularity(granularity)?; let units = inventory(&root, only); + note_inheritance_blockers(&root, only); #[derive(serde::Serialize)] struct Row { @@ -243,6 +260,7 @@ fn route_cmd( } let only = parse_granularity(granularity)?; let units = inventory(&root, only); + note_inheritance_blockers(&root, only); let policy = Policy::load(&root, config.as_deref()); if policy.using_defaults { eprintln!( diff --git a/experiment-baton/src/units.rs b/experiment-baton/src/units.rs index 21b66bf8..eedfb72b 100644 --- a/experiment-baton/src/units.rs +++ b/experiment-baton/src/units.rs @@ -14,7 +14,9 @@ use std::path::{Path, PathBuf}; use serde::Serialize; -use straymark_core::charter::{discover_and_parse, display_title, read_frontmatter_yaml}; +use straymark_core::charter::{ + discover_and_parse, discover_charters, display_title, read_frontmatter_yaml, +}; use straymark_core::charter_files::parse_files_to_modify; use crate::intent::SourceRef; @@ -284,8 +286,91 @@ fn yaml_str(y: &serde_yaml::Value, key: &str) -> Option { // ---- Task (from `specs/**/tasks.md`) -------------------------------------- +/// `(work_verb, design_provenance)` as declared in a frontmatter. +type Declaration = (Option, Option); + +/// A Charter as a candidate task parent: where its `originating_spec` resolves +/// (canonical, `None` if absent or unresolvable) and what it declares. +struct CharterOrigin { + spec: Option, + declaration: Declaration, +} + +/// Every Charter's origin, read from the raw frontmatter so a typed-schema +/// problem (a bad enum, a missing required field) does not hide a parent. +/// `Err` lists the Charters whose frontmatter cannot be read at all: any of them +/// could be a competing parent, so the parent inventory is incomplete. +fn charter_origins(root: &Path) -> Result, Vec> { + let mut origins = Vec::new(); + let mut unreadable = Vec::new(); + for path in discover_charters(root) { + let Ok(yaml) = read_frontmatter_yaml(&path) else { + unreadable.push(rel(root, &path)); + continue; + }; + let spec = match yaml.get("originating_spec") { + None => None, + Some(v) => match v.as_str() { + Some(origin) => root.join(origin).canonicalize().ok(), + None => { + unreadable.push(rel(root, &path)); + continue; + } + }, + }; + origins.push(CharterOrigin { + spec, + declaration: ( + yaml_str(&yaml, "work_verb"), + yaml_str(&yaml, "design_provenance"), + ), + }); + } + if unreadable.is_empty() { + Ok(origins) + } else { + Err(unreadable) + } +} + +/// Charters whose unreadable frontmatter disables task inheritance for the +/// whole inventory (empty = inheritance active). Lets the CLI say *why* tasks +/// stay undeclared instead of only nudging to declare a verb. +pub fn task_inheritance_blockers(root: &Path) -> Vec { + charter_origins(root).err().unwrap_or_default() +} + +/// A task has no declaration slot (#332). Inherit only through explicit +/// Charter origins resolving to its sibling spec, never from titles or nearest +/// directories. Several Charters can cover one spec and none says which tasks it +/// owns, so they must all declare the same thing: a spec gaining a Charter that +/// agrees keeps its tasks classified, one that disagrees (or declares nothing) +/// makes them ambiguous. +fn task_declaration(root: &Path, tasks: &Path, origins: &[CharterOrigin]) -> Declaration { + let resolve = || { + let root = root.canonicalize().ok()?; + let spec = tasks.parent()?.join("spec.md").canonicalize().ok()?; + if !spec.is_file() || !spec.starts_with(&root) { + return None; + } + let mut parents = origins + .iter() + .filter(|o| o.spec.as_ref() == Some(&spec)) + .map(|o| &o.declaration); + let first = parents.next()?; + if parents.any(|d| d != first) { + return None; + } + Some(first.clone()) + }; + resolve().unwrap_or((None, None)) +} + fn read_tasks(root: &Path) -> Vec { let mut out = Vec::new(); + // An unreadable Charter could be a competing parent. Do not silently turn + // an incomplete inventory into permission to recommend a cheaper tier. + let origins = charter_origins(root).unwrap_or_default(); for path in find_files(root, |p| file_name(p) == "tasks.md") { let Ok(content) = std::fs::read_to_string(&path) else { continue; @@ -297,6 +382,7 @@ fn read_tasks(root: &Path) -> Vec { .unwrap_or("spec") .to_string(); let rel_path = rel(root, &path); + let (work_verb, design_provenance) = task_declaration(root, &path, &origins); for line in content.lines() { let t = line.trim(); let body = t @@ -324,8 +410,8 @@ fn read_tasks(root: &Path) -> Vec { effort_estimate: None, followup_bucket: None, followup_severity: None, - work_verb: None, - design_provenance: None, + work_verb: work_verb.clone(), + design_provenance: design_provenance.clone(), scope_globs: Vec::new(), }); } @@ -456,7 +542,7 @@ mod tests { assert_eq!(fu.work_verb.as_deref(), Some("implement")); assert_eq!(fu.design_provenance.as_deref(), Some("upstream")); - // Batches/tasks have no declaration slot in the prototype → undeclared. + // This legacy fixture has no sibling spec.md to resolve as a parent. assert!(u.iter().filter(|u| u.granularity == Granularity::Task).all(|u| u.work_verb.is_none())); } diff --git a/experiment-baton/tests/task_inheritance.rs b/experiment-baton/tests/task_inheritance.rs new file mode 100644 index 00000000..5f45886b --- /dev/null +++ b/experiment-baton/tests/task_inheritance.rs @@ -0,0 +1,295 @@ +//! Ratified #332 task inheritance, using only an explicit, unique Charter origin. +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use straymark_baton::classify::TaskClass; +use straymark_baton::telemetry::build_report; +use straymark_baton::tiers::{Policy, Tier}; +use straymark_baton::units::{inventory, Granularity}; + +static NEXT: AtomicU64 = AtomicU64::new(0); + +struct Project(PathBuf); +impl Project { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "baton-task-inheritance-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&root).unwrap(); + let project = Self(root); + project.write("specs/001-example/spec.md", "# Synthetic specification\n"); + project.write("specs/001-example/tasks.md", "- [ ] T001 A task\n"); + project + } + + fn write(&self, path: &str, content: &str) { + let path = self.0.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + fn charter(&self, number: u8, origin: &str, declaration: &str) { + self.write( + &format!(".straymark/charters/{number:02}-example.md"), + &format!("---\ncharter_id: CHARTER-{number:02}-example\nstatus: in-progress\neffort_estimate: M\ntrigger: synthetic\noriginating_spec: {origin}\n{declaration}---\n# Charter: Example\n"), + ); + } +} +impl Drop for Project { + fn drop(&mut self) { + // Never panic in Drop: a failing test is already unwinding. + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn task_route(root: &Path, only: Option) -> (Option, Tier) { + let units = inventory(root, only); + let (routes, _) = build_report(&units, &Policy::default()); + let task = routes.iter().find(|r| r.id == "001-example:T001").unwrap(); + (task.class, task.tier) +} + +#[test] +fn explicit_parent_declaration_flows_through_inventory_classifier_and_router() { + for (declaration, class, tier) in [ + ( + "work_verb: implement\ndesign_provenance: new\n", + TaskClass::Implementer, + Tier::Economic, + ), + ( + "work_verb: implement\ndesign_provenance: upstream\n", + TaskClass::Operator, + Tier::Local, + ), + ("work_verb: design\n", TaskClass::Planner, Tier::Frontier), + ("work_verb: audit\n", TaskClass::Auditor, Tier::Economic), + ("work_verb: operate\n", TaskClass::Operator, Tier::Local), + ] { + let p = Project::new(); + p.charter(1, "specs/001-example/spec.md", declaration); + assert_eq!(task_route(&p.0, None), (Some(class), tier)); + assert_eq!( + task_route(&p.0, Some(Granularity::Task)), + (Some(class), tier) + ); + let tasks = inventory(&p.0, Some(Granularity::Task)); + assert!( + tasks[0].effort_estimate.is_none(), + "parent effort is not task effort" + ); + } +} + +#[test] +fn canonical_paths_match_but_other_specs_do_not() { + let p = Project::new(); + p.charter( + 1, + "./specs/001-example/../001-example/spec.md", + "work_verb: operate\n", + ); + p.write("specs/002-other/spec.md", "# Other\n"); + p.write("specs/002-other/tasks.md", "- [ ] T001 Other task\n"); + let (routes, _) = build_report(&inventory(&p.0, None), &Policy::default()); + assert_eq!( + routes + .iter() + .find(|r| r.id == "001-example:T001") + .unwrap() + .tier, + Tier::Local + ); + assert_eq!( + routes + .iter() + .find(|r| r.id == "002-other:T001") + .unwrap() + .tier, + Tier::Frontier + ); +} + +#[test] +fn parents_that_all_agree_keep_their_tasks_classified() { + // The charter-chain case: a later Charter for the same spec declaring the + // same thing must not retroactively unclassify the spec's tasks. + let p = Project::new(); + let declaration = "work_verb: implement\ndesign_provenance: new\n"; + p.charter(1, "specs/001-example/spec.md", declaration); + p.charter(2, "specs/001-example/spec.md", declaration); + assert_eq!( + task_route(&p.0, None), + (Some(TaskClass::Implementer), Tier::Economic) + ); +} + +#[test] +fn parents_that_disagree_or_do_not_declare_leave_tasks_undeclared() { + for second in [ + "work_verb: design\n", + "", + "design_provenance: new\n", + // Same class today, different declaration: agreement is on what was + // declared, not on what the current classifier happens to derive. + "work_verb: operate\ndesign_provenance: new\n", + ] { + let p = Project::new(); + p.charter(1, "specs/001-example/spec.md", "work_verb: operate\n"); + p.charter(2, "specs/001-example/spec.md", second); + assert_eq!(task_route(&p.0, None), (None, Tier::Frontier), "{second:?}"); + } +} + +#[test] +fn missing_parent_or_missing_spec_does_not_inherit() { + let p = Project::new(); + assert_eq!(task_route(&p.0, None), (None, Tier::Frontier)); + p.charter(1, "specs/001-example/spec.md", "work_verb: operate\n"); + std::fs::remove_file(p.0.join("specs/001-example/spec.md")).unwrap(); + assert_eq!(task_route(&p.0, None), (None, Tier::Frontier)); +} + +#[test] +fn invalid_or_absent_verb_never_uses_titles_or_task_frontmatter() { + for declaration in ["", "work_verb: invalid\n", "design_provenance: upstream\n"] { + let p = Project::new(); + p.charter(1, "specs/001-example/spec.md", declaration); + p.write( + "specs/001-example/tasks.md", + "---\nwork_verb: operate\n---\n- [ ] T001 operate audit design\n", + ); + assert_eq!(task_route(&p.0, None), (None, Tier::Frontier)); + } +} + +#[test] +fn invalid_provenance_cannot_downgrade_implementation() { + let p = Project::new(); + p.charter( + 1, + "specs/001-example/spec.md", + "work_verb: implement\ndesign_provenance: invalid\n", + ); + assert_eq!( + task_route(&p.0, None), + (Some(TaskClass::Implementer), Tier::Economic) + ); +} + +#[test] +fn malformed_charter_cannot_hide_a_competing_parent() { + let p = Project::new(); + p.charter(1, "specs/001-example/spec.md", "work_verb: operate\n"); + p.write( + ".straymark/charters/02-broken.md", + "---\ncharter_id: [invalid\n---\n", + ); + assert_eq!(task_route(&p.0, None), (None, Tier::Frontier)); +} + +/// A Charter the typed parser rejects (here an out-of-schema effort estimate) +/// still has readable frontmatter: it must count as a parent, neither hidden +/// nor disabling inheritance for the whole project. +fn typed_invalid_charter(p: &Project, number: u8, declaration: &str) { + p.write( + &format!(".straymark/charters/{number:02}-typed-invalid.md"), + &format!("---\ncharter_id: CHARTER-{number:02}-typed-invalid\nstatus: in-progress\neffort_estimate: XXL\ntrigger: synthetic\noriginating_spec: specs/001-example/spec.md\n{declaration}---\n"), + ); +} + +#[test] +fn a_typed_invalid_charter_is_still_a_parent() { + let p = Project::new(); + typed_invalid_charter(&p, 1, "work_verb: operate\n"); + assert_eq!( + task_route(&p.0, None), + (Some(TaskClass::Operator), Tier::Local) + ); +} + +#[test] +fn a_typed_invalid_charter_can_still_compete() { + let p = Project::new(); + p.charter(1, "specs/001-example/spec.md", "work_verb: operate\n"); + typed_invalid_charter(&p, 2, "work_verb: design\n"); + assert_eq!(task_route(&p.0, None), (None, Tier::Frontier)); +} + +#[test] +fn cli_says_why_an_unreadable_charter_disables_inheritance() { + let p = Project::new(); + p.charter(1, "specs/001-example/spec.md", "work_verb: operate\n"); + p.write( + ".straymark/charters/02-broken.md", + "---\ncharter_id: [invalid\n---\n", + ); + let run = std::process::Command::new(env!("CARGO_BIN_EXE_straymark-baton")) + .args(["classify", ".", "--granularity", "task", "--out", "json"]) + .current_dir(&p.0) + .output() + .unwrap(); + assert!(run.status.success()); + let stderr = String::from_utf8_lossy(&run.stderr); + assert!(stderr.contains("task inheritance disabled"), "{stderr}"); + assert!( + stderr.contains(".straymark/charters/02-broken.md"), + "{stderr}" + ); + // The JSON on stdout stays machine-readable. + let json: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap(); + assert_eq!(json[0]["class"], "undeclared"); +} + +#[cfg(unix)] +#[test] +fn a_spec_symlink_outside_the_project_does_not_inherit() { + let p = Project::new(); + let outside = Project::new(); + let spec = p.0.join("specs/001-example/spec.md"); + std::fs::remove_file(&spec).unwrap(); + std::os::unix::fs::symlink(outside.0.join("specs/001-example/spec.md"), &spec).unwrap(); + p.charter(1, "specs/001-example/spec.md", "work_verb: operate\n"); + assert_eq!(task_route(&p.0, None), (None, Tier::Frontier)); +} + +#[test] +fn cli_inherits_without_mutation_and_still_requires_dry_run() { + fn snapshot(root: &Path) -> Vec<(PathBuf, Vec)> { + let mut files = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + } else { + files.push((path.clone(), std::fs::read(path).unwrap())); + } + } + } + files.sort(); + files + } + let p = Project::new(); + p.charter(1, "specs/001-example/spec.md", "work_verb: implement\n"); + let before = snapshot(&p.0); + let run = |args: &[&str]| { + std::process::Command::new(env!("CARGO_BIN_EXE_straymark-baton")) + .args(args) + .current_dir(&p.0) + .output() + .unwrap() + }; + let classified = run(&["classify", ".", "--granularity", "task", "--out", "json"]); + assert!(classified.status.success()); + let json: serde_json::Value = serde_json::from_slice(&classified.stdout).unwrap(); + assert_eq!(json[0]["class"], "implementer"); + assert!(run(&["route", ".", "--dry-run", "--out", "json"]) + .status + .success()); + assert!(!run(&["route", "."]).status.success()); + assert_eq!(before, snapshot(&p.0)); +}