From eb496e94451a521f5fc03b299e097ab43c0b330a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:47:53 -0600 Subject: [PATCH] fix(followups): structural merge driver for the registry (#391) + untracked FU-id validation warning (#392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #391: the follow-ups registry is a single CLI-owned file, so every parallel PR touching it conflicts, and resolving textually (take one side, re-run drift --apply) silently reverted the other side's closures — statuses live only in the file and re-extraction renumbers ids. - New `straymark followups merge-driver `: git merge driver that reconciles structurally. Entries match by title (ids are positional); higher-rank status wins so closures made on either side survive; theirs-only entries appended (renumbered on collision); deletions respected unless ours changed the status; Notes unions append-only extensions; frontmatter recomputed. - drift --apply skips candidates whose normalized title already exists in the registry, so a declaration that moved section cannot spawn a duplicate open entry shadowing the operator's status. #392: validate now warns (FOLLOWUP-UNTRACKED-ID) when an AILOG body mentions a FU-NNN / FU-NNN-NNN id outside its own ## Follow-ups section — where the extractor cannot see it — and that id is not in the registry. Registered ids (cross-references) stay quiet; the check is warn-only and skipped when the project has no registry. Docs: CLI-REFERENCE en/es/zh-CN + FOLLOW-UPS-BACKLOG-PATTERN.md. Folded in: /.qoder/ gitignore (local agent config, mirrors /.claude/). Self-adoption: AILOG-2026-08-04-003 + seeded follow-ups registry via drift --apply. --- .gitignore | 2 +- ...e3-followups-merge-driver-untracked-ids.md | 162 ++++++++++++ .straymark/follow-ups-backlog.md | 58 +++++ cli/src/commands/followups/drift.rs | 10 +- cli/src/commands/followups/merge_driver.rs | 99 ++++++++ cli/src/commands/followups/mod.rs | 1 + cli/src/followups.rs | 235 +++++++++++++++++- cli/src/main.rs | 19 ++ cli/src/validation.rs | 118 +++++++++ cli/tests/followups_test.rs | 166 +++++++++++++ cli/tests/validate_test.rs | 100 ++++++++ .../FOLLOW-UPS-BACKLOG-PATTERN.md | 14 ++ docs/adopters/CLI-REFERENCE.md | 27 +- docs/i18n/es/adopters/CLI-REFERENCE.md | 27 +- docs/i18n/zh-CN/adopters/CLI-REFERENCE.md | 27 +- 15 files changed, 1059 insertions(+), 6 deletions(-) create mode 100644 .straymark/07-ai-audit/agent-logs/AILOG-2026-08-04-003-lote3-followups-merge-driver-untracked-ids.md create mode 100644 .straymark/follow-ups-backlog.md create mode 100644 cli/src/commands/followups/merge_driver.rs diff --git a/.gitignore b/.gitignore index 89a15e6c..ee46bde3 100644 --- a/.gitignore +++ b/.gitignore @@ -139,7 +139,7 @@ experiment-loom/web/dist/ /.claude/skills/ /.gemini/skills/ /.codex/skills/ -/.qoder/skills/ +/.qoder/ /.agent/workflows/ /STRAYMARK.md /.straymark/00-governance/ diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-08-04-003-lote3-followups-merge-driver-untracked-ids.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-08-04-003-lote3-followups-merge-driver-untracked-ids.md new file mode 100644 index 00000000..e96f8196 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-08-04-003-lote3-followups-merge-driver-untracked-ids.md @@ -0,0 +1,162 @@ +--- +id: AILOG-2026-08-04-003 +title: Lote 3 adopter fixes — structural merge driver for the follow-ups registry (#391) and untracked FU-id validation warning (#392) +status: accepted +created: 2026-08-04 +agent: qodercli-v1.0 +confidence: high +review_required: false +risk_level: low +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +lines_changed: 0 +files_modified: [] +observability_scope: none +tags: [follow-ups, merge-driver, git, validation, drift, adopter-feedback, cli] +related: [] +--- + +# AILOG: Lote 3 adopter fixes — structural merge driver for the follow-ups registry (#391) and untracked FU-id validation warning (#392) + +## Summary + +Third remediation batch from the 2026-08-04 open-issue triage. #391 closes +the registry's parallel-PR hazard: `.straymark/follow-ups-backlog.md` is a +single CLI-owned file, so every concurrent PR that touches follow-ups +conflicts on it, and resolving textually (take one side, re-run +`drift --apply`) silently reverted the other side's closures — statuses +live only in the file, and a re-extraction renumbers ids, so even +comparing ids cannot detect the loss. Fix: a structural git merge driver +(`straymark followups merge-driver`) plus title-based dedup in +`drift --apply`. #392 closes the silent-mention gap: FU ids declared +outside an AILOG's `## Follow-ups` section were invisible to the extractor +with no warning; `validate` now flags unregistered ones. + +## Context + +Both issues were reported against the follow-ups backlog pattern +(fw-4.21.0+). The registry's design makes titles, not ids, the stable +identity of an entry: ids are positional (`max(existing) + 1` at +extraction time) and renumber whenever entries are regenerated, while +titles survive. Both fixes lean on that invariant. + +## Actions Performed + +1. **#391 — structural merge driver.** + - `cli/src/followups.rs`: new `merge_registries(base, ours, theirs)` + with `status_rank`, `normalize_title` (whitespace-collapsed, + lowercased) and a `MergeReport`. Entries are matched across sides + **by title**; the higher-rank status wins (`open` < `in-progress` < + `suspected-closed` < `closed`/`superseded`/`promoted`), so a closure + made on either side survives; equal-rank disagreements keep `ours` + and are reported. Entries only in `theirs` are appended (renumbered + on id collision), deletions by `theirs` are respected unless `ours` + changed the entry's status (modify/delete → kept + reported), + `Notes` accepts append-only extensions from `theirs`, and the + frontmatter (`fully_extracted_ailogs` union, newest `last_scan`, + counters) is recomputed from the merged body. + - `cli/src/commands/followups/merge_driver.rs` (new): the git-driver + entry point (`%O %A %B` contract; exit 0 = merged, nonzero = git + marks the file conflicted). Lenient on a missing/unparseable base; + strict on ours/theirs. + - `cli/src/main.rs`: `straymark followups merge-driver + ` subcommand with gitattributes setup in the doc comment. + - `cli/src/commands/followups/drift.rs`: `--apply` now skips + candidates whose normalized title already exists in the registry, + protecting declarations that moved section (and re-extractions after + any conflict resolution) from spawning a duplicate `open` entry that + shadows the operator's status. +2. **#392 — untracked FU-id warning.** + - `cli/src/validation.rs`: new `check_followup_mentions` — for every + AILOG, FU ids (`FU-NNN` and charter-scoped `FU-NNN-NNN`) mentioned + **outside** the document's own `## Follow-ups` section and absent + from the registry emit a warn-only `FOLLOWUP-UNTRACKED-ID` issue + with a fix hint. Mentions of registered ids (legitimate + cross-references) stay quiet, and the check is skipped entirely when + the project has no registry. +3. **Docs.** CLI-REFERENCE updated in en/es/zh-CN (merge-driver section + with the reconciliation table and once-per-clone gitattributes setup, + the new validate rule, and the title-dedup note on `drift --apply`). + `dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md` gained a + "Parallel PRs — structural merge (cli-3.41.0+)" subsection. + +## Modified Files + +| File | Change Description | +|------|--------------------| +| `cli/src/followups.rs` | `merge_registries` + `status_rank` / `normalize_title` / `MergeReport`; `is_followup_heading` made `pub` | +| `cli/src/commands/followups/merge_driver.rs` | git merge-driver entry point (new) | +| `cli/src/commands/followups/mod.rs` | `pub mod merge_driver` | +| `cli/src/main.rs` | `MergeDriver` subcommand + dispatch | +| `cli/src/commands/followups/drift.rs` | title-based dedup in `detect_drift_candidates` | +| `cli/src/validation.rs` | `check_followup_mentions` + `scan_fu_ids` (`FOLLOWUP-UNTRACKED-ID`) | +| `cli/tests/followups_test.rs` | 2 merge-driver tests + 1 title-dedup test | +| `cli/tests/validate_test.rs` | 2 untracked-FU-id tests | +| `dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md` | parallel-PR merge subsection + title-dedup note | +| `docs/adopters/CLI-REFERENCE.md` (+ i18n es/zh-CN) | merge-driver section, validate rule, drift note | +| `.gitignore` | `/.qoder/` (local agent config, mirrors `/.claude/`) | + +## Decisions Made + +- **#391 — match by title, not id.** Ids are positional and do not + survive regeneration; the title is the registry's stable identity. A + structural merge keyed on ids would mis-pair entries after any + re-extraction. +- **#391 — status rank, not timestamp.** The merge driver sees three file + versions with no reliable clock; rank order makes "a closure made on + either side survives" the mechanical outcome, and equal-rank + disagreements stay visible (stderr) instead of being guessed. +- **#392 — warn only, and only for unregistered ids.** A registered id + mentioned elsewhere is a legitimate cross-reference; the hazard is a + declaration the extractor cannot see **and** nothing tracks. Warn-only + keeps the rule advisory for projects mid-adoption. +- **Folded in: `/.qoder/` gitignore.** Lote 2 ignored only + `/.qoder/skills/`; Qoder also writes `.qoder/settings.local.json`, + which is per-machine config like `/.claude/`. + +## Impact + +- **Functionality**: parallel PRs touching the registry merge without + operator intervention and without losing closures; untracked FU-id + declarations surface at `validate` instead of silently never being + extracted. +- **Performance**: N/A +- **Security**: N/A +- **Privacy**: N/A +- **Environmental**: N/A + +## Verification + +- [x] Code compiles without errors +- [x] Tests pass — `cargo test --no-fail-fast` in `cli/`: all suites green + except the pre-existing `audit_template_test::unified_template_has_seven_universal_sections` + failure documented in AILOG-2026-08-04-001 (R1). New tests: 3 in + followups_test (merge closures/unions, deletion + conflict visibility, + title dedup) and 2 in validate_test (unregistered warns, no-registry + stays quiet), all passing. +- [x] Manual review performed +- [ ] Security scan passed (if risk_level: high/critical) — N/A (low) +- [ ] Privacy review completed (if handling PII) — N/A + +## Risk + +- R1 (known, accepted): pre-existing failing test on main — unchanged from + AILOG-2026-08-04-001. +- R2 (new): the merge driver requires once-per-clone setup + (`.gitattributes` + `git config merge.*.driver`); a future + `straymark init`/`update-framework` hook could offer to wire it + automatically, gated on adopter demand. +- R3 (new): title-based dedup compares normalized titles, so a + deliberately *reworded* re-declaration of the same follow-up still + extracts a second entry — accepted; that case is operator-visible at + triage, unlike the silent-status-loss this batch closes. + +## Follow-ups + +- (new) Consider wiring the merge-driver setup into `straymark init` + behind a prompt (see R2). + +--- + + diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md new file mode 100644 index 00000000..a608828d --- /dev/null +++ b/.straymark/follow-ups-backlog.md @@ -0,0 +1,58 @@ +--- +last_scan: 2026-08-04 +schema_version: v1 +total_open: 1 +total_promoted: 0 +total_closed_in_session: 0 +total_phase_blocked: 0 +total_suspected_closed: 0 +buckets: + - ready + - time-triggered + - charter-triggered + - phase-blocked + - operational +fully_extracted_ailogs: + - AILOG-2026-08-04-003 +--- + +# Follow-ups Backlog + +> Central registry of `§Follow-ups` and `R (new, not in Charter)` entries across AILOGs. +> Maintained by `straymark followups drift --apply`; counters are CLI-owned. +> Convention: `.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md` · +> Schema: `.straymark/schemas/follow-ups-backlog.schema.v1.json` + + + +## Bucket: ready + +### FU-001 — (new) Consider wiring the merge-driver setup into `straymark init` behind a prompt (see R2). +- **Origin**: AILOG-2026-08-04-003 §Follow-ups +- **Source-hash**: b455feec70c6 +- **Status**: open +- **Trigger**: TBD +- **Destination**: TBD +- **Cost**: TBD +- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-08-04. + +## Bucket: time-triggered + +## Bucket: charter-triggered + +## Bucket: phase-blocked + +## Bucket: operational diff --git a/cli/src/commands/followups/drift.rs b/cli/src/commands/followups/drift.rs index bd8e087a..55176fd2 100644 --- a/cli/src/commands/followups/drift.rs +++ b/cli/src/commands/followups/drift.rs @@ -201,6 +201,14 @@ pub fn detect_drift_candidates( let candidates = candidate_ailogs(project_root, scan_all, range); let seen_hashes = followups::registry_extracted_hashes(registry); + // GH #391: ids are positional and a re-extraction renumbers them, but the + // title survives. A follow-up whose declaration moved section (changing + // its content hash) must not re-enter as a fresh `open` duplicate that + // shadows the existing entry and its operator-set status. + let existing_titles: std::collections::HashSet = registry + .entries() + .map(|e| followups::normalize_title(&e.description)) + .collect(); let mut drifted: Vec<(String, PathBuf, Vec)> = Vec::new(); for path in candidates { @@ -217,7 +225,7 @@ pub fn detect_drift_candidates( &id, &fu.origin_section, &fu.description, - )) + )) && !existing_titles.contains(&followups::normalize_title(&fu.description)) }) .collect(); if !new.is_empty() { diff --git a/cli/src/commands/followups/merge_driver.rs b/cli/src/commands/followups/merge_driver.rs new file mode 100644 index 00000000..fef054e3 --- /dev/null +++ b/cli/src/commands/followups/merge_driver.rs @@ -0,0 +1,99 @@ +//! `straymark followups merge-driver ` — a git merge +//! driver for the follow-ups registry (GH #391). +//! +//! The registry is CLI-owned and every parallel PR that touches follow-ups +//! conflicts on it; resolving by taking one side and re-running `drift --apply` +//! silently reverted the other side's closures. Wired as a git merge driver, +//! the conflict disappears: git hands us the three file versions and this +//! command writes the structural three-way merge back into `ours`. +//! +//! Setup (once per clone): +//! +//! ```gitattributes +//! .straymark/follow-ups-backlog.md merge=straymark-followups +//! ``` +//! ```sh +//! git config merge.straymark-followups.driver 'straymark followups merge-driver %O %A %B' +//! ``` +//! +//! Exit codes follow git's merge-driver contract: 0 = merged (even with +//! reported soft conflicts), nonzero = unresolved, git falls back to marking +//! the file conflicted. + +use anyhow::{Context, Result}; +use colored::Colorize; +use std::path::Path; + +use crate::followups; + +pub fn run(base: &str, ours: &str, theirs: &str) -> Result<()> { + let ours_path = Path::new(ours); + let theirs_path = Path::new(theirs); + let base_path = Path::new(base); + + let ours_reg = followups::parse_registry(ours_path) + .with_context(|| format!("parse ours ({ours})"))?; + let theirs_reg = followups::parse_registry(theirs_path) + .with_context(|| format!("parse theirs ({theirs})"))?; + + // A missing/unparseable base (unborn branch history, force-pushed roots) + // degrades to a two-way merge: ours is treated as the base, which only + // disables deletion detection — statuses and additions still reconcile. + let base_owned = match followups::parse_registry(base_path) { + Ok(r) => r, + Err(e) => { + eprintln!( + "{} base ({}) unparseable — merging two-way, deletion detection disabled ({e})", + "warn:".yellow().bold(), + base + ); + ours_reg.clone() + } + }; + let base_reg = &base_owned; + + for w in ours_reg + .warnings + .iter() + .chain(theirs_reg.warnings.iter()) + { + eprintln!("{} {w}", "warn:".yellow().bold()); + } + + let (merged, report) = followups::merge_registries(base_reg, &ours_reg, &theirs_reg)?; + std::fs::write(ours_path, &merged) + .with_context(|| format!("write merged registry to {ours}"))?; + + println!( + "{} follow-ups registry merged structurally ({} → {}).", + "✓".green().bold(), + ours_reg.entries().count(), + merged.matches("### FU-").count() + ); + if report.statuses_preserved > 0 { + println!( + " {} {} status(es) preserved from theirs (non-open beats open).", + "→".blue().bold(), + report.statuses_preserved + ); + } + if report.appended > 0 { + println!( + " {} {} entr{} appended from theirs.", + "→".blue().bold(), + report.appended, + if report.appended == 1 { "y" } else { "ies" } + ); + } + if report.deletions_respected > 0 { + println!( + " {} {} deletion(s) from theirs respected.", + "→".blue().bold(), + report.deletions_respected + ); + } + for conflict in &report.conflicts { + eprintln!(" {} {conflict}", "warn:".yellow().bold()); + } + Ok(()) +} diff --git a/cli/src/commands/followups/mod.rs b/cli/src/commands/followups/mod.rs index 65f0501f..acebb8ae 100644 --- a/cli/src/commands/followups/mod.rs +++ b/cli/src/commands/followups/mod.rs @@ -17,6 +17,7 @@ pub mod drift; pub mod list; +pub mod merge_driver; pub mod new; pub mod note; pub mod promote; diff --git a/cli/src/followups.rs b/cli/src/followups.rs index 7e00c6c7..364feefc 100644 --- a/cli/src/followups.rs +++ b/cli/src/followups.rs @@ -952,7 +952,7 @@ fn split_hash_sections(content: &str) -> Vec<(Option, String)> { /// heading that starts with a follow-ups token followed by a non-alphanumeric /// boundary — so `## Follow-ups (auditoría externa)` and `## Seguimientos: /// deuda` are recognized, not just the bare heading (#346 under-capture). -fn is_followup_heading(heading: &str) -> bool { +pub fn is_followup_heading(heading: &str) -> bool { let h = heading.trim(); let hl = h.to_lowercase(); FOLLOWUP_HEADINGS.iter().any(|x| { @@ -1400,6 +1400,239 @@ pub fn render_declared_entry( block } +// ── Registry merging (GH #391) ───────────────────────────────────────────── +// +// The registry is CLI-owned, so a textual three-way merge of it during a git +// conflict resolution is always wrong — and the sanctioned fallback (take +// `main`'s file, re-run `drift --apply`) silently reverted the closures the +// branch had made, because statuses live only in the file. +// +// This module powers `straymark followups merge-driver`, a git merge driver +// that resolves the registry structurally: entries are matched across sides +// by title (ids are positional and get renumbered, titles survive), and a +// non-open status wins over open so closures made on either side are kept. + +/// Resolution rank of a status — higher means "closer to resolved". Used by +/// the merge driver to decide which side's status survives when both sides +/// touched the same entry. +pub fn status_rank(s: FuStatus) -> u8 { + match s { + FuStatus::Open | FuStatus::Unknown => 0, + FuStatus::InProgress => 1, + FuStatus::SuspectedClosed => 2, + FuStatus::Closed | FuStatus::Superseded | FuStatus::Promoted => 3, + } +} + +/// Canonical identity of an entry for cross-side matching. Ids are positional +/// (a regeneration renumbers them), so the title is the only stable key. +pub fn normalize_title(description: &str) -> String { + description + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// Outcome of [`merge_registries`]. +#[derive(Debug, Default)] +pub struct MergeReport { + /// Entries where theirs' more-advanced status was applied onto ours. + pub statuses_preserved: usize, + /// Entries present only in theirs, appended to the result. + pub appended: usize, + /// Entries theirs deleted (present in base, absent in theirs) that ours + /// had not modified — respected as deletions. + pub deletions_respected: usize, + /// Same-rank status disagreements (e.g. closed vs superseded) — ours is + /// kept; the operator should eyeball these. + pub conflicts: Vec, +} + +/// Three-way merge of parsed registries. Returns the merged file content +/// (frontmatter + body) and a report. `ours` is the textual base of the +/// result (its ids, ordering, and unknown fields survive verbatim); entries +/// are matched across sides by [`normalize_title`]. +/// +/// Rules: +/// - status: the higher-rank one wins; equal-rank disagreements keep ours +/// and are reported as conflicts; +/// - notes: theirs wins when ours is empty or a prefix of theirs (the +/// append-only `followups note` shape), else ours is kept; +/// - entries only in theirs: appended (renumbered on id collision); +/// an entry present in base but absent from ours was deleted by ours and +/// is only re-added when theirs advanced its status past base's; +/// - entries deleted by theirs (in base, gone in theirs) are dropped from +/// ours unless ours changed their status (modify/delete → kept + conflict). +pub fn merge_registries( + base: &Registry, + ours: &Registry, + theirs: &Registry, +) -> Result<(String, MergeReport)> { + let mut report = MergeReport::default(); + + let base_by_key: std::collections::HashMap = base + .entries() + .map(|e| (normalize_title(&e.description), e)) + .collect(); + let ours_by_key: std::collections::HashMap = ours + .entries() + .map(|e| (normalize_title(&e.description), e)) + .collect(); + let theirs_by_key: std::collections::HashMap = theirs + .entries() + .map(|e| (normalize_title(&e.description), e)) + .collect(); + + // 1. Deletions made by theirs: present in base and ours, absent in + // theirs. Respect them unless ours modified the status (kept + conflict). + let mut removed_spans: Vec<(usize, usize, String)> = Vec::new(); + for o in ours.entries() { + let key = normalize_title(&o.description); + if theirs_by_key.contains_key(&key) || !base_by_key.contains_key(&key) { + continue; + } + let modified = base_by_key[&key].status != o.status; + if modified { + report.conflicts.push(format!( + "{}: deleted on theirs but status changed on ours ({})", + o.fu_id, + o.status.as_str() + )); + continue; + } + removed_spans.push((o.span_start, o.span_end, o.fu_id.clone())); + } + let mut body = ours.body.clone(); + removed_spans.sort_by(|a, b| b.0.cmp(&a.0)); // remove from the end + for (start, end, _id) in &removed_spans { + body.replace_range(*start..*end, ""); + report.deletions_respected += 1; + } + + // 2. Field reconciliation for entries present on both sides. Re-parse + // after each surgical edit so spans stay valid. + for t in theirs.entries() { + let key = normalize_title(&t.description); + let Some(o) = ours_by_key.get(&key) else { continue }; + if removed_spans.iter().any(|(_, _, id)| id == &o.fu_id) { + continue; + } + { + let current = + parse_registry_str(&ours.path, &assemble(&ours.frontmatter_raw, &body))?; + let found = current.entries().find(|e| e.fu_id == o.fu_id); + if let Some(current_entry) = found { + if t.status != current_entry.status { + if status_rank(t.status) > status_rank(current_entry.status) { + let value = t + .status_raw + .clone() + .unwrap_or_else(|| t.status.as_str().to_string()); + body = set_entry_field(&body, current_entry, "Status", &value); + report.statuses_preserved += 1; + } else if status_rank(t.status) == status_rank(current_entry.status) { + report.conflicts.push(format!( + "{}: status {} (ours) vs {} (theirs) — kept ours", + o.fu_id, + current_entry.status.as_str(), + t.status.as_str() + )); + } + } + } + } + + // Notes: theirs wins when it is an extension of ours (append-only notes). + let current = parse_registry_str(&ours.path, &assemble(&ours.frontmatter_raw, &body))?; + let found = current.entries().find(|e| e.fu_id == o.fu_id); + if let Some(current_entry) = found { + match (¤t_entry.notes, &t.notes) { + (Some(o_notes), Some(t_notes)) if t_notes != o_notes && t_notes.starts_with(o_notes) => { + body = set_entry_field(&body, current_entry, "Notes", t_notes); + } + (None, Some(t_notes)) => { + body = set_entry_field(&body, current_entry, "Notes", t_notes); + } + _ => {} + } + } + } + + // 3. Entries only in theirs: append (or re-add a deletion only when + // theirs advanced the status past base's). + let mut taken_numbers: std::collections::HashSet = + ours.entries().map(|e| e.fu_number).collect(); + let mut next_n = next_fu_number(ours); + for t in theirs.entries() { + let key = normalize_title(&t.description); + if ours_by_key.contains_key(&key) { + continue; + } + if let Some(b) = base_by_key.get(&key) { + // Ours deleted it. Re-add only when theirs advanced its status. + if status_rank(t.status) <= status_rank(b.status) { + continue; + } + } + let mut block = theirs.body[t.span_start..t.span_end].to_string(); + let number = if taken_numbers.contains(&t.fu_number) { + while taken_numbers.contains(&next_n) { + next_n += 1; + } + let n = next_n; + next_n += 1; + block = block.replacen( + &format!("### {} ", t.fu_id), + &format!("### FU-{:03} ", n), + 1, + ); + n + } else { + t.fu_number + }; + taken_numbers.insert(number); + + let current = parse_registry_str(&ours.path, &assemble(&ours.frontmatter_raw, &body))?; + let bucket = if current.sections.iter().any(|s| s.is_bucket && s.name == t.bucket) { + t.bucket.clone() + } else { + "ready".to_string() + }; + body = insert_into_bucket(¤t, &bucket, &block); + report.appended += 1; + } + + // 4. Frontmatter: union fully_extracted_ailogs, take the newest + // last_scan, then recompute the CLI-owned counters from the merged body. + let already: std::collections::HashSet<&str> = ours + .frontmatter + .fully_extracted_ailogs + .iter() + .map(|s| s.as_str()) + .collect(); + let new_ids: Vec = theirs + .frontmatter + .fully_extracted_ailogs + .iter() + .filter(|id| !already.contains(id.as_str())) + .cloned() + .collect(); + let mut fm = fm_append_list_items(&ours.frontmatter_raw, "fully_extracted_ailogs", &new_ids); + let newest_scan = match (&ours.frontmatter.last_scan, &theirs.frontmatter.last_scan) { + (Some(a), Some(b)) => std::cmp::max(a.as_str(), b.as_str()).to_string(), + (Some(a), None) => a.clone(), + (None, Some(b)) => b.clone(), + (None, None) => String::new(), + }; + if !newest_scan.is_empty() { + fm = fm_set_scalar(&fm, "last_scan", &newest_scan); + } + let (fm, _counters) = recounted_frontmatter(&ours.path, &fm, &body)?; + + Ok((assemble(&fm, &body), report)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/cli/src/main.rs b/cli/src/main.rs index 96a72cb1..39fdda03 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -416,6 +416,22 @@ enum FollowupsCommands { #[arg(long = "path", default_value = ".")] path: String, }, + /// Git merge driver for the follow-ups registry (#391). Resolves a + /// `.straymark/follow-ups-backlog.md` conflict structurally instead of + /// textually: entries are matched across sides by title, a non-open + /// status beats open (closures survive), entries only on one side are + /// unioned, and the CLI-owned counters are recomputed from the result. + /// Setup: `git config merge.straymark-followups.driver + /// 'straymark followups merge-driver %O %A %B'` plus a `.gitattributes` + /// line `.straymark/follow-ups-backlog.md merge=straymark-followups`. + MergeDriver { + /// Common ancestor version of the file (git's %O) + base: String, + /// Current branch's version; the merged result is written here (%A) + ours: String, + /// Incoming branch's version (git's %B) + theirs: String, + }, /// Recompute the CLI-owned frontmatter counters from actual entry /// statuses, without scanning AILOGs. The §13-compliant way to reconcile /// counters after a manual-triage session (statuses flipped by hand, @@ -1046,6 +1062,9 @@ fn main() { range, path, } => commands::followups::drift::run(&path, apply, scan_all, range.as_deref()), + FollowupsCommands::MergeDriver { base, ours, theirs } => { + commands::followups::merge_driver::run(&base, &ours, &theirs) + } FollowupsCommands::Recount { path } => commands::followups::recount::run(&path), FollowupsCommands::Promote { fu_id, diff --git a/cli/src/validation.rs b/cli/src/validation.rs index 2e3dc020..b27ff008 100644 --- a/cli/src/validation.rs +++ b/cli/src/validation.rs @@ -430,6 +430,121 @@ fn check_followups_work_verb(straymark_dir: &Path, result: &mut ValidationResult } } +/// GH #392: warn when an AILOG's body mentions a `FU-NNN` / `FU-NNN-NNN` id +/// outside its own `## Follow-ups` section and that id does not exist in the +/// registry. The extractor only reads `## Follow-ups` (plus structural risk +/// declarations), so an id coined anywhere else never enters the backlog — +/// the registry looks complete while silently missing the item. A mention of +/// an id that *is* in the registry is a normal cross-reference and stays +/// quiet. Warn-only: the author may still move the declaration by hand. +fn check_followup_mentions(straymark_dir: &Path, paths: &[PathBuf], result: &mut ValidationResult) { + let backlog = straymark_dir.join("follow-ups-backlog.md"); + let Ok(registry) = crate::followups::parse_registry(&backlog) else { + return; // No registry yet — nothing to compare against. + }; + let known: std::collections::HashSet = + registry.entries().map(|e| e.fu_id.clone()).collect(); + + for path in paths { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.starts_with("AILOG-") { + continue; + } + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + + let mut in_frontmatter = content.starts_with("---"); + let mut in_followups_section = false; + for (idx, line) in content.lines().enumerate() { + if in_frontmatter { + if idx > 0 && line.trim() == "---" { + in_frontmatter = false; + } + continue; + } + let trimmed = line.trim_start(); + if trimmed.starts_with("# ") { + in_followups_section = false; + continue; + } + if let Some(heading) = trimmed.strip_prefix("## ") { + in_followups_section = crate::followups::is_followup_heading(heading); + continue; + } + if in_followups_section { + continue; + } + for fu_id in scan_fu_ids(line) { + if !known.contains(&fu_id) { + result.add(ValidationIssue { + file: path.clone(), + rule: "FOLLOWUP-UNTRACKED-ID".to_string(), + message: format!( + "line {}: mentions `{}` outside this document's `## Follow-ups` \ + section, and the id is not in the registry ({})", + idx + 1, + fu_id, + backlog.display() + ), + severity: Severity::Warning, + fix_hint: Some( + "Follow-ups declared outside `## Follow-ups` are never extracted. \ + Move the declaration into this document's `## Follow-ups` section \ + and run `straymark followups drift --apply` — or, if this is a \ + cross-reference, cite an id that exists in the registry." + .to_string(), + ), + }); + } + } + } + } +} + +/// Extract `FU-` / `FU--` ids from one line. A +/// boundary before the `F` keeps prose like `XFU-1` out; digit runs of fewer +/// than two digits stay out too (the registry numbers entries `FU-{:03}`). +fn scan_fu_ids(line: &str) -> Vec { + let bytes: Vec = line.chars().collect(); + let mut ids = Vec::new(); + let mut i = 0; + while i + 3 <= bytes.len() { + if bytes[i] == 'F' && bytes[i + 1] == 'U' && bytes[i + 2] == '-' { + let preceded_ok = i == 0 || { + let prev = bytes[i - 1]; + !prev.is_alphanumeric() && prev != '-' + }; + if preceded_ok { + let mut j = i + 3; + let start_digits = j; + while j < bytes.len() && bytes[j].is_ascii_digit() { + j += 1; + } + if j - start_digits >= 2 { + let mut end = j; + // Optional second segment: `-`. + if j + 1 < bytes.len() && bytes[j] == '-' && bytes[j + 1].is_ascii_digit() { + let seg_start = j + 1; + let mut k = seg_start; + while k < bytes.len() && bytes[k].is_ascii_digit() { + k += 1; + } + if k - seg_start >= 2 { + end = k; + } + } + ids.push(bytes[i..end].iter().collect()); + i = end; + continue; + } + } + } + i += 1; + } + ids +} + /// True if an AILOG file matching the given ID exists under /// `.straymark/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 @@ -489,6 +604,9 @@ pub fn validate_all(straymark_dir: &Path) -> (ValidationResult, usize) { // Follow-ups backlog: advisory work_verb vocabulary check (Baton #332). check_followups_work_verb(straymark_dir, &mut result); + // GH #392: FU ids mentioned outside `## Follow-ups` never reach the registry. + check_followup_mentions(straymark_dir, &paths, &mut result); + // REF-002: Detect orphan documents (no traceability links) check_orphan_documents(&mut result, &paths, straymark_dir); diff --git a/cli/tests/followups_test.rs b/cli/tests/followups_test.rs index c09a11ea..e391edc3 100644 --- a/cli/tests/followups_test.rs +++ b/cli/tests/followups_test.rs @@ -994,3 +994,169 @@ fn new_then_drift_does_not_re_extract_or_duplicate(/* CHARTER-01 R4 */) { let updated = std::fs::read_to_string(straymark.join("follow-ups-backlog.md")).unwrap(); assert_eq!(updated.matches("### FU-012").count(), 1, "no duplicate: {updated}"); } + +// ───────────────────────── GH #391: registry merging ───────────────────────── + +#[test] +fn drift_apply_does_not_duplicate_entry_declared_from_moved_section() { + // GH #391: when a follow-up declaration moves section, its content hash + // changes — the title match must keep it out of the registry instead of + // re-adding it as a fresh `open` duplicate that shadows the operator's + // status. + let tmp = TempDir::new().unwrap(); + let straymark = scaffold(tmp.path()); + let registry = V1_REGISTRY + .replace("Harden staging probe", "Harden staging probe.") + .replace( + "### FU-010 — Harden staging probe.\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Origin-class**: staging\n- **Status**: open", + "### FU-010 — Harden staging probe.\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Origin-class**: staging\n- **Status**: in-progress", + ); + write_registry(&straymark, ®istry); + // Same description, different heading variant → different origin + // section → different content hash. + write_ailog( + &straymark, + "AILOG-2026-06-01-002-x.md", + "# AILOG\n\n## Follow-ups (auditoría)\n\n- Harden staging probe.\n", + ); + + cmd() + .args(["followups", "drift", "--scan-all", "--apply", "--path", tmp.path().to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains("registry in sync")); + + let updated = std::fs::read_to_string(straymark.join("follow-ups-backlog.md")).unwrap(); + assert_eq!( + updated.matches("Harden staging probe.").count(), + 1, + "no duplicate entry: {updated}" + ); + let idx = updated.find("Harden staging probe.").unwrap(); + assert!( + updated[idx..idx + 400].contains("- **Status**: in-progress"), + "operator status preserved: {updated}" + ); +} + +const MERGE_BASE: &str = r#"--- +schema_version: v1 +last_scan: 2026-07-01 +total_open: 2 +buckets: + - ready +fully_extracted_ailogs: [] +--- + +## Bucket: ready + +### FU-010 — Harden staging probe +- **Origin**: AILOG-2026-06-01-002 §Follow-ups +- **Status**: open + +### FU-011 — Document the rollout runbook +- **Origin**: AILOG-2026-06-01-002 §Follow-ups +- **Status**: open +"#; + +#[test] +fn merge_driver_preserves_closures_and_unions_entries() { + // GH #391: the adopter scenario — the branch closed FU-010 and added a + // new entry; ours added a different entry reusing FU-012's number. The + // structural merge must keep the closure and union both additions. + let tmp = TempDir::new().unwrap(); + let base_path = tmp.path().join("base.md"); + let ours_path = tmp.path().join("ours.md"); + let theirs_path = tmp.path().join("theirs.md"); + + std::fs::write(&base_path, MERGE_BASE).unwrap(); + std::fs::write( + &ours_path, + format!( + "{MERGE_BASE}\n### FU-012 — Add metrics for the sync loop\n- **Origin**: AILOG-2026-07-02-001 §Follow-ups\n- **Status**: open\n" + ), + ) + .unwrap(); + std::fs::write( + &theirs_path, + format!( + "{}\n### FU-012 — Fix probe flake\n- **Origin**: AILOG-2026-07-03-001 §Follow-ups\n- **Status**: open\n", + MERGE_BASE.replace( + "### FU-010 — Harden staging probe\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Status**: open", + "### FU-010 — Harden staging probe\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Status**: closed", + ) + ), + ) + .unwrap(); + + cmd() + .args([ + "followups", "merge-driver", + base_path.to_str().unwrap(), + ours_path.to_str().unwrap(), + theirs_path.to_str().unwrap(), + ]) + .assert() + .success() + .stdout(predicate::str::contains("1 status(es) preserved from theirs")) + .stdout(predicate::str::contains("1 entry appended from theirs")); + + let merged = std::fs::read_to_string(&ours_path).unwrap(); + // Closure from theirs survived (the regression this issue is about). + let idx = merged.find("Harden staging probe").unwrap(); + assert!(merged[idx..idx + 300].contains("- **Status**: closed"), "{merged}"); + // Both additions present; theirs' colliding FU-012 was renumbered. + assert!(merged.contains("Add metrics for the sync loop"), "{merged}"); + assert!(merged.contains("### FU-013 — Fix probe flake"), "{merged}"); + // Counters recomputed from the merged body: FU-011 + FU-012 + FU-013 open. + assert!(merged.contains("total_open: 3"), "{merged}"); +} + +#[test] +fn merge_driver_respects_deletion_and_keeps_conflicts_visible() { + let tmp = TempDir::new().unwrap(); + let base_path = tmp.path().join("base.md"); + let ours_path = tmp.path().join("ours.md"); + let theirs_path = tmp.path().join("theirs.md"); + + std::fs::write(&base_path, MERGE_BASE).unwrap(); + // Ours unchanged vs base; theirs deletes FU-011 and sets FU-010 to + // superseded while ours set it to closed — same rank, real conflict. + std::fs::write( + &ours_path, + MERGE_BASE.replace( + "### FU-010 — Harden staging probe\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Status**: open", + "### FU-010 — Harden staging probe\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Status**: closed", + ), + ) + .unwrap(); + std::fs::write( + &theirs_path, + MERGE_BASE + .replace( + "### FU-010 — Harden staging probe\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Status**: open", + "### FU-010 — Harden staging probe\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Status**: superseded", + ) + .replace( + "\n### FU-011 — Document the rollout runbook\n- **Origin**: AILOG-2026-06-01-002 §Follow-ups\n- **Status**: open\n", + "\n", + ), + ) + .unwrap(); + + cmd() + .args([ + "followups", "merge-driver", + base_path.to_str().unwrap(), + ours_path.to_str().unwrap(), + theirs_path.to_str().unwrap(), + ]) + .assert() + .success() + .stderr(predicate::str::contains("closed (ours) vs superseded (theirs)")); + + let merged = std::fs::read_to_string(&ours_path).unwrap(); + assert!(!merged.contains("rollout runbook"), "theirs' deletion respected: {merged}"); + let idx = merged.find("Harden staging probe").unwrap(); + assert!(merged[idx..].contains("- **Status**: closed"), "ours kept on same-rank conflict: {merged}"); +} diff --git a/cli/tests/validate_test.rs b/cli/tests/validate_test.rs index 25b35c20..a2ebacc1 100644 --- a/cli/tests/validate_test.rs +++ b/cli/tests/validate_test.rs @@ -1070,3 +1070,103 @@ fn test_validate_catches_unparseable_telemetry() { .failure() .stdout(predicate::str::contains("TELEMETRY-PARSE")); } + +const FU_REGISTRY: &str = r#"--- +schema_version: v1 +last_scan: 2026-08-04 +buckets: + - ready +fully_extracted_ailogs: [] +--- + +## Bucket: ready + +### FU-001 — Known follow-up +- **Origin**: AILOG-2026-07-01-001 §Follow-ups +- **Status**: open +"#; + +#[test] +fn fu_id_mentioned_outside_followups_section_warns_when_unregistered() { + // GH #392: an id coined in prose never reaches the registry; validate + // must surface it. Registered ids and ids inside the document's own + // `## Follow-ups` section stay quiet. + let dir = TempDir::new().unwrap(); + setup_straymark(dir.path()); + std::fs::write( + dir.path().join(".straymark/follow-ups-backlog.md"), + FU_REGISTRY, + ) + .unwrap(); + std::fs::write( + dir + .path() + .join(".straymark/07-ai-audit/agent-logs/AILOG-2026-07-29-005-scope.md"), + r#"--- +id: AILOG-2026-07-29-005 +title: Scope amendment +status: accepted +created: 2026-07-29 +agent: test-agent-v1.0 +confidence: high +review_required: false +risk_level: low +--- + +# AILOG: Scope amendment + +Deferred unification is **FU-002**: null behavioural change, but it touches +the one path that has never failed. Cross-reference to FU-001 is legitimate. + +## Follow-ups + +- Close FU-009 once the registry catches up (mentions here are declarations). +"#, + ) + .unwrap(); + + cargo_bin_cmd!("straymark") + .arg("validate") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("FOLLOWUP-UNTRACKED-ID")) + .stdout(predicate::str::contains("FU-002")) + // Exactly one untracked-id warning: FU-001 is registered, FU-009 + // lives in the document's own Follow-ups section. + .stdout(predicate::str::contains("FOLLOWUP-UNTRACKED-ID").count(1)); +} + +#[test] +fn fu_mention_check_stays_quiet_without_registry() { + let dir = TempDir::new().unwrap(); + setup_straymark(dir.path()); + std::fs::write( + dir + .path() + .join(".straymark/07-ai-audit/agent-logs/AILOG-2026-07-29-006-x.md"), + r#"--- +id: AILOG-2026-07-29-006 +title: No registry yet +status: accepted +created: 2026-07-29 +agent: test-agent-v1.0 +confidence: high +review_required: false +risk_level: low +--- + +# AILOG: No registry yet + +This mentions FU-012 but there is no registry to compare against. +"#, + ) + .unwrap(); + + cargo_bin_cmd!("straymark") + .arg("validate") + .arg(dir.path().to_str().unwrap()) + .assert() + .success() + .stdout(predicate::str::contains("FOLLOWUP-UNTRACKED-ID").not()); +} diff --git a/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md b/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md index 083be3ee..365a4119 100644 --- a/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md +++ b/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md @@ -227,6 +227,8 @@ Since cli-3.21.0 the default scan unions the committed git range with the workin Since cli-3.20.0, `--apply` recomputes the counters **even when there is nothing to extract** — so a pre-commit `drift --apply` also reconciles counters left stale by a manual-triage session (first external adopter feedback, issue #222 Finding 1). +Since cli-3.41.0, `--apply` additionally skips any candidate whose **title** already exists in the registry (issue #391). Ids are positional (`max + 1` at extraction) and renumber on regeneration, so the title is the stable identity of an entry; this keeps a declaration that moved section — or a re-extraction after a conflict resolution — from spawning a duplicate `open` entry that shadows the operator's status. + ### Canonical closure-marker idioms The anti-noise refinement recognizes a fixed vocabulary, case-insensitively. AILOG authors should converge on these phrasings at write time so born-resolved entries land as `suspected-closed` instead of TBD noise: @@ -270,6 +272,7 @@ straymark followups verify FU-NNN [--premise "..."] [--verified] [--at DATE] # straymark followups note FU-NNN "" [--source CHARTER-NN|AILOG-…] # append a dated annotation to Notes (cli-3.39.0+) straymark followups set-status FU-NNN # change status AND recompute the counters in one step (cli-3.39.0+) straymark followups new --title "..." --origin "CHARTER-NN §Scope" [--bucket …] [--cost …] [--trigger …] [--premise …] # create an entry declared ex-ante (cli-3.39.0+) +straymark followups merge-driver # git merge driver: structural registry merge (cli-3.41.0+) ``` `verify` and `promote --premise-verified` are the execution-time affordances of the "Epistemic status" discipline: they put the premise in front of the operator at the moment of spending and record that the re-check happened. Human judgment stays out of the CLI — it surfaces and stamps, it never decides truth. @@ -286,6 +289,17 @@ All three refuse to write when the registry has parse warnings: a surgical edit The registry also appears as a synthetic **Follow-ups** group in the `straymark explore` TUI (sub-nodes per bucket) and as a counts block in `straymark status`. +### Parallel PRs — structural merge (cli-3.41.0+) + +The registry is a single CLI-owned file, so every parallel PR that touches follow-ups conflicts on it — and the textual resolution of taking one side and re-running `drift --apply` **silently reverted the other side's closures**: statuses live only in the file, and a re-extraction renumbers ids, so even comparing ids cannot detect the loss (issue #391). `straymark followups merge-driver ` resolves the conflict **structurally**: entries are matched across sides by title, the higher-rank status wins (so a closure made on either side survives; equal-rank disagreements keep `ours` and are reported on stderr), entries only in `theirs` are appended (renumbered on id collision), deletions by `theirs` are respected unless `ours` changed the entry's status, `Notes` unions append-only extensions, and the frontmatter is recomputed from the merged body. + +Setup is once per clone: + +```bash +echo '.straymark/follow-ups-backlog.md merge=straymark-followups' >> .gitattributes +git config merge.straymark-followups.driver 'straymark followups merge-driver %O %A %B' +``` + --- ## Agent integration diff --git a/docs/adopters/CLI-REFERENCE.md b/docs/adopters/CLI-REFERENCE.md index bbe02fbf..bd25a071 100644 --- a/docs/adopters/CLI-REFERENCE.md +++ b/docs/adopters/CLI-REFERENCE.md @@ -375,6 +375,7 @@ Validate StrayMark documents for compliance and correctness. - Sensitive information detection (API keys, passwords) - Related document existence - Declared work-classification vocabulary *(fw-4.38.0+)*: Charter frontmatter (`work_verb` / `design_provenance`) and follow-up backlog entries (`**Work verb**:` / `**Design provenance**:`) are checked against the controlled vocabulary — `design | implement | audit | operate` and `new | upstream`. **Advisory only** (Baton #332): absent fields emit nothing — undeclared is an honest state, never an error — and out-of-vocabulary values emit a warning that never blocks. +- Untracked follow-up ids *(cli-3.41.0+, #392)*: an AILOG whose body mentions a `FU-NNN` / `FU-NNN-NNN` id outside its own `## Follow-ups` section — where the extractor cannot see it — and that id is not in the registry, emits a `FOLLOWUP-UNTRACKED-ID` warning. Mentions of registered ids (cross-references) and ids inside the document's own `## Follow-ups` section stay quiet. **Warn-only**, skipped entirely when the project has no registry. When `regional_scope` includes `china`, twelve additional rules activate (`CROSS-004` to `CROSS-011`, `TYPE-003` to `TYPE-006`) covering TC260 review escalation, PIPIA linkage from sensitive-data documents, CACFILE / AILABEL cross-references, CSL severity-to-deadline coherence, and PIPIA 3-year retention. Without `china` in scope, these rules are skipped — no false positives. @@ -964,6 +965,7 @@ Parsing is **lenient**: v0 registries (pre-fw-4.21.0) are read without errors; t - `straymark followups note` — append a dated annotation to an entry's `Notes` *(cli-3.39.0+)* - `straymark followups set-status` — change an entry's status and recompute the counters in one step *(cli-3.39.0+)* - `straymark followups new` — create an entry declared **ex-ante**, at Charter-declaration time *(cli-3.39.0+)* +- `straymark followups merge-driver` — git merge driver that resolves registry conflicts structurally (#391) *(cli-3.41.0+)* #### `straymark followups list [--bucket ] [--status ] [--severity ] [--label ] [path]` @@ -993,7 +995,7 @@ Detect AILOGs whose follow-up content is not yet extracted into the registry. Gr | Flag | Default | Description | |------|---------|-------------| | *(default)* | — | Scan AILOGs changed in `origin/main..HEAD` (fallback `origin/master..HEAD`, then `HEAD~1..HEAD` with a warning). Warn + **exit 1** on drift. | -| `--apply` | off | Extract the missing entries into `## Bucket: ready` with auto-numbered `FU-NNN` ids, append the AILOG ids to `fully_extracted_ailogs`, **recompute the counters**, and upgrade v0 registries to v1 in place. Seeds the registry from the framework template when absent. Since cli-3.20.0 the counters are recomputed **even when there is nothing to extract** (#222 Finding 1). | +| `--apply` | off | Extract the missing entries into `## Bucket: ready` with auto-numbered `FU-NNN` ids, append the AILOG ids to `fully_extracted_ailogs`, **recompute the counters**, and upgrade v0 registries to v1 in place. Seeds the registry from the framework template when absent. Since cli-3.20.0 the counters are recomputed **even when there is nothing to extract** (#222 Finding 1). Entries whose **title** already exists in the registry are skipped (#391) — ids are positional and renumber on regeneration, so the title is the stable identity; this keeps a declaration that moved section from spawning a duplicate `open` entry that shadows the operator's status. | | `--scan-all` | off | Sweep every AILOG in the project instead of the git range. | | `--range ` | — | Explicit git range for the default scan. | @@ -1074,6 +1076,29 @@ $ straymark followups set-status FU-002 closed Counters: 2 open / 0 in-progress / 0 suspected-closed / 1 closed (was 3 / 0 / 0 / 0). ``` +#### `straymark followups merge-driver ` *(cli-3.41.0+)* + +Git merge driver for `.straymark/follow-ups-backlog.md` (#391). The registry is CLI-owned, so every parallel PR that touches follow-ups conflicts on it — and resolving by taking one side and re-running `drift --apply` **silently reverted the other side's closures** (statuses live only in the file, and a re-extraction renumbers ids, so even comparing ids cannot detect the loss). Wired as a merge driver, the conflict disappears: git hands the three file versions to the CLI and the result is written back into `ours`. + +The merge is **structural, not textual**: entries are matched across sides **by title** (ids are positional and do not survive regeneration; titles do), and reconciled as follows: + +| Situation | Resolution | +|---|---| +| Same entry, different status | The higher-rank status wins (`open` < `in-progress` < `suspected-closed` < `closed`/`superseded`/`promoted`) — a closure made on either side survives. Equal-rank disagreements keep `ours` and are reported on stderr. | +| Entry only in `theirs` | Appended (renumbered if its id collides with an entry in `ours`). | +| Entry deleted by `theirs` | Dropped from `ours` unless `ours` changed its status (modify/delete → kept + reported). | +| `Notes` | `theirs` wins when it is an append-only extension of `ours` (the `followups note` shape). | +| Frontmatter | `fully_extracted_ailogs` unioned, newest `last_scan`, counters recomputed from the merged body. | + +Exit code follows git's merge-driver contract: `0` = merged (soft conflicts reported on stderr), nonzero = unresolved (git falls back to marking the file conflicted). + +**Setup (once per clone):** + +```bash +echo '.straymark/follow-ups-backlog.md merge=straymark-followups' >> .gitattributes +git config merge.straymark-followups.driver 'straymark followups merge-driver %O %A %B' +``` + #### `straymark followups new --title --origin <origin> [--bucket <name>] [--status <s>] [--trigger <t>] [--destination <d>] [--cost <c>] [--premise <p>] [--path <dir>]` *(cli-3.39.0+)* Create an entry whose origin is a **Charter declaration** (ex-ante), before any execution exists (#360). Both older population paths assume an ex-post origin: `drift --apply` extracts from AILOGs, and a deferral decided *at declaration time* — "the Redis CI job is out of scope; register the coverage gap so it is deferred, not silenced" — precedes any AILOG by design. diff --git a/docs/i18n/es/adopters/CLI-REFERENCE.md b/docs/i18n/es/adopters/CLI-REFERENCE.md index 53a1490b..434c1bbe 100644 --- a/docs/i18n/es/adopters/CLI-REFERENCE.md +++ b/docs/i18n/es/adopters/CLI-REFERENCE.md @@ -342,6 +342,7 @@ Valida documentos StrayMark verificando cumplimiento y corrección. - `SEC-001`: No contiene información sensible - `OBS-001`: Tag observabilidad requiere sección de alcance - Vocabulario de clasificación declarada de trabajo *(fw-4.38.0+)*: el frontmatter de Charter (`work_verb` / `design_provenance`) y las entradas del backlog de follow-ups (`**Work verb**:` / `**Design provenance**:`) se verifican contra el vocabulario controlado — `design | implement | audit | operate` y `new | upstream`. **Solo advisory** (Baton #332): campos ausentes no emiten nada — no declarado es un estado honesto, nunca un error — y valores fuera del vocabulario emiten un warning que nunca bloquea. +- Ids de follow-up sin registrar *(cli-3.41.0+, #392)*: un AILOG cuyo cuerpo menciona un id `FU-NNN` / `FU-NNN-NNN` fuera de su propia sección `## Follow-ups` — donde el extractor no puede verlo — y ese id no está en el registro, emite un warning `FOLLOWUP-UNTRACKED-ID`. Menciones de ids registrados (cross-references) e ids dentro de la propia sección `## Follow-ups` del documento no emiten nada. **Solo warning**; se omite por completo si el proyecto no tiene registro. Cuando `regional_scope` incluye `china`, se activan doce reglas adicionales (`CROSS-004` a `CROSS-011`, `TYPE-003` a `TYPE-006`) que cubren escalado de revisión TC260, vínculo PIPIA desde documentos con datos sensibles, cross-references de CACFILE / AILABEL, coherencia severidad-deadline CSL, y retención de 3 años de PIPIA. Sin `china` en scope, estas reglas se omiten — sin falsos positivos. @@ -765,6 +766,7 @@ El parsing es **tolerante**: los registros v0 (pre-fw-4.21.0) se leen sin errore - `straymark followups recount` — recalcula los contadores propiedad del CLI tras una sesión de triage manual *(cli-3.20.0+)* - `straymark followups promote` — eleva una entrada a un documento TDE *(cli-3.19.0+)* - `straymark followups verify` — re-verifica la premisa de una hipótesis fechada en tiempo de ejecución *(cli-3.37.0+)* +- `straymark followups merge-driver` — merge driver de git que resuelve conflictos del registro estructuralmente (#391) *(cli-3.41.0+)* #### `straymark followups list [--bucket <name>] [--status <s>] [--severity <s>] [--label <tag>] [path]` @@ -794,7 +796,7 @@ Detecta los AILOGs cuyo contenido de follow-ups aún no se ha extraído al regis | Flag | Default | Descripción | |------|---------|-------------| | *(default)* | — | Escanea los AILOGs cambiados en `origin/main..HEAD` (fallback `origin/master..HEAD`, luego `HEAD~1..HEAD` con una advertencia). Avisa + **exit 1** ante drift. | -| `--apply` | off | Extrae las entradas faltantes a `## Bucket: ready` con ids `FU-NNN` auto-numerados, añade los ids de los AILOGs a `fully_extracted_ailogs`, **recalcula los contadores**, y actualiza los registros v0 a v1 in place. Siembra el registro desde el template del framework cuando no existe. Desde cli-3.20.0 los contadores se recalculan **incluso cuando no hay nada que extraer** (#222 Finding 1). | +| `--apply` | off | Extrae las entradas faltantes a `## Bucket: ready` con ids `FU-NNN` auto-numerados, añade los ids de los AILOGs a `fully_extracted_ailogs`, **recalcula los contadores**, y actualiza los registros v0 a v1 in place. Siembra el registro desde el template del framework cuando no existe. Desde cli-3.20.0 los contadores se recalculan **incluso cuando no hay nada que extraer** (#222 Finding 1). Las entradas cuyo **título** ya existe en el registro se omiten (#391) — los ids son posicionales y se renumeran al regenerar, así que el título es la identidad estable; esto evita que una declaración que cambió de sección genere una entrada duplicada `open` que opaque el status del operador. | | `--scan-all` | off | Barre cada AILOG del proyecto en lugar del rango de git. | | `--range <REV..REV>` | — | Rango de git explícito para el escaneo por defecto. | @@ -863,6 +865,29 @@ Cambia el status de una entrada **y** recomputa los contadores CLI-owned en el m Statuses válidos: `open` · `in-progress` · `suspected-closed` · `closed` · `superseded`. Un valor fuera de ese vocabulario se **rechaza**, no se escribe — el parser es indulgente, así que un typo no fallaría: sacaría la entrada de todos los contadores en silencio. `promoted` redirige a `followups promote`, que además escribe el TDE que le da a ese status algo a lo que apuntar. +#### `straymark followups merge-driver <base> <ours> <theirs>` *(cli-3.41.0+)* + +Merge driver de git para `.straymark/follow-ups-backlog.md` (#391). El registro es CLI-owned, así que todo PR paralelo que toca follow-ups genera conflicto en él — y resolverlo tomando un lado y re-ejecutando `drift --apply` **revertía en silencio los cierres del otro lado** (los statuses viven solo en el archivo, y una re-extracción renumera los ids, por lo que ni comparando ids se detecta la pérdida). Conectado como merge driver, el conflicto desaparece: git entrega las tres versiones del archivo a la CLI y el resultado se escribe de vuelta en `ours`. + +El merge es **estructural, no textual**: las entradas se emparejan entre lados **por título** (los ids son posicionales y no sobreviven a una regeneración; los títulos sí), y se reconcilian así: + +| Situación | Resolución | +|---|---| +| Misma entrada, distinto status | Gana el status de mayor rango (`open` < `in-progress` < `suspected-closed` < `closed`/`superseded`/`promoted`) — un cierre hecho en cualquier lado sobrevive. Desacuerdos del mismo rango conservan `ours` y se reportan por stderr. | +| Entrada solo en `theirs` | Se añade (renumerada si su id colisiona con una entrada de `ours`). | +| Entrada eliminada por `theirs` | Se quita de `ours` salvo que `ours` haya cambiado su status (modificar/eliminar → se conserva + se reporta). | +| `Notes` | Gana `theirs` cuando es una extensión append-only de `ours` (la forma de `followups note`). | +| Frontmatter | `fully_extracted_ailogs` unido, `last_scan` más reciente, contadores recomputados del cuerpo ya mergeado. | + +El exit code sigue el contrato de merge-driver de git: `0` = mergeado (conflictos suaves reportados por stderr), distinto de cero = sin resolver (git marca el archivo en conflicto). + +**Configuración (una vez por clon):** + +```bash +echo '.straymark/follow-ups-backlog.md merge=straymark-followups' >> .gitattributes +git config merge.straymark-followups.driver 'straymark followups merge-driver %O %A %B' +``` + #### `straymark followups new --title <título> --origin <origen> [--bucket <name>] [--status <s>] [--trigger <t>] [--destination <d>] [--cost <c>] [--premise <p>] [--path <dir>]` *(cli-3.39.0+)* Crea una entrada cuyo origen es una **declaración de Charter** (ex-ante), antes de que exista ejecución alguna (#360). Las dos rutas de poblado anteriores asumen origen ex-post: `drift --apply` extrae de AILOGs, y un diferimiento decidido *en tiempo de declaración* — "el job de CI de Redis queda fuera de alcance; registra el hueco de cobertura para que quede diferido, no silenciado" — precede a cualquier AILOG por diseño. diff --git a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md index a18181b5..e57caefe 100644 --- a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md +++ b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md @@ -360,6 +360,7 @@ Repairing StrayMark in /home/user/my-project - 敏感信息检测(API 密钥、密码) - 关联文档存在性 - 声明式工作分类词汇表 *(fw-4.38.0+)*:章程前置元数据(`work_verb` / `design_provenance`)与 follow-up 待办条目(`**Work verb**:` / `**Design provenance**:`)会按受控词汇表校验 — `design | implement | audit | operate` 及 `new | upstream`。**仅建议性**(Baton #332):字段缺失不产生任何提示 — 未声明是诚实状态,绝非错误 — 词汇表外的值会产生永不阻断的警告。 +- 未登记的 follow-up id *(cli-3.41.0+, #392)*:正文在其自身的 `## Follow-ups` 章节之外 —— 即提取器看不见的地方 —— 提及 `FU-NNN` / `FU-NNN-NNN` id、且该 id 不在注册表中的 AILOG,会产生 `FOLLOWUP-UNTRACKED-ID` 警告。提及已登记的 id(交叉引用)以及文档自身 `## Follow-ups` 章节内的 id 保持静默。**仅警告**;项目没有注册表时完全跳过。 当 `regional_scope` 包含 `china` 时,启用十二条额外规则(`CROSS-004` 至 `CROSS-011`、`TYPE-003` 至 `TYPE-006`),涵盖 TC260 审核升级、敏感数据文档的 PIPIA 关联、CACFILE / AILABEL 交叉引用、CSL 严重程度-时限一致性、PIPIA 三年留存。未启用 `china` 时,这些规则被跳过 — 不会产生误报。 @@ -808,6 +809,7 @@ $ straymark charter audit CHARTER-05 --finalize - `straymark followups recount` — 手动分诊会话后重新计算 CLI 拥有的计数器 *(cli-3.20.0+)* - `straymark followups promote` — 将条目提升为 TDE 文档 *(cli-3.19.0+)* - `straymark followups verify` — 在执行时重新验证一个有日期假设的前提 *(cli-3.37.0+)* +- `straymark followups merge-driver` — 以结构化方式解决注册表冲突的 git merge driver(#391)*(cli-3.41.0+)* #### `straymark followups list [--bucket <name>] [--status <s>] [--severity <s>] [--label <tag>] [path]` @@ -837,7 +839,7 @@ $ straymark followups list --severity blocking | Flag | Default | Description | |------|---------|-------------| | *(default)* | — | 扫描在 `origin/main..HEAD` 中变更的 AILOG(回退到 `origin/master..HEAD`,再回退到带告警的 `HEAD~1..HEAD`)。有漂移时告警并 **exit 1**。 | -| `--apply` | off | 将缺失的条目提取到 `## Bucket: ready`,使用自动编号的 `FU-NNN` id,把 AILOG id 追加到 `fully_extracted_ailogs`,**重新计算计数器**,并就地把 v0 注册表升级为 v1。注册表不存在时从框架模板播种。自 cli-3.20.0 起,**即使没有可提取的内容也会重新计算计数器**(#222 Finding 1)。 | +| `--apply` | off | 将缺失的条目提取到 `## Bucket: ready`,使用自动编号的 `FU-NNN` id,把 AILOG id 追加到 `fully_extracted_ailogs`,**重新计算计数器**,并就地把 v0 注册表升级为 v1。注册表不存在时从框架模板播种。自 cli-3.20.0 起,**即使没有可提取的内容也会重新计算计数器**(#222 Finding 1)。**标题**已存在于注册表中的条目会被跳过(#391)—— id 是位置性的,重新生成时会重新编号,因此标题才是稳定身份;这使得一个移动过章节的声明不会衍生出遮蔽操作员 status 的重复 `open` 条目。 | | `--scan-all` | off | 扫描项目中的每一个 AILOG,而非 git 范围。 | | `--range <REV..REV>` | — | 默认扫描的显式 git 范围。 | @@ -906,6 +908,29 @@ $ straymark followups verify FU-016 --premise "yrs 有一个独立的参照物(Y 有效状态:`open` · `in-progress` · `suspected-closed` · `closed` · `superseded`。词表之外的取值会被**拒绝**而非写入 —— 解析器是宽容的,所以拼写错误不会失败:它会悄悄把该条目从所有计数器中剔除。`promoted` 会重定向到 `followups promote`,后者还会写出让该状态有所指的 TDE。 +#### `straymark followups merge-driver <base> <ours> <theirs>` *(cli-3.41.0+)* + +`.straymark/follow-ups-backlog.md` 的 git merge driver(#391)。注册表是 CLI 拥有的,因此每一个触碰 follow-ups 的并行 PR 都会在它上面产生冲突 —— 而取其中一边再重跑 `drift --apply` 来解决冲突,会**悄悄回退另一边的关闭**(status 只存在于文件中,而重新提取会重新编号 id,所以即便比较 id 也无法检测到丢失)。接成 merge driver 后,冲突消失:git 把三个文件版本交给 CLI,结果被写回 `ours`。 + +该 merge 是**结构性的,而非文本性的**:条目跨两侧**按标题**匹配(id 是位置性的,无法在重新生成中存活;标题可以),并按如下规则调和: + +| 情形 | 解决方式 | +|---|---| +| 同一条目,不同 status | 更高排名的 status 获胜(`open` < `in-progress` < `suspected-closed` < `closed`/`superseded`/`promoted`)—— 任一侧做出的关闭都得以存活。同排名分歧保留 `ours` 并报告到 stderr。 | +| 仅存在于 `theirs` 的条目 | 追加(若其 id 与 `ours` 中的条目冲突则重新编号)。 | +| 被 `theirs` 删除的条目 | 从 `ours` 中移除,除非 `ours` 更改了其 status(修改/删除 → 保留 + 报告)。 | +| `Notes` | 当 `theirs` 是 `ours` 的仅追加扩展时(`followups note` 的形态),`theirs` 获胜。 | +| Frontmatter | `fully_extracted_ailogs` 取并集,`last_scan` 取最新,计数器从合并后的正文重新计算。 | + +退出码遵循 git 的 merge-driver 契约:`0` = 已合并(软冲突报告到 stderr),非零 = 未解决(git 回退为将该文件标记为冲突)。 + +**安装(每个克隆一次):** + +```bash +echo '.straymark/follow-ups-backlog.md merge=straymark-followups' >> .gitattributes +git config merge.straymark-followups.driver 'straymark followups merge-driver %O %A %B' +``` + #### `straymark followups new --title <标题> --origin <来源> [--bucket <name>] [--status <s>] [--trigger <t>] [--destination <d>] [--cost <c>] [--premise <p>] [--path <dir>]` *(cli-3.39.0+)* 创建来源为**Charter 声明**(事前)的条目,此时尚不存在任何执行(#360)。此前两条填充路径都假定来源是事后的:`drift --apply` 从 AILOG 提取,而在**声明时刻**做出的推迟 —— "Redis 的 CI job 不在范围内;登记这个覆盖缺口,使其被推迟而非被消音" —— 按设计先于任何 AILOG。