diff --git a/experiment-baton/AILOG-2026-06-26-002-baton-b1-unit-inventory.md b/experiment-baton/AILOG-2026-06-26-002-baton-b1-unit-inventory.md new file mode 100644 index 00000000..27bd1614 --- /dev/null +++ b/experiment-baton/AILOG-2026-06-26-002-baton-b1-unit-inventory.md @@ -0,0 +1,87 @@ +--- +id: AILOG-2026-06-26-002 +title: Baton Phase 2 B1 — routable-unit inventory +status: accepted +created: 2026-06-26 +agent: claude-code-opus-4.8 +confidence: high +review_required: true +risk_level: low +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +lines_changed: 380 +files_modified: [experiment-baton/src/units.rs, experiment-baton/src/lib.rs, experiment-baton/CHARTER-03-dry-run-router.md, experiment-baton/tests/fixtures/governance-corpus] +observability_scope: none +tags: [baton, phase2, dry-run-router, units, inventory, read-only] +related: [CHARTER-03-dry-run-router, AILOG-2026-06-26-001] +--- + +# AILOG: Baton Phase 2 B1 — routable-unit inventory + +## Summary + +First batch of `CHARTER-03-dry-run-router` (Phase 2). Implements the **routable-unit +inventory**: read-only enumeration of the work StrayMark already recorded, at the +four granularities that already exist — **inventing no new vocabulary** (concept +§4.3 / framing decision #1): Charter, Batch, Follow-up, Task. + +`src/units.rs` exposes `RoutableUnit` + `Granularity` and `inventory(root, only)`. +Each reader harvests only the signals it can read directly; computed signals +(complexity, arch state, coherence findings) are deferred to B2. A signal a reader +cannot see stays `None` — never fabricated. + +## What changed + +- **Charter reader** reuses `straymark_core::charter::{discover_and_parse, + display_title}` and `charter_files::parse_files_to_modify` — no second parser. + Harvests `effort_estimate` (typed XS..L) and the declared `Files to modify` + scope globs. +- **Batch / Follow-up / Task readers** are tolerant, line-oriented scans (the + `scan.rs` philosophy: no regex, char-boundary-safe over accented prose). + - Batch: `### Batch N — ` under `## Batch Ledger` in `AILOG-*.md`; id + keyed `<ailog>#batch-N`. + - Follow-up: `### FU-NNN — <desc>` under `## Bucket: <name>` in + `.straymark/follow-ups-backlog.md`; harvests bucket + optional `**Severity**`. + - Task: `- [ ] T<b>.<n> — <text>` in `specs/**/tasks.md`; id keyed + `<spec-dir>:T<b>.<n>`. +- `--granularity` selection via `Granularity::parse` (`all` → `None`). + +## Verification + +- `cargo test -p straymark-baton` ✓ — 5 new unit tests over a new fixture corpus + (`tests/fixtures/governance-corpus/`) covering all four granularities; full + suite green, `cargo clippy` clean. +- **Sentinel dogfood** (read-only, scratch test, `git status` unchanged): the + inventory runs without panic on the real, messier corpus — + `charter: 45 · batch: 82 · followup: 135 · task: 500`. It also handled + Sentinel's `T001`-style task ids alongside the fixture's `T1.1` form. + +## Impact + +Read-only and non-breaking. No model/network/agent dependency (NFR2 holds by +construction — the module has none). Sets up B2 (signal aggregation) and B3 +(classifier). + +## EU AI Act Considerations + +Not applicable — local developer tooling; no automated decision-making, no +personal data, no model inference. Read-only over the target tree (NFR1). + +## Batch Ledger + +### Batch 1 — B1: routable-unit inventory (T1.1–T1.4) +Completed 2026-06-26 — this AILOG's PR. `src/units.rs` + fixture corpus + 5 tests; +Sentinel dogfood read-only. Charter → `in-progress`. + +### Batch 2 — B2: signal aggregation (T2.1–T2.4) +Pending. + +### Batch 3 — B3: cheap classifier (T3.1–T3.4) +Pending. + +### Batch 4 — B4: tier policy + telemetry + CLI (T4.1–T4.5) +Pending. + +### Batch 5 — B5: Sentinel dogfood + acceptance (T5.1–T5.6) +Pending. diff --git a/experiment-baton/CHARTER-03-dry-run-router.md b/experiment-baton/CHARTER-03-dry-run-router.md index 0c0bf27d..df1ed39d 100644 --- a/experiment-baton/CHARTER-03-dry-run-router.md +++ b/experiment-baton/CHARTER-03-dry-run-router.md @@ -1,6 +1,6 @@ --- charter_id: CHARTER-03-dry-run-router -status: declared +status: in-progress effort_estimate: L trigger: "Baton concepto §7 Fase 2 — no existe ninguna noción de modelo/tier/presupuesto/costo en StrayMark; todo el trabajo corre con el mismo modelo (commit trivial o rediseño). Presión económica §1.1 (fin de subsidios, pago por Mtoken). Tras la Fase 1 (Coherence Bridge), validar empíricamente el principio económico §4.2 sin tocar modelos." originating_concept: experiment-baton/01-baton-concept.md @@ -11,7 +11,7 @@ related_issues: [304] # Charter: Baton Fase 2 — Clasificación + router en seco (dry-run, recomienda no ejecuta) -> **Status (espejo del frontmatter; la fuente de verdad es el frontmatter):** declared. Effort: L. +> **Status (espejo del frontmatter; la fuente de verdad es el frontmatter):** in-progress. Effort: L. > **Origen:** concepto [01-baton-concept.md](01-baton-concept.md) §4.2 + §7 (Fase 2) + §10.4 (unidad enrutable). > **Encuadre:** segunda fase del experimento Baton. **Recomienda, no ejecuta. Toca modelos solo como telemetría — riesgo cero.** Prototipa en `experiment-baton/`, lógica pura/typed diseñada para graduar a `straymark-core`. diff --git a/experiment-baton/src/lib.rs b/experiment-baton/src/lib.rs index 200625bd..807a0628 100644 --- a/experiment-baton/src/lib.rs +++ b/experiment-baton/src/lib.rs @@ -19,3 +19,4 @@ pub mod overlay; pub mod provenance; mod scan; pub mod speckit; +pub mod units; diff --git a/experiment-baton/src/units.rs b/experiment-baton/src/units.rs new file mode 100644 index 00000000..0e35b877 --- /dev/null +++ b/experiment-baton/src/units.rs @@ -0,0 +1,423 @@ +//! Routable-unit inventory (Baton Phase 2, B1). +//! +//! Reads the work StrayMark *already recorded* in a project — at the four +//! granularities that already exist, **inventing no new vocabulary** (concept +//! §4.3 / charter framing decision #1): Charter, Batch, Follow-up, Task. The +//! dry-run router (B2+) classifies these units and recommends a tier; this module +//! only *enumerates* them and harvests the signals a reader can read directly. +//! +//! Read-only. Charter reading reuses `straymark_core::charter`; the other three +//! are tolerant, line-oriented scans (the `scan.rs` philosophy: no regex, +//! char-boundary-safe over accented prose). A signal a reader cannot see stays +//! `None` — never fabricated (honest inputs → honest classification, B3). + +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use straymark_core::charter::{discover_and_parse, display_title}; +use straymark_core::charter_files::parse_files_to_modify; + +use crate::intent::SourceRef; + +/// Directories never walked for governance artifacts. +const SKIP_DIRS: &[&str] = &[ + ".git", + "target", + "node_modules", + "dist", + "build", + ".docusaurus", +]; + +/// The granularity of a routable unit — the existing artifact it came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Granularity { + Charter, + Batch, + Followup, + Task, +} + +impl Granularity { + pub const ALL: [Granularity; 4] = [ + Granularity::Charter, + Granularity::Batch, + Granularity::Followup, + Granularity::Task, + ]; + + pub fn as_str(self) -> &'static str { + match self { + Granularity::Charter => "charter", + Granularity::Batch => "batch", + Granularity::Followup => "followup", + Granularity::Task => "task", + } + } + + /// Parse a `--granularity` value (`all` → `None`). + pub fn parse(s: &str) -> Option<Option<Granularity>> { + match s.trim().to_lowercase().as_str() { + "all" => Some(None), + "charter" => Some(Some(Granularity::Charter)), + "batch" => Some(Some(Granularity::Batch)), + "followup" | "follow-up" => Some(Some(Granularity::Followup)), + "task" => Some(Some(Granularity::Task)), + _ => None, + } + } +} + +/// One unit of recorded work, keyed and located, with the signals a reader could +/// read directly. The computed signals (complexity, arch state, coherence +/// findings) are folded in by B2; this is the inventory record. +#[derive(Debug, Clone, Serialize)] +pub struct RoutableUnit { + pub id: String, + pub granularity: Granularity, + pub source: SourceRef, + pub title: String, + /// Charter human-time estimate (`XS`..`L`), when the unit carries one. + #[serde(skip_serializing_if = "Option::is_none")] + pub effort_estimate: Option<String>, + /// Follow-up bucket (`ready`, …) for `Followup` units. + #[serde(skip_serializing_if = "Option::is_none")] + pub followup_bucket: Option<String>, + /// Follow-up severity, when declared. + #[serde(skip_serializing_if = "Option::is_none")] + pub followup_severity: Option<String>, + /// Declared file scope (charters: the `Files to modify` paths/globs). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub scope_globs: Vec<String>, +} + +/// Inventory routable units under `root`, optionally a single granularity +/// (`None` = all). Read-only; deterministic order. +pub fn inventory(root: &Path, only: Option<Granularity>) -> Vec<RoutableUnit> { + let want = |g: Granularity| only.is_none_or(|o| o == g); + let mut out = Vec::new(); + if want(Granularity::Charter) { + out.extend(read_charters(root)); + } + if want(Granularity::Batch) { + out.extend(read_batches(root)); + } + if want(Granularity::Followup) { + out.extend(read_followups(root)); + } + if want(Granularity::Task) { + out.extend(read_tasks(root)); + } + out.sort_by(|a, b| { + (a.granularity.as_str(), a.id.as_str()).cmp(&(b.granularity.as_str(), b.id.as_str())) + }); + out +} + +// ---- Charter -------------------------------------------------------------- + +fn read_charters(root: &Path) -> Vec<RoutableUnit> { + let (charters, _errs) = discover_and_parse(root); + charters + .iter() + .map(|c| { + let scope_globs = parse_files_to_modify(&c.body) + .into_iter() + .map(|d| d.path) + .collect(); + RoutableUnit { + id: c.frontmatter.charter_id.clone(), + granularity: Granularity::Charter, + source: SourceRef { + file: rel(root, &c.path), + symbol: Some(c.frontmatter.charter_id.clone()), + }, + title: display_title(c), + effort_estimate: Some(c.frontmatter.effort_estimate.as_str().to_string()), + followup_bucket: None, + followup_severity: None, + scope_globs, + } + }) + .collect() +} + +// ---- Batch (from AILOG `## Batch Ledger`) --------------------------------- + +fn read_batches(root: &Path) -> Vec<RoutableUnit> { + let mut out = Vec::new(); + for path in find_files(root, |p| { + ext_is(p, "md") && file_name(p).starts_with("AILOG-") + }) { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let stem = file_stem(&path); + let rel_path = rel(root, &path); + for line in content.lines() { + // `### Batch 1 — B1: crate scaffold + SpecKit adapter (T1.1–T1.5)` + let Some(rest) = line.trim().strip_prefix("### Batch ") else { + continue; + }; + let (num, title) = split_on_dash(rest); + let num = num.split_whitespace().next().unwrap_or("").trim(); + if num.is_empty() { + continue; + } + out.push(RoutableUnit { + id: format!("{stem}#batch-{num}"), + granularity: Granularity::Batch, + source: SourceRef { + file: rel_path.clone(), + symbol: Some(format!("Batch {num}")), + }, + title: if title.is_empty() { rest.trim().to_string() } else { title }, + effort_estimate: None, + followup_bucket: None, + followup_severity: None, + scope_globs: Vec::new(), + }); + } + } + out +} + +// ---- Follow-up (the registry) --------------------------------------------- + +fn read_followups(root: &Path) -> Vec<RoutableUnit> { + let registry = root.join(".straymark").join("follow-ups-backlog.md"); + let Ok(content) = std::fs::read_to_string(®istry) else { + return Vec::new(); + }; + let rel_path = rel(root, ®istry); + let mut out = Vec::new(); + let mut bucket: Option<String> = None; + + for line in content.lines() { + let t = line.trim(); + if let Some(h) = t.strip_prefix("## ") { + // `## Bucket: ready` → `ready`; any other `## …` is a non-bucket section. + bucket = Some( + h.strip_prefix("Bucket:") + .unwrap_or(h) + .trim() + .to_string(), + ); + continue; + } + // `### FU-NNN — <description>` + if let Some(rest) = t.strip_prefix("### ") { + let (head, desc) = split_on_dash(rest); + let Some(id) = head.split_whitespace().next() else { + continue; + }; + if !id.starts_with("FU-") { + continue; + } + out.push(RoutableUnit { + id: id.to_string(), + granularity: Granularity::Followup, + source: SourceRef { + file: rel_path.clone(), + symbol: Some(id.to_string()), + }, + title: if desc.is_empty() { rest.trim().to_string() } else { desc }, + effort_estimate: None, + followup_bucket: bucket.clone(), + followup_severity: None, + scope_globs: Vec::new(), + }); + continue; + } + // `- **Severity**: high` within the current entry. + if t.contains("**Severity**") { + if let Some(last) = out.last_mut() { + if last.granularity == Granularity::Followup && last.followup_severity.is_none() { + if let Some((_, v)) = t.rsplit_once(':') { + let v = v.trim().trim_matches('`').trim(); + if !v.is_empty() { + last.followup_severity = Some(v.to_string()); + } + } + } + } + } + } + out +} + +// ---- Task (from `specs/**/tasks.md`) -------------------------------------- + +fn read_tasks(root: &Path) -> Vec<RoutableUnit> { + let mut out = Vec::new(); + for path in find_files(root, |p| file_name(p) == "tasks.md") { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let spec_id = path + .parent() + .and_then(|d| d.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or("spec") + .to_string(); + let rel_path = rel(root, &path); + for line in content.lines() { + let t = line.trim(); + let body = t + .strip_prefix("- [ ] ") + .or_else(|| t.strip_prefix("- [x] ")) + .or_else(|| t.strip_prefix("- [X] ")); + let Some(body) = body else { continue }; + let (head, text) = split_on_dash(body); + let Some(tid) = head.split_whitespace().next() else { + continue; + }; + // Task ids look like `T1.1` / `T3.5`. + if !(tid.starts_with('T') && tid[1..].chars().next().is_some_and(|c| c.is_ascii_digit())) + { + continue; + } + out.push(RoutableUnit { + id: format!("{spec_id}:{tid}"), + granularity: Granularity::Task, + source: SourceRef { + file: rel_path.clone(), + symbol: Some(tid.to_string()), + }, + title: if text.is_empty() { body.trim().to_string() } else { text }, + effort_estimate: None, + followup_bucket: None, + followup_severity: None, + scope_globs: Vec::new(), + }); + } + } + out +} + +// ---- shared helpers ------------------------------------------------------- + +/// Split on the first em-dash (`—`), trimming both sides. Falls back to +/// `(whole, "")` when there is no dash. +fn split_on_dash(s: &str) -> (String, String) { + match s.split_once('—') { + Some((l, r)) => (l.trim().to_string(), r.trim().to_string()), + None => (s.trim().to_string(), String::new()), + } +} + +fn rel(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .to_string_lossy() + .to_string() +} + +fn file_name(p: &Path) -> &str { + p.file_name().and_then(|n| n.to_str()).unwrap_or_default() +} + +fn file_stem(p: &Path) -> String { + p.file_stem() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_string() +} + +fn ext_is(p: &Path, want: &str) -> bool { + p.extension().and_then(|e| e.to_str()) == Some(want) +} + +/// Recursively collect files under `root` matching `pred`, skipping vendor dirs. +fn find_files(root: &Path, pred: impl Fn(&Path) -> bool) -> Vec<PathBuf> { + let mut out = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let mut entries: Vec<_> = std::fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + if !SKIP_DIRS.contains(&file_name(&p)) { + stack.push(p); + } + } else if pred(&p) { + out.push(p); + } + } + } + out.sort(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn corpus() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/governance-corpus") + } + + fn ids(units: &[RoutableUnit], g: Granularity) -> Vec<&str> { + units + .iter() + .filter(|u| u.granularity == g) + .map(|u| u.id.as_str()) + .collect() + } + + #[test] + fn inventory_finds_all_four_granularities() { + let u = inventory(&corpus(), None); + assert!(!ids(&u, Granularity::Charter).is_empty(), "expected a charter"); + assert!(!ids(&u, Granularity::Batch).is_empty(), "expected a batch"); + assert!(!ids(&u, Granularity::Followup).is_empty(), "expected a follow-up"); + assert!(!ids(&u, Granularity::Task).is_empty(), "expected a task"); + } + + #[test] + fn granularity_filter_is_respected() { + let only = inventory(&corpus(), Some(Granularity::Followup)); + assert!(!only.is_empty()); + assert!(only.iter().all(|u| u.granularity == Granularity::Followup)); + } + + #[test] + fn charter_unit_carries_effort_and_scope() { + let u = inventory(&corpus(), Some(Granularity::Charter)); + let c = u.iter().find(|u| u.id == "CHARTER-01-example").expect("charter"); + assert_eq!(c.effort_estimate.as_deref(), Some("L")); + assert!( + c.scope_globs.iter().any(|g| g.contains("statuscenter")), + "scope globs from Files-to-modify: {:?}", + c.scope_globs + ); + } + + #[test] + fn followup_unit_carries_bucket_and_severity() { + let u = inventory(&corpus(), Some(Granularity::Followup)); + let fu = u.iter().find(|u| u.id == "FU-201").expect("FU-201"); + assert_eq!(fu.followup_bucket.as_deref(), Some("ready")); + assert_eq!(fu.followup_severity.as_deref(), Some("high")); + } + + #[test] + fn batch_and_task_ids_are_keyed() { + let u = inventory(&corpus(), None); + assert!( + ids(&u, Granularity::Batch).iter().any(|i| i.contains("#batch-1")), + "batch id keyed by ailog + number" + ); + assert!( + ids(&u, Granularity::Task).iter().any(|i| i.ends_with(":T1.1")), + "task id keyed by spec dir + task token" + ); + } +} diff --git a/experiment-baton/tests/fixtures/governance-corpus/.straymark/charters/01-example.md b/experiment-baton/tests/fixtures/governance-corpus/.straymark/charters/01-example.md new file mode 100644 index 00000000..38bd4ef6 --- /dev/null +++ b/experiment-baton/tests/fixtures/governance-corpus/.straymark/charters/01-example.md @@ -0,0 +1,27 @@ +--- +charter_id: CHARTER-01-example +status: closed +closed_at: "2026-06-20" +effort_estimate: L +trigger: "Synthetic charter for the Baton Phase-2 routable-unit inventory fixture." +originating_spec: specs/003-example/spec.md +--- + +# Charter: Example — health contract remediation + +> **Status:** closed. Effort: L. + +## Context + +A synthetic charter used only to exercise the routable-unit inventory (B1). + +## Files to modify + +| File | Change | +|---|---| +| `internal/statuscenter/handler.go` | Producer of the health contract | +| `web/src/api/types.gen.ts` | Consumer types | + +## Tasks + +1. Do the work. diff --git a/experiment-baton/tests/fixtures/governance-corpus/.straymark/follow-ups-backlog.md b/experiment-baton/tests/fixtures/governance-corpus/.straymark/follow-ups-backlog.md new file mode 100644 index 00000000..3df82a36 --- /dev/null +++ b/experiment-baton/tests/fixtures/governance-corpus/.straymark/follow-ups-backlog.md @@ -0,0 +1,21 @@ +--- +schema: follow-ups-registry/v1 +buckets: [ready, blocked] +--- + +# Follow-ups backlog + +## Bucket: ready + +### FU-201 — Wire the dev-acceleration env knob for the anomaly detector +- **Origin**: AILOG-2026-06-26-001 §Follow-ups +- **Severity**: high + +### FU-202 — `gofmt -w` the two unformatted files on main +- **Origin**: AILOG-2026-06-26-001 §R5 + +## Bucket: blocked + +### FU-203 — Design the cross-service quota propagation +- **Origin**: AILOG-2026-06-26-001 §Follow-ups +- **Severity**: medium diff --git a/experiment-baton/tests/fixtures/governance-corpus/AILOG-2026-06-26-001-example.md b/experiment-baton/tests/fixtures/governance-corpus/AILOG-2026-06-26-001-example.md new file mode 100644 index 00000000..78de8f39 --- /dev/null +++ b/experiment-baton/tests/fixtures/governance-corpus/AILOG-2026-06-26-001-example.md @@ -0,0 +1,20 @@ +--- +id: AILOG-2026-06-26-001 +title: Example execution log with a batch ledger +status: accepted +--- + +# AILOG: Example + +## Summary + +Synthetic AILOG used only to exercise the Batch granularity of the routable-unit +inventory (B1). + +## Batch Ledger + +### Batch 1 — B1: crate scaffold + adapter (T1.1–T1.5) +Completed 2026-06-26 — AILOG-2026-06-26-001. + +### Batch 2 — B2: signal aggregation (T2.1–T2.4) +Completed 2026-06-26 — this AILOG's PR. diff --git a/experiment-baton/tests/fixtures/governance-corpus/specs/003-example/tasks.md b/experiment-baton/tests/fixtures/governance-corpus/specs/003-example/tasks.md new file mode 100644 index 00000000..7a95c2b2 --- /dev/null +++ b/experiment-baton/tests/fixtures/governance-corpus/specs/003-example/tasks.md @@ -0,0 +1,10 @@ +# Tasks 003 — Example + +## B1 — Inventory + +- [ ] T1.1 — Implement the routable-unit inventory +- [x] T1.2 — Add the four granularity readers + +## B2 — Signals + +- [ ] T2.1 — Aggregate cheap signals