Skip to content

fix(stow): add opt-in pass horizon for memory decay - #2850

Merged
kunchenguid merged 4 commits into
kunchenguid:mainfrom
karotkriss:fm/fm-2808-drain-fold
Aug 23, 2026
Merged

fix(stow): add opt-in pass horizon for memory decay#2850
kunchenguid merged 4 commits into
kunchenguid:mainfrom
karotkriss:fm/fm-2808-drain-fold

Conversation

@karotkriss

@karotkriss karotkriss commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Intent

Fix #2410: the /stow skill's tiered memory-decay clock never fires in a home that stows daily, so data/learnings.md only grows and the startup-memory budget never converges. The reporter measured eight consecutive passes going 12,325 -> 12,733 estimated tokens against a 7,500 budget, with all 40 entries carrying last-reinforced dates 0-3 days old, so no entry could ever age out.

Root cause: admission and decay are not commensurable. A pass admits the findings that pass produced, so growth is a per-pass quantity, while the only decay horizon was wall-clock (30 days aging, 7 days perishable). In a home that stows daily those rates diverge by the stow cadence, an entry the fleet keeps exercising never sits unreinforced for 30 wall-clock days, and the date horizon is evaluated vacuously every pass.

This revision follows maintainer triage of the first attempt, which was ineligible because it made the pass horizon a new default archival cadence. New capability should arrive opt-in, so the wall-clock contract is preserved exactly as the only default and the pass horizon becomes something a home switches on.

Default, unchanged: an aging entry is stale at >= 30 days since its last-reinforced date and a perishable entry at >= 7 days. While the opt-in is absent, no unreinforced-pass counter is ever written and no counter already present in a file is ever read.

Opt-in: an aging entry additionally becomes stale after 10 passes that evaluated it without reinforcing it, and a perishable entry after 3, whichever horizon it reaches first.

The two skill surfaces are deliberately independent files with no shared code, so each opts in through its own existing convention:

  • .agents/skills/stow/SKILL.md gates the horizon on the local, gitignored config/stow-pass-horizon presence flag, matching the shape of config/trace-context. It is registered in AGENTS.md's layout and documented in a new docs/configuration.md section. It is per home and not inherited by secondmate homes, because stow cadence is a property of the home doing the stowing.
  • skills/stow/SKILL.md is installer-facing and cannot see a Firstmate config directory, so it reuses the per-file header pointer that already optionally names a file's default tier: <!-- memory tiers: see the stow skill; pass horizon -->, one file at a time, and the skill never adds that opt-in on its own initiative.

The optional marker spelling is <!--a:YYYY-MM-DD/N-->, where an absent /N reads as zero, so opting in needs no migration. Reinforcement refreshes the date and clears the counter, and nothing else clears it, so the pre-existing evidence-based restamp hard rule remains the only way an entry renews its lease. Removing the opt-in freezes any /N already written and preserves it byte-for-byte rather than normalizing it away. Archive provenance records the counter and the exact unreinforced <N>p reason only when the pass horizon is what made the entry stale, and omits it when the wall clock or any other reason caused archival.

Deliberately unchanged: the decay clocks live in skill policy text rather than in any executable, so this adds no script and no parser. No test is added because there is no executable decay consumer and the repo's guidelines forbid tests that assert instruction-source bytes, so the behavioral evidence is the deterministic simulation below. The last-reinforced date is retained because budget eviction is oldest-reinforced-first, and docs/verification/stow-memory.md is untouched because its guarantee is about git-excluded skill discovery.

What Changed

  • Preserve the default 30-day aging and 7-day perishable clocks while adding opt-in pass horizons of 10 and 3 unreinforced passes.
  • Enable the horizon per Firstmate home through config/stow-pass-horizon, or per public-skill memory file through its header pointer.
  • Track optional pass counters without migration, clear them only on evidence-based reinforcement, freeze them when disabled, and record pass-based archive provenance.

Fixes #2410

Risk Assessment

✅ Low: The change cleanly preserves default wall-clock decay while adding the bounded pass horizon behind explicit opt-ins with correct counter freezing and archive provenance.

Testing

The supplied simulation was rerun, the corrected amended simulation exercised all eight acceptance scenarios plus archive provenance, its output matched the recorded evidence byte for byte, the PR body matched that evidence, and the overall targeted validation passed.

Evidence: Amended stow decay simulation transcript

Source: Amended stow decay simulation transcript

Stow decay policy simulation (amended opt-in acceptance)
========================================================
1. Defaults unchanged for all 60 daily passes: True; 40 -> 132
2. Daily home with opt-in is bounded: 40 -> 82
3. Monthly serialization identical either way: True; 40 -> 2
4. Exact thresholds: aging 10 (unreinforced 10p); perishable 3 (unreinforced 3p)
5. Default aging entry survives 29 passes with counter 0, then archives on day 30 (unreinforced 30d)
6. Evidence on pass 9 refreshes day to 9 and clears counter to 0
7. Legacy marker has implicit zero and default serialization: <!--a:2026-01-01-->
8. Removing opt-in freezes and byte-preserves the unread counter: <!--a:2026-01-01/5--> == <!--a:2026-01-01/5-->
Provenance: pass reasons carry exact counter spelling; wall-clock reasons omit the counter
Evidence: Reproducible amended simulation source

Source: Reproducible amended simulation source

#!/usr/bin/env python3
"""Deterministic acceptance model for issue #2410's opt-in policy."""

from dataclasses import dataclass
from datetime import date, timedelta


PASS_HORIZON = {"aging": 10, "perishable": 3}
DAY_HORIZON = {"aging": 30, "perishable": 7}


@dataclass
class Entry:
    entry_id: int
    tier: str
    reinforced_day: int
    counter: int = 0
    usage: str = "never"


def evidence(entry: Entry, pass_number: int) -> bool:
    if entry.usage == "frequent":
        return pass_number % 5 == entry.entry_id % 5
    if entry.usage == "occasional":
        return pass_number % 16 == entry.entry_id % 16
    return False


def usage(entry_id: int) -> str:
    slot = entry_id % 40
    return "frequent" if slot < 14 else "occasional" if slot < 26 else "never"


def marker(entry: Entry, opted_in: bool) -> str:
    stamp = date(2026, 1, 1) + timedelta(days=entry.reinforced_day)
    # Opt-out freezes an existing counter byte-for-byte. It is not interpreted.
    suffix = f"/{entry.counter}" if entry.counter else ""
    return f"<!--{entry.tier[0]}:{stamp.isoformat()}{suffix}-->"


def population(opted_in: bool, cadence: int) -> tuple[list[int], list[str]]:
    entries = {
        i: Entry(i, "aging", 0, usage=usage(i))
        for i in range(40)
    }
    next_id = 40
    counts = [40]
    states = []
    for pass_number in range(1, 61):
        day = pass_number * cadence
        for entry_id, entry in list(entries.items()):
            if evidence(entry, pass_number):
                entry.reinforced_day = day
                entry.counter = 0
            elif opted_in:
                entry.counter += 1
            stale_by_day = day - entry.reinforced_day >= DAY_HORIZON[entry.tier]
            stale_by_pass = opted_in and entry.counter >= PASS_HORIZON[entry.tier]
            if stale_by_day or stale_by_pass:
                del entries[entry_id]
        for _ in range(2):
            entries[next_id] = Entry(next_id, "aging", day, usage=usage(next_id))
            next_id += 1
        counts.append(len(entries))
        states.append("\n".join(f"{i}:{marker(e, opted_in)}" for i, e in sorted(entries.items())))
    return counts, states


def pre_change(cadence: int) -> tuple[list[int], list[str]]:
    entries = {
        i: Entry(i, "aging", 0, usage=usage(i))
        for i in range(40)
    }
    next_id = 40
    counts = [40]
    states = []
    for pass_number in range(1, 61):
        day = pass_number * cadence
        for entry_id, entry in list(entries.items()):
            if evidence(entry, pass_number):
                entry.reinforced_day = day
            if day - entry.reinforced_day >= DAY_HORIZON[entry.tier]:
                del entries[entry_id]
        for _ in range(2):
            entries[next_id] = Entry(next_id, "aging", day, usage=usage(next_id))
            next_id += 1
        counts.append(len(entries))
        states.append("\n".join(f"{i}:{marker(e, False)}" for i, e in sorted(entries.items())))
    return counts, states


