Skip to content

fix(stop-hook): honour conversational turns on transcript-free harnesses - #687

Merged
apackeer merged 2 commits into
awslabs:v2from
csotomon:fix/kiro-stop-hook-conversational-carveout
Aug 8, 2026
Merged

fix(stop-hook): honour conversational turns on transcript-free harnesses#687
apackeer merged 2 commits into
awslabs:v2from
csotomon:fix/kiro-stop-hook-conversational-carveout

Conversation

@csotomon

@csotomon csotomon commented Jul 30, 2026

Copy link
Copy Markdown

Summary

The Stop hook's tier-3 conversational carve-out was inert on Kiro IDE, Kiro CLI and opencode. It answers "was the human's last prompt answered with zero engine calls?" by reading the harness transcript, and only Claude Code and Codex deliver transcript_path. On the other three the predicate always returned false, so every purely conversational turn mid-stage — asking why an earlier decision was made, reading code without advancing the workflow — fell through to the cap-bounded block and was counted as no-progress.

This gives the same predicate a second evidence source so it reaches a verdict without a transcript. What that is worth to a user depends on the host, and it is not uniform — see User experience. Full diagnosis in #688.

Revised after review (#687 review @ 90a8eee): the probe test was vacuous, the parity claim was too broad, a failed marker write could fail open, and the original reproduction claim did not hold on Kiro IDE. Per-finding response in the comments.

Round 2 addressed at 2d384b54: the stale-path test now directly asserts physical cleanup, both passthrough comments are contract-accurate, and the Kiro CLI 2.16.0 evidence is reflected throughout this body, the CHANGELOG, and the reference docs.

Changes

One predicate, two evidence sources. Where transcript_path is delivered the transcript stays authoritative — including when it disagrees with the new signal, which is pinned. Where it is not, the hook compares two mtimes:

conversational  <=>  mtime(.aidlc-human-turn) > mtime(.aidlc-engine-touch)

The two seams already existed. .aidlc-human-turn is touched by the UserPromptSubmit mint, beside the HUMAN_TURN ledger event it already appends — the core aidlc-mint-presence.ts hook (Claude, opencode) and both Kiro adapters' inlined mint targets. .aidlc-engine-touch is touched by every advancing aidlc-orchestrate next / report / park. Read-only routing (--status, --doctor, --help, --version, the workspace verbs) is excluded, so "what stage am I on?" stays a chat turn.

Markers rather than the audit ledger. A ledger-only predicate looks cheaper — one file, no engine coordination — but next is read-only and emits no audit event, so it would be blind to a conductor that consulted the engine and then bailed mid-loop. That is the failure the forwarding loop exists to catch, so a ledger reading would have weakened enforcement to fix an annoyance.

The load-bearing subtlety. The Stop hook consults the engine itself, on every stop. An unmarked probe would refresh the engine marker each time, leaving the predicate false forever and the carve-out dead code that looks implemented. The hook sets AIDLC_STOP_HOOK_PROBE=1 on its spawn and markEngineTouch() no-ops on it. Both halves are now pinned — see Test plan, because the first attempt at pinning this was vacuous and review caught it by mutation.

Known gap — the marker path is more permissive than the transcript path. isEngineToolCall counts as engagement any non-read-only aidlc-jump / aidlc-bolt / aidlc-swarm call and the mutating aidlc-state verbs. None of those tools touch the engine marker — its only writers are aidlc-orchestrate's three subcommands. So a conductor that runs aidlc-jump (mutating the stage pointer, emitting audit) and then ends its turn without consulting the engine is released on the transcript-free harnesses where the same turn blocks on Claude Code. Those turns were nudged before this change, so it is a real if narrow relaxation. Closing it means touching the marker from a seam all four tools cross (the audit path, or writeStateFile), which widens the blast radius well past this carve-out — so it is documented, in the code and in docs/reference/06-hooks-and-tools.md, rather than closed. Happy to do it as a follow-up PR if preferred.

Fail-closed throughout. A missing or non-regular-file marker reads as "no evidence" and falls through to the cap-bounded block. The autonomy guard still suppresses the carve-out under Construction Autonomy Mode: autonomous. A marker whose write fails is deleted rather than left stale: a stale engine marker would be a persistent silent fail-open, since the human marker keeps advancing past it. markHumanTurn / markEngineTouch self-gate on a born workflow, keeping next a pure read that scaffolds nothing (pinned by t165/t171).

Session scope. Both markers are per-intent with no session key, where the transcript predicate was inherently per-session. Two concurrent sessions on one intent can cross-talk. Narrow, and the failure mode is a released stop rather than a wrong transition, so it is accepted and documented; session_id is available if it is ever worth closing.

User experience

The {decision: block} contract is Claude Code's. Each other host consumes it, or not, on its own terms — so the visible effect differs, and an earlier revision of this PR wrongly implied it was uniform.

Host Acts on the block? What changes
Claude Code, Codex Yes, native The spurious nudge is suppressed; the conversational turn ends clean
opencode Yes — the plugin parses the block itself and re-prompts the session with the reason (aidlc-opencode-adapter.ts:502-527) Same: nudge suppressed
Kiro IDE No Nothing user-visible. Only stop.drops and the no-progress counter are corrected
Kiro CLI 2.16.0 legacy/V2 Yes — measured live through this adapter The block is consumed, reason is reinjected, and Stop fires again after the induced continuation (two invocations total)
Kiro CLI 2.16.0 --v3/KAS Yes — measured live through standalone .kiro/hooks registration The block is consumed and reason is reinjected; Stop fires once and does not re-fire after the induced continuation

On Kiro IDE the Stop trigger is observational. Probed live on the current build with a temporary hook emitting unique sentinels: the command ran (witness written), and neither its stdout nor its stderr reached the agent's context. The Stop payload is {session_id, hook_event_name, cwd} — three fields, no transcript, no turn id. That matches Kiro's docs (Stop sits outside the blockable set; stdout is forwarded only for SessionStart / UserPromptSubmit; no {"decision":"block"} contract exists for any trigger).

So the reproduction this PR originally led with was wrong. The nudge was never delivered on Kiro IDE — the hook produced it and the host discarded it. The block counter on disk, which is what #688 reported, is written before the decision is emitted, so it was evidence the hook ran, not that the human saw anything. aidlc-stop.json and the kiro-ide guide always said the trigger was advisory; the adapter comment claiming parity with Claude's contract was wrong before this PR and I made it worse by stating it explicitly. Both are corrected, with the probe evidence recorded in the comment.

Checklist

  • I have reviewed the contributing guidelines
  • I have performed a self-review of this change
  • Changes have been tested
  • Changes are documented

Test Plan

Coverage

t121 (integration, drives the real hook) gained eight (f2) cases plus a seedTurnMarkers helper; t259 (new, unit, in-process) pins the lib half.

Case Pins
markers, human newer A conversational turn on a transcript-free harness is no longer counted as a block
markers, engine newer Keeps the fix from being an off-switch — engaged-then-bailed still BLOCKS
missing engine marker / missing human marker Fail-closed both sides: absent marker is "no evidence"
autonomy guard Conversational-shaped markers under autonomous Construction still BLOCK
transcript wins A delivered transcript overrides the markers when they disagree
(f2) THE PROBE IS MARKED Asserts, via an env witness the mock engine writes, that the hook handed AIDLC_STOP_HOOK_PROBE=1 to its own consultation
probe mark does not leak The mark is scoped to the child; it must not land on the hook's own process.env
t259 × 13 markEngineTouch no-ops under the env var, writes without it, does not refresh an existing marker under the probe, only literal "1" suppresses; the predicate both directions; three fail-closed cases; the birth self-gate; and a failed write physically removing the stale path (plus the fail-closed predicate check)

Why two files. Review proved by mutation that the original single assertion was vacuous: t121 runs against a mock engine that never calls markEngineTouch, so .aidlc-engine-touch is unrefreshable there and an mtime-equality check passes even with the probe marking deleted from the hook. The env witness pins the hook half; t259 pins the lib half in-process. Both are needed — the carve-out is dead code if either the hook forgets to mark its probe or markEngineTouch forgets to honour the mark.

Mutation re-verified. With [STOP_HOOK_PROBE_ENV]: "1" removed from the spawn, (f2) THE PROBE IS MARKED fails: Expected: "1" Received: null. Restored: green.

Validation

Run on the rebased branch (2.5.33 on top of v2 at 2.5.32 / 57ae3d05).

  • bun run check (dist byte-parity across five harnesses, three tsconfigs, biome over 545 files): clean
  • bun tests/gen-coverage-registry.ts --check, bun scripts/ci-changelog-guard.ts upstream/v2: clean
  • Every test touching the Stop hook is green, including outside the changed files: t121 (61), t259 (13), t195, t205, t27, t131, t228, t241, t245, t01, t02, plus t68, t147, t165, t171. t122 is e2e (--release only) and is not claimed here
  • Full default suite: 285 files, 5810 assertions, 3 failed files — all three pre-existing

The three failures are pre-existing

Verified three independent ways: by stashing this work and re-running in place, by a git worktree at bare upstream/v2, and by the sibling branch #689 producing the identical three.

File Cause Touched here?
t66 #647's claim-sources sensor — Stage "intent-capture" imports unknown sensor id "claim-sources"; the golden export fixture was not updated No
t89 Same claim-sources root cause; the sensor-resolution fixtures were not updated No
t19 Live preflight; needs authenticated AWS credentials No

Matches what #615 reported for t66/t89.

Live host verification and remaining limits

Kiro CLI 2.16.0 was verified live on both executable paths. Legacy/V2 consumed the adapter-relayed block, reinjected reason, and invoked Stop twice across the induced continuation. --v3/KAS consumed the same shape through standalone .kiro/hooks, reinjected reason, and invoked Stop once without re-firing after the induced continuation. This is CLI-only evidence; it does not revise the separate Kiro IDE probe, where Stop-hook stdout and stderr were discarded.

Not verified live here: I have not watched this exact conversational carve-out end cleanly in a live opencode session. Its plugin-owned re-prompt path is exercised in code/tests, but the remaining live-host claim is intentionally limited to Kiro CLI 2.16.0.

stop_hook_active is still hardcoded false on Kiro, deliberately. There is no honest way to derive it: reading the block counter is circular (it is prior, and the seeding branch only matters when prior === null), a block emits no audit event, and a sibling marker would live in the same directory and fail for the same reasons. Guessing true would be worse — it would seed at 2 on a legitimately fresh first block, weakening the guard in the common case to fix a rare one. Residual cost: at most one extra counted block in the narrow window where the counter file is missing mid-sequence.

The jump/bolt/swarm/state gap is open by choice, not closed. See Changes.

Impact

Bounded, and narrower than the first revision of this PR claimed. On hosts that act on the block it removes one spurious nudge per conversational detour; the cap already limited the damage, and no state was corrupted, no stages skipped, no artifacts lost. On Kiro IDE — the harness where the bug was reported — it corrects the ledger and the counter and nothing else.

The stronger argument is second-order: the nudge is the only mechanism that catches a conductor that genuinely abandoned the loop, and a warning that fires every time a human asks a question mid-stage teaches users to ignore it.

Lowering CLAUDE_CODE_STOP_HOOK_BLOCK_CAP is not an alternative: nextCount starts at 1 against if (nextCount >= cap) return false, so cap=1 disables forwarding-loop enforcement entirely rather than softening it.

Upgrade

mkdir -p your-project/.kiro && cp -R dist/kiro-ide/.kiro/. your-project/.kiro/

Version

Bumps to 2.5.33, rebased onto v2 at 2.5.32 (57ae3d05). Upstream now owns 2.5.31 and 2.5.32; this PR takes the next free patch and #689 takes 2.5.34, so the two remain independently mergeable without duplicate headings. The final rebase was force-updated with an explicit lease after local validation.

References

Acknowledgment

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of the project license.

@apackeer

Copy link
Copy Markdown
Contributor

Review @ 90a8eee

Verified against the head commit in a clean worktree: t121 60/60, t68 + t147 + t165 + t171 59/59, t122 + t195 15/15, the coverage-registry guard 33/33, and bun scripts/package.ts --check clean across all five trees. The mechanism is the right shape: one predicate, two evidence sources, fail-closed marker reads, the autonomy guard intact on the marker path, transcript precedence pinned, and the birth self-gate keeping next a pure read (t165/t171 green). I also confirmed the load-bearing piece live against the shipped lib: markEngineTouch no-ops with AIDLC_STOP_HOOK_PROBE=1 and touches without it, and the Stop hook is the only spawner of orchestrate next among the hooks, so the probe env covers every self-consultation path.

That said, I found one test-efficacy hole (proved by mutation), one real enforcement gap, two contract problems, and a mechanical blocker. Requesting changes on findings 1-4 plus the rebase.

1. P1 (test-efficacy): the "(f2) THE PROBE MUST NOT SELF-SATISFY" case is vacuous - it passes with the fix deleted

Mutation check: I removed [STOP_HOOK_PROBE_ENV]: "1" from the hook's spawn env (core/hooks/aidlc-stop.ts:808, mirrored in dist) and re-ran the case (tests/integration/t121-stop-hook-enforce.test.ts:1224). It still passes.

The reason: t121 runs the hook against the mock engine (t121:173), and the mock never calls markEngineTouch, so .aidlc-engine-touch cannot be refreshed by the probe regardless of what the spawn env carries. expect(statSync(enginePath).mtimeMs).toBe(before) is satisfied trivially. The PR body states that without this assertion every other case can pass while the fix is silently broken in production - that is exactly the current state of the suite. The production code itself is correct (I verified the no-op live), but nothing pins it.

Fix options: (a) have the mock engine write a witness file recording process.env.AIDLC_STOP_HOOK_PROBE and assert the hook's spawn delivered "1" (pins the hook side), plus a small unit case pinning that markEngineTouch no-ops under the env var (pins the lib side); or (b) run this one case against the real engine, the way t165 does.

2. P1: the marker predicate is blind to aidlc-jump / aidlc-bolt / aidlc-swarm / non-engine-owned aidlc-state mutations

The transcript predicate counts any non-read-only jump/bolt/swarm call and the mutating aidlc-state verbs as engagement (isEngineEngagementSegment, core/tools/aidlc-lib.ts:820-843 and :893-896). The marker predicate sees only orchestrate next/report/park (core/tools/aidlc-orchestrate.ts:1407, :3533, :3986) - none of jump, bolt, swarm, or the state tool touch .aidlc-engine-touch.

Concrete divergence on a transcript-free harness: the human asks to reroute, the conductor runs aidlc-jump (mutates the stage pointer, emits audit) and ends the turn without running next. The human marker is newer, the carve-out fires, the stop is allowed. On Claude the same transcript blocks. Another legal path: aidlc-state set-construction-iteration is not in the engine-owned transition set, so a conductor may call it directly; the transcript regex (\bset\b) classifies it as engagement, the marker path never sees it. Before this PR those turns were always nudged on Kiro/opencode, so this is a genuine weakening, and it contradicts both the "Enforcement is unchanged in strength" claim and the code comment saying the exclusions mirror isEngineToolCall.

Fix: touch the engine marker from a shared seam those tools all cross (the audit-emission path or writeStateFile in lib would cover jump/bolt/swarm/state in one place), or narrow the parity claims in the PR body, the code comments, and docs/reference/06-hooks-and-tools.md to name the gap honestly.

3. P2: a failed engine-marker write fails OPEN, persistently, and the comment claims the opposite

touchTurnMarker (core/tools/aidlc-lib.ts:2404) swallows write failures, and its comment says a dropped touch "costs at most one spurious nudge". That is only true for the human marker, whose absence fails closed. For the engine marker the asymmetry inverts: if a stale .aidlc-engine-touch exists and subsequent writes fail (concrete case: an engine run under sudo leaves the file root-owned, after which every user-mode writeFileSync fails EACCES while the stale file persists), the human marker keeps advancing and every engaged-then-bailed turn is classified conversational from then on. A persistent, silent fail-open in the one direction the forwarding loop exists to catch.

Cheap fix: in the catch, attempt rmSync of the marker path - a missing marker fails closed on the read side, and in the root-owned-file case the unlink succeeds because the directory stays user-writable. And correct the comment either way.

4. P2: the Kiro IDE stop contract contradicts itself, and the headline repro depends on which half is true

harness/kiro-ide/hooks/aidlc-stop.json describes the Stop registration as advisory-only ("the IDE's Stop trigger cannot block; enforcement relies on the conductor's own Stop protocol"), and docs/guide/harnesses/kiro-ide.md:111 repeats it. The adapter comment this PR rewrites (harness/kiro-ide/hooks/aidlc-kiro-adapter.ts:482) says the {"decision":"block"} stdout contract is identical to Claude's. Both texts predate this PR, but the PR's Before/After reproduction is stated on Kiro IDE, and #688 reports the nudge observed live there with the block counter written on disk - so something on current builds does surface the block output, and one of the two contracts is stale.

Ask: reconcile them in this PR, since the user-visible claim rides on it - either current-build evidence that the IDE consumes the stdout (then fix stop.json and the guide), or scope the Kiro IDE claim to the drop-log/counter behavior. Worth noting the fix is valuable regardless: Kiro CLI and opencode demonstrably act on the block (opencode injects the reason as a new prompt), and stop.drops/block-count.json on the IDE are corrected either way.

5. P3: markers are intent-global, so concurrent sessions can cross-talk

Both markers live per-intent with no session key. Two sessions on one intent (say IDE plus CLI): session B's prompt mint can make session A's engaged stop read as conversational. The transcript predicate was per-session; the fallback is not. Not blocking given how narrow the window is, but it deserves an acknowledgment in the marker-family comment and the docs section. If it is ever worth closing, the Kiro 1.x payload does carry session_id.

6. Blocker (mechanical): stale version slot, PR is CONFLICTING

The branch ships 2.5.27 on a 2.5.26 base, but v2 moved to 2.5.30 (#675). GitHub already reports the branch as conflicting (version trio + CHANGELOG). Needs a rebase onto current v2, a re-bump past 2.5.30 with the CHANGELOG heading renamed to match (t68 will police), and dist regenerated. Note 2.5.31 is already contested and #689 must re-bump too, so the "2.5.28 is left to #689" pairing in the PR body is stale as well.

Nits (non-blocking)

  • tests/integration/t121-stop-hook-enforce.test.ts:1135 and docs/reference/06-hooks-and-tools.md:260 call the quoted {session_id, ...} shape "the Kiro v2 payload". Per docs/reference/kiro-ide-hook-payload.md, v2 is the hook registration schema; that snake_case shape is the 1.x stdin payload.
  • The six new lib exports land in tests/.coverage-registry.json as UNCOVERED even though the (f2) cases exercise them through the hook; a covers: annotation in t121 would wire them up.
  • handleNext touches the marker before the Branch 0 roll-forward latch check, so a latch-swallowed no-op next counts as engagement. Transcript parity actually holds here (a bare next counts there too), so this is fine as-is; a one-line comment would save the next reader the trace.

Verified sound

Fail-closed reads on both missing-marker sides, the autonomy guard on the marker path, transcript-wins precedence, the birth self-gate (no record-tree scaffold before birth), the aidlc/spaces/*/intents/*/.aidlc-* gitignore rule present in all five harness dot-gitignore files, the mint seams wired on all three transcript-free harnesses (core hook for Claude/opencode, both Kiro adapters' inlined mint, the kiro-ide v2 aidlc-mint.json registration), opencode's nudge sentinel keeping the synthetic re-prompt from minting a human turn, and the Kiro CLI pre-dispatch spawn correctly NOT setting the probe var (it advances on the user's behalf, so it must count).

The mechanism is right and worth landing once 1-4 and the rebase are addressed.

@csotomon
csotomon force-pushed the fix/kiro-stop-hook-conversational-carveout branch 2 times, most recently from 53d5b80 to 9a752e9 Compare July 31, 2026 13:37
@csotomon

csotomon commented Jul 31, 2026

Copy link
Copy Markdown
Author

Addressed @ 9a752e9 (rebased onto v2 2.5.30, re-bumped to 2.5.32)

Thank you for the mutation check on finding 1 — that one landed hard. It invalidated the exact claim I had been making in the PR body, the commit message, and to my collaborator: that the probe assertion was what kept the fix from being silently dead. It was the opposite. I reproduced your result before fixing anything (removed [STOP_HOOK_PROBE_ENV]: "1" from the spawn, all 60 cases still green) and then made both halves bite.

Findings 1-4 addressed, 5 documented, all three nits done, and the rebase is in. I took your option (a) on finding 1 and your scoping option on finding 2 — reasoning below.

1. P1 test-efficacy — fixed, and re-verified by mutation in both directions

Your diagnosis was exact: t121 drives the mock engine, the mock never calls markEngineTouch, so .aidlc-engine-touch is unrefreshable there and expect(mtime).toBe(before) was trivially satisfied.

Took option (a), both halves:

  • Hook half. MOCK_ENGINE now writes <proj>/.probe-env-witness.json recording process.env.AIDLC_STOP_HOOK_PROBE. The case is renamed (f2) THE PROBE IS MARKED and asserts witness.probe === "1" — i.e. what the hook actually handed the child, which is the fact that matters. The mtime assertion is retained but demoted to what it can honestly be: a regression guard on the mock's own inertness, with a comment saying so.
  • Lib half. New tests/unit/t259-turn-markers.test.ts, 13 cases in-process: the no-op under the env var, the write without it, that a probe-marked call does not refresh an existing marker, that only the literal "1" suppresses (an unset-looking value must not disable marking), the predicate both directions, three fail-closed cases, the birth self-gate, and the finding-3 cases below.
  • Added a case asserting the mark does not leak into the hook's own process.env — that would suppress real touches for the rest of the turn.

Mutation re-run with the marking deleted: (f2) THE PROBE IS MARKED fails with Expected: "1" Received: null. Restored: green.

2. P1 jump/bolt/swarm/state blindness — scoped, not closed, deliberately

Confirmed your finding first: markEngineTouch has exactly one caller file (aidlc-orchestrate.ts); aidlc-jump, aidlc-bolt, aidlc-swarm and aidlc-state have zero references to it.

We chose your second option — narrow the claims — rather than touching the marker from a shared seam. Reasoning: writeStateFile and the audit-emission path are crossed by far more than this carve-out, and widening a Stop-hook fallback into them is a bigger change than the problem justifies. That is a judgement call and I am happy to be overruled; if you want it closed I would rather do it as its own PR than bolt it on here.

So every parity claim is gone, replaced by an explicit gap statement in four places:

  • markEngineTouch's doc comment (a KNOWN COVERAGE GAP paragraph naming the four tools, the concrete divergent scenario, and why it is documented rather than closed)
  • touchEngineMarker in aidlc-orchestrate.ts — now says the predicates agree on the read-only exemption and points at the gap note, with do not restate this as full parity
  • the tier-3 section of the aidlc-stop.ts header — NOT FULL PARITY sub-note
  • docs/reference/06-hooks-and-tools.md — a Coverage gap — the marker path is more permissive subsection, and the table cell no longer says "the marker spelling of the same fact"

Enforcement is unchanged in strength is removed from the PR body and the CHANGELOG entry. The entry now carries the gap as its own bullet.

3. P2 persistent fail-open on a failed engine-marker write — fixed, plus a second hole your case exposed

Took your fix: touchTurnMarker's catch now rmSynces the path, so a failed write degrades to "no evidence" instead of leaving a stale mtime that the human marker keeps advancing past. The comment is corrected — it claimed "at most one spurious nudge", which was only ever true of the human marker.

Writing the test for it surfaced something adjacent that you did not report: turnMarkersShowConversational used existsSync + statSync, so anything on the marker path contributed an mtime. A directory squatting there yielded a bogus-but-old engine mtime, which reads as conversational and releases the stop. Both markers are now required to be regular files (statSync(..., {throwIfNoEntry:false}) + isFile()), and the rmSync carries recursive: true so a directory is cleared too. My first attempt at the test failed for exactly this reason, which is how it turned up.

4. P2 Kiro IDE stop contract — reconciled, and the PR's headline claim was wrong

You were right, and the consequence is bigger than a stale comment: the reproduction in the PR body did not happen on Kiro IDE. I probed the current build — a temporary Stop hook emitting unique sentinels on stdout and stderr and capturing its stdin.

  • The hook ran (witness file written; v2 hook registrations hot-load, no session restart needed).
  • Neither sentinel reached the agent's context.
  • The Stop payload is {session_id, hook_event_name, cwd} — three fields, no transcript, no turn id.

That matches Kiro's own docs: Stop is outside the blockable set (only PreToolUse, UserPromptSubmit, PreTaskExec), stdout is forwarded only for SessionStart and UserPromptSubmit, and there is no {"decision":"block"} contract in Kiro for any trigger — that shape is Claude Code's. aidlc-stop.json and the kiro-ide guide were correct all along; the adapter comment was wrong before this PR and I made it worse by asserting parity explicitly. Corrected, with the probe evidence recorded in the comment.

So what the fix delivers is host-dependent, and the PR now says so instead of implying uniform benefit:

Host Acts on the block? Effect
Claude Code, Codex Yes, native Nudge suppressed
opencode Yes — the plugin parses the block itself and re-prompts with the reason (aidlc-opencode-adapter.ts:502-527) Nudge suppressed
Kiro IDE No (probed) Nothing user-visible; only stop.drops and the counter are corrected
Kiro CLI legacy/V2 path Yes — live-probed on 2.16.0 with the embedded agent hooks.stop registration decision:block was consumed, reason was re-injected, and Stop ran twice: once for the original response and once after the induced continuation
Kiro CLI --v3 / KAS Yes — live-probed on 2.16.0 with a standalone .kiro/hooks Stop registration decision:block was consumed and reason was re-injected; a reason asking for XVII × XIX produced a fresh inferred answer, 323. Stop ran once for the original turn and did not run again after that induced continuation

The Kiro CLI adapter relays stdout and exit status verbatim; its live host behavior is measured in the follow-up below rather than inferred from that relay. I did not test exit 2 on Stop: the framework's stop path always exits 0 (blockStop() logs and returns 0, the adapter relays verbatim), so its behaviour cannot affect this PR.

5. P3 intent-global markers — acknowledged in prose

Added a Session scope paragraph to docs/reference/06-hooks-and-tools.md: the markers are per-intent with no session key where the transcript predicate was inherently per-session, two concurrent sessions on one intent can cross-talk, the failure mode is a released stop rather than a wrong transition, and session_id is available in the payload if it is ever worth closing. Not closed, per your read that it is not blocking.

6. Blocker rebase — done, and 2.5.31 was indeed contested

Rebased onto v2 at 2.5.30 (d0cd10a6). Confirmed #686 holds 2.5.31, so this PR takes 2.5.32 and #689 re-bumped to 2.5.33; the stale "2.5.28 is left to #689" pairing is gone from the body. Conflicts were confined to the version trio, the CHANGELOG and generated files — no source file of mine collided.

Nits

  • v2 payload — corrected in both t121 and docs 06. Both now state the Stop payload is {session_id, hook_event_name, cwd}, note the richer {tool_name, tool_input, tool_response} shape belongs to the tool triggers, and that "v1"/"v2" names the registration schema, not the payload.
  • UNCOVERED lib exports — resolved by t259's covers: line. All five now report status: "covered" in .coverage-registry.json; gen-coverage-registry --check is clean.
  • handleNext ordering — comment added. It stays before the Branch 0 latch on purpose: isEngineToolCall reads the command, not its outcome, so a latch-swallowed next must still count or the two predicates disagree about the same command.

Validation

  • bun run check (dist parity across five harnesses, three tsconfigs, biome over 545 files): clean

  • t259 13, t121 61, and green across t68, t147, t165, t171, t195, t205, t241, t245, t27, t131, t228, t01, t02

  • Full default suite: 285 files, 5810 assertions, 3 failed files on this branch — 285 because t259 is a new file — and 284 files, 5791 assertions, 3 failed files on fix(kiro-ide): stop reporting a failed write as harness decay #689. The same three fail in both: t19 (needs credentials), t66 and t89 (fix: ground Intent Capture claims (2.5.10) #647's claim-sources fixtures), all failing identically on bare upstream/v2, none touched here.

    (Corrected after posting: this line originally read "284 files on both branches", which undercounted this branch by the file it adds. The PR body always carried 285.)

  • gen-coverage-registry --check clean

Follow-up: live Kiro CLI Stop verification

The earlier Kiro CLI non-measurement is now closed for Kiro CLI 2.16.0 in both executable paths. In the legacy/V2 interactive path, one user turn produced the initial response and then the reason-driven continuation, with the hook called twice. In --v3/KAS, a standalone Stop hook used a reason that contained only the Roman-numeral multiplication request—not the answer—and KAS generated 323; the hook was called once for the original turn and not again after the induced continuation.

Scope remains host-specific: this verifies Kiro CLI 2.16.0 and Kiro CLI --v3/KAS. It does not overturn the separate Kiro IDE probe or claim that the IDE consumes Stop stdout.

iuryeng added a commit to iuryeng/aidlc-workflows that referenced this pull request Jul 31, 2026
v2 shipped 2.5.30 while this branch still declared 2.5.27. Scanned the
open PRs by their actual AIDLC_VERSION diff rather than their titles:
2.5.31 (awslabs#535, awslabs#661, awslabs#686), 2.5.32 (awslabs#660, awslabs#687) and 2.5.33 (awslabs#689) are
claimed, so this takes 2.5.34.

The CHANGELOG entry was rebuilt from v2's file with this branch's block
reinserted, so no upstream heading is lost. Its sensor-cache bullet now
describes the engine-path match rather than the leaf-name one, and a new
bullet covers the clean-filter binding. Coverage registry regenerated
with the tool, not hand-edited.
@apackeer

Copy link
Copy Markdown
Contributor

Round 2 @ 9a752e9

All six round-1 items are addressed, and the fixes verify. Re-checked in a clean worktree at the new head: t121 61/61, the new t259 13/13, t68 + t147 + t165 + t171 + t195 green, bun scripts/package.ts --check clean across the five trees, gen-coverage-registry --check clean, CI green on the PR.

The mutation re-check on finding 1 now bites in both directions: with [STOP_HOOK_PROBE_ENV]: "1" removed from the hook's spawn env (aidlc-stop.ts:815), "(f2) THE PROBE IS MARKED" fails with Expected: "1" Received: null; restored, 61/61. The witness design is exactly what I had in mind, the t259 lib half is thorough, and the env-leak case at t121:1283 is a good addition I did not ask for. On finding 2 I accept the scoping: the four-place gap documentation plus the CHANGELOG bullet is one of the two options I offered, and doing the closure (if ever) as its own PR is the right call. The finding 4 probe work is the highlight of the round: it corrected the PR's own headline claim rather than defending it, and the Kiro CLI 2.16.0 follow-up turned an unknown into a measured yes on both executable paths.

That said, applying the same mutation discipline to the new code found one more hole, plus a leftover comment sweep. Requesting these before approval; both are small.

1. P2 (test-efficacy): the t259 stale-marker cleanup case passes with the cleanup deleted

Mutation check: I removed the entire rmSync recovery from touchTurnMarker's catch (core/tools/aidlc-lib.ts:2426) and re-ran t259. All 13 cases stay green. Restored: green again.

The reason: the case simulates the failed write with a directory at the marker path (tests/unit/t259-turn-markers.test.ts:182) and then asserts only turnMarkersShowConversational(proj) === false. But directories are already rejected by the isFile() hardening this same round added on the read side (aidlc-lib.ts:2511), so that assertion is satisfied whether or not the recovery ran. It pins the read-side check, not the write-side cleanup; the "never throws" case at :200 does not pin the cleanup either. This is finding 1's failure mode again, one layer down: the production code is correct, and nothing bites on it.

One-line fix: after the failing markEngineTouch(proj), assert the path itself is gone: expect(existsSync(path)).toBe(false). Under the mutation above the directory survives and that assertion fails; with the recovery in place the recursive rmSync removes it. Keep the predicate assertion as a second check if you like, but the existence assertion is the one with teeth.

2. P3: the passthrough-dispatch comment still claims the block contract "is shared", in both adapters

The big contract comments are now right, but the one-line comment on the stop passthrough branch survived in both files and contradicts the corrected text a screen above it:

  • harness/kiro-ide/hooks/aidlc-kiro-adapter.ts:569 says stop stdout is forwarded verbatim because the decision/reason block contract "is shared" - in the same file whose ADVISORY ONLY block (:470) now records, with probe evidence, that no such contract exists on the IDE.
  • harness/kiro/hooks/aidlc-kiro-adapter.ts:749 carries the identical line, while :682 in that file says consumption is unverified (and your follow-up makes it verified-yes on 2.16.0, so this side probably wants the precise statement rather than "shared").

Both lines pre-date this PR (they are on the d0cd10a base), so ordinarily I would not block on them, but finding 4 was "reconcile the contradiction" and a grep for the block contract lands on these lines first. A one-line sweep in each adapter finishes the job; dist regenerates with it.

Nit (non-blocking): the Kiro CLI row now undersells your own follow-up

The PR body table and the CHANGELOG bullet still say Kiro CLI is unverified and to treat it like the IDE until measured, but your follow-up comment measures exactly that on 2.16.0, both paths, consumption confirmed. Stale-conservative rather than wrong; your call whether to touch those two lines while you are in there.

With the t259 assertion added and the two comment lines swept, this is an approve from me.

@csotomon

csotomon commented Aug 1, 2026

Copy link
Copy Markdown
Author

Round 2 addressed @ 2d384b54

Thanks @apackeer — both requested fixes and the CLI-evidence nit are addressed.

  1. t259 cleanup efficacy: the stale-marker case now imports existsSync and asserts expect(existsSync(path)).toBe(false) immediately after the failed markEngineTouch, while retaining the independent fail-closed predicate assertion.
  2. Contract-comment sweep: both passthrough comments were corrected. The IDE comment now states that its host discards Stop output and that relaying it does not imply a shared block contract. The CLI comment is scoped to the measured 2.16.0 legacy/V2 path and explicitly separates --v3/KAS standalone hooks.
  3. Kiro CLI evidence: CHANGELOG 2.5.32, docs/reference/06-hooks-and-tools.md, adapter comments, and the PR body now record the live 2.16.0 results for both paths; none of that evidence is generalized to Kiro IDE.

Regenerated dist/kiro and dist/kiro-ide. Validation: t259 13/13, t121 61/61, package byte-parity, coverage registry, all three TypeScript configs, and Biome are green. The PR body has also been updated to remove the stale unverified claims.

Final base refresh: rebased onto current upstream/v2 at 57ae3d05, rebumped 2.5.32 → 2.5.33, regenerated all distributions, and re-ran the same checks plus t68 and the changelog guard. The protected update leased against 14c666db.

Ready for re-review.

@csotomon
csotomon force-pushed the fix/kiro-stop-hook-conversational-carveout branch from 14c666d to 2d384b5 Compare August 1, 2026 03:07
@apackeer

apackeer commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Re-review @ 2d384b54

The two requested round-2 fixes verify:

  • The stale-marker cleanup assertion now has teeth. t259 passes 13/13 on the branch; with only the rmSync recovery removed, it fails at expect(existsSync(path)).toBe(false) with Received: true.
  • The two passthrough comments now distinguish Kiro IDE's discarded Stop output from Kiro CLI 2.16.0's measured legacy/V2 behavior.

Focused verification is green: t259 13/13, t121 61/61, package byte-parity across all five harnesses, and the coverage-registry guard.

Two items remain:

1. Blocker: the branch is conflicting again

GitHub reports CONFLICTING / DIRTY. v2 moved to 6b264081 after #658 landed and now owns 2.5.33, while this branch also ships 2.5.33. A local git merge-tree --write-tree origin/v2 origin/pr687 reports content conflicts in:

  • CHANGELOG.md
  • tests/.coverage-registry.json

The upstream change also overlaps core/hooks/aidlc-stop.ts, core/tools/aidlc-orchestrate.ts, docs/reference/06-hooks-and-tools.md, and tests/integration/t121-stop-hook-enforce.test.ts (those auto-merge in the simulation, but need focused re-verification after the rebase). Please rebase, coordinate the next free version with #689, regenerate dist/coverage metadata, and rerun t121/t259.

2. P2: the Kiro --v3/KAS user-visible claim is not wired into the shipped CLI tree

The updated CHANGELOG says the spurious nudge is suppressed on both Kiro CLI runtimes, and docs 06 gives KAS a user-visible "nudge suppressed" row. But the shipped tree does not contain the standalone Stop registration used by the KAS probe:

  • harness/kiro/hooks/aidlc-kiro-adapter.ts:688-690 explicitly says KAS does not use this adapter and instead uses standalone .kiro/hooks registration.
  • harness/kiro/manifest.ts:66 packages only aidlc-kiro-adapter.ts.
  • dist/kiro/.kiro/hooks/ contains the adapter and core aidlc-stop.ts, but no standalone Stop registration.
  • dist/kiro/.kiro/agents/aidlc.json:106-110 registers Stop through the adapter, which the comment says KAS does not use.
  • An authored harness/kiro/hooks/aidlc-stop.kiro.hook exists, but it is omitted from the manifest and therefore absent from dist/kiro.

The live KAS probe establishes the host contract, but it does not establish that a stock dist/kiro install executes this PR's changed path under KAS. Please either package the standalone KAS registration or scope the CHANGELOG/docs/body wording to contract-probe evidence and state that the shipped KAS path is currently unaffected.

Once those two items are addressed, the round-2 code itself is ready.

iuryeng added a commit to iuryeng/aidlc-workflows that referenced this pull request Aug 3, 2026
v2 shipped 2.5.30 while this branch still declared 2.5.27. Scanned the
open PRs by their actual AIDLC_VERSION diff rather than their titles:
2.5.31 (awslabs#535, awslabs#661, awslabs#686), 2.5.32 (awslabs#660, awslabs#687) and 2.5.33 (awslabs#689) are
claimed, so this takes 2.5.34.

The CHANGELOG entry was rebuilt from v2's file with this branch's block
reinserted, so no upstream heading is lost. Its sensor-cache bullet now
describes the engine-path match rather than the leaf-name one, and a new
bullet covers the clean-filter binding. Coverage registry regenerated
with the tool, not hand-edited.
@apackeer

apackeer commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Thanks, Camilo. The marker fallback addresses a real gap on transcript-free harnesses, and the round-two fixes and mutation coverage look sound. Current v2 still has the bug, and I do not see another open PR that supersedes this work.

The branch has drifted again, but please do not spend time rebasing or renumbering it. Maintainers can integrate the authored changes onto current v2, choose the release slot, regenerate the distributions and coverage registry, and rerun t121/t259.

One scope correction needs to be preserved during that integration: the Kiro --v3/KAS probe proved the host contract using a standalone Stop registration, but that registration is not included in the shipped Kiro tree. We should either package that path or describe KAS as verified host capability rather than shipped behavior. The documented jump/bolt/swarm/aidlc-state coverage gap and intent-global concurrent-session limitation should remain explicit.

With those integration points handled, this is work we should carry forward.

@csotomon

csotomon commented Aug 5, 2026

Copy link
Copy Markdown
Author

Thank you, @apackeer, for the thorough review, the mutation checks, and for following this through across the repeated base churn. I appreciate the confirmation that the marker fallback and the round-two fixes are technically sound.

I’ll leave the branch as-is per your guidance and won’t spend more cycles rebasing or renumbering it. During maintainer integration, the Kiro --v3/KAS distinction should remain explicit: the probe verifies host capability, not shipped behavior unless the standalone Stop registration is packaged. The documented tool-coverage and concurrent-session limits should remain explicit as well.

Thanks for carrying this work forward.

@apackeer

apackeer commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

@leandrodamascena can you take another pass? LGTM but if it does to you can you approve/merge?

@apackeer apackeer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved. The marker fallback and review fixes are sound. Maintainers can carry the authored changes onto current v2 during integration, preserving the documented Kiro v3/KAS distinction and coverage limitations.

Camilo Soto added 2 commits August 7, 2026 23:56
The Stop hook's tier-3 conversational carve-out was inert on Kiro IDE, Kiro CLI
and opencode. It answers "was the human's last prompt answered with zero engine
calls?" by reading the harness transcript, and only Claude Code and Codex
deliver `transcript_path`. On the other three the predicate always returned
false, so every purely conversational turn mid-stage fell through to the
cap-bounded block and was counted as no-progress.

Give the same predicate a second evidence source: two mtime markers the
framework writes on seams that already exist.

  .aidlc-human-turn   touched by the UserPromptSubmit mint, beside the
                      HUMAN_TURN ledger event it already appends
  .aidlc-engine-touch touched by every advancing `next` / `report` / `park`

  conversational <=> mtime(.aidlc-human-turn) > mtime(.aidlc-engine-touch)

Markers rather than the audit ledger because `next` is read-only and emits no
audit event: a ledger-only predicate would be blind to a conductor that
consulted the engine and then bailed mid-loop, which is the precise failure the
forwarding loop exists to catch.

The load-bearing subtlety is that the Stop hook consults the engine itself. An
unmarked probe would refresh the engine marker on every stop, leaving the
predicate false forever and the carve-out dead code that looks implemented. The
hook sets AIDLC_STOP_HOOK_PROBE=1 on its spawn and markEngineTouch() no-ops on
it. Both halves are pinned: t121 asserts via an env witness written by the mock
engine what the hook actually handed the child, and t259 asserts in-process that
markEngineTouch honours the mark. An earlier revision asserted only marker
mtimes in t121, which was vacuous — the mock never calls markEngineTouch, so
that assertion passed with the marking deleted.

WHAT THIS CHANGES IS HOST-DEPENDENT, and not uniform. On Claude Code, Codex and
opencode the nudge is suppressed and the turn ends clean (opencode's plugin
parses the block itself and re-prompts with the reason). On Kiro IDE nothing
user-visible changes: that host's Stop trigger is observational — probed live,
neither stdout nor stderr reaches the agent, and the Stop payload carries only
{session_id, hook_event_name, cwd} — so the nudge was never delivered there and
only stop.drops and the counter are corrected. aidlc-stop.json always said as
much; the adapter comment claiming parity with Claude's block contract was
wrong and is corrected. On Kiro CLI the host's behaviour is unmeasured and the
comment now says so rather than assuming parity.

KNOWN GAP, documented rather than closed: the marker path is blind to
aidlc-jump / aidlc-bolt / aidlc-swarm and the mutating aidlc-state verbs, which
the transcript predicate does count as engagement, because none of those tools
touch the engine marker. A conductor that jumps the pointer and then quits is
released here and blocked on Claude. Those turns were nudged before the marker
path existed, so this is a real if narrow relaxation; closing it means touching
the marker from a seam all four tools cross, which widens the blast radius well
past this carve-out.

Fail-closed throughout: a missing or non-file marker reads as "no evidence", the
autonomy guard still suppresses the carve-out under autonomous Construction, a
delivered transcript still wins, and a marker whose write fails is DELETED
rather than left stale — a stale engine marker would be a persistent silent
fail-open, since the human marker keeps advancing past it. markHumanTurn and
markEngineTouch self-gate on a born workflow, keeping `next` a pure read that
scaffolds nothing.
@apackeer
apackeer force-pushed the fix/kiro-stop-hook-conversational-carveout branch from 2d384b5 to 30b1ae8 Compare August 8, 2026 01:43
@apackeer
apackeer merged commit 5f6b310 into awslabs:v2 Aug 8, 2026
5 checks passed
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.

2 participants