Skip to content

feat(hook): propose a time-boxed lock when a directive states its own window (#1315) - #1395

Merged
github-actions[bot] merged 17 commits into
mainfrom
feat/issue-1315-directive-window-proposal
Aug 9, 2026
Merged

feat(hook): propose a time-boxed lock when a directive states its own window (#1315)#1395
github-actions[bot] merged 17 commits into
mainfrom
feat/issue-1315-directive-window-proposal

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Closes #1315. Follow-on to #1314.

Saying "always use tabs in this repo. remember this for the next week" in chat
now surfaces a pre-filled aelf lock '…' --for 1w at session end. Any directive
surfaces a pre-filled aelf lock '…'; the --for is added only when the belief
states a window about its own retention.

Confirmation-gated by construction

Per the operator ruling of 2026-08-06. The detector proposes; the user runs
the command; nothing reaches the store until they do.

That is what dissolved the blocker this issue carried. Directive-detector
precision measured P=0.665 against the H1 bar of 0.80, with no open issue
owning the campaign (#199, #374, #1341 all closed) — four separate sessions
re-derived that same number. Under confirm-before-write the bar stops applying:
a false positive costs a declined suggestion, not a silently-wrong expiring
lock.

The argument is now carrying more weight than when it was written, and that is
worth stating rather than inheriting. Coupled, it covered a population of
zero. Decoupled it covers 3,003 beliefs on this repo's store, so at
P=0.665 roughly a thousand of them are expected to be proposals the user
declines. Each still costs one declined suggestion — the argument holds — but it
should be accepted against the real number, not the old one. The lever if that
volume is judged unacceptable is the block cap in #1442, not the precision bar.

Two rulings, and this branch implements both

First (earlier on 2026-08-06): the --for window must be governed by a
memory verb
. The extractor could not tell "remember this for two weeks" from
"the rule is: do Y for two weeks", and the user is shown a copy-pasteable
aelf lock '<text>' --for 9d. Measured before the gate: the arm fired 9 times
on a 44,679-belief store and 0 of the 9 stated a retention window — all were
subject-matter durations (Blocked for 9 days, traveling for a week).

Second (~23:15Z, superseding the narrow reading of the first): candidacy
is decoupled from the --for suffix.
The gates exist to prevent a wrong
expiry literal, never to withhold a proposal. Keying _belief_is_lock_candidate
on _directive_window_spec(...) is not None made the feature unreachable.

coupled decoupled
#1315 candidates on the live store 0 / 44,683 3,003 / 44,683
of those, rendering a --for 0 0

The recall cost is real and confined to the window. The decoupling is what makes
the feature reach data.

No second anchor

This renders the --for spec; aelf lock --for resolves it to an absolute
UTC instant at write time through the existing parse_for. That is the binding
constraint from the issue, and the standing lesson from this repo's
temporal-sort incident, where a relative expression against a moving anchor
underflowed once real time passed it. There is no date arithmetic in this
diff
— which is also why AC1 is deviated-from rather than met; see the AC
disposition in the review comment.

The extractor refuses far more than it accepts, deliberately

Every pattern requires an explicit unit word:

"remember this for the next week"    -> "1w"
"keep this for two months"           -> "2mo"
"remember this"                      -> None
"keep this for the trip"             -> None      <- the issue's own example
"hold onto this until I'm back"      -> None      <- and this one
"Always keep CI logs for 30 days."   -> None      <- the window is the logs'
"…for two days, then for a week"     -> None      <- two windows, refused

None means no window was stated, not use a default — and since the
decoupling it also does not mean no proposal: the belief is still offered, as
a permanent lock, which is what the user would get by typing aelf lock
themselves.

Tests

Mutation-checked against the decoupled code, __pycache__ cleared between runs:

mutation result
re-couple candidacy to _directive_window_spec 2 RED
drop stated_window_attaches_to_memory from the suffix 16 RED
drop the _belief_is_correction_class filter feeding autolock 1 RED
drop the detect_directive guard inside _directive_window_spec 1 RED

The load-bearing test is test_proposing_writes_nothing_to_the_store. Asserting
only that a directive yields a lock command would pass just as happily on a
design that writes the lock first and shows the command afterwards — which is
precisely what the precision bar existed to prevent. So it asserts the store is
untouched: lock_level, lock_expires_at, and count_feedback_events() == 0.

The fourth mutation is the one worth keeping: deleting detect_directive from
_directive_window_spec as now-redundant is the obvious cleanup the decoupling
invites. It is wrong — _format_stop_prompt renders a suffix for every
candidate, and a correction-class candidate arrives having never been through
the directive arm, so a correction reading "Why would anyone retain this for
two years?"
would render --for 2y on a question.

Prose corrections that came out of this

PHILOSOPHY.md pointed a safety audit at _belief_is_lock_candidate as the
population AELF_AUTOLOCK_CORRECTIONS=1 writes. It is
_belief_is_correction_class — already wrong before this branch, and the
widening turns it into a false alarm about 3,003 proposal-only beliefs. The
#1163 changelog entry repeated it while claiming to be test-pinned, and the
pin could not see it. Also corrected: aelf setup --stop-hook help,
INSTALL.md, ARCHITECTURE.md. PRIVACY.md deliberately untouched.

Filed rather than fixed

Merge order

#1425 merges first per the operator ruling — it collides with
CHANGELOG/v4.md. #1411 is the sensible second, since this PR renders --for
specs straight into a command the user pastes and #1411 is what makes an
unrepresentable one fail as a LockExpiryError.

@robotrocketscience robotrocketscience added the author-idnn PR authored by session idnn label Aug 6, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 37 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e2d50a0-38c8-48fa-a761-3b46fc31c148

📥 Commits

Reviewing files that changed from the base of the PR and between 155169a and 968d9db.

📒 Files selected for processing (10)
  • CHANGELOG/v4.md
  • docs/concepts/ARCHITECTURE.md
  • docs/concepts/PHILOSOPHY.md
  • docs/user/INSTALL.md
  • src/aelfrice/cli.py
  • src/aelfrice/hook.py
  • src/aelfrice/lock_expiry.py
  • tests/test_directive_window_proposal_1315.py
  • tests/test_docs_locks_framing_1163.py
  • tests/test_hook_stop_lock_prompt.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 1241 changed lines (limit: 200)
  • 10 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements time-boxed lock suggestions for chat directives that explicitly state a window, by extracting natural-language windows into --for specs, wiring them into lock-candidate selection and stop-hook prompt rendering, and adding tests to ensure suggestions never write to the store and only handle unambiguous, explicitly stated windows.

File-Level Changes

Change Details Files
Add natural-language window extraction utilities that map explicitly stated durations in directive text to --for specs, while rejecting ambiguous or non-countable windows.
  • Export extract_stated_window and stated_window_is_ambiguous from lock_expiry and implement a constrained regex-based extractor for phrases like "for the next week" or "for two months" that returns parse_for-compatible specs.
  • Normalize number words and unit words to compact duration codes (e.g., two months -> 2mo) and treat zero-length or missing windows as None without applying any defaults.
  • Detect ambiguity by collecting all distinct windows mentioned in a text and flagging cases with more than one distinct window as ambiguous so callers can refuse them.
src/aelfrice/lock_expiry.py
Extend lock-candidate detection and stop-hook rendering so directives that clearly state their own time window surface pre-filled aelf lock ... --for <spec> commands without auto-writing locks.
  • Update _belief_is_lock_candidate so that beliefs whose content is a directive with a single, unambiguous stated window are considered lock candidates regardless of type or origin, while still excluding already-locked beliefs.
  • Introduce _directive_window_spec to gate on detect_directive, reject ambiguous-window texts using stated_window_is_ambiguous, and then derive the --for spec via extract_stated_window.
  • Modify _format_stop_prompt to append a --for <spec> suffix to the suggested aelf lock command when _directive_window_spec returns a window, leaving non-windowed directives unchanged and ensuring no date arithmetic is performed in the hook.
src/aelfrice/hook.py
Document the new time-boxed lock suggestion behavior and its confirmation-gated design in the v4 changelog.
  • Add a detailed bullet in CHANGELOG/v4.md explaining that directives stating their own window now propose pre-filled aelf lock ... --for commands, emphasizing explicit-window extraction, ambiguity refusal, lack of defaulting, and reliance on existing parse_for for absolute expiry resolution.
CHANGELOG/v4.md
Add focused tests that pin the window extraction semantics, directive gating, proposal rendering, and the requirement that proposals never mutate the store.
  • Create test_directive_window_proposal_1315.py to parametrically verify accepted explicit windows, rejected implicit/relative/zero windows, and ambiguity handling that only resolves via _directive_window_spec when unambiguous.
  • Assert that non-directive sentences with windows do not produce proposals, that windowed directives render --for in the stop prompt while windowless ones do not, and that windowed directives become lock candidates independent of belief type or origin.
  • Introduce a load-bearing test test_proposing_writes_nothing_to_the_store that inserts a belief into a MemoryStore, renders the stop prompt, and then verifies lock fields and feedback events remain untouched, ensuring the feature stays confirmation-gated by construction.
tests/test_directive_window_proposal_1315.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1315 Implement confirmation‑gated natural‑language handling for directives that explicitly state a time window, by detecting such directives and proposing a pre‑filled aelf lock ... --for <spec> command instead of writing a lock directly.
#1315 Use the existing parse_for / time‑boxed lock machinery without introducing a second date parser or anchor, and refuse ambiguous or unmeasurable windows instead of guessing or inferring a default expiry.
#1315 Ensure that a directive firing is not observable in the store until confirmation: proposing must not change locks, expiries, or audit rows, while confirmation writes the same lock aelf lock --for would write (with persisted anchor/expiry), and tests must explicitly distinguish proposal from write.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Garsecg:2026-08-06T15:52:30Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Blocking finding — the widened candidate arm also feeds the autolock path, where nothing asks the user

Not a review claim on this PR (it is claimed), and the diff is otherwise clean and green.
Surfacing this because it inverts the safety property the rescope was granted on, and the
green rollup will not catch it.

The defect

_belief_is_lock_candidate is widened at hook.py:3610-3633 with a final arm:

    # #1315: a directive that STATES its own window is a candidate too,
    # whatever its type or origin. The prompt proposes; nothing is
    # written until the user runs the command, so a false positive here
    # costs a declined suggestion rather than a wrong expiring lock —
    # which is why this does not need the H1 precision bar.
    return _directive_window_spec(b.content) is not None

That reasoning holds for the prompt path only. The predicate has two consumers, not
one. _collect_lock_candidates (:3657) is called once at :3803, and the result
branches at :3804-3808:

                candidates = _collect_lock_candidates(store, session_id)
                if candidates:
                    if _autolock_enabled(env):
                        _autolock_candidates(store, candidates, serr)
                    else:
                        block = _format_stop_prompt(candidates)

_autolock_candidates (:3713) writes immediately, with no confirmation step:

            b.lock_level = LOCK_USER
            b.locked_at = now
            b.origin = ORIGIN_USER_STATED
            b.lock_expires_at = None
            store.update_belief(b)

So under AELF_AUTOLOCK_CORRECTIONS, every belief this PR newly admits is silently
locked. Three consequences, in increasing order of how bad they are:

  1. The stated cost model is wrong on that branch. A false positive costs a written
    lock, not a declined suggestion. That is precisely the property the confirmation-gated
    rescope was granted on — "a false positive costs a declined prompt, not a wrong
    expiring lock" — and it does not hold here.
  2. origin is laundered. The new arm admits beliefs of any type or origin;
    _autolock_candidates then rewrites origin = ORIGIN_USER_STATED. An
    agent_inferred belief that merely contains "for the next week" is promoted to
    user-stated ground truth with no user in the loop.
  3. The window is dropped, and the lock is permanent. The belief is admitted
    because it names a window, and then lock_expires_at = None — so the one arm whose
    entire justification is "it stated its own window" produces the only lock shape that
    has no window at all. _directive_window_spec returns e.g. '1w' and nothing
    consumes it on this path.

AELF_AUTOLOCK_CORRECTIONS is opt-in (:3603-3607, default off), so this is gated, not
shipped-on. That bounds the blast radius; it does not make the branch correct, and the
docstring asserting the opposite is the part that will outlive the review.

Suggested fix — one parameter, not a redesign

The prompt path and the autolock path want different populations. Give the predicate an
explicit flag rather than letting the widened arm leak into a writer:

def _belief_is_lock_candidate(
    b: "Belief", session_id: str, *, include_windowed: bool = True
) -> bool:
    ...
    if not include_windowed:
        return False
    return _directive_window_spec(b.content) is not None

and thread it from _collect_lock_candidates(store, session_id, include_windowed=...),
passing not _autolock_enabled(env) at :3803. Autolock keeps exactly the population it
has on main; the proposal path gets the widened one. The distinguishing test is a
belief that is neither BELIEF_CORRECTION nor an agent origin but does state a window,
asserted under AELF_AUTOLOCK_CORRECTIONS=1 to be unchanged in the store — it fails
on this head and passes after the fix. test_proposing_writes_nothing_to_the_store does
not cover it: it exercises the prompt branch, where autolock is off.

Second, smaller: the ambiguity refusal has a hole

stated_window_is_ambiguous does not catch every multi-window sentence. Executing the
shipped regexes from this head, 'Always use tabs for the next week, then for two days.'
returns not-ambiguous with a spec of '2d' — the second window silently wins and the
first is discarded. Worth either widening the ambiguity test or documenting the
precedence, since "refuses rather than picking one" is what AC4 claims.

Also worth knowing before this merges

The green rollup is not review coverage this week — CodeRabbit is rate-limited
("review limit reached") and Sourcery is skipping on its weekly diff quota, so no bot
actually read this diff. Worth a human pass on the hook.py hunks specifically.

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:review Needs review (PR open, awaiting reviewer) labels Aug 6, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — the confirmation gate did not hold, and the test that was supposed to prove it was watching the wrong database

Both defects are fixed and pushed to this branch (bb11f4a8..fa8d564e, 5 commits, full suite green at 7,405 passed). One finding is left, and it is the author's call rather than mine.

Everything below was verified by running it, not by reading.

Fixed — the autolock path wrote these without asking

The new candidate arm feeds _collect_lock_candidates, and that output goes to _autolock_candidates as well as to the prompt. With the documented AELF_AUTOLOCK_CORRECTIONS=1, a windowed directive was therefore written with no confirmation. A/B on one seeded belief:

PR head:  aelfrice: auto-locked B1 (factual, origin→user_stated)
          lock_level='user'  lock_expires_at=None  origin='user_stated'
main:     lock_level='none'  origin='user_transcript'      (untouched)

This is the claim the rescope rests on — "nothing reaches the store until they do" — failing on a supported path. The outcome is also worse than the case the H1 bar guarded: autolock grants a permanent lock (lock_expires_at = None) and discards the window, so "for the next week" becomes forever, on a detector measured at P=0.665, with the origin laundered to user_stated.

Filtered at the stop() call site rather than inside _autolock_candidates — that helper locks exactly what it is handed, and #1314's test_autolock_relocking_an_expired_lock_survives_the_next_sweep legitimately passes it a non-correction belief. The test drives stop() end-to-end, because a unit test of the helper cannot see a defect about which candidates reach it; the correction beside the directive is the control, so a filter that disabled autolock outright fails too.

Fixed — the load-bearing test inspected a database no write could reach

The store fixture opened tmp_path/"prop.db"; the autouse fixture set AELFRICE_DB to tmp_path/"pinned.db"; _open_store() resolves $AELFRICE_DB.

So the assertion this file's own docstring calls load-bearing was watching an empty file. The consequence is exactly the design it exists to exclude: making _directive_window_spec open the production store and lock the belief before returning the spec left the whole file green. That is the write-first-ask-second shape, and the argument for retiring the precision bar rests on this test.

One-line fix, both fixtures now resolve one path. Mutation-checked: the write-first mutation now fails test_proposing_writes_nothing_to_the_store, and passed before.

Fixed — ambiguity was blind to half its own grammar

stated_window_is_ambiguous scanned only _STATED_WINDOW_RE, which requires a count word, so the bare for the next <unit> form was invisible to it:

"…for the next week, and for the next month."   -> proposed 1w   (no refusal)
"…for the next week, then for two days."        -> proposed 2d   (second window)
"…for two days -- actually, for the next month" -> proposed 2d   (the retracted one)

The first contradicts "two distinct windows in one sentence refuse rather than resolving to the first"; the second and third also contradict extract_stated_window's "only the first match is used", since the counted pattern was searched over the whole string before the other was consulted at all. The third is the canonical self-correction the guard exists for.

Both patterns now feed one positional scan, so "first" means first in the sentence. A zero-length window stays in the scan as None so it still counts as a stated window rather than being dropped next to a usable one. Four arms, mutation-checked: narrowing the scan back to one pattern fails three, dropping zero-length windows fails the fourth.

Also updated _belief_is_lock_candidate's docstring, which still said "iff … correction-class" and omitted the arm added three lines below it, and the CHANGELOG entry, which asserted the confirmation gate without stating the autolock exclusion that now makes it true.

Not fixed — the window is attached to the wrong noun, and on live data it is wrong every time

This one needs your judgment, and it is the reason I have not labelled this ready.

The extractor cannot distinguish "remember this for two weeks" from "the rule is: do Y for two weeks". Both halves of the guard pass: it is a directive, and it states a countable window.

"Always retain build artifacts for 90 days."   -> aelf lock '…' --for 90d
"Always keep CI logs for 30 days."             -> --for 30d
"Never cache the index for two weeks."         -> --for 2w
"Always support each release for two years."   -> --for 2y

for 90 days is a property of the artifacts, not of how long to remember the policy. Running that suggestion forgets the retention rule in 90 days — the failure the module docstring names, "a lock that expires on a date the user never agreed to".

Measured rather than argued. Sweeping this repo's own store (46,019 beliefs, read-only) the new arm fires 9 times, and 0 of the 9 state a memory-retention window — they are all subject-matter durations, e.g. Blocked for 9 days after both blockers closed. proposing --for 9d. A wider sweep over the other local stores puts it at 90 firings with the same 0. So realized attachment precision on live data is 0, and the arm is what admits most of these in the first place: at origin=user_transcript, type=factual the old arms return False and the new predicate returns True.

The confirmation gate does contain the damage — nothing is written, and with the autolock fix above that is now true on every path. But the PR's framing, that a false positive costs a declined suggestion, holds for the candidate arm and not for the --for value: the suggestion is plausible-looking and carries a wrong number, which is a worse thing to put in front of a user than an obviously spurious one.

What I need from you: whether to narrow the extractor so the window must attach to a memory verb (remember/keep/prioritise this for X) at the cost of recall, ship as-is on the strength of the confirmation gate, or split the --for suffix out and land only the candidate arm. I have not guessed at this because all three are defensible and the tradeoff is yours.

Housekeeping

Branch is FF on main, no unresolved review threads, all checks were green before my push. Separately filed #1396: parse_for lets a window past datetime.max escape as OverflowError/bare ValueError, which cli._cmd_lock does not catch, so aelf lock --for 999999y exits on a traceback. That code is byte-identical on main and untouched here, so it is not this PR's defect — I wrote the fix, then dropped it from this branch rather than widen a PR already over the size cap.

@robotrocketscience robotrocketscience added attn:unblock Needs answer from another session and removed attn:unblock Needs answer from another session labels Aug 6, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Garsecg:2026-08-06T16:22:57Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-08-06T22:02:12Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review: the open question is decided, implemented and pushed (762a442e)

I picked this up under the stale-author rule (findings posted 16:22Z, no author push since). The prior review's one open finding — the window attaches to the wrong noun — is the right finding, and I reproduced it independently before acting on it.

Reproduced, on the same store, read-only

Sweeping 44,679 active beliefs with _directive_window_spec: 9 firings, 0 of which state a memory-retention window. All nine are subject-matter durations:

--for  1w  | ...only answerable by watching it run for a week.
--for  9d  | Blocked for 9 days after both blockers closed.
--for 29d  | Results available for 29 days after creation
--for  1w  | ...I am only going to be traveling for a week, so
--for 24w  | ...Uncertain beliefs get exp[ired]...
  (+4 more, same shape)

Realized attachment precision 0/9, matching the 0/90 across other stores. The user is shown a copy-pasteable aelf lock '<text>' --for 9d.

Operator ruling

Put to the operator with three options (narrow the extractor / ship as-is / drop --for and land only the candidate arm). Ratified: narrow the extractor to memory verbs, accept the recall cost.

Implemented

lock_expiry.stated_window_attaches_to_memory requires a memory verb and a self-referential object, then the window within one short phrase with no clause break. The object is what carries the gate — the verb alone still admits keep CI logs for 30 days, the single most common live shape, and there is a test pinning exactly that.

mutation result
drop the gate from _directive_window_spec 6 RED
anchor on the verb alone (no self-referential object) 5 RED
drop the clause-break / gap check 1 RED

Full suite: 7410 passed, 70 skipped, 71 xfailed.

Two things you should know before this merges

1. The arm is now inert on live data. The same sweep after the gate fires 0 times on 44,679 beliefs. And because _belief_is_lock_candidate's #1315 clause keys on _directive_window_spec(...) is not None, the gate removes candidacy too — not just the --for suffix. So #1315 currently proposes nothing on this store, despite 8,838 active beliefs passing detect_directive.

That is the accepted recall cost, and I flagged it in the option text before the ruling rather than after. But it is worth stating plainly: this ships a correct mechanism that no current data reaches. I disclosed it in the CHANGELOG rather than leaving a reader to find out. If you would rather the candidate arm stay wide and only the suffix be gated, that is a one-line change and a different ruling — say so and I will make it.

2. Your own canonical example was the wrong shape. _DIRECTIVE in the test file, and the headline example in the CHANGELOG, were both "Always use tabs in this repo for the next week." — which states how long to use tabs, not how long to remember the rule. Both are updated. That the PR's own example was a subject-matter window is the clearest evidence available that these two readings are easy to conflate, and it is why the gate is worth its complexity.

Housekeeping

Branch is FF on main, all commits signed, no unresolved threads. Actions is still in a major outage (97 queued, 0 executing) so no required check has run on this head — the suite figure above is local, run as ci.yml invokes it. Not labelling ready-to-merge.

Also confirmed #1396 (the parse_for overflow you split out) is still open and unclaimed.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Garsecg:2026-08-06T22:56:36Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Garsecg:2026-08-06T22:56:41Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Author pass on the six commits I did not write — five defects, four fixed here, one filed as #1440

Picking my own PR back up. The prior reviewer's stated reason for not labelling this was that
Actions was in a major outage and no required check had run on the head; that has cleared, and
the checks are green on the rebased head. The operator's ruling (narrow to memory verbs, accept
the recall cost) is settled and I have not re-opened it.

Before labelling I reviewed what landed on my branch while I was away, and everything below was
found by running the shipped code, not by reading it.

The confirmation gate held, but the proposal did not survive the fix that made it hold

Withholding #1315 candidates from _autolock_candidates was right. But stop() was an
if/else, so the excluded belief never reached _format_stop_prompt either. Under the
documented AELF_AUTOLOCK_CORRECTIONS=1 the belief was neither locked nor suggested:

autolock OFF:  lock_level='none'  proposal shown? True
autolock ON:   lock_level='none'  proposal shown? False   <- stderr empty

The prompt is a proposal's only surface, and the block itself recommends that flag — so the
advice was advertising its own suppression. This is new on this branch: on main every
candidate is correction-class, the filter is a no-op, and no population can be dropped. Fixed;
the correction-class subset auto-locks as before and the remainder falls through to the prompt.
Mutation-checked in both directions — restoring the if/else fails on the --for assertion,
and dropping the exclusion entirely fails the control that an auto-locked correction must not
also be proposed.

The prompt described a requirement to the user as a "correction"

Real production derive() mints #1315-only candidates from ordinary phrasing —
derive("You must remember this for two weeks.") gives type=requirement,
origin=user_transcript, correction-class False — and the header rendered:

Found 1 correction in this session that aren't locked.
… or set AELF_AUTOLOCK_CORRECTIONS=1 to auto-lock corrections at session end.
  - <id> (requirement, origin=user_transcript): You must remember this for two weeks.

Both statements were false for that row, and the per-item line contradicted the header two
lines below it. The noun is neutral now, its verb agrees with it, and the autolock line appears
only when something listed would actually be auto-locked (with the "does not cover the rest"
caveat only when there is a rest). test_format_pluralizes_correctly did pin the old noun, so
this is a deliberate change with its test updated, not an unguarded string edit.

The attachment gate could be bypassed by prefixing a memory clause

This is the one that matters most, because the gate is the whole operator ruling.
Always cache the index for two weeks. is correctly refused — and prefixing a memory clause
licensed it:

Always remember this, and cache the index for two weeks.     -> --for 2w
Always remember this and cache the index for two weeks.      -> --for 2w
Always remember this but cache the index for two weeks.      -> --for 2w
Always remember this then cache the index for two weeks.     -> --for 2w
Always remember this while caching the index for two weeks.  -> --for 2w
Always remember this so cache the index for two weeks.       -> --for 2w
Always remember this - cache the index for two weeks.        -> --for 2w

The two weeks is still how long to cache. The gap check was length plus clause-punctuation
only, and none of those spellings needs punctuation — so adding , to _CLAUSE_BREAKS,
which is the obvious fix, closes exactly one of the seven. The gap must now open no new
predicate. Tokens are stripped of punctuation rather than split on it, so the clause-joining
bare - stays distinct from a compound word (Always remember this build-time rule for a week. still proposes 1w, and there is a test for it).

Three of the four gates in _directive_window_spec were not pinned at all

Each of these mutations left the suite at 44/44 green before this push:

mutation before after
delete if stated_window_is_ambiguous(...) 0 red 5 red
delete if not detect_directive(...) 0 red 1 red
collapse the candidate arm to extract_stated_window(...) is not None 0 red 1 red
delete if not stated_window_attaches_to_memory(...) 15 red

Cause: every ambiguity fixture said Always use tabs …, which is not a memory anchor, so the
attachment gate — added upstream of the ambiguity arm after those tests were written —
refused them one gate early and every assert ... is None passed for the wrong reason. The
file already carried a detect_directive reachability guard written against exactly this trap;
the new gate reopened it somewhere that guard could not see. Fixtures are memory-anchored now
and reachability is asserted for both gates, so the next gate added upstream fails loudly
instead of silently. The candidate arm gained negative controls that state a window and fail an
upstream gate — without them it reduces to "mentions any duration anywhere".

One prose claim was false

keep CI logs for 30 days was described as "the single most common live shape". It appears in
none of the nine measured live hits, and the only beliefs in that store mentioning CI logs
state no duration. The assertion it decorates is correct and load-bearing; only the frequency
claim is gone.

Filed rather than fixed: #1440

A window outside the count/unit vocabulary (sub-day units, spelled counts above ten,
quantifier/range forms) is not recorded as a window at all — not even as unusable — so
extract_stated_window returns the window stated second and stated_window_is_ambiguous
reports one where there are two. That contradicts both functions' documented contracts, and
contradicts the treatment the module already gives the zero-length case for the stated reason.

Not fixed here because the clause-gap fix above already refuses the shapes a user would write
(…for 30 minutes, then for a week has a connective in the gap); what remains reachable at the
caller needs two for … phrases juxtaposed with no connective. So the shipped path is not
wrong today — the violation is on the two exported functions and is inherited by any future
caller that consults them without the attachment gate in front. It wants its own test matrix
rather than a widening of this PR.

Housekeeping

Rebased onto main (7d48c958, picking up #1403 and #1421); FF, 13 commits, all signed, no
unresolved threads. Full suite 7,436 passed, 70 skipped, 71 xfailed. Discretion grep clean
on added lines. The CHANGELOG entry now states the autolock fall-through, the prompt-copy
change and the clause gate, none of which it described before.

Two things a reader should know that the diff does not say. The arm remains inert on live
data
— that is the accepted recall cost from the ruling, and the clause gate above makes the
gate slightly stricter still, so it stays inert. And #1396 (the parse_for overflow split
out of this branch) is still open and unclaimed.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Garsecg:2026-08-09T03:53:42Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Garsecg:2026-08-09T03:53:47Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Ruling 3 implemented: candidacy decoupled from the --for suffix (f998017f)

This PR sat on attn:unblock because the operator ruling of 2026-08-06 ~23:15Z
superseded the narrow reading this branch shipped:

Ruling 3 — #1315 candidacy is DECOUPLED from the --for suffix.
_belief_is_lock_candidate must NOT key on _directive_window_spec(...) is not None. Propose the lock for all directive beliefs; emit --for only when
a memory verb governs the window. Rejected: shipping inert, and closing #1315
as unreachable.

Implemented. _belief_is_lock_candidate now keys on detect_directive alone;
the ambiguity and attachment gates stay on the suffix.

Measured, on the same store as the prior rounds

Read-only sweep of .git/aelfrice/memory.db (44,683 active beliefs),
restricted to what _collect_lock_candidates would actually return:

coupled (before) decoupled (now)
#1315 candidates 0 3,003
of those, rendering a --for 0 0

So the recall cost the earlier ruling accepted is real and unchanged — it is
confined to the window. What the decoupling buys is that the feature reaches
data at all. Types of the 3,003: factual/user_transcript 2,224,
requirement/user_transcript 449, factual/unknown 282, rest preference.
That confirms the changelog's existing "factual or requirement" claim rather
than contradicting it.

Mutations, re-run against the decoupled code

mutation result
re-couple candidacy to _directive_window_spec (the superseded shape) 2 RED
drop stated_window_attaches_to_memory from the suffix 16 RED
drop the _belief_is_correction_class filter feeding autolock 1 RED
drop the detect_directive guard inside _directive_window_spec 1 RED

Control green. __pycache__ cleared between each — same-second same-size edits
otherwise report the opposite result.

The guard I did not delete, and why

Deleting detect_directive from _directive_window_spec as now-redundant is
the obvious cleanup the decoupling invites, and it is wrong.
_format_stop_prompt renders a suffix for every candidate, and a
correction-class candidate becomes one by type or origin having never been
through the directive arm. A type=correction belief reading "Why would
anyone retain this for two years?"
clears attachment and extraction and would
render --for 2y on a question. There is now a test that fails if the guard
goes.

Four false claims removed from prose

  • CHANGELOG/v4.md said the candidate arm "is inert on live data too". After
    the ruling that is the opposite of what ships.
  • docs/concepts/PHILOSOPHY.md told a safety auditor to check
    _belief_is_lock_candidate as the population AELF_AUTOLOCK_CORRECTIONS=1
    writes. It is _belief_is_correction_class. This was already wrong on this
    branch before the decoupling
    , and the widening turns it into a false alarm
    reporting 3,003 proposal-only beliefs as auto-lockable — the direction that
    gets a correct safety filter reverted. The #1163 changelog entry repeated it
    verbatim while claiming to be test-pinned.
  • That pin did not hold: test_docs_locks_framing_1163.py's negative control
    passes because _mk hard-codes non-directive content, so it cannot see a
    candidacy change in either direction. It now carries a directive belief and
    asserts the two predicates disagree on it.
  • aelf setup --stop-hook help, INSTALL.md and ARCHITECTURE.md all
    described the prompt as correction-class only, and the setup help said the
    flag auto-locks instead of prompting. It writes only the correction-class
    subset; the rest still print. PRIVACY.md is deliberately untouched — its
    sentence remains exactly true.

Acceptance criteria, dispositioned

Not claiming a clean sweep. Two need an operator call before this closes #1315.

AC verdict
1 — proposal names the resolved absolute expiry DEVIATED. We render the --for spec (1w), never the resolved instant. Deliberate: binding constraints 1 and 2 forbid a second date parser and a second anchor, and resolving at prompt time is exactly the moving-anchor shape that caused this repo's temporal-sort underflow. The AC's literal wording and the constraints cannot both be satisfied.
2 — confirmation writes what aelf lock --for writes, via parse_for Met by construction — confirmation is running that command; there is no second write path. Asserted only to the extent that parse_for accepts every spec we emit.
3 — declining writes nothing Met, test_proposing_writes_nothing_to_the_store.
4 — ambiguous window refuses and asks Met, and the decoupling improves it. Coupled, an ambiguous window refused and did not ask. Now the belief is still proposed, with no --for — which is what "refuses and asks" says.
5 — the anchor is persisted with the resolved expiry APPROXIMATE, and unasserted. _cmd_lock persists locked_at and lock_expires_at, but they come from two separate clock readsparse_for(…, now=datetime.now(timezone.utc)) before _open_store(), and _utc_now_iso() after it. So the persisted anchor is the resolved anchor plus store-open latency, not the anchor itself. Immaterial at 1w; it is still not what the AC says. This is #1314 code, byte-identical on main, and not this PR's defect.
6 — a test distinguishing proposal from write Met, and materially stronger now: it guards a 3,003-belief live population instead of an empty one.

Filed rather than fixed: #1442

_format_stop_prompt bounds neither the candidate count nor any belief's
length, and stop() fires once per assistant turn. Worst live session renders a
3,448,428-byte stderr block of 6,427 entries; longest single belief is
14,360 characters on one aelf lock '…' line.

This is pre-existing, and I want to be precise about that because the
obvious reading is that the widening causes it. It does not: the same sweep over
the pre-#1315 population gives a byte-identical maximum — the worst session's
candidates are all correction-class. p90 actually falls (15,811 → 14,977 B);
p99 rises (90,792 → 126,419 B). So the tail is a property of
_format_stop_prompt on main today and the widening moves the distribution
modestly. Filed as #1442 rather than widened into this PR.

Housekeeping

Rebased onto main (23d4d21d); FF, 17 commits, all signed. Five CHANGELOG
conflicts resolved by hand, one at a time under the standing rule — each
resolution asserted that no pre-existing entry was dropped, and the final
CHANGELOG/v4.md differs from main by exactly one added entry (128 → 129)
plus the #1163 predicate correction.

Full suite: 7,460 passed, 70 skipped, 71 xfailed, plus one flake —
test_uninstall_dotdir.py::test_dotdir_plan_scales_linearly, a wall-clock
scaling assertion that fails ~1 in 3 in isolation and is untouched by this diff.
Not labelling ready-to-merge: #1425 is ruled to merge first, and it
collides with CHANGELOG/v4.md.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Garsecg:2026-08-09T06:25:51Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Garsecg:2026-08-09T06:25:56Z]

@robotrocketscience
robotrocketscience force-pushed the feat/issue-1315-directive-window-proposal branch from f998017 to 3e0728f Compare August 9, 2026 06:27
@robotrocketscience robotrocketscience added ready-to-merge Trigger merge-train: FF main to this PR's head and removed ready-to-merge Trigger merge-train: FF main to this PR's head labels Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

merge-train: blocked

branch is not fast-forward on main (branch base 6a207041b7716d1581896c185a15f94448f5069a, current main 155169a555f5f37343b734b8d8609558c52e239f). Rebase locally (git rebase github/main), force-push, and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 9, 2026
… window

Per the operator ruling of 2026-08-06, this lane is confirmation-gated:
the detector proposes a pre-filled `aelf lock ... --for <spec>` command
and the user runs it. Nothing reaches the store until they do, which is
why the H1 precision bar stopped being the blocker — a false positive
costs a declined suggestion rather than a silently-wrong expiring lock.

Reuses the surface that already exists. The Stop hook renders pre-filled
`aelf lock` commands for lock candidates; this appends the window when
the belief's own text states one, and admits a windowed directive as a
candidate whatever its type or origin.

`extract_stated_window` only maps windows the user SPELLED OUT. Every
pattern requires an explicit unit word, so "for the trip" and "until I'm
back" return None and the caller proposes nothing — inferring an expiry
the user did not state is an explicit non-goal, and a guessed window
expires their lock on a date they never agreed to. Two distinct windows
in one sentence refuse rather than resolving to the first.

No second date parser and no second anchor: this renders the `--for`
spec, and `aelf lock --for` resolves it to an absolute UTC instant at
write time through the existing `parse_for`.

Closes #1315.
States why the H1 precision bar no longer applies rather than implying
it was met: under confirm-before-write a false positive costs a declined
suggestion, not a wrong expiring lock.

Refs #1315.
`stated_window_is_ambiguous` scanned only `_STATED_WINDOW_RE`, which
requires a count word, so the bare "for the next <unit>" form was
invisible to it. A sentence naming one window in each spelling therefore
reported a single window and resolved to whichever one the counted
pattern could see: "for the next week, then for two days" proposed `2d`
— the window stated second, which also contradicts the documented
"first stated wins" rule — and "for the next week, and for the next
month" proposed `1w` with no refusal at all.

Both patterns now feed one positional scan. "First" means first in the
sentence rather than first pattern tried, and a zero-length window stays
in the scan as `None` so that it still counts as a stated window instead
of being silently discarded next to a usable one.

Four test arms, each mutation-checked: narrowing the scan back to one
pattern fails three, and dropping zero-length windows fails the fourth.
…admits

The #1315 arm widened `_belief_is_lock_candidate` beyond correction-class
beliefs, but its docstring still said "iff ... correction-class" and
listed only the two original signals — so the contract text now excluded
the case the new clause exists to admit.

Also states that the session and lock guards precede the new arm, which
is what makes "an already-locked directive is still not a candidate"
true for the right reason rather than by luck of ordering.
… reach

The `store` fixture opened `tmp_path/"prop.db"` while the autouse fixture
set `AELFRICE_DB` to `tmp_path/"pinned.db"`, and `_open_store()` resolves
`$AELFRICE_DB`. So the one assertion the file calls load-bearing — that
proposing writes nothing — inspected a database no production write could
ever land in.

The consequence is the exact design the test exists to exclude: making
`_directive_window_spec` open the production store and lock the belief
before returning the spec leaves the whole file green. That is the
write-first-ask-second shape the H1 precision bar existed to prevent, and
the PR's argument for retiring the bar rests on this test.

Both fixtures now resolve one path. Mutation-checked: the write-first
mutation fails `test_proposing_writes_nothing_to_the_store`, and passed
before this change.
The new candidate arm fed `_collect_lock_candidates`, whose output goes
to `_autolock_candidates` as well as to the prompt. With
`AELF_AUTOLOCK_CORRECTIONS=1` a windowed directive was therefore written
with no confirmation at all — A/B against main on one seeded belief:

  PR head:  auto-locked B1 (factual, origin→user_stated)
            lock_level='user' lock_expires_at=None origin='user_stated'
  main:     lock_level='none' origin='user_transcript'  (untouched)

That is the claim the PR rests on ("nothing reaches the store until they
do") failing on a documented, supported path, and the outcome is worse
than the one the H1 precision bar guarded against: autolock grants a
*permanent* lock and discards the window, so "for the next week" becomes
forever, on a detector measured at P=0.665, with the origin laundered to
user_stated.

The filter goes at the `stop()` call site rather than inside
`_autolock_candidates`, which locks exactly what it is handed and has
callers that legitimately pass non-correction beliefs.

Test drives `stop()` end-to-end, since a unit test of the helper cannot
see a defect about which candidates reach it. The correction beside the
directive is the control, so a filter that disabled autolock outright
would fail too. Mutation-checked: dropping the filter fails it.
The entry claimed the design is confirmation-gated by construction and
that nothing reaches the store until the user runs the command. That is
only true because autolock is now explicitly held to correction-class
beliefs, so the published text says so rather than leaving the reader to
infer an invariant from its absence.
…tter's

Operator ruling 2026-08-06, over the alternatives of shipping the suffix
as-is or dropping --for and landing only the candidate arm.

The extractor could not tell "remember this for two weeks" from "the
rule is: do Y for two weeks". Both are directives and both state a
countable window; only the first says how long to remember. Swept over
the live store (44,679 active beliefs, read-only) the arm fired 9 times
and 0 of the 9 stated a retention window -- Blocked for 9 days,
traveling for a week, Results available for 29 days. Realized attachment
precision was 0/9 here and 0/90 across other local stores, and the
proposal is a copy-pasteable 'aelf lock ... --for 9d'.

stated_window_attaches_to_memory requires a memory verb AND a
self-referential object, then the window within one short phrase with no
clause break. The object is what carries it: the verb alone still admits
"keep CI logs for 30 days", the single most common live shape. The
anchor deliberately does not absorb trailing words -- an anchor that did
would absorb the window too and never match, which is how the first cut
of this rejected every input including its own positives.

The cost is recall and on current data it is total: the same sweep now
fires 0 times. The candidate arm keys on the same spec, so #1315 is
inert on live data until someone states a window about retention. That
is disclosed in the CHANGELOG rather than left for a reader to discover.

The test fixture _DIRECTIVE was itself a subject-matter window ("always
use tabs in this repo for the next week"), so it is updated -- the PR's
own canonical example being the wrong shape is the clearest evidence
available that the two are easy to conflate. The CHANGELOG headline
example was the same string.

Mutations, __pycache__ cleared between runs: drop the gate -> 6 RED;
anchor on the verb alone -> 5 RED; drop the clause-break check -> 1 RED.
Full suite 7410 passed.
Withholding a #1315 windowed directive from `_autolock_candidates` was
correct, but `stop()` was an if/else, so the excluded belief never
reached `_format_stop_prompt` either: under AELF_AUTOLOCK_CORRECTIONS=1
it was neither locked nor suggested. The prompt is a proposal's only
surface, and the block itself recommends that flag, so the advice was
advertising its own suppression. The correction-class subset is
auto-locked as before and the remainder now falls through to the prompt.
New on this branch: on main every candidate is correction-class, so the
filter is a no-op and no population can be dropped.
Since #1315 the candidate population includes windowed directives, which
production `derive()` types as `factual` or `requirement`, so the header
announced a `requirement` row as a "correction" while the per-item line
below it printed the real type. It also advertised
AELF_AUTOLOCK_CORRECTIONS=1 unconditionally, which does not cover that
arm: on a list of windowed directives the flag leaves the list exactly as
it is. The noun is now neutral, its verb agrees with it, and the autolock
line appears only when something listed would actually be auto-locked —
with the "does not cover the rest" caveat only when there is a rest.
The attachment gate could be bypassed by prefixing a memory clause to a
sentence it had just rejected: `Always cache the index for two weeks.`
returns None, and `Always remember this and cache the index for two
weeks.` proposed `--for 2w` — still how long to cache, not how long to
remember. The gap check was length plus clause-punctuation only, and none
of the leaking spellings needs punctuation at all: `and`, `but`, `then`,
`while`, `so` and a bare dash all opened a new predicate that then owned
the window. The gap must now open no new predicate. Tokens are stripped
of punctuation rather than split on it, so the clause-joining bare `-`
stays distinct from a compound word like `build-time`.
Three of the four gates in `_directive_window_spec` were unguarded:
deleting the ambiguity check, deleting the `detect_directive` check, or
collapsing the whole predicate to a bare `extract_stated_window` call each
left 44/44 green. Every ambiguity fixture said "use tabs", which is not a
memory anchor, so the attachment gate — added upstream of the ambiguity
arm after those tests were written — refused them one gate early and the
assertions passed for the wrong reason. The file already carried a
`detect_directive` reachability guard against exactly this trap; the new
gate reopened it somewhere the guard could not see, so the fixtures are
memory-anchored now and reachability is asserted for both gates. The
candidate arm gains negative controls that state a window and fail an
upstream gate, without which it reduces to "mentions any duration".
Also drops a frequency claim that was not measured: `keep CI logs for 30
days` appears in none of the nine live hits.
Three behaviour changes since the entry was written: what autolock may not
write is now proposed rather than dropped, the prompt block counts beliefs
rather than corrections and offers the flag only where it applies, and the
attachment gate refuses a window whose governing clause is not the memory
one.
Operator ruling of 2026-08-06 (~23:15Z), superseding the narrow reading
of the window-attachment ruling earlier the same day.
`_belief_is_lock_candidate` no longer keys on `_directive_window_spec(...)
is not None`; it keys on `detect_directive` alone. The ambiguity and
memory-verb-attachment gates exist to keep a wrong expiry literal off the
rendered command, never to withhold the proposal, so they move to the
`--for` suffix only.

Coupled, the arm proposed nothing: 0 candidates against 44,683 active
beliefs on this repo's store, 3,003 of which read as directives. Those
3,003 are now proposed as permanent locks and 0 carry a `--for`, so the
recall cost stays confined to the window.

`_directive_window_spec` keeps its own `detect_directive` guard even
though candidacy applies one upstream. It is not redundant:
`_format_stop_prompt` renders a suffix for every candidate, and a
correction-class candidate arrives having never been through the
directive arm — a correction reading "Why would anyone retain this for
two years?" would otherwise render `--for 2y`. Pinned by a test.
The safety paragraph told an auditor to check
`hook._belief_is_lock_candidate`. The population
`AELF_AUTOLOCK_CORRECTIONS=1` writes is `_belief_is_correction_class` —
the filter at the `_autolock_candidates` call site — and the two came
apart in #1315. Naming candidacy reports 3,003 proposal-only beliefs as
auto-lockable, which is a false alarm in the direction that gets a
correct filter reverted.

`test_docs_locks_framing_1163.py` claimed to pin this and could not:
`_mk` hard-codes non-directive content, so its negative control passes
whatever candidacy does. It now carries a directive belief and asserts
the two predicates disagree on it.
…ctions

`aelf setup --stop-hook` help, INSTALL.md and ARCHITECTURE.md all
described the Stop prompt as correction-class only, and the setup help
also said AELF_AUTOLOCK_CORRECTIONS=1 auto-locks "instead of" prompting.
Since #1315 the prompt covers directive beliefs too, and the flag writes
only the correction-class subset — the rest still print.

PRIVACY.md is deliberately untouched: "lets the Stop hook auto-lock
session corrections at turn end" remains exactly true.
The entry disclosed that the candidate arm keys on the same spec as the
suffix and is "inert on live data too". The decoupling ruling reverses
that, so the sentence would have shipped as a false claim about a
feature that now reaches 3,003 beliefs. Scopes the total-recall-cost
figure to the `--for` suffix, records the superseding ruling with its
measured population, and corrects the #1163 entry's predicate name to
match PHILOSOPHY.md.
@robotrocketscience
robotrocketscience force-pushed the feat/issue-1315-directive-window-proposal branch from 3e0728f to 968d9db Compare August 9, 2026 06:46
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 9, 2026
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

merge-train: merged 968d9dbmain via FF push.

@github-actions
github-actions Bot merged commit 968d9db into main Aug 9, 2026
30 checks passed
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attn:unblock Needs answer from another session author-idnn PR authored by session idnn

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: confirmation-gated natural-language capture for time-boxed locks (follow-on to #1314)

1 participant