Skip to content

Commit 36bb3f4

Browse files
montfortclaude
andauthored
fix(cli): followups drift correctness — see the working tree (#229) + catch appended follow-ups (#231) (#235)
Two silent-data-loss bugs in `straymark followups drift`, both surfaced by the reference adopter (Sentinel) and both prerequisites for wiring drift into the Charter-close flow (RFC #135 Tier 3). - #229: the default scan considered only the committed git range, so an uncommitted/untracked AILOG (the normal pre-commit state) was invisible and `drift --apply` reported "in sync" while real follow-up content went unextracted. The scan now unions the git range with the working tree (`git status --porcelain`), mirroring the v0 reference script. - #231: `fully_extracted_ailogs` was a whole-AILOG idempotency gate — once an AILOG id was in it the file was never re-scanned, so follow-ups appended later (the multi-batch Charter pattern) were silently dropped. Drift now dedups per follow-up by a stable content hash (`Source-hash`, stored on each entry): already-extracted AILOGs are re-scanned and individual follow-ups deduped. The stored hash is captured at extraction time, immune to later triage rewording — preserving the zero-false-positive property; legacy entries fall back to recomputing the hash from Origin + description. Updates FOLLOW-UPS-BACKLOG-PATTERN.md (EN/ES/zh-CN) to match; the old "Per-AILOG vs per-bullet granularity" section documented the #231 bug as an intentional trade-off. Bump fw-4.24.0 → 4.25.0 / cli-3.20.0 → 3.21.0. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2ecb980 commit 36bb3f4

28 files changed

Lines changed: 406 additions & 80 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ and this project uses [independent versioning](README.md#versioning) for Framewo
77

88
---
99

10+
## Framework 4.25.0 / CLI 3.21.0 — follow-ups drift correctness: see the working tree + catch appended follow-ups
11+
12+
Two silent-data-loss bugs in `straymark followups drift`, both surfaced by the reference adopter (Sentinel) and both prerequisites for wiring drift into the Charter-close flow (RFC [#135](https://github.com/StrangeDaysTech/straymark/issues/135) Tier 3).
13+
14+
### Fixed (CLI)
15+
16+
- **`followups drift` default scan now sees the working tree** ([#229](https://github.com/StrangeDaysTech/straymark/issues/229)): the default scan considered only the committed git range (`git diff origin/main..HEAD`), so an uncommitted/untracked AILOG — the normal state at pre-commit time — was invisible, and the documented `drift --apply` flow reported "in sync" while real follow-up content went unextracted. The scan now unions the git range with the working tree (`git status --porcelain`), mirroring the v0 reference script. `--scan-all` semantics unchanged.
17+
- **`followups drift` catches follow-ups appended to an already-extracted AILOG** ([#231](https://github.com/StrangeDaysTech/straymark/issues/231)): `fully_extracted_ailogs` was a whole-AILOG idempotency gate — once an AILOG id was in it, the file was never re-scanned, so follow-ups added later (the multi-batch Charter pattern, where one AILOG's `§Follow-ups` grows across batches) were silently dropped. Drift now dedups **per follow-up by a stable content hash** (`Source-hash`, stored on each entry): already-extracted AILOGs are re-scanned and individual follow-ups deduped, so appended content is caught. The stored hash is captured at extraction time and immune to later triage rewording, preserving the zero-false-positive property that motivated the original per-AILOG choice; legacy entries (pre-cli-3.21.0, no `Source-hash`) fall back to recomputing the hash from `Origin` + `description`. `fully_extracted_ailogs` is retained as informational metadata, no longer the skip gate.
18+
19+
### Changed (Framework)
20+
21+
- **`FOLLOW-UPS-BACKLOG-PATTERN.md`** (EN/ES/zh-CN): documents the working-tree union, the per-follow-up content-hash dedup (replacing the "Per-AILOG vs per-bullet granularity" section that described the #231 bug as an intentional trade-off), the new auto-managed `Source-hash` entry field, and the revised role of `fully_extracted_ailogs`.
22+
23+
---
24+
1025
## Framework 4.24.0 — portable installed skills: runtime-agnostic identity + current-work git context
1126

1227
Five portability/correctness fixes to the installed skills, surfaced by a Codex (`gpt-5.5`) review of the StrayMark skills running under Codex CLI in the reference adopter Sentinel ([#232](https://github.com/StrangeDaysTech/straymark/issues/232)). The skills ship in four families under `dist/` (`.claude/`, `.gemini/`, `.agent/workflows/` hand-maintained; `.codex/` generated from `.claude/` by `gen_codex_skills`); the fixes were applied to the hand-maintained sources and the Codex tree regenerated.

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,8 @@ StrayMark uses independent version tags for each component:
277277

278278
| Component | Tag prefix | Example | Includes |
279279
| --- | --- | --- | --- |
280-
| Framework | `fw-` | `fw-4.24.0` | Templates (12 types), governance, directives, Charter template + schema |
281-
| CLI | `cli-` | `cli-3.20.0` | The `straymark` binary |
280+
| Framework | `fw-` | `fw-4.25.0` | Templates (12 types), governance, directives, Charter template + schema |
281+
| CLI | `cli-` | `cli-3.21.0` | The `straymark` binary |
282282

283283
Check installed versions with `straymark status` or `straymark about`.
284284

@@ -311,7 +311,7 @@ See [CLI Reference](https://github.com/StrangeDaysTech/straymark/blob/main/docs/
311311
```bash
312312
# Download the latest framework release ZIP from GitHub
313313
# Go to https://github.com/StrangeDaysTech/straymark/releases
314-
# and download the latest fw-* release (e.g., fw-4.24.0)
314+
# and download the latest fw-* release (e.g., fw-4.25.0)
315315
316316
# Extract and copy to your project
317317
unzip straymark-fw-*.zip -d your-project/

cli/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "straymark-cli"
3-
version = "3.20.0"
3+
version = "3.21.0"
44
edition = "2021"
55
description = "CLI for StrayMark — the cognitive discipline your AI-assisted projects need"
66
license = "MIT"

cli/src/commands/followups/drift.rs

Lines changed: 169 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
//! `check-followups-drift.sh`. Three modes, mirroring the v0 script:
55
//!
66
//! - **default** — scan AILOGs changed in `git diff origin/main..HEAD`
7-
//! (fallback `origin/master..HEAD`, then `HEAD~1..HEAD` with a warning).
8-
//! Warn + exit 1 when an AILOG with follow-up content is not in
9-
//! `fully_extracted_ailogs`.
7+
//! (fallback `origin/master..HEAD`, then `HEAD~1..HEAD` with a warning)
8+
//! **unioned with the working tree** (`git status --porcelain`) so an
9+
//! uncommitted/untracked AILOG is visible to the documented pre-commit flow
10+
//! (#229). Warn + exit 1 when an AILOG carries follow-up content not yet in
11+
//! the registry.
1012
//! - **`--apply`** — same scan + extract new entries into `## Bucket: ready`,
1113
//! append the AILOG ids to `fully_extracted_ailogs`, **recompute the
1214
//! `total_*` counters** (#214 Signal 2) and upgrade v0 → v1 in place.
@@ -15,9 +17,14 @@
1517
//! refinement).
1618
//! - **`--scan-all`** — sweep every AILOG in the project.
1719
//!
18-
//! Granularity is **per-AILOG**, never per-bullet — the empirically validated
19-
//! design choice (0 false positives across 76 AILOGs in the reference
20-
//! adopter). See `FOLLOW-UPS-BACKLOG-PATTERN.md` §Drift detection.
20+
//! Drift is detected **per follow-up by content hash** (`fu_content_hash`),
21+
//! not by a whole-AILOG idempotency gate. Already-extracted AILOGs are
22+
//! re-scanned and individual follow-ups deduped against the registry, so
23+
//! content appended to a multi-batch AILOG after its first extraction is
24+
//! caught instead of silently skipped (#231). The hash dedup preserves the
25+
//! empirically validated zero-false-positive property (no AILOG re-flags once
26+
//! its content is in the registry). See `FOLLOW-UPS-BACKLOG-PATTERN.md`
27+
//! §Drift detection.
2128
//!
2229
//! ## Exit codes
2330
//! - `0` — no drift (or `--apply` extracted everything)
@@ -75,37 +82,50 @@ pub fn run(path: &str, apply: bool, scan_all: bool, range: Option<&str>) -> Resu
7582
utils::warn(w);
7683
}
7784

78-
// Candidate AILOG files.
85+
// Candidate AILOG files. Default mode unions the committed git range with
86+
// the working tree (#229): at pre-commit time the just-written AILOG is
87+
// usually untracked, so a range-only scan is blind to exactly the file the
88+
// documented `drift --apply` flow targets.
7989
let agent_logs = ailog::agent_logs_dir(project_root);
8090
let candidates: Vec<PathBuf> = if scan_all {
8191
walk_ailogs(&agent_logs)
8292
} else {
83-
ailogs_in_git_range(project_root, &agent_logs, range)
93+
let mut set = ailogs_in_git_range(project_root, &agent_logs, range);
94+
for p in ailogs_in_working_tree(project_root, &agent_logs) {
95+
if !set.contains(&p) {
96+
set.push(p);
97+
}
98+
}
99+
set
84100
};
85101

86-
// Drift = AILOG with follow-up content whose id is NOT in
87-
// fully_extracted_ailogs.
88-
let extracted_set: std::collections::HashSet<&str> = registry
89-
.frontmatter
90-
.fully_extracted_ailogs
91-
.iter()
92-
.map(|s| s.as_str())
93-
.collect();
102+
// Drift = an AILOG carrying follow-up content whose content hash is NOT yet
103+
// in the registry (#231). Re-scanning already-extracted AILOGs and deduping
104+
// per follow-up by content hash — instead of skipping the whole file once
105+
// its id lands in `fully_extracted_ailogs` — is what catches follow-ups
106+
// appended to a multi-batch AILOG after its first extraction.
107+
let seen_hashes = followups::registry_extracted_hashes(&registry);
94108

95109
let mut drifted: Vec<(String, PathBuf, Vec<ExtractedFu>)> = Vec::new();
96110
for path in candidates {
97111
let Some(id) = followups::ailog_id_from_path(&path) else {
98112
continue;
99113
};
100-
if extracted_set.contains(id.as_str()) {
101-
continue;
102-
}
103114
let Ok(content) = std::fs::read_to_string(&path) else {
104115
continue;
105116
};
106-
let found = followups::extract_followups_from_ailog(&content);
107-
if !found.is_empty() {
108-
drifted.push((id, path, found));
117+
let new: Vec<ExtractedFu> = followups::extract_followups_from_ailog(&content)
118+
.into_iter()
119+
.filter(|fu| {
120+
!seen_hashes.contains(&followups::fu_content_hash(
121+
&id,
122+
&fu.origin_section,
123+
&fu.description,
124+
))
125+
})
126+
.collect();
127+
if !new.is_empty() {
128+
drifted.push((id, path, new));
109129
}
110130
}
111131

@@ -182,8 +202,20 @@ pub fn run(path: &str, apply: bool, scan_all: bool, range: Option<&str>) -> Resu
182202
}
183203
}
184204

185-
// Frontmatter: append AILOG ids, set last_scan, recompute counters + v1.
186-
let new_ids: Vec<String> = drifted.iter().map(|(id, _, _)| id.clone()).collect();
205+
// Frontmatter: append AILOG ids (deduped — a re-scanned AILOG already in
206+
// the list must not be appended twice), set last_scan, recompute counters.
207+
let already: std::collections::HashSet<&str> = registry
208+
.frontmatter
209+
.fully_extracted_ailogs
210+
.iter()
211+
.map(|s| s.as_str())
212+
.collect();
213+
let mut new_ids: Vec<String> = Vec::new();
214+
for (id, _, _) in &drifted {
215+
if !already.contains(id.as_str()) && !new_ids.contains(id) {
216+
new_ids.push(id.clone());
217+
}
218+
}
187219
let mut fm = followups::fm_append_list_items(
188220
&registry.frontmatter_raw,
189221
"fully_extracted_ailogs",
@@ -303,6 +335,64 @@ fn ailogs_in_git_range(
303335
.collect()
304336
}
305337

338+
/// AILOG files present in the working tree (staged, unstaged, or untracked),
339+
/// via `git status --porcelain`. Unioned into the default scan so the
340+
/// documented pre-commit `drift --apply` flow sees the just-written AILOG
341+
/// before it is committed (#229) — mirroring the v0 reference script, which
342+
/// folded `git status --porcelain` into its default scan set for exactly this
343+
/// in-progress agent workflow.
344+
fn ailogs_in_working_tree(project_root: &Path, agent_logs: &Path) -> Vec<PathBuf> {
345+
let output = Command::new("git")
346+
.args(["status", "--porcelain"])
347+
.current_dir(project_root)
348+
.output();
349+
let Ok(output) = output else {
350+
return Vec::new();
351+
};
352+
if !output.status.success() {
353+
return Vec::new();
354+
}
355+
let agent_logs_rel = agent_logs
356+
.strip_prefix(project_root)
357+
.unwrap_or(agent_logs)
358+
.to_path_buf();
359+
parse_porcelain_ailogs(&String::from_utf8_lossy(&output.stdout), &agent_logs_rel)
360+
.into_iter()
361+
.map(|p| project_root.join(p))
362+
.filter(|p| p.exists())
363+
.collect()
364+
}
365+
366+
/// Parse `git status --porcelain` (v1) output into AILOG file paths relative to
367+
/// the repo root, filtered to the agent-logs dir. Handles the `XY <path>`
368+
/// shape, untracked `?? <path>`, and rename/copy `R <old> -> <new>` (takes the
369+
/// destination). Pure for testability.
370+
fn parse_porcelain_ailogs(stdout: &str, agent_logs_rel: &Path) -> Vec<PathBuf> {
371+
let mut out = Vec::new();
372+
for line in stdout.lines() {
373+
// Porcelain v1: two status chars + a space, then the path (≥ 4 chars).
374+
if line.len() < 4 {
375+
continue;
376+
}
377+
let rest = &line[3..];
378+
// Rename/copy entries render as `old -> new`; the destination is what
379+
// exists on disk.
380+
let path_str = rest.rsplit(" -> ").next().unwrap_or(rest).trim();
381+
// Porcelain quotes paths containing special chars; strip the quotes.
382+
let path_str = path_str.trim_matches('"');
383+
let p = PathBuf::from(path_str);
384+
if p.starts_with(agent_logs_rel)
385+
&& p.file_name()
386+
.and_then(|n| n.to_str())
387+
.map(|n| n.starts_with("AILOG-") && n.ends_with(".md"))
388+
.unwrap_or(false)
389+
{
390+
out.push(p);
391+
}
392+
}
393+
out
394+
}
395+
306396
fn git_ref_exists(project_root: &Path, r: &str) -> bool {
307397
Command::new("git")
308398
.args(["rev-parse", "--verify", "--quiet", r])
@@ -311,3 +401,58 @@ fn git_ref_exists(project_root: &Path, r: &str) -> bool {
311401
.map(|o| o.status.success())
312402
.unwrap_or(false)
313403
}
404+
405+
#[cfg(test)]
406+
mod tests {
407+
use super::*;
408+
409+
#[test]
410+
fn parse_porcelain_picks_ailogs_across_status_codes() {
411+
let rel = Path::new(".straymark/07-ai-audit/agent-logs");
412+
// Built via join so leading status spaces (` M`) survive verbatim — a
413+
// `\`-continued string literal would strip them.
414+
let stdout = [
415+
"?? .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-001-untracked.md",
416+
" M .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-002-modified.md",
417+
"A .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-003-staged.md",
418+
"R .straymark/07-ai-audit/agent-logs/AILOG-old.md -> .straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-004-renamed.md",
419+
" M .straymark/07-ai-audit/agent-logs/notes.md",
420+
"?? src/main.rs",
421+
" M .straymark/07-ai-audit/decisions/AIDEC-2026-06-09-001.md",
422+
]
423+
.join("\n");
424+
let got = parse_porcelain_ailogs(&stdout, rel);
425+
let names: Vec<String> = got
426+
.iter()
427+
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
428+
.collect();
429+
assert_eq!(
430+
names,
431+
vec![
432+
"AILOG-2026-06-09-001-untracked.md",
433+
"AILOG-2026-06-09-002-modified.md",
434+
"AILOG-2026-06-09-003-staged.md",
435+
"AILOG-2026-06-09-004-renamed.md", // rename → destination path
436+
]
437+
);
438+
// notes.md (not AILOG-*), src/main.rs (outside dir), and the AIDEC
439+
// (sibling dir) are all excluded.
440+
}
441+
442+
#[test]
443+
fn parse_porcelain_tolerates_quoted_paths_and_short_lines() {
444+
let rel = Path::new(".straymark/07-ai-audit/agent-logs");
445+
let stdout = [
446+
"?? \".straymark/07-ai-audit/agent-logs/AILOG-2026-06-09-005-spaced name.md\"",
447+
"",
448+
"XX",
449+
]
450+
.join("\n");
451+
let got = parse_porcelain_ailogs(&stdout, rel);
452+
assert_eq!(got.len(), 1);
453+
assert_eq!(
454+
got[0].file_name().unwrap().to_string_lossy(),
455+
"AILOG-2026-06-09-005-spaced name.md"
456+
);
457+
}
458+
}

0 commit comments

Comments
 (0)