def advance(
    tier: str,
    passes: int,
    opted_in: bool,
    evidence_on: set[int] | None = None,
    opt_out_after: int | None = None,
) -> tuple[Entry | None, int | None, str | None]:
    entry = Entry(1, tier, 0)
    evidence_on = evidence_on or set()
    for pass_number in range(1, passes + 1):
        enabled = opted_in and (opt_out_after is None or pass_number <= opt_out_after)
        if pass_number in evidence_on:
            entry.reinforced_day = pass_number
            entry.counter = 0
        elif enabled:
            entry.counter += 1
        by_day = pass_number - entry.reinforced_day >= DAY_HORIZON[tier]
        by_pass = enabled and entry.counter >= PASS_HORIZON[tier]
        if by_day or by_pass:
            reason = f"unreinforced {entry.counter}p" if by_pass and not by_day else f"unreinforced {pass_number - entry.reinforced_day}d"
            return None, pass_number, reason
    return entry, None, None


old_daily, old_daily_states = pre_change(1)
default_daily, default_daily_states = population(False, 1)
optin_daily, _ = population(True, 1)
default_monthly, default_monthly_states = population(False, 30)
optin_monthly, optin_monthly_states = population(True, 30)

assert default_daily_states == old_daily_states
assert default_daily == old_daily and default_daily[-1] == 132
assert optin_daily[-1] == 82
assert default_monthly_states == optin_monthly_states

aging_9, _, _ = advance("aging", 9, True)
_, aging_fire, aging_reason = advance("aging", 10, True)
perishable_2, _, _ = advance("perishable", 2, True)
_, perishable_fire, perishable_reason = advance("perishable", 3, True)
assert aging_9 and aging_9.counter == 9 and aging_fire == 10
assert perishable_2 and perishable_2.counter == 2 and perishable_fire == 3
assert aging_reason == "unreinforced 10p"
assert perishable_reason == "unreinforced 3p"

day_29, _, _ = advance("aging", 29, False)
_, day_30, day_reason = advance("aging", 30, False)
assert day_29 and day_29.counter == 0 and day_30 == 30
assert day_reason == "unreinforced 30d"

reinforced, _, _ = advance("aging", 9, True, evidence_on={9})
assert reinforced and reinforced.reinforced_day == 9 and reinforced.counter == 0

legacy = Entry(1, "aging", 0)
assert marker(legacy, True) == "<!--a:2026-01-01-->"

frozen, _, _ = advance("aging", 12, True, opt_out_after=5)
assert frozen and frozen.counter == 5
before_opt_out = marker(frozen, True)
after_opt_out = marker(frozen, False)
assert before_opt_out == after_opt_out == "<!--a:2026-01-01/5-->"

print("Stow decay policy simulation (amended opt-in acceptance)")
print("========================================================")
print(f"1. Defaults unchanged for all 60 daily passes: {default_daily_states == old_daily_states}; 40 -> {default_daily[-1]}")
print(f"2. Daily home with opt-in is bounded: 40 -> {optin_daily[-1]}")
print(f"3. Monthly serialization identical either way: {default_monthly_states == optin_monthly_states}; 40 -> {default_monthly[-1]}")
print(f"4. Exact thresholds: aging {aging_fire} ({aging_reason}); perishable {perishable_fire} ({perishable_reason})")
print(f"5. Default aging entry survives 29 passes with counter {day_29.counter}, then archives on day {day_30} ({day_reason})")
print(f"6. Evidence on pass 9 refreshes day to {reinforced.reinforced_day} and clears counter to {reinforced.counter}")
print(f"7. Legacy marker has implicit zero and default serialization: {marker(legacy, False)}")
print(f"8. Removing opt-in freezes and byte-preserves the unread counter: {before_opt_out} == {after_opt_out}")
print("Provenance: pass reasons carry exact counter spelling; wall-clock reasons omit the counter")
- Outcome: 🔧 2 issues found → auto-fixed (3) ✅ across 4 runs (16m55s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed ✅
  • 🚨 skills/stow/SKILL.md:97 - The required opt-out invariant says “removing the opt-in freezes any /N already written rather than rewriting it,” but this changed text says an unopted file “never carries one,” and the only later rule says not to read or write it. Trace &lt;!--a:2026-08-01/6--&gt; after removing ; pass horizon: an agent is expressly told the file cannot carry /6 and may normalize it to the date-only marker, so the counter is not guaranteed to survive re-enabling. Replace “never carries one” with “never writes one” and explicitly require an existing /N to remain byte-preserved while the header opt-in is absent, matching the internal skill’s removal rule.
  • 🚨 .agents/skills/stow/SKILL.md:135 - The required provenance contract says the counter and unreinforced &lt;N&gt;p reason appear only when the pass horizon fired, but both skill surfaces currently include the counter whenever the marker carried one, and the public surface does not require the exact unreinforced &lt;N&gt;p spelling. For an opted-in monthly pass, a legacy marker at day 30 is incremented from 0 to 1 and then archives on the wall-clock horizon; this rule serializes counter 1, so opted-in and default monthly output differ despite the required identical serialization. Require both the counter and exact pass reason only for pass-horizon archival, and omit the counter when the date or another reason caused archival.

🔧 Fix: Preserve frozen counters and correct archive provenance
✅ Re-checked - no issues remain.

🔧 **Test** - 2 issues found → auto-fixed (3) ✅
  • 🚨 /tmp/fm-fm-2410-optin-amend/stow-decay-simulation.py:31 - The amended simulation does not demonstrate byte-preserving opt-out. marker(entry, False) removes an existing /N; a focused assertion expected &lt;!--a:2026-01-01/5--&gt; but received &lt;!--a:2026-01-01--&gt;. The simulation only preserves the in-memory counter, so requirement (8) remains unproven and its serialization model contradicts the policy.
  • 🚨 PR fix(stow): add opt-in pass horizon for memory decay #2850 still describes the superseded default-on dual-horizon design and embeds the old simulation transcript. It does not describe the internal config/stow-pass-horizon presence flag or public per-file header opt-in. Fixes #2410 is present, but the body must be amended to the accepted opt-in shape.
  • Inspected git diff 505c8195122b6d3e3a04fa48c13cd184df0321ba..a5a242571fe7e232fc32b8c8f644b99290930b56.
  • Ran python3 /tmp/fm-fm-2410-optin-amend/stow-decay-simulation.py and recorded its transcript.
  • Ran a focused Python assertion against marker(entry, False) with an existing counter of 5 to verify byte-preserving opt-out serialization.
  • Ran gh-axi pr view 2850 --full to verify the live PR description and Fixes #2410.
  • Ran git status --short after testing to confirm no worktree artifacts were introduced.

🔧 Fix: Captain: preserve frozen counters in opt-out simulation
1 error still open:

  • 🚨 PR fix(stow): add opt-in pass horizon for memory decay #2850 still describes the superseded default-on dual-horizon behavior in its Intent, What Changed, Risk Assessment, Testing, and embedded simulation. It does not explain the internal config/stow-pass-horizon presence flag or public per-file header opt-in, and its embedded model removes /N during opt-out instead of byte-preserving it. Update the body and evidence using the amended opt-in transcript. Fixes #2410 is already present and must remain.
  • Inspected git diff 505c8195122b6d3e3a04fa48c13cd184df0321ba..a5a242571fe7e232fc32b8c8f644b99290930b56 for both stow skills, configuration documentation, and AGENTS.md.
  • Ran python3 /tmp/fm-fm-2410-optin-amend/stow-decay-simulation.py.
  • Ran python3 /home/cmckay/.no-mistakes/evidence/01M0QDY41DXAETXDHB4ABPK8XW/stow-decay-simulation-amended.py.
  • Inspected the complete PR body with gh-axi pr view 2850 --full.
  • Verified git status --short remained empty and git rev-parse HEAD remained a5a242571fe7e232fc32b8c8f644b99290930b56.

🔧 Fix: Update PR evidence for opt-in stow horizon
1 error still open:

  • 🚨 /tmp/fm-fm-2410-optin-amend/stow-decay-simulation.py:31 - The supplied amended simulation still contradicts required scenario 8. After a counter reaches /5, marker(entry, False) serializes &lt;!--a:2026-01-01--&gt; instead of byte-preserving &lt;!--a:2026-01-01/5--&gt;. The PR body claims the corrected scenario passed, so its recorded evidence is not reproducible from the named source.
  • python3 /tmp/fm-fm-2410-optin-amend/stow-decay-simulation.py
  • Focused Python assertion of marker(Entry(..., unreinforced_passes=5), False) == &#39;&lt;!--a:2026-01-01/5--&gt;&#39;
  • gh-axi pr view 2850 --full
  • Manual comparison of the target diff against the eight behavioral acceptance scenarios

🔧 Fix: Verify byte-preserving opt-out counter simulation
✅ Re-checked - no issues remain.

  • Inspected git diff 505c8195122b6d3e3a04fa48c13cd184df0321ba..a5a242571fe7e232fc32b8c8f644b99290930b56 for the four changed policy and configuration files
  • python3 /tmp/fm-fm-2410-optin-amend/stow-decay-simulation.py
  • python3 /home/cmckay/.no-mistakes/evidence/01M0QDY41DXAETXDHB4ABPK8XW/stow-decay-simulation-amended.py
  • diff -u /home/cmckay/.no-mistakes/evidence/01M0QDY41DXAETXDHB4ABPK8XW/stow-decay-simulation-amended.txt <(python3 /home/cmckay/.no-mistakes/evidence/01M0QDY41DXAETXDHB4ABPK8XW/stow-decay-simulation-amended.py)
  • Inspected gh-axi pr view 2850 --full to verify the PR body describes the accepted opt-in design, includes the reproducible transcript, and retains Fixes #2410
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 1)
✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Aug 23, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up review scope.

