feat(hooks): fire SessionLaneChanged when a session's board lane changes - #7669
feat(hooks): fire SessionLaneChanged when a session's board lane changes#7669rnoack1 wants to merge 1 commit into
Conversation
56fbd26 to
edb18bc
Compare
edb18bc to
3b8c1f3
Compare
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, well-fenced feature — but it carries a rework of the hook engine's read path and a new fail-closed audit dependency for all five pre-existing events. Watch
Suggestions
[DESIGN-REVIEWED] 10477c1 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS The fail-closed audit gate on every script-hook run rests on an uncheckable "security-class blocking finding" — the verifiable gap justifies recording, not refusing. Not justified as shipped
What this change shipsInventory (10 items) — 8 justifiedIntent: let an automation react when a session enters a board lane (motivating case: close-out on Done) — an ADDITION, with six declared riders.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 10477c1 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsBoth candidates rest on the same premise: that reading shared state "after the write lock is released" opens a race window. I traced the actual control flow to test that. In the drop handler ( Candidate 1: Candidate 2: No findings. [OPUS-REVIEWED] 10477c1 |
3b8c1f3 to
b521748
Compare
b521748 to
a47c047
Compare
023ecb9 to
c98486e
Compare
c98486e to
026424e
Compare
026424e to
4cd27f8
Compare
4cd27f8 to
680b1ed
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Coordination from the #7779 side, following the maintainer's 2026-09-04 relationship audit (it asked our two PRs to agree on two things):
Mirrored on #7779. No changes requested to this PR. |
UX Review (Fable 5, fork) — 🟡 CONCERNSUX-level review of I have what I need: the full frontend diff (HooksPage form, event picker, lane matcher help/picker/warning/empty/error states, TagManagerList status-create mode, ChatSidebar deep link, all locale strings), the PR description, and confirmation that all six screenshots are fork-added binaries not materialized in this checkout. Reconciliation done; emitting the review. UX-Verdict: CONCERNS Careful, layered copy throughout — but nothing here has been seen: no blind read, no materialized screenshots, and the tag-manager handoff mode was never photographed. Watch
Evidence gaps
Suggestions
[UX-REVIEWED] 10477c1 |
|
@rnoack1 Thanks for this. Audited at bed0642, so the head has moved since; the overlaps below are file and design level and should still hold. Four other open PRs touch the same seams, and I would like the coordination settled before any of them lands. #8185 (@Pearcekieser) instruments the same three writers in #7779 (@jeeshofone) adds a third lane writer, #7877 (@billygerhard) writes slot tags through its own #7843 (@aniruddhaadak80) reworks Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Nothing server-side reacted to a lane change, so the board could not trigger anything. Dispatch is off the request path, so a hook can neither block nor fail the tag write.
Problem / Motivation
Nothing server-side reacts when a chat session's tags change, so the kanban board
displays state but cannot trigger anything. There is no way to run an automation
when a session enters a lane — the motivating case being a close-out prompt when a
session is marked Done (tickets to close, follow-ups to open, branches to
delete).
HOOK_EVENTScarries five events —AgentSpawn,UserPromptSubmit,PreToolUse,PostToolUse,Stop. All five are agent-turn lifecycle events, sonone can fire for a tag moved from the UI outside any turn.
Six riders travel with this feature — declared up front
Named here so they are visible without reading to the end; the reasoning and the
revert consequences are in Revert coupling below.
argued from a
no-blocking-call-on-event-loopviolation reachable from thisevent's dispatch. Symptom-level; the cause-level fix is named and out of scope.
"status"strings intags.jsonare repaired to real bools at load.Cause-level rather than a rider in spirit — base readers (
chat_tags.py:964,980)already treat the string
"false"as truthy, so the delta filter this event needswould inherit that bug — but it does change board-lane classification for legacy and
hand-edited tags independently of hooks, so it belongs on this list rather than
being counted as part of the event.
run_script_hookaudited only the governance DECISION; the RESULT (ok,errorwith its exit code,
timeout) lived solely in the in-memoryhook.last_status,which no audit query can reach. One row builder feeds every completion path; the
terminal ones write it INLINE, because deferring a cancellation does not ensure the
deferred write is ever driven, and the singleton is warmed before the command runs
so that inline write is never the blocking first touch. So this is a
cross-cutting change: it applies to all five pre-existing
events, not only
SessionLaneChanged. It ships because the absence was filed as asecurity-class blocking finding requiring the record uniformly across every
script-hook execution — so scoping it to this event alone is not available.
It also makes hook availability depend on audit availability: if the audit log cannot
be warmed,
run_script_hookREFUSES the run (exit_code=2,last_status="blocked")rather than running it unrecorded. Exit 2 is the BLOCK signal on
PreToolUse, so whileaudit is unavailable a broad matcher there denies every tool call it covers; on the other
four events the hook silently does not run. Fail-closed is intended for the security gate,
and for the rest it trades availability for auditability — the spec's failure-mode section
carries the per-event table.
last_runandrun_countare coerced to numbers whenhooks.jsonis loaded.This is REQUIRED BY RIDER 5, not a pre-existing latent bug, and the two should be
judged as one unit. Rider 5's
_merge_run_bookkeepingcompares these fields(
ran.last_run >= live.last_run,ran.run_count > base_count); the base only everASSIGNS them, so a string from a hand-edited or older
hooks.jsonraises noTypeErrorbefore this PR and the dispatch was never at risk. Measured at merge basee86469c1f1: 8last_runoccurrences, 7 assignments, 0 comparisons._normalize_hook_numberfollows the file's existing_normalize_hook_timeoutpattern.
ScriptHookStorenow serves readers a committed snapshot and folds a finishedrun back into it.
fire()was reworked around_committed_fire_targets,_merge_run_bookkeepingandrun_and_publish: readers getlist_all()off adeep-copied snapshot instead of the live dict, and a completed run's
run_count/last_runare merged back as a DIFFERENCE under the mutex, off the event loop.This is the largest rider and the least visible: it is the read and bookkeeping
path for ALL FIVE pre-existing events, including the security-critical
PreToolUsegate, so an error in the fold would degrade every event silentlyrather than fail loudly. It is a fix to a PRE-EXISTING hazard rather than something
this event introduces, and an earlier revision of this list wrongly framed it as
feature-necessity: base
fire()already iterates live hook objects and awaitsasyncio.to_threadinside that loop, so the interleaving predates the lane path.The lane worker fires off the request path and so reaches it more readily, which is
why the fix rides here, but the hazard is base's. The revert consequence is Revert coupling
item 2 below, which this list previously left as the only place it was declared.
TestPreExistingEventBookkeepingIsUnchangedpins the two invariants that carrythe risk — the count is applied as a difference so a concurrent increment is never
rolled back, and a slow older run cannot overwrite a newer outcome — and
TestEveryPreExistingEventFoldsThroughTheSnapshotshows each of the five eventstraversing the path.
offers a link to the board so an author with no status columns can create one, and
following it unmounted the route and destroyed the name, command and matcher
already typed — on the first-run path, every time.
website/src/utils/hookDrafts.tsis a thin instance of the repo's existing
createSlotDraftStore, alongsidechatDraftsandgoalDrafts, session-scoped and keyed by the form's identity(
new, or the hook id when editing). Cross-cutting because it applies to EVERYhook form and not only a
SessionLaneChangedone, so it is declared here ratherthan counted as part of the feature. The draft is dropped in the create/update
mutation's
onSuccess, NOT on the Save click: a rejected save leaves the formmounted with its edits on screen, so an early clear would lose them at the next
navigation.
HooksPage.draftSurvivesNavigation.test.tsxpins that rejected-savepath separately from the plain-navigation one.
Why it matters
The only mechanism available today is polling
GET /api/chat/slotson a timer anddiffing against a remembered snapshot. Three costs fall on every consumer:
reaction latency equals the poll interval; each one reimplements the same delta
computation; and one that forgets to baseline fires on every session already
carrying the tag. The board is the natural place to express "when this reaches
Done, do X", and it currently cannot.
What this buys, stated exactly: latency and a shared delta — NOT the removal of
reconciliation. Reaction stops being bounded by a poll interval, and the delta is
computed once here instead of in every consumer. It does not retire the polling
loop for the motivating subscriber: delivery is at-most-once and in-memory, dropped
on overflow and on gateway restart, so close-out automation doing irreversible work
still has to reconcile against the board — which the spec says in those words. A
subscriber that can tolerate a missed transition may drop its timer; one that cannot
gains latency and keeps the loop. Retiring it needs a durable queue with redelivery
semantics, which is a different change and is not attempted here.
What changed (motivation → approach → change)
Goal: let something react once a status tag is set, without adding a
subsystem.
Approach. Both tag writers already end in the same shape — mutate,
save_slot_off_loop,push_slots_update(), audit — andpush_slots_update()isa browser broadcast with no server-side consumer, so there was no existing seam to
subscribe to. Rather than add one, this reuses the script-hook mechanism already
present:
hooks.json), notper-agent. That is what makes the event possible at all: a tag change has no
agent, so a per-agent lookup would have had nothing to key on.
shape —
api_hook_testdoes exactly that, andfire()builds its own defaultpayload when handed none.
capabilities.script_hooks(default off), this needs no new trust decision andwidens no capability surface.
Considered and rejected: consuming
push_slots_update()server-side (couplesautomation to a render notification, and fires on non-tag changes); a generic
session-state-changed event (broader, and every extra field is a compatibility
commitment — a tag-scoped event can be widened later); reusing
Stop(cannot seea tag moved while no turn is running, which is the motivating case).
What was built:
SessionLaneChangedadded toHOOK_EVENTS;fire()stampsslot,added,removed.fire_session_lane_changed, a fire-and-forget wrapper, dispatched from theTHREE session-level status-tag transitions:
api_chat_slot_tags(PUT tags),api_chat_slot_drop(drag-drop), and theapi_chat_tag_deletestrip loop —deleting a status tag means every session holding it just left that lane, so
omitting it would make a
*removed:done*hook silently miss lane deletion.Three decisions worth flagging for review, because they are the parts expensive to
reverse once a hook subscribes:
slot/added/removed.A resulting-list-only payload would make every consumer persist its own prior
snapshot to answer "was Done just added?" — the polling problem relocated rather
than solved. An earlier revision ALSO sent a
tagslist holding the post-changeset; it is dropped. Dispatch is off the request path, so the board can move again
before the hook runs, meaning such a list could only answer "what did this
transition land on" while being shaped like an answer to "what is true now". The
spec had to spend a paragraph telling subscribers not to trust it, which is worse
than not sending it: a subscriber needing current state re-reads the live store.
Dropped now because the event has zero subscribers — removing a payload key later
is breaking, and this is the free moment.
PreToolUsecall; this event ignores it. By the time it fires the write is persisted and
the user has already performed the drag, so a veto would make the board
unusable when a hook breaks rather than preventing anything. Dispatch is off
the request path so a slow hook cannot delay the response either.
maybe_auto_tagwrites non-status tags routinely (anddeliberately never writes status ones), so firing on every tag would make the
event chatty for the board-lane case that motivates it while adding nothing.
This is additive: with no hook registered, every writer behaves exactly as before.
Three cross-cutting changes in
hooks.py, all shippingTHREE changes here alter behaviour for every hook event, and all ship: an
asyncio.to_threadoffload of thecapabilities.script_hooksgate, aninvocation-outcome audit row when a hook run finishes, and a rework of
ScriptHookStoreso readers are served an immutable snapshot instead of the livehook objects. The snapshot is the load-bearing one:
list_all()returns a committedcopy rather than
self._hooks.values(), every mutation republishes under the storemutex, and a hook run republishes through
run_and_publishso its status reachesreaders. That changes what a reader observes mid-write, so it is named here rather
than left to the diff. A third — an
allowedgovernance row per permitted run — IS shipped, and every governance write now goes
through the same off-loop audit seam: a cold
sel()does synchronous trust-dircreation and an HMAC key load, so emitting the row inline would stall the gateway for
each permitted hook. Routing it, rather than deleting it, is what keeps the permitted
decision auditable on its own without putting filesystem I/O on the event loop.
The offload, because resolving that gate walks
profiles/synchronously, so anasynccaller resolving it inline stalls the event loop — ano-blocking-call-on-event-loopviolation reachable from this event's dispatch.The audit row, because
run_script_hookfiled a row when governance DENIED a hookand nothing when it ALLOWED one, so a permitted run left no trace. Its own sibling,
the skills-only path in
fire(), already audits both arms with the same helper andthe same
capabilities.script_hooksscope; this makes the command-hook path matchit, so the two outcomes filter as one queryable pair. This asymmetry is
pre-existing in
main, not introduced here —_audit_governance_hook_decisionappears four times in
mainand this PR adds none of those call sites. It is fixedhere because this event's dispatch is what reaches the un-audited path.
It is ONE seam, not a hop per caller and not keyed on the event:
_script_hooks_capability_denied_asyncwraps the synchronous gate, and bothasyncsites in
hooks.pyawait it. Scoping it toSessionLaneChangedwas tried andwithdrawn — it put an equality branch in shared dispatch that every future event
would grow, while leaving the other events stalling anyway. What changes for a
pre-existing event: its capability lookup resolves on a worker thread instead of
the event loop, and a finished hook run files one invocation-outcome row recording
what it did. Volume is bounded by hook RUNS, not by UI
activity — the lane permit gate deliberately does not also record an allow, which
would have filed a row on every lane drag including the default state where
capabilities.script_hooksis off and no hook can run.This remains a CALLER-side workaround. The cause-level remedy is non-blocking
resolution, or a cached fingerprint, in the owning module — and it is centralised
in one wrapper precisely so that change has one seam to delete rather than a hop at
every call site.
Also outside the event's own surface:
validation.ALLOWED_HOOK_EVENTSgains theevent, so a hook can be registered through the create/update API rather than only
by hand-editing
hooks.json. Three event allowlists now diverge intentionally anda test pins all three memberships together with the reason for each, so a follow-up
cannot "fix" the divergence by syncing them.
The dispatch is bounded, and absorbs rather than sheds. Deltas go onto ONE
bounded FIFO queue —
_LANE_QUEUE_MAXSIZE(512) — drained by ONE worker, so anordinary burst is absorbed instead of dropped. Each dispatch can spawn a
hook subprocess, so an unbounded fire-and-forget scheduler was an fd/process
exhaustion path; a single drainer bounds that real resource, and the queue depth
bounds memory. Only an overflow past 512 is dropped, and that drop is audited
(SEL
outcome=rejected) — it means hooks are not draining at all, not thattraffic is merely brisk. One queue and one worker make delivery totally ordered,
which subsumes the per-session ordering this event actually promises: a session
dragged out of a lane and back must not deliver "entered" and "left" in
either order, because a close-out subscriber acting on the wrong one does something
irreversible. An earlier revision sharded the queue 4 ways on a digest of the slot
key so one wedged hook could not defer another session's fire; that is withdrawn.
It bought cross-session latency isolation this event's own at-most-once contract
already tolerates, and cost four queues, four workers and a hashing rule to
document and test. A slow hook now delays later fires, bounded by the 1-300s hook
timeout. Dispatch stays off the request path either way, so a slow or broken hook
can neither veto nor delay the tag write.
An earlier revision capped in-flight dispatches at 8 and dropped past the cap
at scheduling time. That was replaced because routine shedding is a poor trade
for an event whose subscribers act irreversibly; the constant it used no longer
exists.
One persisted-data repair, named because it rewrites user state.
DashboardState.load_tagsnow coerces a non-boolstatusfield to a real bool atload and persists the repair, so
tags.jsoncan be rewritten on load. This is notcosmetic, and the reason is this PR's own new readers rather than any pre-existing
disagreement: every reader of that field on the base tree already agrees on plain
truthiness (
chat_auto_tag.py, and two sites inchat_tags.py, plus the frontendtag list), and
["status"] is Trueappears zero times. The gap is that thecreate/update API coerces with
bool(...)on WRITE, so a value that never passedthrough it — a hand-edited
tags.json— can still hold a string, and"false"istruthy. This PR adds new readers of the field on the lane-delta path, so a stringy
value would now decide whether a transition is a LANE transition at all.
Normalising once at the single load entry point is therefore the cause-level fix,
and it keeps every reader, old and new, on plain truthiness. The alternative — a
defensive
is Trueat each reader — leaves the persisted state wrong and the nextreader exposed.
Revert coupling: TWO hunk groups a revert would take with it. Everything else here
belongs to this feature, so a revert of the feature is a revert of the feature and
needs no hunk-retention recipe. These two are not, and are called out so the
decision to couple them is explicit rather than discovered later:
The off-loop gate resolution and the invocation-outcome audit row change
behaviour for EVERY hook event, not just this one. Reverting this PR returns every
event's capability lookup to the event loop and stops recording what a hook run
actually did. Both are argued above; both are cross-cutting.
The committed-snapshot freeze in
_committed_fire_targetsis cross-cutting forthe same reason.
fire()previously iterated the live hook dict, so a commandbelonging to a mutation that
_atomic_mutationrolled back could still execute, withno undo —
TestARolledBackHookNeverFirespins that. Reverting this PR reopens thatwindow for all five existing events, not just this one.
Named deferral, not a claim of completeness. The
governance_permitswalk isNOT fixed generally by this PR. It is offloaded only at the two call sites inside
hooks.py, because those are the two this event's dispatch traverses. The samesynchronous walk sits behind every other caller — 26 sites across
src/at thetime of writing, including
dashboard/chat_runner.pyon the async turn path. Thegeneral fix belongs in
governance_profiles(non-blocking resolution, or a cachedfingerprint) rather than a
to_threadat each of 26 callers, and is deliberatelyout of scope here. This is stated in the code at the offload site and in the spec.
Writer coverage is enumerated in the spec, including one known gap. "Status
tags changed" is not true of every site that assigns
slot.tags: hydration,restore and fork-copy paths assign it while no transition is happening, and firing
there would fire on process start. The spec now tables which sites fire and which
do not, with the reason for each. One is a genuine gap rather than a deliberate
exclusion: folder inheritance (
validate_folder_tag_idscallers) can stamp astatus tag at filing time and does NOT fire, so a hook cannot use this event to
catch lane membership acquired by filing. That is stated in the spec as a known
gap rather than left for a subscriber to discover; instrumenting the filing path
belongs with that endpoint family, not bolted onto the tag writers.
Event name narrowed to
SessionLaneChangedbefore merge. The name is the onecompatibility surface that cannot be corrected once a hook subscribes. The firing
contract is status-tags-only, so a tag-general name would over-promise — and it
would make the obvious later widening (fire on ALL tag changes) a BREAKING change
rather than an additive one, since every no-matcher subscriber would begin receiving
auto-tag noise from
maybe_auto_tag. Under the lane-scoped name that widening is aNEW event beside this one. The rationale is recorded in the spec so it is not undone.
Spec updated in this commit.
docs/system-specs/modules/memory-skills-hooks.mdgains a
SessionLaneChangedsection documenting the payload keys, thedirection-tagged matcher grammar, the whole-string-glob wildcard requirement (a
bare
donematches nothing), the lane-rename behaviour (name tokens follow arename, id tokens are stable), the informational-only contract, the dispatch cap,
and the allowlist divergence — so the first registered hook is not what freezes an
undocumented compatibility surface.
The event is deliberately absent from
_VALID_HOOK_EVENTS, so it does not leakinto the generated kiro-cli agent config, which would reject an event it does not
know.
Tests
test/test_session_lane_changed_hook.py, 51 tests:HOOK_EVENTS, and is not in_VALID_HOOK_EVENTS(locks in that it cannot leak to the kiro-cli spec).PUT /tagsfires once withadded/removed/slotcorrect.POST /dropfires and reports the replaced lane inremoved.fireraises, having reallydispatched.
Nonestore is a no-op.Full affected surface re-run green: 773 passed, 0 failed across
test_chat_tags,test_hooks,test_hooks_coverage,test_dashboard_hooks,test_agent,test_api_kiro_hooks,test_fire_tool_hooks,test_hook_validation_parityand the new file.Negative control: removing the dispatch from the PUT writer turns exactly one
test red, and restoring it returns the diff byte-identically — so the new tests
can fail for the intended reason. One caveat stated plainly: the
non-status-tag test passes with and without the dispatch, as any absence
assertion does; it is meaningful only paired with the positive test.
Manual verification
Both writers are exercised end-to-end through the real aiohttp handlers in the new
tests. There is ONE user-visible change: the dashboard hooks page offers
SessionLaneChangedin its event picker, so the feature is reachable withoutcalling the API by hand. The picker's event list was a second hard-coded copy of
the backend allowlist, so a review round found the two had silently diverged and
the described user could not reach the feature at all; a test now asserts the two
lists are equal, so the drift fails the build instead of needing a reviewer.
Screenshot evidence
Captured from the real page through an isolated capture entry
(
website/capture/session-lane-changed-hook.html), driven bywebsite/scripts/capture-session-lane-changed-hook.mjs. The harness asserts as well asphotographs: it exits non-zero unless the picker really offers six events including this
one, so a silent regression cannot produce a passing capture. Every hook shown is a
fixture -- no real session, name or token appears in either frame.
Related Issues
Part of #7663.
Checklist
follow-up phase. The event picker is NO LONGER deferred: it ships here,
because deferring it left the event registrable only through the API
and the feature unreachable from the dashboard
Contribution License Agreement
Placeholder per the PR template — the CLA wording is to be supplied by OSPO and
has not been invented here.