Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
id: AILOG-2026-09-17-002
title: Baton inventory hygiene — skip nested checkouts, ignore follow-up examples (#434, #431)
status: accepted
created: 2026-09-17
agent: claude-opus-5-1m
confidence: high
review_required: false
risk_level: low
eu_ai_act_risk: not_applicable
nist_genai_risks: [information_integrity]
iso_42001_clause: []
files_modified:
- experiment-baton/src/scan.rs
- experiment-baton/src/units.rs
- experiment-baton/src/coherence.rs
- experiment-baton/src/codescan.rs
- experiment-baton/tests/inventory_hygiene.rs
observability_scope: none
tags: [baton, adopter-feedback, inventory, track-c]
related:
- 07-ai-audit/agent-logs/AILOG-2026-09-13-001-baton-task-inheritance.md
---

# AILOG: Baton inventory hygiene (#434, #431)

## Summary

Baton counted work that is not the project's live work. It walked into other git checkouts nested
in the project (#434), and it read the shipped registry's commented `### FU-NNN` example as a
follow-up (#431). Both inflate Track C denominators, and the first one also duplicates unit IDs.

## Context

Found while reviewing Estoa's #430. On Estoa's local checkout, one linked worktree under
`.worktrees/` inflated the inventory to 1946 tasks, with 1839 duplicated unit IDs. #431 was
reported by Estoa itself: its 43/50 raw rows were 42/49 real units.

## Actions Performed

1. `scan::is_nested_checkout`: a sub-directory with its own `.git` entry is another checkout. A
`.git` file marks a linked worktree or a submodule, and a `.git` directory marks a nested clone.
The check is structural, so there is no gitignore parsing and nothing for adopters to configure.
2. All three Baton walkers use it: `units::find_files` (tasks, batch ledgers),
`coherence::Inventory::scan` and `codescan::walk_code`.
3. `read_followups` inventories only live entries:
- text inside HTML comments (including multi-line and inline ones) and fenced blocks is ignored;
- IDs must have the canonical `FU-<digits>` shape that `straymark followups` also requires;
- metadata lines only attach to the entry heading directly above them. Before, a `- **Work
verb**:` line under an unrelated `###` heading or inside a commented example could fill an
earlier entry's field.
4. `tests/inventory_hygiene.rs`: 6 tests. Each walker is checked against a fixture copied three
times (root, worktree, nested clone). The shipped empty registry is read from `dist/` as data,
plus the #431 reproduction and a mixed registry of live entries and examples. All 6 fail on the
previous code.

## Decisions Made

- Skipping any directory with `.git`, rather than matching names like `.worktrees`. Worktrees can
live anywhere, and submodules and nested clones are other repositories whose governance is not
this project's work.
- Out of scope, as stated in #434: `straymark_core::architecture::collect_source_files` has the
same blind spot. Fixing it needs a `core` bump, which also affects the CLI and Loom. Copies of
`specs/` inside the main tree (for example, evidence snapshots) cannot be told apart
structurally, so handling them would need an explicit exclude setting, gated on an adopter
needing it.
- The CLI half of #431 (`straymark validate` warning on the template's example line) belongs to
the CLI and is handled with the CLI follow-up work (#432).

## Impact

Measured read-only with the binaries built from `main` and from this branch (git status unchanged
in every repository):

| Corpus | Before | After |
|---|---|---|
| Estoa (local) | task 1946, batch 22, follow-up 6, duplicated IDs 1839 | task 79, batch 11, follow-up 5, duplicated IDs 0 |
| LNXDrive | follow-up 18 | follow-up 17 (template placeholder) |
| Sentinel | unchanged | unchanged (its one duplicated ID is an AILOG with two `### Batch 1` headings: adopter content) |

- **Performance / Security / Privacy / Environmental**: N/A. Still read-only.

## Verification

- [x] `cargo test -p straymark-baton --locked`: 90 passed (84 existing + 6 new)
- [x] The 6 new tests fail against the unfixed source
- [x] `cargo clippy -p straymark-baton --all-targets --locked -- -D warnings`
- [x] Read-only measurement on three adopter corpora
4 changes: 2 additions & 2 deletions experiment-baton/src/codescan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use std::collections::BTreeMap;
use std::path::Path;

use crate::intent::{ContractShape, EnumDef, Field, Lang, ShapeRole, SourceRef};
use crate::scan::{normalize_endpoint, scan_endpoints};
use crate::scan::{is_nested_checkout, normalize_endpoint, scan_endpoints};

const SKIP_DIRS: &[&str] = &[
".git",
Expand Down Expand Up @@ -595,7 +595,7 @@ fn walk_code(root: &Path) -> Vec<std::path::PathBuf> {
for p in entries {
if p.is_dir() {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if !SKIP_DIRS.contains(&name) {
if !SKIP_DIRS.contains(&name) && !is_nested_checkout(&p) {
stack.push(p);
}
} else if matches!(ext(&p), Some("go") | Some("ts") | Some("tsx"))
Expand Down
4 changes: 2 additions & 2 deletions experiment-baton/src/coherence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use std::path::Path;
use serde::Serialize;

use crate::intent::{Confidence, IntentContract, IntentModel, SourceRef};
use crate::scan::normalize_endpoint;
use crate::scan::{is_nested_checkout, normalize_endpoint};
use crate::speckit::IntendedComponent;

/// A flat, lowercased listing of on-disk file paths (read-only) — used to tell
Expand Down Expand Up @@ -61,7 +61,7 @@ impl Inventory {
for p in entries {
if p.is_dir() {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or_default();
if !SKIP.contains(&name) {
if !SKIP.contains(&name) && !is_nested_checkout(&p) {
stack.push(p);
}
} else if let Ok(rel) = p.strip_prefix(root) {
Expand Down
12 changes: 12 additions & 0 deletions experiment-baton/src/scan.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
//! Low-level, dependency-free text scanners shared across the adapter, the
//! code-shape extractor, and provenance inference. No regex (matching the
//! `core` philosophy); char-boundary-safe over accented prose.
//!
//! Also the one rule every directory walker shares: [`is_nested_checkout`].

use std::collections::HashSet;
use std::path::Path;

/// True when `dir` is the root of *another* checkout — a linked git worktree
/// or a submodule (a `.git` file) or a nested clone (a `.git` directory).
/// Walkers never descend into one: its artifacts belong to another working
/// copy, and reading them duplicates units and contracts (#434). Structural on
/// purpose — no gitignore parsing, nothing for an adopter to configure.
pub(crate) fn is_nested_checkout(dir: &Path) -> bool {
dir.join(".git").exists()
}

/// Scan `text` for all identifiers shaped `<prefix><body>` where `body` is made
/// of ASCII alphanumerics / hyphens and contains at least one digit. Returns
Expand Down
71 changes: 63 additions & 8 deletions experiment-baton/src/units.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use straymark_core::charter::{discover_and_parse, display_title, read_frontmatte
use straymark_core::charter_files::parse_files_to_modify;

use crate::intent::SourceRef;
use crate::scan::is_nested_checkout;

/// Directories never walked for governance artifacts.
const SKIP_DIRS: &[&str] = &[
Expand Down Expand Up @@ -211,9 +212,24 @@ fn read_followups(root: &Path) -> Vec<RoutableUnit> {
let rel_path = rel(root, &registry);
let mut out = Vec::new();
let mut bucket: Option<String> = None;
// Only live entries count (#431): the shipped registry documents the entry
// shape as a commented `### FU-NNN` example, and adopters keep examples in
// fenced blocks. Metadata lines belong to the entry heading right above
// them, never to an earlier entry across another heading.
let mut in_comment = false;
let mut in_fence = false;
let mut in_entry = false;

for line in content.lines() {
let t = line.trim();
let visible = outside_html_comments(line, &mut in_comment);
let t = visible.trim();
if t.starts_with("```") || t.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence || t.is_empty() {
continue;
}
if let Some(h) = t.strip_prefix("## ") {
// `## Bucket: ready` → `ready`; any other `## …` is a non-bucket section.
bucket = Some(
Expand All @@ -222,23 +238,23 @@ fn read_followups(root: &Path) -> Vec<RoutableUnit> {
.trim()
.to_string(),
);
in_entry = false;
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 {
let id = head.split_whitespace().next().and_then(followup_id);
in_entry = id.is_some();
let Some(id) = id else {
continue;
};
if !id.starts_with("FU-") {
continue;
}
out.push(RoutableUnit {
id: id.to_string(),
id: id.clone(),
granularity: Granularity::Followup,
source: SourceRef {
file: rel_path.clone(),
symbol: Some(id.to_string()),
symbol: Some(id),
},
title: if desc.is_empty() { rest.trim().to_string() } else { desc },
effort_estimate: None,
Expand All @@ -250,6 +266,9 @@ fn read_followups(root: &Path) -> Vec<RoutableUnit> {
});
continue;
}
if !in_entry {
continue;
}
// `- **Label**: value` metadata lines within the current entry.
if let Some(last) = out.last_mut() {
if last.granularity == Granularity::Followup {
Expand All @@ -266,6 +285,42 @@ fn read_followups(root: &Path) -> Vec<RoutableUnit> {
out
}

/// Canonical follow-up id of a heading token: `FU-` + digits (`FU-012`, also
/// `FU-012:`), as `straymark followups` reads it. `FU-NNN` and other
/// placeholders are not ids.
fn followup_id(token: &str) -> Option<String> {
let digits: String = token
.strip_prefix("FU-")?
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
(!digits.is_empty()).then(|| format!("FU-{digits}"))
}

/// The part of `line` outside HTML comments. `in_comment` carries an open
/// `<!--` across lines, since a comment may span many.
fn outside_html_comments(line: &str, in_comment: &mut bool) -> String {
let mut out = String::new();
let mut rest = line;
loop {
if *in_comment {
let Some(end) = rest.find("-->") else {
return out;
};
rest = &rest[end + 3..];
*in_comment = false;
} else {
let Some(start) = rest.find("<!--") else {
out.push_str(rest);
return out;
};
out.push_str(&rest[..start]);
rest = &rest[start + 4..];
*in_comment = true;
}
}
}

/// Value of a `- **Label**: value` metadata line (trimmed, backtick-stripped),
/// or `None` if the line doesn't carry that label.
fn field_value(line: &str, label: &str) -> Option<String> {
Expand Down Expand Up @@ -380,7 +435,7 @@ fn find_files(root: &Path, pred: impl Fn(&Path) -> bool) -> Vec<PathBuf> {
entries.sort();
for p in entries {
if p.is_dir() {
if !SKIP_DIRS.contains(&file_name(&p)) {
if !SKIP_DIRS.contains(&file_name(&p)) && !is_nested_checkout(&p) {
stack.push(p);
}
} else if pred(&p) {
Expand Down
Loading