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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ experiment-loom/web/dist/
/.claude/skills/
/.gemini/skills/
/.codex/skills/
/.qoder/skills/
/.qoder/
/.agent/workflows/
/STRAYMARK.md
/.straymark/00-governance/
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <base> <ours>
<theirs>` 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).

---

<!-- AILOG generated by qodercli-v1.0 | StrayMark | https://strangedays.tech -->
58 changes: 58 additions & 0 deletions .straymark/follow-ups-backlog.md
Original file line number Diff line number Diff line change
@@ -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<N> (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`

<!--
Entry shape (v1 — optional fields marked):

### FU-NNN — <short description>
- **Origin**: AILOG-NNNN-NN-NN-NNN <pointer to source section>
- **Origin-class**: ex-ante-planning | testing | telemetry | staging | real-env-bug (optional)
- **Status**: open | in-progress | suspected-closed | closed | superseded | promoted
- **Severity**: normal | blocking (optional)
- **Trigger**: ready | <calendar date> | when <X> | <other>
- **Destination**: chore | mini-charter | charter-replanning | operations | <charter-id> | <TDE id>
- **Cost**: <effort estimate>
- **Labels**: <free tags, comma-separated> (optional)
- **Notes**: <free-form context>
-->

## 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
10 changes: 9 additions & 1 deletion cli/src/commands/followups/drift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = registry
.entries()
.map(|e| followups::normalize_title(&e.description))
.collect();

let mut drifted: Vec<(String, PathBuf, Vec<ExtractedFu>)> = Vec::new();
for path in candidates {
Expand All @@ -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() {
Expand Down
99 changes: 99 additions & 0 deletions cli/src/commands/followups/merge_driver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! `straymark followups merge-driver <base> <ours> <theirs>` — 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(())
}
1 change: 1 addition & 0 deletions cli/src/commands/followups/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

pub mod drift;
pub mod list;
pub mod merge_driver;
pub mod new;
pub mod note;
pub mod promote;
Expand Down
Loading