diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-08-07-001-issues-415-416.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-08-07-001-issues-415-416.md new file mode 100644 index 0000000..c358bd8 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-08-07-001-issues-415-416.md @@ -0,0 +1,126 @@ +--- +id: AILOG-2026-08-07-001 +title: Two silent failures from adopter reports — an inert refresh heuristic (#416) and duplicate FU ids that misdirected writes (#415) +status: accepted +created: 2026-08-07 +agent: claude-opus-5-v1.0 +confidence: high +review_required: false +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +lines_changed: 520 +files_modified: + - cli/src/commands/charter/mod.rs + - cli/src/commands/charter/close.rs + - cli/src/commands/charter/refresh_suggest.rs + - cli/src/commands/charter/amend.rs + - cli/src/followups.rs + - cli/src/validation.rs + - cli/src/commands/followups/ + - cli/tests/duplicate_fu_ids_test.rs + - dist/STRAYMARK.md + - dist/.straymark/00-governance/QUICK-REFERENCE.md + - CHANGELOG.md +observability_scope: none +tags: [charter, telemetry, follow-ups, adopter-feedback, silent-failure, cli, i18n] +related: + - AILOG-2026-08-06-001 + - AILOG-2026-08-04-003 +--- + +# AILOG: issues #415 and #416 + +## Summary + +Two adopter reports, both the same shape: the failure produced a message that +looked normal. #416 had a heuristic reporting "not enough Charters yet" in every +repo because the reader and writer disagreed on a filename. #415 had a registry +where a write could land on the wrong entry and answer `already closed`. + +Both fixed. In #415 the reported cause turned out to be wrong, and the real one +had been sitting in the adopter's own registry since June. + +## #416 — `refresh-suggest` never found telemetry + +The report was correct in every particular, including which of the two +filenames was the right one to keep. `close` writes `CHARTER-NN.telemetry.yaml` +with the slug stripped so the name survives a rename; `refresh_suggest` built +`.telemetry.yaml` from the Charter's own file stem. + +Reproduced on a second repo before touching anything: Sentinel has 27 telemetry +files, all canonical, and `refresh-suggest` reported `(missing)` for its Charter +with `Chain length: 0`. Not specific to the reporting adopter. + +**Fix**: one `canonical_telemetry_name` in `charter/mod.rs`, used by both sides, +with a fallback to the legacy name for telemetry older CLIs may have written. +The regression test asserts what actually matters — not the string, but that +writer and reader derive it from the same place. + +Also corrected: the two thresholds printed as bare numbers (`3` sample size vs +`6` trigger value) read as contradictory; each line now names what it gates. + +**Scope correction.** In my first comment on #416 I said the stale convention +lived in five files including `SPECKIT-CHARTER-BRIDGE.md`. It does not — my grep +matched a line where `NN-slug.md` (correct, that is the Charter file) and the +word "telemetry" merely co-occur. Four files: `STRAYMARK.md` and +`QUICK-REFERENCE.md` in three languages, plus the `charter amend` guidance +string. Corrected in the closing comment. + +## #415 — duplicate FU ids + +**The reported cause does not reproduce.** The issue proposed that ids are +assigned per-AILOG without atomic reservation; `next_n += 1` has been inside the +per-entry loop since before cli-3.41.0, and two AILOGs in one pass produce +`FU-001` and `FU-002` correctly. Suggestion 1 was already implemented. + +Two real paths, both reproduced: + +1. **Parallel branches.** `next_fu_number` is `max + 1` over the *local* copy, + so two branches extracting different follow-ups both take the same number. A + merge then carries both. Reproduced with the exact reported shape — same id, + different `Origin`, different `Source-hash`. With the #391 merge driver wired + the collision does not survive: it renumbers. It was wired nowhere. +2. **Triage pruning.** Pruning a closed entry to a provenance bullet removes its + heading, `max()` drops, and the next `drift --apply` reissues the number. + This one was documented in Sentinel's own registry on **2026-06-04** — + *"the CLI numbers from the max `### FU-` heading and prune-to-provenance-bullet + hides closed ids … collision reported upstream"* — and had gone unfixed for + two months. + +**Fixes**: the high-water mark now scans every `FU-NNN` mention in the registry +body, so a pruned id stays retired; `find_entry_unique` refuses an ambiguous id +and names both entries, replacing first-match resolution in `note`, +`set-status`, `verify`, `promote` and `status`; and `FOLLOWUP-DUPLICATE-ID` +reports the state as an **error** — unlike most registry findings it silently +misdirects writes, and no reading of a duplicate id is intentional. + +## Verification + +- `cargo test`: **967 passed, 0 failed**. +- `charter/mod.rs`: 4 unit tests, including the writer/reader agreement one. +- `duplicate_fu_ids_test.rs`: 5 tests reproducing the reported sequence — the + refused `note`/`set-status` leave the file byte-identical; `validate` reports + the duplicate; a clean registry stays silent (the rule keys on headings, so a + cited id is not a second entry); a pruned id is not reissued. +- Against real repos: `refresh-suggest` on Sentinel now resolves + `CHARTER-02.telemetry.yaml`, chain length `1`, rolling mean computed — it was + `(missing)` and `0`. `validate` reports no duplicate ids there today (the + operator repaired the FU-345 collision by hand before reporting). + +## Risk + +| Id | Risk | Handling | +|----|------|----------| +| R1 | `find_entry_unique` makes `status` fail where it used to print something. A read-only command refusing is a behaviour change. | Deliberate: showing one of two entries sharing an id is how an operator concludes the wrong one is the one they are about to write to. `followups list` still shows everything unfiltered. | +| R2 | `FOLLOWUP-DUPLICATE-ID` is an **error**, so a repo that currently has duplicates will start failing `validate` — including in CI. | Intended. The state misdirects writes and the fix is mechanical (renumber, then `recount`); the hint says so. A warning would reproduce the original failure, which is that nothing said anything. | +| R3 (new, not in Charter) | The high-water mark now derives from raw text, so a stray `FU-9999` in prose would inflate every future id. | Accepted: numbers are cheap and a collision is not, so the scan errs toward retiring too much. Worth revisiting only if an adopter reports id inflation. | +| R4 (new, not in Charter) | The legacy telemetry fallback is unverified — no adopter is known to have `.telemetry.yaml` on disk. It was added because the reporter suggested it and it costs one `exists()` call. | Noted rather than claimed as tested; the unit test covers the resolution order, not a real-world file. | + +## Follow-ups + +- Wire the merge driver in Sentinel and LNXDrive. It is the mitigation for + #415's parallel-branch path and remains unexercised in both — already tracked + from AILOG-2026-08-06-001, restated here because #415 is the first confirmed + instance of that path causing damage in production. diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md index a485295..ebeb149 100644 --- a/.straymark/follow-ups-backlog.md +++ b/.straymark/follow-ups-backlog.md @@ -1,7 +1,7 @@ --- -last_scan: 2026-08-06 +last_scan: 2026-08-07 schema_version: v1 -total_open: 3 +total_open: 4 total_promoted: 0 total_closed_in_session: 2 total_phase_blocked: 0 @@ -17,6 +17,7 @@ fully_extracted_ailogs: - AILOG-2026-08-05-003 - AILOG-2026-08-05-004 - AILOG-2026-08-06-001 + - AILOG-2026-08-07-001 --- # Follow-ups Backlog @@ -88,6 +89,15 @@ Entry shape (v1 — optional fields marked): - **Cost**: TBD - **Notes**: Auto-appended by `straymark followups drift --apply` 2026-08-06. +### FU-006 — Wire the merge driver in Sentinel and LNXDrive. +- **Origin**: AILOG-2026-08-07-001 §Follow-ups +- **Source-hash**: 937ac2c96b96 +- **Status**: open +- **Trigger**: TBD +- **Destination**: TBD +- **Cost**: TBD +- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-08-07. + ## Bucket: time-triggered ## Bucket: charter-triggered diff --git a/CHANGELOG.md b/CHANGELOG.md index 137c8c0..0857685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project uses [independent versioning](README.md#versioning) for Framewo --- +## Framework 4.43.0 / CLI 3.45.0 — 2026-08-06 + +Two adopter reports, both silent-failure shaped: a heuristic that had been inert in every repo while reporting a reassuring message, and a registry that let writes land on the wrong entry. + +### Fixed (CLI) + +- **`charter refresh-suggest` never found telemetry** (#416). `close` writes `CHARTER-NN.telemetry.yaml` (slug stripped, so the name survives a Charter rename); `refresh-suggest` built `.telemetry.yaml` from the Charter's own filename. The reader never found what the writer wrote, so the refresh heuristic — the mechanism that detects accumulated spec drift across a Charter chain — has been inert in **every repo with closed Charters** since the names diverged. Both sides now derive the name from one helper, with a fallback to the legacy name for telemetry written by older CLIs. Confirmed on a second repo: 27 telemetry files, every Charter reported `(missing)`, chain length `0`. + + The failure mode is why this matters more than its size: `Chain shorter than 3 closed Charters with telemetry — heuristic not yet meaningful` is indistinguishable from the normal early state, so a seven-Charter chain ran past it without anyone suspecting a defect. + +- **Duplicate FU ids silently misdirected writes** (#415). Commands resolving by id took the **first** match, so with two entries sharing a number `note` annotated the wrong follow-up and `set-status` answered `already closed — nothing to change` — which reads as success while the intended entry stayed open. `note` / `set-status` / `verify` / `promote` / `status` now refuse an ambiguous id and name both entries. + +- **Triage pruning released ids for reuse** (#415, reported from Sentinel 2026-06-04). `next_fu_number` was `max(parsed entries) + 1`, so an id stopped being reserved the moment its entry lost its `### FU-NNN` heading — which is exactly what triage does when it prunes a closed entry to a provenance bullet. The high-water mark now comes from every `FU-NNN` mentioned anywhere in the registry body. + + The other collision path — parallel branches each computing `max + 1` against their own copy — is handled by the #391 merge driver, which renumbers on merge. Reproduced both. + +### Added (CLI) + +- **`FOLLOWUP-DUPLICATE-ID`** validation rule (#415): two entries sharing a `### FU-NNN` heading are reported as an **error**. Unlike most registry findings this one silently misdirects writes, and no reading of a duplicate id is intentional. + +### Changed (CLI) + +- **`refresh-suggest` labels its two thresholds** (#416). `3` (sample size needed to evaluate) and `6` (value the rolling mean must beat) were both printed as bare numbers and read as contradictory. Each line now names what it gates. + +### Fixed (Framework) + +- **The telemetry filename convention was documented wrong** (#416) in `STRAYMARK.md` (§10 and §15) and `QUICK-REFERENCE.md` (EN/es/zh-CN) — the docs corroborated the reader, not the writer, in every language. Same correction in the `charter amend --merge-into` guidance the CLI prints. + ## CLI 3.44.0 — 2026-08-06 ### Added (CLI) diff --git a/Cargo.lock b/Cargo.lock index 617f7f3..5b0e867 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2574,7 +2574,7 @@ dependencies = [ [[package]] name = "straymark-cli" -version = "3.44.0" +version = "3.45.0" dependencies = [ "anyhow", "arborist-metrics", diff --git a/README.md b/README.md index 18f3774..44bddec 100644 --- a/README.md +++ b/README.md @@ -278,8 +278,8 @@ StrayMark uses independent version tags for each component: | Component | Tag prefix | Example | Includes | | --- | --- | --- | --- | -| Framework | `fw-` | `fw-4.42.0` | Templates (12 types), governance, directives, Charter template + schema | -| CLI | `cli-` | `cli-3.44.0` | The `straymark` binary | +| Framework | `fw-` | `fw-4.43.0` | Templates (12 types), governance, directives, Charter template + schema | +| CLI | `cli-` | `cli-3.45.0` | The `straymark` binary | | Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | The `straymark-loom` visualization server, downloaded on demand by `straymark loom serve` | Check installed versions with `straymark status` or `straymark about`. diff --git a/cli/Cargo.toml b/cli/Cargo.toml index eafd1e2..d2146fc 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "straymark-cli" -version = "3.44.0" +version = "3.45.0" edition = "2021" description = "CLI for StrayMark — the cognitive discipline your AI-assisted projects need" license = "MIT" diff --git a/cli/src/commands/charter/amend.rs b/cli/src/commands/charter/amend.rs index 524ab61..6bbd55c 100644 --- a/cli/src/commands/charter/amend.rs +++ b/cli/src/commands/charter/amend.rs @@ -352,7 +352,7 @@ findings_closed: {findings_closed}\n\ \n\ ## Telemetry\n\ \n\ -This amendment populates `charter_telemetry.post_close_amendment:` in the Charter's `.telemetry.yaml`. Run `straymark charter amend {charter_id} --trigger {trigger} --merge-into .straymark/charters/.telemetry.yaml` (this command) with the appropriate path to auto-merge, or paste the YAML block printed by the CLI.\n\ +This amendment populates `charter_telemetry.post_close_amendment:` in the Charter's `.telemetry.yaml`. Run `straymark charter amend {charter_id} --trigger {trigger} --merge-into .straymark/charters/CHARTER-NN.telemetry.yaml` (this command) with the appropriate path to auto-merge, or paste the YAML block printed by the CLI.\n\ ", amends .map(|a| format!("- **Amends**: [{a}]({a}.md) (forward pointer; the original gets a `## Historical correction` subsection)\n")) diff --git a/cli/src/commands/charter/close.rs b/cli/src/commands/charter/close.rs index 8eb7f92..eea6a6e 100644 --- a/cli/src/commands/charter/close.rs +++ b/cli/src/commands/charter/close.rs @@ -305,19 +305,11 @@ fn truncate_desc(s: &str, max: usize) -> String { } /// Build the `.straymark/charters/CHARTER-NN.telemetry.yaml` path for a Charter. +/// +/// Delegates to the shared helper so the writer and every reader derive the +/// name from one place (GH #416). fn telemetry_path_for(charters_state_dir: &Path, charter: &Charter) -> PathBuf { - // Strip optional slug suffix to keep the telemetry filename stable across - // Charter renames: CHARTER-01-foo → CHARTER-01.telemetry.yaml. - let id = &charter.frontmatter.charter_id; - let canonical = id - .split_once('-') - .and_then(|(prefix, rest)| { - // CHARTER-NN[-slug] → CHARTER-NN - let nn = rest.split('-').next()?; - Some(format!("{}-{}", prefix, nn)) - }) - .unwrap_or_else(|| id.clone()); - charters_state_dir.join(format!("{}.telemetry.yaml", canonical)) + super::canonical_telemetry_path(charters_state_dir, &charter.frontmatter.charter_id) } /// Copy the template YAML to the telemetry path. Refuses to overwrite an diff --git a/cli/src/commands/charter/mod.rs b/cli/src/commands/charter/mod.rs index 11cb053..bc6fa57 100644 --- a/cli/src/commands/charter/mod.rs +++ b/cli/src/commands/charter/mod.rs @@ -15,3 +15,111 @@ pub mod list; pub mod new; pub mod refresh_suggest; pub mod status; + +use std::path::{Path, PathBuf}; + +/// Canonical name of a Charter's telemetry sidecar: `CHARTER-NN.telemetry.yaml`. +/// +/// Derived from `charter_id` with any slug suffix stripped, so the filename +/// survives a Charter rename (`CHARTER-01-foo` → `CHARTER-01.telemetry.yaml`). +/// +/// **This is the single definition on purpose** (GH #416). `close` built this +/// name while `refresh-suggest` built `.telemetry.yaml` from the +/// Charter's own filename, so the reader never found what the writer wrote and +/// the refresh heuristic was inert in every repo — reported as `(missing)` for +/// every Charter, indistinguishable from "not enough closed Charters yet". +pub fn canonical_telemetry_name(charter_id: &str) -> String { + let canonical = charter_id + .split_once('-') + .and_then(|(prefix, rest)| { + // CHARTER-NN[-slug] → CHARTER-NN + let nn = rest.split('-').next()?; + Some(format!("{prefix}-{nn}")) + }) + .unwrap_or_else(|| charter_id.to_string()); + format!("{canonical}.telemetry.yaml") +} + +/// Where `close` writes a Charter's telemetry. +pub fn canonical_telemetry_path(charters_state_dir: &Path, charter_id: &str) -> PathBuf { + charters_state_dir.join(canonical_telemetry_name(charter_id)) +} + +/// Legacy sidecar name written before the canonical one: the Charter's own file +/// stem plus `.telemetry.yaml`. Read-only — never written any more, but adopters +/// may still have files under it. +pub fn legacy_telemetry_path(charter_path: &Path) -> Option { + let stem = charter_path.file_stem()?.to_str()?; + let parent = charter_path.parent()?; + Some(parent.join(format!("{stem}.telemetry.yaml"))) +} + +/// Locate a Charter's telemetry on disk: canonical name first, legacy second. +/// +/// Returns the canonical path even when nothing exists, so callers can report a +/// meaningful "expected here" rather than `None`. +pub fn resolve_telemetry_path(charter_path: &Path, charter_id: &str) -> PathBuf { + let dir = charter_path.parent().unwrap_or_else(|| Path::new(".")); + let canonical = canonical_telemetry_path(dir, charter_id); + if canonical.exists() { + return canonical; + } + if let Some(legacy) = legacy_telemetry_path(charter_path) { + if legacy.exists() { + return legacy; + } + } + canonical +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_name_strips_the_slug() { + assert_eq!( + canonical_telemetry_name("CHARTER-22-fundacion-expedientes"), + "CHARTER-22.telemetry.yaml" + ); + assert_eq!(canonical_telemetry_name("CHARTER-02"), "CHARTER-02.telemetry.yaml"); + } + + #[test] + fn canonical_name_passes_through_unrecognized_shapes() { + assert_eq!(canonical_telemetry_name("WEIRD"), "WEIRD.telemetry.yaml"); + } + + /// GH #416: the writer and the reader disagreed for two releases and the + /// only visible symptom was a reassuring "not enough Charters yet" message. + /// What must never regress is not the name — it is that both sides derive + /// it from the same place. + #[test] + fn writer_and_reader_agree_on_the_name() { + let dir = std::path::Path::new("/p/.straymark/charters"); + let charter_path = dir.join("22-fundacion-expedientes.md"); + let id = "CHARTER-22-fundacion-expedientes"; + + let written = canonical_telemetry_path(dir, id); + let read = resolve_telemetry_path(&charter_path, id); + assert_eq!(written, read); + assert!(written.ends_with("CHARTER-22.telemetry.yaml")); + } + + #[test] + fn falls_back_to_the_legacy_name_when_only_that_exists() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let charter_path = dir.join("07-old.md"); + std::fs::write(&charter_path, "x").unwrap(); + std::fs::write(dir.join("07-old.telemetry.yaml"), "y").unwrap(); + + let resolved = resolve_telemetry_path(&charter_path, "CHARTER-07-old"); + assert!(resolved.ends_with("07-old.telemetry.yaml")); + + // Once the canonical file appears it wins, without touching the legacy one. + std::fs::write(dir.join("CHARTER-07.telemetry.yaml"), "z").unwrap(); + let resolved = resolve_telemetry_path(&charter_path, "CHARTER-07-old"); + assert!(resolved.ends_with("CHARTER-07.telemetry.yaml")); + } +} diff --git a/cli/src/commands/charter/refresh_suggest.rs b/cli/src/commands/charter/refresh_suggest.rs index bbd1bff..bb30c70 100644 --- a/cli/src/commands/charter/refresh_suggest.rs +++ b/cli/src/commands/charter/refresh_suggest.rs @@ -127,13 +127,18 @@ fn load_charter_row(project_root: &Path, charter: &Charter) -> Result.md`; telemetry sits next to them as -/// `.telemetry.yaml` per the convention in charter.rs. +/// Locate the telemetry sidecar for a Charter. +/// +/// Canonical name first (`CHARTER-NN.telemetry.yaml`, what `close` writes), +/// then the legacy `.telemetry.yaml` for telemetry written by older +/// CLIs. Until cli-3.45.0 this built only the legacy name, from the Charter's +/// own file stem — so it never found anything and the heuristic reported +/// `(missing)` for every Charter in every repo (GH #416). fn telemetry_path_for(_project_root: &Path, charter: &Charter) -> Option { - let stem = charter.path.file_stem()?.to_str()?; - let parent = charter.path.parent()?; - Some(parent.join(format!("{stem}.telemetry.yaml"))) + Some(super::resolve_telemetry_path( + &charter.path, + &charter.frontmatter.charter_id, + )) } fn parse_telemetry(path: &Path) -> Result<(Option, Option)> { @@ -229,12 +234,20 @@ fn print_recommendation( window: &[&CharterRow], rolling_mean: Option, ) { + // Two distinct gates, and printing both as bare numbers read as a + // contradiction (GH #416): `3` is the sample size the mean needs to mean + // anything, `6` is the value that mean must beat. Each line now names what + // it gates. println!(" {}", "Heuristic".bold()); println!( - " Chain length (closed Charters in window): {}", - window.len() + " Sample size — closed Charters with telemetry: {} (need {} to evaluate)", + window.len(), + ROLLING_WINDOW + ); + println!( + " Refresh trigger — rolling mean must exceed: {}", + threshold ); - println!(" Threshold for rolling mean: > {}", threshold); match rolling_mean { Some(m) => println!( " Rolling mean of agent_quality.r_n_plus_one_emergent_count: {:.2}", diff --git a/cli/src/commands/followups/note.rs b/cli/src/commands/followups/note.rs index da0ec7b..bb231c6 100644 --- a/cli/src/commands/followups/note.rs +++ b/cli/src/commands/followups/note.rs @@ -42,14 +42,7 @@ pub fn run(path: &str, fu_id: &str, text: &str, source: Option<&str>) -> Result< // artifact gets half-corrupted. guard_parse_warnings(®istry)?; - let entry = followups::find_entry(®istry, fu_id) - .ok_or_else(|| { - anyhow!( - "Entry {} not found in the registry.\n hint: run `straymark followups list` to see entries.", - fu_id - ) - })? - .clone(); + let entry = followups::find_entry_unique(®istry, fu_id)?.clone(); let date = Local::now().format("%Y-%m-%d").to_string(); let notes = followups::append_note(entry.notes.as_deref(), text, &date, source); diff --git a/cli/src/commands/followups/promote.rs b/cli/src/commands/followups/promote.rs index 1f99354..2b55750 100644 --- a/cli/src/commands/followups/promote.rs +++ b/cli/src/commands/followups/promote.rs @@ -37,14 +37,7 @@ pub fn run(path: &str, fu_id: &str, title: Option<&str>, premise_verified: bool) ); } let registry = followups::parse_registry(®istry_path)?; - let entry = followups::find_entry(®istry, fu_id) - .ok_or_else(|| { - anyhow!( - "Entry {} not found in the registry.\n hint: run `straymark followups list` to see entries.", - fu_id - ) - })? - .clone(); + let entry = followups::find_entry_unique(®istry, fu_id)?.clone(); if entry.status == FuStatus::Promoted { bail!( diff --git a/cli/src/commands/followups/set_status.rs b/cli/src/commands/followups/set_status.rs index 0ccb118..749bf02 100644 --- a/cli/src/commands/followups/set_status.rs +++ b/cli/src/commands/followups/set_status.rs @@ -54,14 +54,7 @@ pub fn run(path: &str, fu_id: &str, status_input: &str) -> Result<()> { let registry = followups::parse_registry(®istry_path)?; guard_parse_warnings(®istry)?; - let entry = followups::find_entry(®istry, fu_id) - .ok_or_else(|| { - anyhow!( - "Entry {} not found in the registry.\n hint: run `straymark followups list` to see entries.", - fu_id - ) - })? - .clone(); + let entry = followups::find_entry_unique(®istry, fu_id)?.clone(); let before = followups::compute_counters(®istry); let previous = entry.status_raw.clone().unwrap_or_else(|| entry.status.as_str().to_string()); diff --git a/cli/src/commands/followups/status.rs b/cli/src/commands/followups/status.rs index 88cc2a0..f227eee 100644 --- a/cli/src/commands/followups/status.rs +++ b/cli/src/commands/followups/status.rs @@ -197,12 +197,10 @@ fn print_pulse(registry: &Registry) { } fn print_entry_detail(registry: &Registry, id: &str) -> Result<()> { - let entry = followups::find_entry(registry, id).ok_or_else(|| { - anyhow!( - "Entry {} not found in the registry.\n hint: run `straymark followups list` to see entries.", - id - ) - })?; + // Read-only, but it still refuses an ambiguous id: showing one of two + // entries that share a number is how an operator concludes the wrong one is + // the one they are about to write to (GH #415). + let entry = followups::find_entry_unique(registry, id)?; println!(); println!(" {} — {}", entry.fu_id.bold().cyan(), entry.description.bold()); diff --git a/cli/src/commands/followups/verify.rs b/cli/src/commands/followups/verify.rs index de6b584..3d56ba0 100644 --- a/cli/src/commands/followups/verify.rs +++ b/cli/src/commands/followups/verify.rs @@ -47,14 +47,7 @@ pub fn run( ); } let registry = followups::parse_registry(®istry_path)?; - let entry = followups::find_entry(®istry, fu_id) - .ok_or_else(|| { - anyhow!( - "Entry {} not found in the registry.\n hint: run `straymark followups list` to see entries.", - fu_id - ) - })? - .clone(); + let entry = followups::find_entry_unique(®istry, fu_id)?.clone(); // ── Surface the premise (the assumption to re-check) ── let effective_premise = premise diff --git a/cli/src/followups.rs b/cli/src/followups.rs index 364feef..b1d6ee1 100644 --- a/cli/src/followups.rs +++ b/cli/src/followups.rs @@ -20,7 +20,7 @@ //! Move target: straymark-core (Loom M0) — keep this module free of //! clap/colored/dialoguer; presentation lives in `commands/followups/`. -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context, Result, bail}; use sha2::{Digest, Sha256}; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -512,14 +512,109 @@ pub fn find_entry<'a>(registry: &'a Registry, id_input: &str) -> Option<&'a Entr None } -/// Next sequential FU number: `max(NNN) + 1`, or 1 on an empty registry. +/// Every entry an id resolves to. Normally one; more than one means the +/// registry carries duplicate ids and any write addressed by that id is a +/// coin flip. +pub fn find_entries<'a>(registry: &'a Registry, id_input: &str) -> Vec<&'a Entry> { + let trimmed = id_input.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + let exact: Vec<&Entry> = registry.entries().filter(|e| e.fu_id == trimmed).collect(); + if !exact.is_empty() { + return exact; + } + let digits = trimmed.strip_prefix("FU-").unwrap_or(trimmed); + match digits.parse::() { + Ok(n) => registry.entries().filter(|e| e.fu_number == n).collect(), + Err(_) => Vec::new(), + } +} + +/// Resolve an id to exactly one entry, or fail. +/// +/// GH #415: every mutating command resolved by taking the **first** match. +/// With duplicate ids in the registry — which parallel branches and triage +/// pruning both produce — `note` wrote into the wrong entry and `set-status` +/// answered `already closed — nothing to change`, which reads as success. The +/// operator's actual follow-up stayed open and unannotated, and nothing said so. +/// +/// Refusing is the only safe answer: the command cannot know which entry was +/// meant, and guessing is what caused the silent write. +pub fn find_entry_unique<'a>(registry: &'a Registry, id_input: &str) -> Result<&'a Entry> { + let matches = find_entries(registry, id_input); + match matches.len() { + 1 => Ok(matches[0]), + 0 => bail!( + "{} not found in {}", + id_input, + registry.path.display() + ), + n => { + let mut detail = String::new(); + for e in &matches { + detail.push_str(&format!( + "\n - {} [{}] {} — {}", + e.fu_id, + e.status.as_str(), + e.origin.as_deref().unwrap_or("origin unknown"), + crate::utils::truncate_visual(&e.description, 60) + )); + } + bail!( + "{id_input} is ambiguous: {n} entries share that id in {}.{detail}\n \ + hint: ids are positional, so a merge of parallel branches — or triage pruning a \ + closed entry's heading — can hand the same number to two follow-ups. Renumber \ + all but one to a free id (`straymark followups status` shows the highest in \ + use), then re-run. Addressing a duplicate id would write into whichever entry \ + happens to come first in the file.", + registry.path.display() + ) + } + } +} + +/// Next sequential FU number: one past the highest number the registry has +/// *ever* used, not one past the highest entry still standing. +/// +/// GH #415: the high-water mark used to come from parsed entries alone, so an +/// id stopped being reserved the moment its entry lost its `### FU-NNN` +/// heading — which is exactly what triage does when it prunes a closed entry +/// down to a provenance bullet. The next `drift --apply` then handed that +/// number to an unrelated follow-up, and the registry ended up with two +/// different items answering to one id. Reported from Sentinel on 2026-06-04 +/// (`FU-123` reused after supersession) and again in #415. +/// +/// So the scan is over the raw body, not the entry list: any `FU-NNN` mention +/// anywhere — a pruned bullet, a `Notes` back-reference, a superseded entry — +/// keeps its number retired. Numbers are cheap; a collision is not. pub fn next_fu_number(registry: &Registry) -> u32 { - registry - .entries() - .map(|e| e.fu_number) - .max() - .map(|n| n + 1) - .unwrap_or(1) + let from_entries = registry.entries().map(|e| e.fu_number).max().unwrap_or(0); + let from_body = highest_fu_mention(®istry.body); + from_entries.max(from_body) + 1 +} + +/// Highest `FU-NNN` number mentioned anywhere in the registry body, including +/// ids that no longer have an entry of their own. Author-scoped ids like +/// `FU-058-022` contribute their first group only — the registry-assigned part. +fn highest_fu_mention(body: &str) -> u32 { + let mut highest = 0u32; + let bytes = body.as_bytes(); + let mut i = 0usize; + while let Some(pos) = body[i..].find("FU-") { + let start = i + pos + 3; + let mut end = start; + while end < bytes.len() && bytes[end].is_ascii_digit() { + end += 1; + } + if end > start { + if let Ok(n) = body[start..end].parse::() { + highest = highest.max(n); + } + } + i = start.max(i + pos + 1); + } + highest } /// Counters recomputed from actual entry statuses — the CLI-owned source of diff --git a/cli/src/validation.rs b/cli/src/validation.rs index 404acba..14cf65f 100644 --- a/cli/src/validation.rs +++ b/cli/src/validation.rs @@ -430,6 +430,73 @@ fn check_followups_work_verb(straymark_dir: &Path, result: &mut ValidationResult } } +/// GH #415: two entries carrying the same `### FU-NNN` heading. +/// +/// Ids are positional, and two paths hand out a number that is already taken: +/// parallel branches each computing `max + 1` against their own copy of the +/// registry, and (before cli-3.45.0) triage pruning a closed entry's heading, +/// which released its number for reuse. +/// +/// The state itself is recoverable — renumber one of them — but it is +/// **invisible until something writes to the wrong entry**, which is how #415 +/// was found: a `note` landed on the wrong follow-up and the follow-up +/// `set-status` reported as "already closed" was a different item entirely. +/// The mutating commands now refuse an ambiguous id; this rule surfaces the +/// duplicate before anyone reaches for one. +/// +/// Error, not warning: unlike most registry findings this one silently +/// misdirects writes, and there is no reading of a duplicate id that is +/// intentional. +fn check_followup_duplicate_ids(straymark_dir: &Path, result: &mut ValidationResult) { + let backlog = straymark_dir.join("follow-ups-backlog.md"); + let Ok(content) = std::fs::read_to_string(&backlog) else { + return; // No registry — nothing to check. + }; + + // Heading lines only. A bare `FU-NNN` in prose or a Notes back-reference is + // a citation, not a second entry. + let mut seen: std::collections::BTreeMap> = Default::default(); + for (line_no, line) in content.lines().enumerate() { + let Some(rest) = line.strip_prefix("### ") else { + continue; + }; + let Some(after) = rest.strip_prefix("FU-") else { + continue; + }; + let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + continue; + } + seen.entry(format!("FU-{digits}")) + .or_default() + .push(line_no + 1); + } + + for (fu_id, lines) in seen.iter().filter(|(_, l)| l.len() > 1) { + let where_ = lines + .iter() + .map(|l| l.to_string()) + .collect::>() + .join(", "); + result.add(ValidationIssue { + file: backlog.clone(), + rule: "FOLLOWUP-DUPLICATE-ID".to_string(), + message: format!( + "{} is the heading of {} different entries (lines {}). Commands that resolve by \ + id cannot tell them apart.", + fu_id, + lines.len(), + where_ + ), + severity: Severity::Error, + fix_hint: Some(format!( + "Renumber all but one {fu_id} to a free id, then run `straymark followups recount`. \ + `straymark followups status` shows the highest id in use." + )), + }); + } +} + /// GH #392: warn when an AILOG's body mentions a `FU-NNN` / `FU-NNN-NNN` id /// outside its own `## Follow-ups` section and *nothing* knows about that id. /// The extractor only reads `## Follow-ups` (plus structural risk @@ -637,6 +704,7 @@ pub fn validate_all(straymark_dir: &Path) -> (ValidationResult, usize) { check_followups_work_verb(straymark_dir, &mut result); // GH #392: FU ids mentioned outside `## Follow-ups` never reach the registry. + check_followup_duplicate_ids(straymark_dir, &mut result); check_followup_mentions(straymark_dir, &paths, &mut result); // REF-002: Detect orphan documents (no traceability links) diff --git a/cli/tests/duplicate_fu_ids_test.rs b/cli/tests/duplicate_fu_ids_test.rs new file mode 100644 index 0000000..8e708a8 --- /dev/null +++ b/cli/tests/duplicate_fu_ids_test.rs @@ -0,0 +1,168 @@ +//! GH #415 — duplicate FU ids in the registry. +//! +//! Two entries answering to one id is recoverable state; what was not +//! recoverable was finding out. `note` wrote into whichever entry came first +//! and `set-status` answered `already closed — nothing to change`, which reads +//! as success while the intended follow-up stayed open and unannotated. +//! +//! These tests pin the three surfaces that close that: mutating commands refuse +//! an ambiguous id, `validate` reports the duplicate, and a pruned entry's +//! number is never handed out again. + +use assert_cmd::cargo_bin_cmd; +use predicates::prelude::*; +use tempfile::TempDir; + +const REGISTRY_WITH_DUPLICATES: &str = r#"--- +schema_version: v1 +last_scan: 2026-08-06 +buckets: [ready] +fully_extracted_ailogs: [] +total_open: 2 +--- + +# Follow-ups Backlog + +## Bucket: ready + +### FU-007 — primera entrada, la que quedó cerrada +- **Origin**: AILOG-2026-08-06-003 §Follow-ups +- **Source-hash**: aaaaaaaaaaaa +- **Status**: closed + +### FU-007 — segunda entrada, distinta, que el operador quería anotar +- **Origin**: AILOG-2026-08-06-004 §Follow-ups +- **Source-hash**: bbbbbbbbbbbb +- **Status**: open +"#; + +fn project_with(registry: &str) -> TempDir { + let tmp = TempDir::new().unwrap(); + let sm = tmp.path().join(".straymark"); + std::fs::create_dir_all(&sm).unwrap(); + std::fs::write(sm.join("config.yml"), "language: en\n").unwrap(); + std::fs::write(tmp.path().join("STRAYMARK.md"), "# rules\n").unwrap(); + std::fs::write(sm.join("follow-ups-backlog.md"), registry).unwrap(); + tmp +} + +/// The exact sequence from the report: the note landed on the wrong entry and +/// `set-status` reported a no-op that read as success. +#[test] +fn mutating_commands_refuse_an_ambiguous_id() { + let tmp = project_with(REGISTRY_WITH_DUPLICATES); + let before = std::fs::read_to_string(tmp.path().join(".straymark/follow-ups-backlog.md")).unwrap(); + + for args in [ + vec!["followups", "note", "FU-007", "la medición"], + vec!["followups", "set-status", "FU-007", "closed"], + ] { + cargo_bin_cmd!("straymark") + .args(&args) + .args(["--path", tmp.path().to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("ambiguous")) + // Both entries named, so the operator can tell which is which. + .stderr(predicate::str::contains("AILOG-2026-08-06-003")) + .stderr(predicate::str::contains("AILOG-2026-08-06-004")); + } + + let after = std::fs::read_to_string(tmp.path().join(".straymark/follow-ups-backlog.md")).unwrap(); + assert_eq!( + before, after, + "a refused command must not have written anything" + ); +} + +/// Reading is refused too: showing one of the two is how an operator concludes +/// the wrong entry is the one they are about to act on. +#[test] +fn status_detail_refuses_an_ambiguous_id() { + let tmp = project_with(REGISTRY_WITH_DUPLICATES); + cargo_bin_cmd!("straymark") + .args(["followups", "status", "FU-007", "--path"]) + .arg(tmp.path()) + .assert() + .failure() + .stderr(predicate::str::contains("ambiguous")); +} + +#[test] +fn validate_reports_duplicate_ids_as_an_error() { + let tmp = project_with(REGISTRY_WITH_DUPLICATES); + cargo_bin_cmd!("straymark") + .args(["validate"]) + .arg(tmp.path()) + .assert() + .failure() + .stdout(predicate::str::contains("FOLLOWUP-DUPLICATE-ID")) + .stdout(predicate::str::contains("FU-007")); +} + +/// A registry with unique ids must stay silent — the rule keys on headings, so +/// a `FU-NNN` cited in prose or in `Notes` is not a second entry. +#[test] +fn a_clean_registry_reports_nothing() { + let clean = REGISTRY_WITH_DUPLICATES + .replacen("### FU-007 — segunda", "### FU-008 — segunda", 1) + + "\n### FU-009 — cita a FU-007 y FU-008 en su cuerpo\n- **Status**: open\n"; + let tmp = project_with(&clean); + cargo_bin_cmd!("straymark") + .args(["validate"]) + .arg(tmp.path()) + .assert() + .stdout(predicate::str::contains("FOLLOWUP-DUPLICATE-ID").not()); +} + +/// The id of an entry triage pruned down to a provenance bullet must stay +/// retired. Reported from Sentinel on 2026-06-04, before #415 restated it. +#[test] +fn a_pruned_id_is_never_handed_out_again() { + let pruned = r#"--- +schema_version: v1 +last_scan: 2026-08-06 +buckets: [ready] +fully_extracted_ailogs: [] +total_open: 1 +--- + +# Follow-ups Backlog + +## Bucket: ready + +### FU-001 — la que sigue viva +- **Origin**: AILOG-2026-08-06-001 §Follow-ups +- **Status**: open + +## Closed at triage + +- FU-002 (closed 2026-08-06) — pruned to a provenance bullet, heading gone +"#; + let tmp = project_with(pruned); + std::fs::create_dir_all(tmp.path().join(".straymark/07-ai-audit/agent-logs")).unwrap(); + std::fs::write( + tmp.path().join(".straymark/07-ai-audit/agent-logs/AILOG-2026-08-06-009-x.md"), + "---\nid: AILOG-2026-08-06-009\ntitle: x\nstatus: accepted\ncreated: 2026-08-06\n\ + agent: claude-opus-5-v1.0\nconfidence: high\nreview_required: false\nrisk_level: low\n---\n\n\ + # AILOG: x\n\n## Summary\ns\n\n## Follow-ups\n\n- (new) una entrada nueva que no debe reusar FU-002\n", + ) + .unwrap(); + + cargo_bin_cmd!("straymark") + .args(["followups", "drift", "--apply", "--scan-all", "--path"]) + .arg(tmp.path()) + .assert() + .success(); + + let registry = + std::fs::read_to_string(tmp.path().join(".straymark/follow-ups-backlog.md")).unwrap(); + assert!( + !registry.contains("### FU-002 "), + "FU-002 was pruned, not freed — reassigning it collides with the pruned record:\n{registry}" + ); + assert!( + registry.contains("### FU-003 "), + "the new entry should take the next number past the high-water mark:\n{registry}" + ); +} diff --git a/dist/.straymark/00-governance/AGENT-RULES.md b/dist/.straymark/00-governance/AGENT-RULES.md index c97e243..44cd0f1 100644 --- a/dist/.straymark/00-governance/AGENT-RULES.md +++ b/dist/.straymark/00-governance/AGENT-RULES.md @@ -418,4 +418,4 @@ When a project accumulates a high volume of AILOGs across multiple Charters and --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/C4-DIAGRAM-GUIDE.md b/dist/.straymark/00-governance/C4-DIAGRAM-GUIDE.md index 1fd5f14..3d88f59 100644 --- a/dist/.straymark/00-governance/C4-DIAGRAM-GUIDE.md +++ b/dist/.straymark/00-governance/C4-DIAGRAM-GUIDE.md @@ -234,4 +234,4 @@ Use a Level 1 (Context) diagram to illustrate: --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/DOCUMENTATION-POLICY.md b/dist/.straymark/00-governance/DOCUMENTATION-POLICY.md index bcf20f4..c9ef0f4 100644 --- a/dist/.straymark/00-governance/DOCUMENTATION-POLICY.md +++ b/dist/.straymark/00-governance/DOCUMENTATION-POLICY.md @@ -319,4 +319,4 @@ See also [ADR-2025-01-20-001] for architectural context. --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md b/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md index 97c797c..690ab97 100644 --- a/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md +++ b/dist/.straymark/00-governance/FOLLOW-UPS-BACKLOG-PATTERN.md @@ -370,4 +370,4 @@ Contributed via [issue #111](https://github.com/StrangeDaysTech/straymark/issues --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/QUICK-REFERENCE.md b/dist/.straymark/00-governance/QUICK-REFERENCE.md index 5eb4152..c3288b4 100644 --- a/dist/.straymark/00-governance/QUICK-REFERENCE.md +++ b/dist/.straymark/00-governance/QUICK-REFERENCE.md @@ -72,7 +72,7 @@ Charters are **not** doc types — they wrap a multi-session implementation bloc | Concept | Folder | Agent Autonomy | |---------|--------|---------------| -| `Charter` | `.straymark/charters/` (declarative `NN-slug.md` + telemetry `NN-slug.telemetry.yaml`) | Scaffold via `charter new`; operator owns trigger and lifecycle transitions | +| `Charter` | `.straymark/charters/` (declarative `NN-slug.md` + telemetry `CHARTER-NN.telemetry.yaml`) | Scaffold via `charter new`; operator owns trigger and lifecycle transitions | > See section 15 of `STRAYMARK.md` and `.straymark/00-governance/SPECKIT-CHARTER-BRIDGE.md` for granularity heuristics, lifecycle, and the SpecKit ↔ Charter bridge. @@ -167,7 +167,7 @@ Mark `review_required: true` when: ├── 08-security/ ← SEC ├── 09-ai-models/ ← MCARD │ └── labeling/ ← AILABEL* -├── charters/ ← Charter (NN-slug.md + NN-slug.telemetry.yaml) +├── charters/ ← Charter (NN-slug.md + CHARTER-NN.telemetry.yaml) ├── follow-ups-backlog.md ← Follow-ups registry (FU-NNN entries, first-class since fw-4.21.0) └── templates/ ← Templates (incl. charter/ subdir + follow-ups-backlog.md) @@ -263,4 +263,4 @@ Mark `review_required: true` when: --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/es/AGENT-RULES.md b/dist/.straymark/00-governance/i18n/es/AGENT-RULES.md index 719d459..829aba4 100644 --- a/dist/.straymark/00-governance/i18n/es/AGENT-RULES.md +++ b/dist/.straymark/00-governance/i18n/es/AGENT-RULES.md @@ -418,4 +418,4 @@ Cuando un proyecto acumula un volumen alto de AILOGs a lo largo de múltiples Ch --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md b/dist/.straymark/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md index c4487f6..750b73f 100644 --- a/dist/.straymark/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md +++ b/dist/.straymark/00-governance/i18n/es/C4-DIAGRAM-GUIDE.md @@ -234,4 +234,4 @@ Usar un diagrama de Nivel 1 (Contexto) para ilustrar: --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/es/DOCUMENTATION-POLICY.md b/dist/.straymark/00-governance/i18n/es/DOCUMENTATION-POLICY.md index f140375..90af2c7 100644 --- a/dist/.straymark/00-governance/i18n/es/DOCUMENTATION-POLICY.md +++ b/dist/.straymark/00-governance/i18n/es/DOCUMENTATION-POLICY.md @@ -312,4 +312,4 @@ Ver también [ADR-2025-01-20-001] para contexto arquitectónico. --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/es/FOLLOW-UPS-BACKLOG-PATTERN.md b/dist/.straymark/00-governance/i18n/es/FOLLOW-UPS-BACKLOG-PATTERN.md index 913a8f4..0498949 100644 --- a/dist/.straymark/00-governance/i18n/es/FOLLOW-UPS-BACKLOG-PATTERN.md +++ b/dist/.straymark/00-governance/i18n/es/FOLLOW-UPS-BACKLOG-PATTERN.md @@ -356,4 +356,4 @@ Contribuido vía [issue #111](https://github.com/StrangeDaysTech/straymark/issue --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/es/QUICK-REFERENCE.md b/dist/.straymark/00-governance/i18n/es/QUICK-REFERENCE.md index 9e11f52..05941e7 100644 --- a/dist/.straymark/00-governance/i18n/es/QUICK-REFERENCE.md +++ b/dist/.straymark/00-governance/i18n/es/QUICK-REFERENCE.md @@ -63,7 +63,7 @@ Los Charters **no** son tipos de documento — envuelven un bloque de implementa | Concepto | Carpeta | Autonomía del Agente | |----------|---------|---------------------| -| `Charter` | `.straymark/charters/` (declarativo `NN-slug.md` + telemetría `NN-slug.telemetry.yaml`) | Andamiar vía `charter new`; el operador es dueño del trigger y de las transiciones de ciclo de vida | +| `Charter` | `.straymark/charters/` (declarativo `NN-slug.md` + telemetría `CHARTER-NN.telemetry.yaml`) | Andamiar vía `charter new`; el operador es dueño del trigger y de las transiciones de ciclo de vida | > Ver sección 15 de `STRAYMARK.md` y `.straymark/00-governance/SPECKIT-CHARTER-BRIDGE.md` para heurísticas de granularidad, ciclo de vida y el puente SpecKit ↔ Charter. @@ -155,7 +155,7 @@ Marcar `review_required: true` cuando: │ └── ethical-reviews/ ← ETH, DPIA ├── 08-security/ ← SEC ├── 09-ai-models/ ← MCARD -├── charters/ ← Charter (NN-slug.md + NN-slug.telemetry.yaml) +├── charters/ ← Charter (NN-slug.md + CHARTER-NN.telemetry.yaml) ├── follow-ups-backlog.md ← Registry de follow-ups (entradas FU-NNN, primera clase desde fw-4.21.0) └── templates/ ← Plantillas (incl. subdir charter/ + follow-ups-backlog.md) ``` @@ -235,4 +235,4 @@ Marcar `review_required: true` cuando: --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/zh-CN/AGENT-RULES.md b/dist/.straymark/00-governance/i18n/zh-CN/AGENT-RULES.md index 0df9850..fdfd92a 100644 --- a/dist/.straymark/00-governance/i18n/zh-CN/AGENT-RULES.md +++ b/dist/.straymark/00-governance/i18n/zh-CN/AGENT-RULES.md @@ -413,4 +413,4 @@ confidence: high | medium | low --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md b/dist/.straymark/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md index 22c3b19..dd8e077 100644 --- a/dist/.straymark/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md +++ b/dist/.straymark/00-governance/i18n/zh-CN/C4-DIAGRAM-GUIDE.md @@ -234,4 +234,4 @@ Rel(api, db, "Reads/Writes", "SQL") --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md b/dist/.straymark/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md index b895360..81f2e28 100644 --- a/dist/.straymark/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md +++ b/dist/.straymark/00-governance/i18n/zh-CN/DOCUMENTATION-POLICY.md @@ -311,4 +311,4 @@ review_outcome: approved # approved | revisions_requested | rejec --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/zh-CN/FOLLOW-UPS-BACKLOG-PATTERN.md b/dist/.straymark/00-governance/i18n/zh-CN/FOLLOW-UPS-BACKLOG-PATTERN.md index da41f24..0f17d12 100644 --- a/dist/.straymark/00-governance/i18n/zh-CN/FOLLOW-UPS-BACKLOG-PATTERN.md +++ b/dist/.straymark/00-governance/i18n/zh-CN/FOLLOW-UPS-BACKLOG-PATTERN.md @@ -356,4 +356,4 @@ straymark followups new --title "..." --origin "CHARTER-NN §Scope" [--bucket --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/.straymark/00-governance/i18n/zh-CN/QUICK-REFERENCE.md b/dist/.straymark/00-governance/i18n/zh-CN/QUICK-REFERENCE.md index 218ae7a..14d645d 100644 --- a/dist/.straymark/00-governance/i18n/zh-CN/QUICK-REFERENCE.md +++ b/dist/.straymark/00-governance/i18n/zh-CN/QUICK-REFERENCE.md @@ -63,7 +63,7 @@ Charter **不是**文档类型——它包裹一个跨多个会话的实施块 | 概念 | 目录 | 代理自主权 | |------|------|-----------| -| `Charter` | `.straymark/charters/`(声明式 `NN-slug.md` + 遥测 `NN-slug.telemetry.yaml`) | 通过 `charter new` 搭建脚手架;操作者拥有 trigger 与生命周期的转换 | +| `Charter` | `.straymark/charters/`(声明式 `NN-slug.md` + 遥测 `CHARTER-NN.telemetry.yaml`) | 通过 `charter new` 搭建脚手架;操作者拥有 trigger 与生命周期的转换 | > 参见 `STRAYMARK.md` 第 15 节及 `.straymark/00-governance/SPECKIT-CHARTER-BRIDGE.md`,了解粒度启发式、生命周期与 SpecKit ↔ Charter 桥接。 @@ -155,7 +155,7 @@ risk_level: low | medium | high | critical │ └── ethical-reviews/ ← ETH, DPIA ├── 08-security/ ← SEC ├── 09-ai-models/ ← MCARD -├── charters/ ← Charter(NN-slug.md + NN-slug.telemetry.yaml) +├── charters/ ← Charter(NN-slug.md + CHARTER-NN.telemetry.yaml) ├── follow-ups-backlog.md ← Follow-ups 注册表(FU-NNN 条目,自 fw-4.21.0 起为一等公民) └── templates/ ← 模板(包括 charter/ 子目录 + follow-ups-backlog.md) ``` @@ -235,4 +235,4 @@ risk_level: low | medium | high | critical --- -*StrayMark fw-4.42.0 | [Strange Days Tech](https://strangedays.tech)* +*StrayMark fw-4.43.0 | [Strange Days Tech](https://strangedays.tech)* diff --git a/dist/STRAYMARK.md b/dist/STRAYMARK.md index 14c7531..f0462a1 100644 --- a/dist/STRAYMARK.md +++ b/dist/STRAYMARK.md @@ -284,7 +284,7 @@ related: │ ├── charters/ ← CHARTERS — BOUNDED UNITS OF WORK │ ├── [NN-slug.md] # Declarative ex-ante scope (filename: NN-slug.md, NOT TYPE-YYYY-...) -│ └── [NN-slug.telemetry.yaml] # Post-close telemetry (created by `straymark charter close`) +│ └── [CHARTER-NN.telemetry.yaml] # Post-close telemetry (created by `straymark charter close`) │ ├── templates/ ← TEMPLATES (12 base + 4 China* + Charter) @@ -382,7 +382,7 @@ Charters are **conceptually distinct** from the 12+4 document types listed in se - They live at `.straymark/charters/NN-slug.md` (sequential prefix, not date-prefix). - Their lifecycle is `declared` → `in-progress` → `closed`, persisted in frontmatter as the source of truth. -- Telemetry sits beside them as `NN-slug.telemetry.yaml`, written by `straymark charter close`. +- Telemetry sits beside them as `CHARTER-NN.telemetry.yaml`, written by `straymark charter close`. The slug is deliberately absent so the filename survives a Charter rename. - External audits resolve to `.straymark/audits/CHARTER-NN/` and merge back into telemetry. ### When to declare a Charter diff --git a/dist/dist-manifest.yml b/dist/dist-manifest.yml index bb6ab30..05accd6 100644 --- a/dist/dist-manifest.yml +++ b/dist/dist-manifest.yml @@ -1,4 +1,4 @@ -version: "4.42.0" +version: "4.43.0" description: "StrayMark distribution manifest" repository: "https://github.com/StrangeDaysTech/straymark" diff --git a/docs/adopters/CLI-REFERENCE.md b/docs/adopters/CLI-REFERENCE.md index 8913e56..f4a47e0 100644 --- a/docs/adopters/CLI-REFERENCE.md +++ b/docs/adopters/CLI-REFERENCE.md @@ -45,8 +45,8 @@ StrayMark uses **independent version tags** for each component: | Component | Tag prefix | Example | What it includes | |-----------|-----------|---------|------------------| -| Framework | `fw-` | `fw-4.42.0` | Templates (12 types), governance docs, directives, Charter template + schema | -| CLI | `cli-` | `cli-3.44.0` | The `straymark` binary | +| Framework | `fw-` | `fw-4.43.0` | Templates (12 types), governance docs, directives, Charter template + schema | +| CLI | `cli-` | `cli-3.45.0` | The `straymark` binary | | Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | The `straymark-loom` visualization server, downloaded on demand by `straymark loom serve` | Framework and CLI are released independently. A framework update does not require a CLI update, and vice versa. diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md index 327b8d8..5299ffb 100644 --- a/docs/i18n/es/README.md +++ b/docs/i18n/es/README.md @@ -241,8 +241,8 @@ StrayMark usa tags de versión independientes para cada componente: | Componente | Prefijo de tag | Ejemplo | Incluye | |------------|---------------|---------|---------| -| Framework | `fw-` | `fw-4.42.0` | Plantillas (12 tipos), gobernanza, directivas, plantilla + schema de Charter | -| CLI | `cli-` | `cli-3.44.0` | El binario `straymark` | +| Framework | `fw-` | `fw-4.43.0` | Plantillas (12 tipos), gobernanza, directivas, plantilla + schema de Charter | +| CLI | `cli-` | `cli-3.45.0` | El binario `straymark` | | Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | El servidor de visualización `straymark-loom`, descargado bajo demanda por `straymark loom serve` | Verifica las versiones instaladas con `straymark status` o `straymark about`. diff --git a/docs/i18n/es/adopters/CLI-REFERENCE.md b/docs/i18n/es/adopters/CLI-REFERENCE.md index 2b50d96..bd04265 100644 --- a/docs/i18n/es/adopters/CLI-REFERENCE.md +++ b/docs/i18n/es/adopters/CLI-REFERENCE.md @@ -45,8 +45,8 @@ StrayMark usa **tags de versión independientes** para cada componente: | Componente | Prefijo de tag | Ejemplo | Qué incluye | |------------|---------------|---------|-------------| -| Framework | `fw-` | `fw-4.42.0` | Plantillas (12 tipos), docs de gobernanza, directivas | -| CLI | `cli-` | `cli-3.44.0` | El binario `straymark` | +| Framework | `fw-` | `fw-4.43.0` | Plantillas (12 tipos), docs de gobernanza, directivas | +| CLI | `cli-` | `cli-3.45.0` | El binario `straymark` | | Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | El servidor de visualización `straymark-loom`, descargado bajo demanda por `straymark loom serve` | Framework y CLI se publican de forma independiente. Una actualización del framework no requiere actualización del CLI, y viceversa. diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 8aea7a1..dd633d7 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -259,8 +259,8 @@ StrayMark 为每个组件使用独立的版本标签: | 组件 | 标签前缀 | 示例 | 包含内容 | |------|----------|------|----------| -| Framework | `fw-` | `fw-4.42.0` | 模板(12 种类型)、治理文档、指令、Charter 模板 + schema | -| CLI | `cli-` | `cli-3.44.0` | `straymark` 二进制文件 | +| Framework | `fw-` | `fw-4.43.0` | 模板(12 种类型)、治理文档、指令、Charter 模板 + schema | +| CLI | `cli-` | `cli-3.45.0` | `straymark` 二进制文件 | | Loom(实验性) | `loom-` | `loom-0.4.2` | `straymark-loom` 可视化服务器,由 `straymark loom serve` 按需下载 | 使用 `straymark status` 或 `straymark about` 查看已安装的版本。 diff --git a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md index cee59d1..6d4f5af 100644 --- a/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md +++ b/docs/i18n/zh-CN/adopters/CLI-REFERENCE.md @@ -45,8 +45,8 @@ StrayMark 为每个组件使用**独立的版本标签**: | 组件 | 标签前缀 | 示例 | 包含内容 | |------|----------|------|----------| -| Framework | `fw-` | `fw-4.42.0` | 模板(12 种类型)、治理文档、指令 | -| CLI | `cli-` | `cli-3.44.0` | `straymark` 二进制文件 | +| Framework | `fw-` | `fw-4.43.0` | 模板(12 种类型)、治理文档、指令 | +| CLI | `cli-` | `cli-3.45.0` | `straymark` 二进制文件 | | Loom(实验性) | `loom-` | `loom-0.4.2` | `straymark-loom` 可视化服务器,由 `straymark loom serve` 按需下载 | Framework 和 CLI 独立发布。Framework 更新不需要 CLI 更新,反之亦然。