No blocking failure remains.

Reviews (2): Last reviewed commit: "no-mistakes(review): Preserve frozen cou..." | Re-trigger Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Scheduled 3:10am PT 8/23 pass. Main reconfirmed 8714c9a78c1b4355782fcb9ce1ccf14337478268 (#2811 squash). VISION.md read in full from that SHA.

VISION (inspected .agents/skills/stow/SKILL.md and skills/stow/SKILL.md). Per-rule: token efficiency aligns (startup memory that only grows spends attention every session); restart is a non-event / compact operating map aligns (decay exists so the next session is not an accumulating journal); scripts vs judgment aligns (clocks stay skill policy, no new executable adjudicating meaning); field incidents become coverage cannot tell (no executable decay consumer; simulation is PR evidence, not a repo test); new capability as opt-in does not align — every /stow pass now archives on a new default 10/3 unreinforced-pass horizon, not behind a flag. Dual wall-clock retention is compatible with rare-stow homes, but daily-stow archival cadence is a product default.

Class: default-behavior. NEVER auto-eligible. The 30-day clock never firing is the reported defect, but the chosen fix is a new default policy (N=10 aging / N=3 perishable), not restoration of the existing wall-clock contract. Issue #2410 ready-for-pr asked for a horizon that fires in daily-stow homes without dropping evidence-based restamp; that is a queue label, not a merge vote.

Security: none. Skill policy text only. No .github files, no secrets, no workflow injection.

Overlap / HOLD: none of the standing spawn/teardown/herdr holds. No bin/backends/herdr.sh, no fm-spawn.sh. Does not overlap #2637/#2692/#2760/#2770/#2768/#2622/#2693/#2154/#2586/#2804/#2827/#2829.

CI / NM: HEAD 71eacbb539fec5e78ce58546e5f482b0ebe596bd. MERGEABLE / CLEAN, ahead 2 / behind 0. Matching no-mistakes-pipeline-attestation:v1 for THIS HEAD. Require no-mistakes SUCCESS (runs 32629683354, 32630470746). CI run 32629683320 all SUCCESS including Lint. Greptile SUCCESS — not a gate. Pipeline lint warning in the body is not a CI failure.

Workflows: already approved (CI completed SUCCESS on this HEAD). Run IDs: 32629683320 (CI), 32629683354 (Require no-mistakes), 32630470746 (Require no-mistakes). No pending first-time-fork approval.

Land-eligible rec: NO (default-behavior). Captain-flag NOW: yes — N=10/3 as a default archival cadence is a product call; firstmate already asked for a pass-count horizon on #2410, but that is not consent to land a new default.

The tiered decay clocks were wall-clock only, while admission is per-pass:
each /stow admits the findings that pass produced. In a home that stows
daily those two rates diverge by the stow cadence, an entry the fleet keeps
exercising never reaches 30 days unreinforced, and memory only grows while
the pass reports decay evaluated.

Give each dated marker an optional unreinforced-pass counter and make both
tiers stale at whichever horizon comes first: 10 passes or 30 days for
aging, 3 passes or 7 days for perishable. Reinforcement clears the counter
and nothing else does, so the existing evidence-based restamp rule stays
the only way an entry renews its lease. An absent /N means zero, so entries
that stay exercised carry no extra marker bytes, and a rarely stowed home
keeps its current behaviour through the unchanged date horizon.
The unreinforced-pass horizon shipped as a new default archival cadence,
which is a product default rather than a restoration of the existing
wall-clock contract. Keep the 30-day and 7-day horizons as the only
default clock, and put the 10-pass and 3-pass horizons behind an explicit
opt-in: config/stow-pass-horizon for the firstmate home, and the file's
own header pointer for the public skill.

With the opt-in absent no counter is written and no counter is read, so a
home that does not ask for it decays exactly as it does today.
@karotkriss
karotkriss force-pushed the fm/fm-2808-drain-fold branch from 71eacbb to a5a2425 Compare August 23, 2026 14:13
@karotkriss karotkriss changed the title fix(stow): bound memory decay by unreinforced passes fix(stow): add opt-in pass horizon for memory decay Aug 23, 2026
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Re-inspect after the 3:10am flag. Title and HEAD moved: now a5a242571fe7, files include AGENTS.md + docs/configuration.md. Inspected the stow skill: wall-clock 30/7 remains the default; config/stow-pass-horizon is a local gitignored presence flag. Class is now opt-in, not default-behavior.

VISION (current main 266fdb9654d8): authority never inferred / new capability as option aligns. Token efficiency aligns for homes that stow daily. Restart/durable records align. Default contract unchanged.

Matching no-mistakes-pipeline-attestation:v1 for THIS HEAD. NM SUCCESS. CI not green yet (serials pending at stamp). MERGEABLE / UNSTABLE. No standing-hold overlap.

Not land-eligible this pass. When CI is fully green this is auto-eligible as opt-in. Not waiting on a captain product call anymore.

@karotkriss

Copy link
Copy Markdown
Contributor Author

Amended per the triage: the unreinforced-pass horizon is now opt-in, and defaults are unchanged.

The wall-clock contract is restored as the only default - aging stale at >= 30 days since its last-reinforced date, perishable at >= 7. While the opt-in is absent, no pass counter is ever written and no counter already present in a file is ever read. The N=10/3 horizon now arrives only where it is asked for, and because the two skill surfaces are deliberately independent files, each uses its own existing convention:

  • Internal skill - the local, gitignored config/stow-pass-horizon presence flag, matching the shape of config/trace-context, registered in AGENTS.md's layout and documented in a new docs/configuration.md section. Per home, and not inherited by secondmate homes, since stow cadence is a property of the home doing the stowing.
  • Public installer-facing skill - the per-file header pointer that already optionally names a file's default tier: <!-- memory tiers: see the stow skill; pass horizon -->, one file at a time, and never added on the skill's own initiative.

Still policy text only: no executable decay consumer, no new script or parser.

On the "field incidents become coverage - cannot tell" rule, that is unchanged and for the same reason - nothing executable implements decay, and the coding guidelines forbid tests that assert instruction-source bytes, so the evidence stays a deterministic simulation rather than a repo test. It now leads with default-equivalence: with the opt-in absent, 60 daily passes are byte-identical to a separately written pre-change wall-clock-only model (40 -> 132 entries), against 40 -> 82 with the opt-in on, and a monthly-cadence home serializes identically either way.

Two contract gaps surfaced during this round and are fixed here, both worth noting since they bear directly on "defaults unchanged":

  • An opted-out file could have normalized an existing /N away rather than leaving it alone; an existing counter is now byte-preserved while the opt-in is absent.
  • Archive provenance recorded the counter whenever the marker carried one, so an opted-in monthly home archiving on the wall-clock horizon serialized a counter where a default home serialized none. The counter and the exact unreinforced <N>p reason are now scoped to pass-horizon archival only.

Fixes #2410 is unchanged.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Scheduled 7:10am PT 8/23 pass. VISION.md was read in full from then-main 505c8195122b6d3e3a04fa48c13cd184df0321ba (#2846). Current main is now 266fdb9654d8e19f5f17e21794e03dd48ad31ae6 (#2838 then #2837 squash). VISION.md is unchanged by those landings. Issue #2410 remains ready-for-pr; that is a queue label, not a merge vote. No captain comment authorizing a merge. Author confirmed the opt-in amend on-thread at 14:32Z.

Newer activity since 3:10am and since the 14:23Z waiting-CI stamp: HEAD a5a242571fe7e232fc32b8c8f644b99290930b56. Title is now "fix(stow): add opt-in pass horizon for memory decay". Default 30-day aging / 7-day perishable clocks are preserved. CI run 32644793587 has since completed SUCCESS.

VISION (inspected .agents/skills/stow/SKILL.md, skills/stow/SKILL.md, AGENTS.md, docs/configuration.md). Per-rule: token efficiency aligns (a daily-stow home can bound memory once it opts in); restart is a non-event / compact operating map aligns; scripts vs judgment aligns (clocks stay skill policy, no new executable adjudicating meaning); field incidents become coverage cannot tell (no executable decay consumer; simulation is PR evidence, not a repo test); new capability as opt-in aligns — wall-clock 30/7 remains the only default; the 10/3 unreinforced-pass horizon is behind config/stow-pass-horizon (internal, gitignored, not inherited) or a public-skill header pointer (<!-- memory tiers: see the stow skill; pass horizon -->), and the public skill must never add that pointer on its own initiative. Absent the flag, no counter is written or read.

Class: opt-in. Auto-eligible class. The previous default-behavior 10/3-on-every-/stow shape is gone.

Security: none. Skill policy text, AGENTS.md layout line, and configuration docs only. No .github files, no secrets, no workflow injection.

Overlap / HOLD: none of the standing spawn/teardown/herdr holds. No bin/backends/herdr.sh, no fm-spawn.sh, no fm-teardown.sh. Shared docs/configuration.md with some open holds is a new disjoint section. Does not overlap #2637/#2692/#2760/#2770/#2768/#2622/#2693/#2154/#2586/#2804/#2827/#2829. File-disjoint from just-landed #2838/#2837.

CI / NM: HEAD a5a242571fe7e232fc32b8c8f644b99290930b56. MERGEABLE / CLEAN, ahead 4 / behind 2 vs current main. Matching no-mistakes-pipeline-attestation:v1 for THIS HEAD. Require no-mistakes SUCCESS (runs 32644793598, 32644837231). CI run 32644793587 all SUCCESS including Lint and Behavior timing aggregate. Greptile SUCCESS — not a gate. Pipeline lint warning in the body is not a CI failure. Behind 2 is #2838/#2837; no file overlap, so no rebase this pass.

Workflows: already approved (CI completed SUCCESS on this HEAD). Run IDs: 32644793587 (CI), 32644793598 (Require no-mistakes), 32644837231 (Require no-mistakes, body edited). No pending first-time-fork approval.

Land-eligible rec: YES (opt-in; default clocks unchanged; matching NM attestation; green CI; no hold overlap). Captain-flag NOW: no — the 3:10am product call was the default-on horizon; that default is now off.

@kunchenguid
kunchenguid merged commit f170ced into kunchenguid:main Aug 23, 2026
16 checks passed
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is merged. Thank you @karotkriss — really appreciate you taking the time on this.

doitdigital0495 added a commit to doitdigital0495/firstmate that referenced this pull request Aug 23, 2026
* fix(bearings): restore decision options and add close controls (kunchenguid#2707)

* fix(bearings): always show decision options and a close/drop control

Freeform-only Captain's Call cards hid the option buttons the board was designed around, and there was no way to drop a stale hold without inventing an answer. Require selectable options, keep freeform as a supplement, and route the reserved __drop__ answer through decline so the hold leaves Captain's Call.

* no-mistakes(review): Fix drop closure and decision-only option validation

* no-mistakes(review): Preserve answerability for non-decision cards

* no-mistakes(document): Clarify decision drop documentation

* ci: require no-mistakes pipeline step attestation (kunchenguid#2710)

Signature-only PRs can hide skipped review, test, or document steps. Fail unless no-mistakes >= 1.46.0 attests those three steps completed.

* feat: collapse decisions into tasks held for the captain (kunchenguid#2728)

* feat(captain-hold): collapse the decisions concept into tasks held for the captain

A decision is no longer a separate type: it is an ordinary backlog task held
for the captain, identified by its task id. bin/fm-captain-hold.sh owns the
surviving behaviors - guarded hold creation, the recorded-answer close
(answer/answers with a release mode for captain-gated work), the source
bindings, and the investigation completion gate - and bin/fm-decision-hold.sh
becomes a one-release compatibility shim over it.

The fleet snapshot now parses hold-until and computes captain_actionable as
queued + captain-held + unblocked + due, independent of row kind, plus a
presentation-only deferred_marker for prose-deferred rows. Bearings renders
every due captain-held task in Captain's Call, date-deferred holds as dated
Charted Next gates, suppresses prose-deferred rows from default views with an
omitted disclosure, and excludes from Recently Landed anything that closed
while still held for the captain.

Legacy compatibility: pre-collapse <origin>-decision-<key> rows are already
plain task ids and keep working; short keys in recorded metadata, concrete
origin bindings, chat --resolve-key fallbacks, and old resolution records all
resolve in place.

* no-mistakes(review): Fix captain answer replay and body preservation

* no-mistakes(review): Fix captain hold idempotency and legacy replay

* no-mistakes(review): Validate card close modes and compatibility routing

* no-mistakes(review): Enforce release replay mode matching

* no-mistakes(review): Prevent duplicate decision cards and released replay mismatches

* no-mistakes(review): Preserve answer columns and legacy resolve replays

* no-mistakes(document): Document strict replay and legacy compatibility

* no-mistakes(lint): Quote done literals to satisfy ShellCheck

* no-mistakes: apply CI fixes

* fix(rebase): keep collapsed captain hold board semantics

* fix: bound recovery announcements and preserve supervision (kunchenguid#2733)

* fix(watch): announce recovery once per generation and keep successors supervising

A lost Pi/OpenCode handling handshake re-announced the same recovery
generation on every cycle and spent the successor's first ~55s blind, so
a real crew event could be ignored and then dropped. Record the
announcement in the durable marker, confirm the handshake before the
follow-up without swallowing failure, and enter the poll loop immediately.

* no-mistakes(review): Tighten recovery event timing regression

* no-mistakes(document): Document recovery-loop supervision guarantees

* fix(bin): surface captain-call record divergence (kunchenguid#2744)

* fix(bin): signal a captain call resolved in the log but still held

A captain call has two records and closing one has never closed the
other: a `resolved [key=...]` line closes the status-log fold, while the
backlog task held for the captain closes only through
`fm-captain-hold.sh answer`. Answering on the status side alone left no
trace of the disagreement - the fold went quiet, the durable record kept
saying the captain owed an answer, and nothing warned. The defect was
never the separation; it was the silence.

Add `fm-captain-hold.sh diverged`, a read-only report of that
contradiction, and print it from `fm-wake-drain.sh` as a bounded RECORD
DIVERGENCE section beside OPEN DECISIONS on every drain. It flags one
condition: a task still open and still carrying the captain-hold
annotations whose key was closed on the status side by the resolve verb,
under the collapsed identity or the legacy derived one.

It closes nothing, ever. A captain call closed wrongly leaves review
entirely, which is worse than the noise, so both reconciliation
directions stay human-owned and the printed hint names both - a
resolution is not proof the captain ruled, since a call can dissolve on a
false premise or turn out to have been a question of fact.

Three states are deliberately not divergence: a `captain-held` close is
the verified transfer `complete` writes, a still-open keyed decision
belongs to the OPEN DECISIONS fold, and a captain call with no routed
work item is legitimate rather than incomplete, so routed work is no part
of the test.

`fm-classify-lib.sh` gains `status_key_closing_verb`, which reports how
the status side currently reads one key by replaying the existing
`_fm_decision_fold_line` rule rather than re-deriving it, so the two
closing verbs stay distinguishable in one place. The per-wake cost is one
`tasks-axi list`, one key scan per status log, and the precise per-key
fold only for a key that already names a still-open task; the call is
hard-bounded so a slow backlog tool can never delay wake presentation.

* fix(document): Correct divergence lifecycle documentation

* fix(document): Neutralize divergence lifecycle prose

* fix(bin): re-arm after an abandoned auto-arm claim and defer a wedge escalation while a worktree is written (kunchenguid#2524)

* fix(watch): re-arm supervision after an abandoned auto-arm claim

A Claude auto-arm cycle that armed, delivered one rewake, and exited left
its single-flight lock behind. Both Stop-event participants then deferred
to that lock forever, because its recorded pid was still live: the
turn-end guard read it as recovery under way and allowed the stop, and the
next Stop firing treated it as another owner and declined to arm. On
2026-08-14 a home with two tasks in flight lost supervision for about 40
minutes with no watcher process and no watcher lock, its beacon frozen at
the one delivery, and both crewmates' finished reports sat in the durable
queue until an operator drained it by hand.

Abandonment is now proven from the epoch ledger instead of inferred from
pid liveness. A lock whose holder pid matches the ledger's own owner_pid
while the recorded outcome is anything other than arming has already
finished its decision, so that claim is reclaimed under the lock's steal
mutex, stops counting as recovery ownership in the guard, and is cleared
by the guard's terminal check rather than deferred to. A failed clear
re-blocks instead of allowing a blind stop, and an arming entry stays in
flight however old it is, because its owner foregrounds the arm for the
whole watcher cycle.

Issue kunchenguid#2251's PR kunchenguid#2263 does not cover this failure. It is closed and
unmerged, lives entirely in bin/fm-watch-arm.sh, and retires the stalled
watcher and matching stale watcher lock of an arm that is currently
running. Here no arm and no watcher were running and no watcher lock
existed, so it has nothing to retire and the home stays blind.

tests/fm-claude-stop-autoarm.test.sh covers the reclaim, the still-arming
and unnamed-owner cases that must keep the gate closed, and the failed
clear. tests/fm-turnend-guard.test.sh covers the guard side of the same
boundary. Both fail without this change.

* fix(watch): defer a wedge escalation while the task worktree is written

The wedge detector had two inputs, rendered pane quietness and the run
step, and neither can see a crew that is writing source, then tests, then
documentation behind a static pane. On 2026-08-14 one crewmate produced
eight consecutive possible-wedge escalations in a single afternoon, three
of them demanding deep inspection, while it was demonstrably working and
then committed. Every one of them cost a supervision turn to disprove by
hand.

Add write activity inside the crew's own recorded worktree as a third
liveness input. crew_worktree_written_since compares the worktree against
the caller's existing idle-window timer file, so -newer needs no clock
arithmetic, no temp file, and no portable mtime write. The probe runs only
inside the branch that was about to escalate, which bounds it to one
pruned, depth-bounded walk per window per FM_STALE_ESCALATE_SECS and
leaves the per-poll stale sweep exactly as cheap as before.

Positive evidence defers rather than cancels. The idle timer restarts so
the next window probes again, the escalation counter is neither advanced
nor reset so a later genuine wedge keeps the demand-deep-inspection
history it earned, and a .writing-since marker ages the whole deferral
chain so the pane still re-surfaces once per FM_PAUSE_RESURFACE_SECS,
through the same throttle shape a declared pause already uses, labeled as
a recheck rather than a wedge. This can only reduce false positives: every
absence of evidence, including no recorded worktree, a torn-down worktree,
a missing anchor, and a failed walk, falls through to the unchanged
escalation schedule, so a crew that writes nothing still escalates on the
existing timetable.

What the signal cannot see, by design or by construction:

- CPU burn with no writes, such as a long compaction, is invisible. That
  case keeps the old behavior exactly.
- A commit-only phase writes only .git, which is pruned first so that
  firstmate's own read-only git commands against the worktree can never
  make the probe self-fulfilling.
- Writes under the pruned generated trees, or deeper than
  FM_WORKTREE_WRITE_MAXDEPTH, do not count.
- The probe cannot attribute a write to the crew, so a background build or
  another process touching the tree looks the same. The hourly re-surface
  is what bounds that, and a churny file cannot buy silence.
- The away-mode daemon's own escalation path is deliberately untouched.

tests/fm-watch-triage.test.sh covers the classifier including the .git
prune, both halves of the live case on one fixture (quiet plus writing
defers, quiet plus silent still escalates and counts), and the bounded
re-surface. All three fail without this change.

* no-mistakes(review): prove autoarm claims by identity; skip mate-home write probe

* no-mistakes(document): document away-mode wedge boundary and probe filesystem limit

* no-mistakes(document): qualify turn-end recovery condition for abandoned auto-arm claims

* fix(watch): keep a write deferral scoped to its own idle window

Two consistency gaps in the worktree write probe, both found while reviewing
the wedge-deferral change on this branch.

A write deferral is a bounded chain: its .writing-since marker ages the whole
chain so a churning worktree still re-surfaces once per resurface window. That
is only sound while the chain belongs to the current quiet stretch, so every
path that restarts the idle-window timer has to drop it too. Two did not: the
corrupt-timer repair in wedge_timer_check, and both first-sight branches for a
captain-relevant status. A chain left over from an earlier quiet stretch made
the first deferral of the new window re-surface immediately instead of after a
full fresh window.

FM_WORKTREE_WRITE_PRUNE is a skip list, so clearing it reads as "skip nothing"
and is the obvious way to widen the probe to the whole depth-bounded tree.
Instead an empty list reported no evidence at all, quietly costing the wedge
detector its third liveness input on a home that meant to widen the walk. An
empty list now widens the walk, and the header says so.

Neither change alters when a stall that writes nothing escalates.

Regressions in tests/fm-watch-triage.test.sh cover all three paths and each
one fails on the pre-fix code.

* no-mistakes(review): honor an empty write-prune, bound the probe, share window_key

* no-mistakes(document): align probe knob count and guard regression-coverage ownership

* no-mistakes(lint): silence deliberate single-quote SC2016 in write-prune env test

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause (kunchenguid#2748)

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause

Two supervisors read a finished task's last status line and disagreed about which
declarations mean an idle endpoint is expected. bin/fm-inactive-reconcile.sh
suppresses its inactive-outcome scan only on `captain-held`, while the away-mode
daemon's wedge path gated deferral on `paused` alone. Both read the LAST line, so
the two verbs are mutually exclusive and no finished task waiting on a person
could satisfy both at once. Marking 11 such tasks `captain-held:` silenced the
900s outcome scan and immediately produced five possible-wedge escalations in one
batch, because the 240s wedge detector no longer saw a pause verb.

fm-classify-lib.sh's status_is_paused_or_captain_held already owns the combined
question, and bin/fm-watch.sh's ordinary-crew wedge path already asked it. This
extends that same answer to the paths still asking the narrower one:

- bin/fm-supervise-daemon.sh, all six sites, which form one subsystem and have to
  move together. classify_stale returns the pause action, reconcile_pause_tracking
  and migrate_watcher_pause_markers record and migrate the marker, and
  housekeeping defers the wedge and then re-surfaces the recheck. Changing only
  the stale-persistence gate would defer the escalation while
  reconcile_pause_tracking recorded nothing, so the wedge marker would persist and
  the sweep would `continue` past it forever: quiet, but never re-surfacing.
- bin/fm-watch.sh's secondmate stale gate, whose downstream owner
  pause_state_class already treats both declarations identically.
- bin/fm-push-transition-lib.sh's absorb, where either declaration already names
  the human the transition would report and the wait is already durably recorded.

Quieting alone would be half a fix, so the bounded re-surface had to reach a hold
too. A hold has no current-state mapping, unlike `paused`, so authoritative crew
state reports it as unknown and pause_state_class received `none`. An ordinary
crew recovers pause classification from that state through confirmed agent death,
which proves no live decision gate is being silenced. A secondmate's endpoint
liveness is deliberately never read there, because an idle mate is healthy by
design, so that confirmation is unavailable by construction and cannot be
required: without recovering the classification for a mate, every caller silenced
a held mate outright and its hold would rot invisibly. That promotion is bounded
by the declared-wait guard at the top of the function, so it can only reclassify a
task that already declared a wait and shows no positive working evidence.

Two narrow `status_is_paused` calls are deliberately left alone.
bin/fm-crew-state.sh's map_log_state is a current-state reporting contract, not a
wedge path; reporting a hold as `paused` would erase the distinction
status_key_closing_verb and fm-captain-hold.sh depend on, where a `captain-held`
close is a verified durable transfer and a `resolved` close claims outright
settlement. fm-classify-lib.sh's call inside status_is_captain_relevant needs no
change because that function's own case list already returns non-relevant for
`captain-held`.

bin/fm-inactive-reconcile.sh keeps its `captain-held` suppression as it is. Its
guard exists because a finished task's crew state still reports done from a
higher-priority source than the log, and a declared pause needs no such guard: the
scan only reports done or failed, and nothing else reaches its record path.
Widening it would change a separate subsystem's reporting contract, which this
defect does not require.

Coverage extends the existing colocated patterns for these predicates and asserts
both halves. tests/fm-daemon.test.sh covers the classification, the wedge marker
converting to pause tracking with no escalation, the bounded re-surface with its
window reset, and the boundary case where an answered hold stops claiming the
cadence. tests/fm-watch-triage.test.sh covers a held secondmate re-surfacing on
the same bounded cadence without being labeled a wedge.
tests/fm-supervision-events.test.sh covers the absorbed push transition. Every one
of these fails on the pre-fix code except the answered-hold boundary case, which
is there to pin that the quieting was not widened too far.

The `paused:` workaround appended to those 11 tasks is live supervision state and
is untouched here. It can be retired once this lands.

* no-mistakes(review): name the captain in a held task's bounded recheck

* no-mistakes(document): extend declared-wait supervision docs to captain-held holds

* fix(bin): make lint prerequisites and harness tests reliable (kunchenguid#2758)

* fix(lint): name the installer when ShellCheck or actionlint is missing

A missing actionlint exited 127 like a bare command-not-found. Fail with
exit 1 and point at the pinned installer, matching the missing-ShellCheck
path, without weakening the version pin.

* test: isolate kimi and muse detection from inherited Cursor markers

Harness detection checks CURSOR_AGENT before ancestry, so these
markerless-adapter cases failed when the suite itself ran under Cursor.
Clear the verified markers the same way the secondmate harness tests already do.

* no-mistakes(document): Document Muse Cursor marker cleanup

* feat(bin): report watched tooling updates that are available or installed but inert (kunchenguid#2684)

* feat(checks): report tool updates that are available or installed but inert

Firstmate had no way to notice that tooling this home depends on needs an
update, and no way at all to notice the worse case: an update that installed
correctly and then did nothing.

That second case is why this exists. A tool that self-installs into
~/.local/bin while a version manager keeps its own older copy earlier on PATH
looks completely up to date to anything that asks only "is a newer version
published". On 2026-08-20 a Herdr update landed at 0.8.2 while an older 0.8.0
copy stayed earlier on PATH, so every Herdr command failed on a protocol
mismatch and firstmate could not read its own fleet.

bin/fm-tool-update-check.sh reports the two conditions separately:

  <tool> update available      a newer version exists at the update source.
  <tool> update not in effect  a newer copy is installed on this host, but
                               PATH still resolves an older one.

PATH skew is measured, never inferred. Every executable copy of a watched
command on PATH is asked for its own version and those answers are compared,
so one lookup cannot hide the skew, and a directory name is never read as a
version because a version manager's "latest" directory can hold an older
build. A copy that will not report a version is a check failure, not a pass.

The watched tools live in local, gitignored config/watched-tools.json, so
adding a tool is a config edit rather than a code change, and the file is
never propagated to another home. Update sources cover both shapes: a local
clone's commit distance from its remote branch, and a command's own version
and update announcement, including a tool like no-mistakes that prints its
version on one command and announces a new release on another.

The check prints one line when something needs attention and prints nothing
otherwise, so it rides the existing watcher state-check contract with its
trust binding instead of introducing a schedule of its own, and
state/.tool-updates keeps the same pending update from being reported on
every poll.

The check only reports. It never installs, updates, reorders PATH, touches a
version manager, or fetches into a watched repository; every git probe is
read-only.

Tests cover the skew case as a regression, and it was verified by mutation:
removing the skew report, or stopping after the first PATH hit as a single
lookup would, each make that test fail.

* no-mistakes(review): fix tool update check probe reporting, budget, and shim write

* no-mistakes(review): keep sweeps alive on broken patterns and oversized budgets

* no-mistakes(review): roll back failed arm, widen budget clamp, bound repo probe

* no-mistakes(review): guard git probes at the budget, record uncut findings

* no-mistakes(document): fix stale watched-tool report-record wording in docs and header

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

The behavior shard's watch-triage suite failed on the new worktree-write wedge
tests. Those five tests are the only ones in the file that do not use its
standard waits. They give a fixed 3 second liveness budget to the one poll that
now spawns the bounded worktree walk, and 4 seconds to an escalating watcher
where every other test in the file gives 10. On a loaded runner that poll
outlives the fixed budget, so the round is reaped before the deferral it asserts
on is recorded, and the test reports a lost deferral instead of the deferral
under test. Wait for a completed poll cycle through the file's own
wait_poll_cycle, which is what its header documents this hazard for, and use the
file's standard 100 tick exit budget.

Verified against a load that reproduces the failure: 11 of 12 runs failed
before, 8 of 8 pass after. Verified by mutation too, so the waits still prove
the behavior: removing the write deferral, and keeping a finished deferral chain
across an idle-timer repair, each still fail their test.

* fix: decouple ask-user decisions from yolo (kunchenguid#2764)

* fix: treat yolo as merge authority only, not ask-user finding authority

Yolo on/off was documented as also deciding no-mistakes ask-user findings, which hid firstmate's duty to judge unambiguous-toward-design findings itself. Keep every safety boundary; this is a contract clarification, not a relaxation.

* no-mistakes(document): Clarify yolo documentation ownership and merge posture

* feat(bin): add a spoken interface that answers from records and hands work over (kunchenguid#2767)

* feat(voice): spoken round trip on Nova Sonic 2 with a measured relay cost

Step one of the spoken interface: the laptop captures and plays audio, this
desktop holds the model session, and no AWS credential leaves the desktop.

Measured, amazon.nova-2-sonic-v1:0 in eu-north-1, end of speech to first byte
of reply audio, 6 runs each, all answered, on a question that forces a records
read:

  relay path   1.229 1.379 1.428 1.447 1.481 1.516  median 1.438
  direct       1.147 1.179 1.203 1.237 1.244 1.317  median 1.220

The relay costs about 0.22s of the median. The direct figure reproduces the
earlier survey, which is what makes it a usable control. Excluded: the
captain's own ssh round trip, microphone capture, and speaker output. This
desktop has no microphone and no speaker, so every run used audio files.

Three pieces:

  bin/fm-voice-relay.py    holds the conversation on this host
  bin/fm_voice_records.py  what a spoken answer may read, and the handover
  bin/fm-voice-client.py   the laptop end; audio devices UNVERIFIED
  bin/fm_voice_frame.py    the wire format both machines share

Real work is handed to the existing bin/fm-inbox.sh rather than a second
queueing surface, and the agent says it is handing over rather than answering
as firstmate.

Read scope: Done history and free-form note bodies are never assembled at any
scope, so the wide default cannot reach the places commercial detail
accumulates. config/voice-read-scope narrows it to counts only, and
config/voice-read-deny excludes a named item in one line. The boundary is an
executable test that widening the reader fails.

Push to talk is the default because it is cheaper and the choice is still open;
--listen open-mic is the single flip.

Two traps worth knowing: a clip with no trailing silence is never answered, and
the end of a reply is contentEnd with stopReason END_TURN, not completionEnd.
A second user turn in one session is treated as barge-in unconditionally, and
an interrupted turn that calls a tool is lost, so the session reconnects per
turn and gives up conversational memory. That is the concrete thing step three
has to solve.

* no-mistakes(review): fix voice relay credential reuse, frame validation and record parsing

* no-mistakes(review): test uplink header guard, bound unknown expiry, align state dir

* no-mistakes(review): decide deny per item, guard turn failures, bound ambient credentials

* no-mistakes(review): read account config from home, harden deny and turn failures

* no-mistakes(review): close status verb set, fix inbox help, pair data override

* no-mistakes(review): keep profile-free relay alive, unblock loop, fix dead assertion

* no-mistakes(review): hide finished pull requests, refuse open mic, keep suite offline

* no-mistakes(review): survive reader failures, release devices, fix claims

A failure while handling a model event, or while sending a tool result,
left the reader task dead with ended and turn_done clear, and close()
re-raised the stored failure on every await. One dropped stream became a
relay that could never build another session. The reader now reports the
session over in a finally whatever killed it, and close() absorbs the
task the same way it already absorbed its sends.

The laptop client releases what it already started when a later startup
step refuses, SystemExit from the handshake wait included, and names a
device refusal instead of leaking a raw PortAudio error. Whether it
releases correctly against a real device is still unverified here.

The records docstring claimed every reading was filtered to open ids.
Only the pull request count and list are; the worker count and the state
histogram cover every live runtime record, finished ids included,
because a meta file still on disk still needs tearing down.

The finished-work deny half of the suite asserted things that held with
the deny list absent. It is replaced by a deny on an open title, which
removes the row and says so while the count stays honest.

* no-mistakes(review): name reader failures, split file and device refusals

A failure inside the model reader released the waiting turn and told
nobody. The session was not marked spent, no notice reached the client,
and the client waits for a reply end or a notice, so the captain got
their whole timeout of silence and then a record saying the turn went
unanswered with nothing about why. Both ends of the relay now name a
failed turn through one function, once per turn, and --self-test carries
the cause in relay_error the way the client's own record does.

Two things that are not failures stay that way. A stream that simply
ends is the end of a session, which serve still reads on its own terms.
A stream that goes away because close() asked it to is an ordinary
renew, and announcing it would have put a failure notice in front of the
captain on every turn.

On the laptop end, the refusal that became a device error covered the
file-backed playback and capture too, so a mistyped --in-file was
reported as an audio device failure and the advice named the flag that
had just failed. The file ends now report the path and the flag that
chose it and stay an OSError; the device ends keep the device advice and
name the flag for that end. The device paths remain unrun here, so only
the file halves are covered by a test.

* no-mistakes(test): survive model session end, order client turn frames

* no-mistakes(document): sync voice relay docs with reviewed relay behavior

* no-mistakes(document): re-measure relay latency and correct its cause

* no-mistakes(document): correct measurement date and name the unmeasured SSH hop

* no-mistakes(document): describe the unpublished control measurement, fix list formatting

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* fix(bin): preserve Relay follow-up loops until explicit disposition (kunchenguid#2763)

* fix: keep Relay public loops open until retire

Delivering a promised-final reply was deleting the only record that tied a public thread to later work, so a follow-on ship silently owed no closing reply. Retain the registration after delivery, rechain follow-on work onto the same thread, and make retire --reason the only close.

* no-mistakes(review): Propagate public follow-up registration removal failures

* no-mistakes(review): Persist retire receipts and align parent resolution

* no-mistakes(review): Make rechain resumable after partial obligation creation

* no-mistakes(review): Repair follow-up state, briefs, and expiry escalation

* no-mistakes(review): Serialize follow-up delivery stamps with retirement

* no-mistakes(review): Serialize rechain claims and protect registration terminal states

* no-mistakes(review): Avoid reporting retired delivery loops as open

* no-mistakes(document): Refresh public-loop documentation and verification evidence

* no-mistakes: apply CI fixes

* no-mistakes(review): Preserve delivered follow-up bindings during registration replay

* no-mistakes(review): Harden public follow-up retirement and rechain races

* no-mistakes(review): Fail closed on unresolved secondmate retirement

* no-mistakes(review): Bind secondmate cleanup to its recorded canonical home

* no-mistakes(review): Fix rechain command output and expiry validation

* no-mistakes(review): Validate brief keys and warn on remote promotion

* no-mistakes(document): Document retained public follow-up loops

* no-mistakes(lint): Remove unused bounded-wait loop variable

* feat(bin): merge GitLab merge requests through the guarded PR merge path (kunchenguid#2779)

* feat(bin): merge GitLab merge requests through the guarded PR merge path

bin/fm-pr-lib.sh already parses a GitLab merge request URL for the watcher,
but bin/fm-pr-merge.sh refused every non-github provider, so a merge request
had to be merged by hand and got none of the recording, guards, or audit
trail a pull request gets.

The merge path now dispatches on the parsed provider. A GitHub URL keeps its
exact previous behavior. A GitLab URL is addressed through glab by the project
URL rebuilt from the parsed host and path, so a merge request on any instance
resolves and no host is hardcoded, and no merge-method flag is added because
the project's own merge method is what should apply.

A GitLab merge happens only after one live read of the merge request confirms
it is open, detailed_merge_status is mergeable, has_conflicts is false,
blocking_discussions_resolved is true, and the head pipeline succeeded at the
exact current head. Every failing condition is reported, not just the first.
The verified head is bound to the merge with glab's --sha, so a push landing
between the read and the merge fails the merge instead of landing commits
nothing verified. Recorded metadata is never the authority for any of this: a
rebase moves the head and leaves a recorded value stale, so a recorded head
that disagrees with the live one is reported rather than trusted, and the
recorded value is read before the recording step because that step drops a
GitLab head it cannot resolve.

* no-mistakes(review): reject bundled -R clusters and make tool-absence cases host-independent

* no-mistakes(test): state authorised GitHub narrowing of bundled -R guard

This branch NARROWS GitHub behaviour. The narrowing was authorised
deliberately rather than slipping in by accident, and it applies to both
providers, GitHub and GitLab alike, because a script that guards one provider
and not the other is a trap for the next reader.

What bin/fm-pr-merge.sh now refuses is extra merge arguments containing a
bundled short-option cluster that includes R, for example "-dR other/repo".
The forge CLIs expand such a cluster one character at a time, so it carries
"--repo other/repo", and that later value wins over the repository the URL
named. Before this change, "fm-pr-merge.sh <task> <github-url> -- -dR
other/repo" reached "gh-axi pr merge 12 --repo example/repo --squash -dR
other/repo" and exited 0 with pr= recorded and the merge poll armed. It now
exits 1 with "extra merge arguments must not override the repository", records
nothing, and invokes no forge merge command. Every other GitHub invocation is
byte-identical to the base commit.

Closing that hole honours the existing rule rather than departing from it. The
file header already forbids --repo and -R because the repository must come
only from the URL, so a bundled cluster carrying a repository override was
never legitimate behaviour to preserve: it was that guard being evaded.
Redirecting a merge to a repository the URL does not name is exactly what the
guard exists to prevent.

The refusal is already pinned on both paths by the existing case
test_bundled_repo_override_args_refuse_before_recording in
tests/fm-pr-merge.test.sh. On GitHub ("-dR wrong/repo") and on GitLab ("-yR
https://other.example/g/p") it asserts exit 1, the refusal wording, no pr= in
the task meta, no armed merge poll, and no forge merge command invoked, with a
control case proving a cluster that carries no repository override still
reaches the forge. No duplicate assertion was added. Both assertions were
confirmed to have teeth by narrowing the guard back to a bare -R and watching
each path fail.

This commit carries no file change: the guard and its coverage landed in
614853d, and this message exists so the pull request description states the
narrowing.

* no-mistakes(document): fix README pointer for GitLab watch and merge doc

* no-mistakes: apply CI fixes

* fix(bin): record a lost relay connection instead of an unanswered turn (kunchenguid#2788)

* no-mistakes: apply CI fixes

* fix(bin): drop a private record citation and narrow the review rule

Three corrections to the spoken interface that landed in kunchenguid#2767, plus one
fix carried over from that branch after its pull request had already been
merged.

The confidentiality fix. The module docstring of bin/fm-voice-relay.py
cited a private, gitignored fleet record by exact path and section number.
That widens what this public repository points at, and it cannot resolve
for any reader here, because the path has never been in the repository.
Both traps it pointed at are already described in full in the list
immediately below it, and docs/voice-relay.md carries the same two for
operators with no citation at all, so the pointer is removed and no claim
is weakened by losing it. Two comments that referred to "the survey" as
though it were something a reader could open are reworded the same way.
Neither exposed a path, so that half is comprehensibility rather than
confidentiality.

The review rule. .greptile/rules.md is kept, because its conditions are
right and deleting it would leave the next reviewer to re-litigate a
decision already argued out. What was wrong with it is narrower than its
existence: it read as settled repository policy, when whether VISION.md
itself should be reconciled is an open question belonging to the captain.
One sentence now says so, and says that the conditions listed below it are
what the interpretation depends on. That narrows the claim rather than
widening it.

The carried-over fix. The first commit on this branch is 7f98e79 from
fm/voice-relay-build-v4, taken verbatim rather than rewritten. It closes
the window where a transport failure was recorded and then erased, so a
run could be emitted as answered false with relay_error null. That matters
more than it looks: relay_error is the field that keeps an infrastructure
failure from being averaged into a latency figure, so the failure mode is
a dead connection wearing the costume of a slow reply. It landed fifteen
minutes after kunchenguid#2767 merged and so never reached the default branch.

* no-mistakes(review): name a reason on every unanswered-turn close path

* no-mistakes(review): guard the downlink body and pin frames to their turn

* no-mistakes(review): attribute reply audio to its own turn and tell endings apart

* no-mistakes(review): tell a cut-short reply from an unanswered turn

* no-mistakes(review): discard reply audio arriving after the output closes

* no-mistakes(review): count discarded reply audio on the speaker path too

* no-mistakes(review): keep a reason off a turn already answered in full

* no-mistakes(review): say a reset cut a reply short, not that none arrived

* no-mistakes(review): read one turn's audio count once, and hush a tidy exit

* no-mistakes(document): fix stale session-end relay_error claim in voice-relay guide

* fix(composer): stop a blocked pi pane from proving an empty composer (kunchenguid#2811)

A pi worker parked on an interactive prompt - a permission dialog, a
question menu, a trust dialog - reports agent_status=blocked, because it
is waiting on a human keystroke. Pi draws that menu above its separator
pair, so the composer region between the rules is blank and structure
alone looks like a free composer. _fm_composer_pi_verdict admitted
blocked alongside idle and done, so the shared classifier reported an
affirmatively empty composer for exactly the pane where typing is unsafe.

Every "is it safe to type here?" consumer reads that verdict and proceeds
only on an affirmative empty, so both are told yes on a parked prompt:
the away-mode injection guard in bin/fm-supervise-daemon.sh, and fm-send's
pre-type refusal. The keys then answer the menu instead of composing a
message - the highlighted default is selected, the text is discarded, and
the record attributes a decision to a human who never made it.

blocked now defers to unknown, which every consumer already treats as
fail-closed. idle and done still prove an empty composer, so ordinary
steering is unchanged, and Cursor is unaffected because its always-blocked
panes never reach this pi-only branch.

Regression coverage lands first at both levels: the verdict owner
(a blocked pi defers) and the herdr adapter (a parked pi prompt is not an
empty composer).

* fix(bin): require project clone roots during fleet sync (kunchenguid#2849)

* fix(bin): require a clone root before fleet-sync touches a project

Git repository discovery walks upward, so `git -C projects/<dir>` on a plain
directory nested under projects/ resolves to the enclosing repository - in a
firstmate home, the firstmate checkout itself. fm-fleet-sync.sh guarded its
candidates with `rev-parse --is-inside-work-tree`, which such a directory
passes, so every later git call read, pruned and fast-forwarded firstmate's own
default branch and reported it under the project directory's label. A running
session's AGENTS.md changed underneath it, and the report named a project that
had nothing to do with the change.

Require each candidate to be the root of its own work tree before any other git
command: compare `rev-parse --show-toplevel` against the directory's own
physical path. Both sides are physical, so a symlinked clone still compares
equal. Anything else is skipped by name, naming the repository that would have
been touched, and bootstrap relays that as a FLEET_SYNC line.

Regression coverage reproduces the wrong-repo fast-forward against a home nested
inside another repository, in both the whole-fleet and single-project forms, and
pins that a symlinked clone dir still syncs.

* no-mistakes(review): Keep enclosing fixture clean during clone-root regression

* fix(bin): retry transient Lavish poll interruptions (kunchenguid#2846)

* fix(procevent): retry a transient Lavish poll interruption quietly

A live Lavish listener can be cut short by the server with exactly

    error: Lavish Editor poll response was interrupted
    code: SERVER_ERROR

while the session's marks remain available. Firstmate registered raw
`lavish-axi poll` output, so the generic process-event runner captured
that transient response as a result and woke the whole fleet over what is
really an internal retry.

The Lavish adapter now registers its own listener command, which reruns
the published blocking poll up to 12 times at 5 second intervals for that
one exact two-line response. The match is deliberately narrow: real
feedback, ended and missing sessions, any other SERVER_ERROR, and the same
interruption still standing once the bound is spent all pass straight
through and are captured and announced as before. The retry is a Lavish
fact, so the generic runner stays adapter-agnostic.

`FM_LAVISH_POLL_RETRY_DELAY` is a bounded 0 to 60 second override for the
interval only, refused rather than rounded when malformed, so a test can
exercise the real bound without waiting it out.

* no-mistakes(review): Harden Lavish retry matching, validation, and cleanup

* no-mistakes(review): Bound Lavish retry staging and stabilize regression

* no-mistakes(document): docs: explain Lavish retry adoption

* no-mistakes(lint): Restore Lavish trap ShellCheck suppression

* fix(brief): stop the documented {TASK} fill from corrupting the Herdr gate (kunchenguid#2838)

The unguarded Herdr declaration quoted `{TASK}` in its own prose while the
scaffold instructs firstmate to replace every `{TASK}` placeholder. The
documented global replace therefore spliced the whole task body into the
middle of the safety gate's sentence, silently destroying the one contract
that exists precisely because the scaffold cannot inspect the task text.

Reword the gate to refer to the task text filled in above, leaving the
placeholder only at its genuine fill site. Rewording rather than renaming the
token keeps the unfilled-charter guards in fm-home-seed.sh and
fm-remote-home-seed.sh working unchanged.

Add a regression test that performs the documented global fill on ship and
scout scaffolds and asserts the body lands once and the gate survives.

* fix(bin): resolve the busy-state lock mtime with the platform's own stat form (kunchenguid#2837)

The writer lock's stale-lock branch read the lock's mtime with
`stat -f %m ... || stat -c %Y ...`. On GNU coreutils `-f` is filesystem
stat, so it consumed the format string as a path, complained on stderr,
printed a partial filesystem dump ("  File: ...") on stdout, and still
exited 0. The GNU form in the fallback therefore never ran, and the
following arithmetic evaluated the word `File`, aborting the writer under
`set -u` with "File: unbound variable".

fm-teardown.sh died there after returning the worktree, leaving
state/<id>.meta, .status, .busy-gen, .busy-state, .busy-state.lock/ and
.turn-ended behind. The surviving metadata kept the watcher monitoring an
endpoint whose agent was gone, so a finished task produced stale wakes
forever, and every re-run died identically because the abandoned lock was
never broken.

Detect the platform once and pick the right stat form, the pattern
bin/fm-watch.sh already documents, and treat any non-numeric result as
"just created" so a future portability surprise degrades to a lock-timeout
refusal rather than killing teardown mid-way.

* fix(stow): add opt-in pass horizon for memory decay (kunchenguid#2850)

* fix(stow): give memory decay a per-pass horizon so the clock fires

The tiered decay clocks were wall-clock only, while admission is per-pass:
each /stow admits the findings that pass produced. In a home that stows
daily those two rates diverge by the stow cadence, an entry the fleet keeps
exercising never reaches 30 days unreinforced, and memory only grows while
the pass reports decay evaluated.

Give each dated marker an optional unreinforced-pass counter and make both
tiers stale at whichever horizon comes first: 10 passes or 30 days for
aging, 3 passes or 7 days for perishable. Reinforcement clears the counter
and nothing else does, so the existing evidence-based restamp rule stays
the only way an entry renews its lease. An absent /N means zero, so entries
that stay exercised carry no extra marker bytes, and a rarely stowed home
keeps its current behaviour through the unchanged date horizon.

* no-mistakes(document): Align stow workflow with dual decay clocks

* fix(stow): make the per-pass decay horizon opt-in

The unreinforced-pass horizon shipped as a new default archival cadence,
which is a product default rather than a restoration of the existing
wall-clock contract. Keep the 30-day and 7-day horizons as the only
default clock, and put the 10-pass and 3-pass horizons behind an explicit
opt-in: config/stow-pass-horizon for the firstmate home, and the file's
own header pointer for the public skill.

With the opt-in absent no counter is written and no counter is read, so a
home that does not ask for it decays exactly as it does today.

* no-mistakes(review): Preserve frozen counters and correct archive provenance

* test(watcher): stop fixture confirmation budgets racing real child startup (kunchenguid#2876)

tests/fm-watcher-lock.test.sh passed in isolation but failed intermittently
under full-suite and ambient concurrent load. bin/fm-watch-arm.sh computes its
confirmation deadline immediately after forking the real child watcher, so the
child's entire fork, exec, lock acquisition and beacon publication has to land
inside that wall clock. Two cases shrank that budget to one second, leaving a
two-second window for work measured at 3.1-4.9s under CPU oversubscription, so
the arm honestly reported "FAILED - no live watcher with a fresh beacon" and
their premises collapsed. A third case ran on the production budget, but its
child must also execute a registered check before exiting: measured at 1.9-2.3s
idle and 9.1-13.1s under load, against an 11s budget.

The two cases that must confirm a real child now hold the arm to production's
own budget instead of a shrunken fixture one, the immediate-wake case gets an
explicit budget with headroom over its measured loaded cost, and the two waits
for the arm's typed failure are sized off the largest production default rather
than a fixed eight seconds.

No bin/ change and no default behavior change: the lock's fail-closed semantics,
SIGSTOP handling, stale-heartbeat detection and the arm's typed failures are
untouched. Verified 4/4 green at 3x CPU oversubscription (loadavg 75-80) after
3/3 red before the change, and CONTRIBUTING.md records the convention.

* fix(bin): deterministically order remote tool paths (kunchenguid#2870)

* fix(bin): order discovered tool installs by the shell's own expansion

fm_remote_job_compose_operator_path built the asdf and mise install
directories with `compgen -G`, which does not sort. Bash sorts glob
matches in pathexp.c, on the shell's own pathname-expansion path only;
`compgen -G` reaches the same glob_filename through pcomplete.c, which
sorts nothing. On bash 3.2 (macOS /bin/bash) and every bash before 5.3
that handed the composition raw readdir order, so which install of a
multi-version tool a remote job resolved was decided by directory order
on disk rather than by this composition.

Expand the globs at the call sites and let the function take the matches,
so the composition and the documented portable-PATH contract are the same
operation. Quoting the account home at the call site also stops a home
whose name contains glob metacharacters from being reinterpreted.

The colocated regression pins both the order and the mechanism: bash 5.3
moved sorting into the glob library, so an order-only assertion cannot
see the defect there.

* no-mistakes(review): Remove source-reading PATH regression guard

* no-mistakes(document): fix portable serial shard counts after upstream merge

* no-mistakes: apply CI fixes

---------

Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
Co-authored-by: Mickaël Rémond <mremond@process-one.net>
Co-authored-by: Inthuson <iaminthuson@gmail.com>
Co-authored-by: Inthuson <inthuson@amazon.com>
Co-authored-by: Christopher McKay <101884182+karotkriss@users.noreply.github.com>
Co-authored-by: Daan Aerts <daan@daeverhuur.nl>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stow: the aging decay clock never fires in a daily-stow home, so memory only grows

2 participants