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 [--bucket ] [--status ] [--trigger ] [--destination ] [--cost ] [--premise ] [--path
]` *(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 ] [--status ] [--severity ] [--label ] [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 ` | — | 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 ` *(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 --origin [--bucket ] [--status ] [--trigger ] [--destination ] [--cost ] [--premise ] [--path
]` *(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 ] [--status ] [--severity ] [--label ] [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 ` | — | 默认扫描的显式 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 ` *(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 ] [--status ] [--trigger ] [--destination ] [--cost ] [--premise ] [--path
]` *(cli-3.39.0+)*
创建来源为**Charter 声明**(事前)的条目,此时尚不存在任何执行(#360)。此前两条填充路径都假定来源是事后的:`drift --apply` 从 AILOG 提取,而在**声明时刻**做出的推迟 —— "Redis 的 CI job 不在范围内;登记这个覆盖缺口,使其被推迟而非被消音" —— 按设计先于任何 AILOG。